diff --git a/src/app/(dashboard)/contacts/page.tsx b/src/app/(dashboard)/contacts/page.tsx index 0facba6932..d643943b20 100644 --- a/src/app/(dashboard)/contacts/page.tsx +++ b/src/app/(dashboard)/contacts/page.tsx @@ -21,6 +21,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, } from '@/components/ui/dropdown-menu'; import { Dialog, @@ -49,6 +52,7 @@ import { SlidersHorizontal, Filter, X, + Tags, } from 'lucide-react'; import { ContactForm } from '@/components/contacts/contact-form'; import { ContactDetailView } from '@/components/contacts/contact-detail-view'; @@ -314,6 +318,48 @@ export default function ContactsPage() { setBulkDeleteOpen(false); } + async function handleBulkAddTag(tagId: string) { + const ids = [...selected]; + if (ids.length === 0) return; + + const rows = ids.map((contactId) => ({ + contact_id: contactId, + tag_id: tagId, + })); + + const { error } = await supabase.from('contact_tags').upsert(rows, { + onConflict: 'contact_id,tag_id', + ignoreDuplicates: true, + }); + + if (error) { + toast.error(t('toastBulkTagAddFailed')); + } else { + toast.success(t('toastBulkTagAdded', { count: ids.length })); + setSelected(new Set()); + fetchContacts(); + } + } + + async function handleBulkRemoveTag(tagId: string) { + const ids = [...selected]; + if (ids.length === 0) return; + + const { error } = await supabase + .from('contact_tags') + .delete() + .in('contact_id', ids) + .eq('tag_id', tagId); + + if (error) { + toast.error(t('toastBulkTagRemoveFailed')); + } else { + toast.success(t('toastBulkTagRemoved', { count: ids.length })); + setSelected(new Set()); + fetchContacts(); + } + } + const totalPages = Math.ceil(totalCount / PAGE_SIZE); const hasNext = page < totalPages - 1; const hasPrev = page > 0; @@ -513,6 +559,77 @@ export default function ContactsPage() { > {t('clearSelection')} + + + + } + > + + {t('bulkTags')} + + + + + {t('addTag')} + + + {allTags.length === 0 ? ( +
+ No tags available +
+ ) : ( + allTags.map((tag) => ( + handleBulkAddTag(tag.id)} + className="text-popover-foreground focus:bg-muted focus:text-foreground" + > + + {tag.name} + + )) + )} +
+
+ + + {t('removeTag')} + + + {allTags.length === 0 ? ( +
+ No tags available +
+ ) : ( + allTags.map((tag) => ( + handleBulkRemoveTag(tag.id)} + className="text-popover-foreground focus:bg-muted focus:text-foreground" + > + + {tag.name} + + )) + )} +
+
+
+
+ ` deep-link support. Used when landing here from the * dashboard's recent-conversations list so the right thread opens @@ -172,45 +174,7 @@ function InboxPageInner() { } }, []); - // Check WhatsApp connection status on mount - useEffect(() => { - const checkConnection = async () => { - const supabase = createClient(); - const { - data: { session }, - } = await supabase.auth.getSession(); - const user = session?.user; - - if (!user) return; - - // whatsapp_config is one-row-per-account post-multi-user, so - // the previous `.eq('user_id', user.id)` would miss the row - // for any teammate who didn't personally save the config — - // the "WhatsApp not connected" banner would show in the - // shared inbox even though the admin had it configured. - // Resolve account_id via the profile and query by that. - const { data: profile } = await supabase - .from("profiles") - .select("account_id") - .eq("user_id", user.id) - .maybeSingle(); - const accountId = profile?.account_id as string | undefined; - if (!accountId) { - setWhatsappConnected(false); - return; - } - const { data } = await supabase - .from("whatsapp_config") - .select("status") - .eq("account_id", accountId) - .maybeSingle(); - - setWhatsappConnected(data?.status === "connected"); - }; - - checkConnection(); - }, []); // Handle realtime message events const handleMessageEvent = useCallback( @@ -244,14 +208,14 @@ function InboxPageInner() { prev.map((c) => c.id === newMsg.conversation_id ? { - ...c, - last_message_text: newMsg.content_text ?? "", - last_message_at: newMsg.created_at, - unread_count: - activeConversation?.id === newMsg.conversation_id - ? 0 - : c.unread_count + 1, - } + ...c, + last_message_text: newMsg.content_text ?? "", + last_message_at: newMsg.created_at, + unread_count: + activeConversation?.id === newMsg.conversation_id + ? 0 + : c.unread_count + 1, + } : c, ), ); @@ -311,10 +275,10 @@ function InboxPageInner() { prev.map((c) => c.id === conv.id ? { - ...c, - ...conv, - unread_count: isActive ? 0 : conv.unread_count, - } + ...c, + ...conv, + unread_count: isActive ? 0 : conv.unread_count, + } : c, ), ); @@ -345,7 +309,7 @@ function InboxPageInner() { channelName: "inbox-realtime", onMessageEvent: handleMessageEvent, onConversationEvent: handleConversationEvent, - enabled: true, + enabled: !loading && !!user, }); /** @@ -390,6 +354,36 @@ function InboxPageInner() { }; }, []); + /** + * Periodic resync safety net — refreshes active thread and list every 20 seconds + * when the page is visible, acting as a fallback for slow/blocked websocket events. + * Also polls /api/whatsapp/config?quick=true to keep the Vercel server warm so webhooks + * are delivered instantly without cold-start timeouts. + */ + useEffect(() => { + if (loading || !user) return; + + const checkStatus = async () => { + try { + const res = await fetch("/api/whatsapp/config?quick=true"); + if (res.status === 401) return; + const data = await res.json(); + setWhatsappConnected(data.connected === true); + } catch (err) { + console.error("Failed to check WhatsApp config:", err); + } + }; + checkStatus(); + + const interval = setInterval(() => { + if (document.visibilityState === "visible") { + setResyncToken((n) => n + 1); + checkStatus(); + } + }, 60000); + return () => clearInterval(interval); + }, [user, loading]); + /** * Manual refresh trigger for the thread-header refresh button. * Bumps the same resyncToken the reconnect / visibility paths use, diff --git a/src/app/(dashboard)/webhooks/page.tsx b/src/app/(dashboard)/webhooks/page.tsx new file mode 100644 index 0000000000..7ed3dbe82c --- /dev/null +++ b/src/app/(dashboard)/webhooks/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { WebhookTabs } from '@/components/webhooks/webhook-tabs'; + +export default function WebhooksPage() { + return ( +
+
+

Webhook Integration

+

+ Connect your account with various systems and applications to trigger WhatsApp templates in real time. +

+
+ + +
+ ); +} diff --git a/src/app/api/webhooks/config/route.ts b/src/app/api/webhooks/config/route.ts new file mode 100644 index 0000000000..018480f707 --- /dev/null +++ b/src/app/api/webhooks/config/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +export async function GET() { + try { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { data: profile } = await supabase.from('profiles').select('account_id').eq('user_id', user.id).maybeSingle(); + if (!profile?.account_id) return NextResponse.json({ error: 'No account linked' }, { status: 403 }); + + const { data: config, error } = await supabase + .from('webhook_integrations') + .select('*') + .eq('account_id', profile.account_id) + .maybeSingle(); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ config }); + } catch (e) { + return NextResponse.json({ error: e instanceof Error ? e.message : 'Unknown error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { data: profile } = await supabase.from('profiles').select('account_id').eq('user_id', user.id).maybeSingle(); + if (!profile?.account_id) return NextResponse.json({ error: 'No account linked' }, { status: 403 }); + + const { name, whatsapp_config_id, hmac_secret, default_phone_prefix } = await request.json(); + if (!name) return NextResponse.json({ error: 'Integration Name is required' }, { status: 400 }); + + const { data: config, error } = await supabase + .from('webhook_integrations') + .upsert( + { + account_id: profile.account_id, + name, + whatsapp_config_id: whatsapp_config_id || null, + hmac_secret: hmac_secret || null, + default_phone_prefix: default_phone_prefix || '91', + updated_at: new Date().toISOString() + }, + { onConflict: 'account_id' } + ) + .select() + .single(); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ config }); + } catch (e) { + return NextResponse.json({ error: e instanceof Error ? e.message : 'Unknown error' }, { status: 500 }); + } +} diff --git a/src/app/api/webhooks/incoming/[id]/route.ts b/src/app/api/webhooks/incoming/[id]/route.ts new file mode 100644 index 0000000000..22aac24c16 --- /dev/null +++ b/src/app/api/webhooks/incoming/[id]/route.ts @@ -0,0 +1,592 @@ +import { NextResponse } from 'next/server'; +import { supabaseAdmin } from '@/lib/automations/admin-client'; +import { + getNestedValue, + evaluateConditions, + resolveMapping, + type WebhookCondition +} from '@/lib/generic-webhooks/utils'; +import { decrypt } from '@/lib/whatsapp/encryption'; +import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'; +import { findExistingContact } from '@/lib/contacts/dedupe'; +import { + sanitizePhoneForMeta, + isValidE164, + phoneVariants, + isRecipientNotAllowedError +} from '@/lib/whatsapp/phone-utils'; +import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'; + +interface WebhookWorkflow { + id: string; + account_id: string; + integration_id: string; + name: string; + recipient_name_field: string; + recipient_phone_field: string; + recipient_email_field: string | null; + conditions: { + matchType?: 'all' | 'any'; + rules?: WebhookCondition[]; + } | null; + actions: { + type: string; + template_name: string; + language?: string; + mappings?: { + body?: import('@/lib/generic-webhooks/utils').WebhookMapping[]; + headerText?: import('@/lib/generic-webhooks/utils').WebhookMapping; + headerMedia?: import('@/lib/generic-webhooks/utils').WebhookMapping; + buttons?: Record; + }; + }[] | null; + is_active: boolean; + create_contacts?: boolean; +} + +interface WebhookIntegration { + id: string; + account_id: string; + name: string; + whatsapp_config_id: string | null; + is_connected: boolean; + last_payload: unknown; + hmac_secret: string | null; + default_phone_prefix: string | null; +} + +interface Contact { + id: string; + name?: string | null; + email?: string | null; + phone?: string | null; +} + +interface Conversation { + id: string; +} + +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> } +) { + const { id: workflowId } = await context.params; + const db = supabaseAdmin(); + + // 1. Rate limiting check + const rateLimitResult = checkRateLimit(`incoming-webhook:${workflowId}`, RATE_LIMITS.incomingWebhook); + if (!rateLimitResult.success) { + return rateLimitResponse(rateLimitResult); + } + + const rawBody = await request.text(); + + let resolvedAccountId: string | null = null; + let payload: Record | null = null; + + try { + // Check if the ID belongs to a workflow or directly to an integration + let integration: any = null; + let workflowRow: any = null; + + const { data: wfRow } = await db + .from('webhook_workflows') + .select('*, integration:webhook_integrations(*)') + .eq('id', workflowId) + .maybeSingle(); + + if (wfRow) { + workflowRow = wfRow; + integration = wfRow.integration; + } else { + const { data: intRow } = await db + .from('webhook_integrations') + .select('*') + .eq('id', workflowId) + .maybeSingle(); + if (intRow) { + integration = intRow; + } + } + + if (!integration) { + console.warn('[webhooks] workflow/integration not found for id:', workflowId); + return NextResponse.json({ error: 'Workflow or Integration not found' }, { status: 404 }); + } + + // 2. HMAC signature verification + if (integration.hmac_secret) { + const signatureHeader = + request.headers.get('x-webhook-signature') || + request.headers.get('x-signature') || + request.headers.get('x-hub-signature-256'); + + if (!signatureHeader) { + return NextResponse.json({ error: 'Signature header is missing' }, { status: 401 }); + } + + let provided = signatureHeader; + if (provided.startsWith('sha256=')) { + provided = provided.slice(7); + } + + const crypto = await import('node:crypto'); + const computed = crypto.createHmac('sha256', integration.hmac_secret).update(rawBody).digest('hex'); + + const a = Buffer.from(provided); + const b = Buffer.from(computed); + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); + } + } + + // 3. Parse JSON body + if (rawBody) { + try { + payload = JSON.parse(rawBody) as Record; + } catch (e) { + console.error('[webhooks] failed to parse incoming body as JSON:', e); + return NextResponse.json({ error: 'Payload must be valid JSON' }, { status: 400 }); + } + } + + resolvedAccountId = integration.account_id; + + // Update integration's last_payload + if (payload) { + await db + .from('webhook_integrations') + .update({ + last_payload: payload, + is_connected: true, + updated_at: new Date().toISOString() + }) + .eq('id', integration.id); + } + + if (!payload || Object.keys(payload).length === 0) { + return NextResponse.json({ status: 'connected', message: 'Empty test payload received successfully' }); + } + + // If it's a single workflow ID + if (workflowRow) { + if (!workflowRow.is_active) { + return NextResponse.json({ status: 'skipped', message: 'Workflow is inactive' }); + } + const result = await executeWorkflow(workflowRow, integration, payload); + return NextResponse.json(result); + } + + // If it's an integration ID, execute all its active workflows + const { data: workflows, error: wfErr } = await db + .from('webhook_workflows') + .select('*') + .eq('integration_id', integration.id) + .eq('is_active', true); + + if (wfErr) { + throw new Error(`Failed to fetch workflows: ${wfErr.message}`); + } + + if (!workflows || workflows.length === 0) { + return NextResponse.json({ status: 'connected', message: 'No active workflows configured for this integration.' }); + } + + const results = []; + for (const wf of workflows) { + try { + const res = await executeWorkflow(wf, integration, payload); + results.push({ workflow: wf.name, status: 'success', details: res }); + } catch (err) { + results.push({ workflow: wf.name, status: 'failed', error: err instanceof Error ? err.message : String(err) }); + } + } + + return NextResponse.json({ success: true, results }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown execution error'; + console.error('[webhooks] incoming trigger failed:', errorMsg); + + // Attempt logging failure for single workflow if we can identify it + if (resolvedAccountId) { + try { + await db.from('webhook_logs').insert({ + account_id: resolvedAccountId, + workflow_name: 'Webhook Error', + status: 'failed', + error_message: errorMsg, + payload: payload + }); + } catch (logErr) { + console.error('[webhooks] logging failure failed:', logErr); + } + } + + return NextResponse.json({ error: errorMsg }, { status: 500 }); + } +} + +// Single workflow execution runner +async function executeWorkflow(workflow: WebhookWorkflow, integration: WebhookIntegration, payload: Record | null) { + const db = supabaseAdmin(); + let customerPhone = ''; + let customerName = ''; + + try { + // 1. Evaluate conditions + const condObj = workflow.conditions || {}; + const conditions: WebhookCondition[] = Array.isArray(condObj.rules) ? condObj.rules : []; + const matchType: 'all' | 'any' = condObj.matchType === 'any' ? 'any' : 'all'; + + const conditionsMatch = evaluateConditions(payload, conditions, matchType); + if (!conditionsMatch) { + // Record a log with no_match status + await db.from('webhook_logs').insert({ + account_id: workflow.account_id, + integration_id: integration.id, + workflow_id: workflow.id, + workflow_name: workflow.name, + status: 'no_match', + payload: payload, + error_message: 'Conditions did not match' + }); + return { status: 'no_match', message: 'Conditions did not match' }; + } + + // 2. Resolve recipient phone and name + const phonePath = workflow.recipient_phone_field; + const rawPhone = getNestedValue(payload, phonePath); + if (!rawPhone) { + throw new Error(`Recipient phone path "${phonePath}" resolved to empty value.`); + } + + let sanitizedPhone = sanitizePhoneForMeta(String(rawPhone)); + if (sanitizedPhone.length === 10) { + const prefix = integration.default_phone_prefix || '91'; + sanitizedPhone = prefix + sanitizedPhone; + } + + if (!isValidE164(sanitizedPhone)) { + throw new Error(`Sanitized phone number "${sanitizedPhone}" is not valid E164 format.`); + } + customerPhone = sanitizedPhone; + + const namePath = workflow.recipient_name_field; + const rawName = getNestedValue(payload, namePath); + customerName = rawName ? String(rawName) : sanitizedPhone; + + const emailPath = workflow.recipient_email_field; + let customerEmail: string | null = null; + if (emailPath) { + const rawEmail = getNestedValue(payload, emailPath); + customerEmail = rawEmail ? String(rawEmail).trim() : null; + } + + // 3. Resolve WhatsApp configuration + const configId = integration.whatsapp_config_id; + if (!configId) { + throw new Error('No WhatsApp channel selected for integration.'); + } + + const { data: config, error: configErr } = await db + .from('whatsapp_config') + .select('*') + .eq('id', configId) + .maybeSingle(); + + if (configErr || !config) { + throw new Error('WhatsApp configuration record not found.'); + } + + const accessToken = decrypt(config.access_token); + + // 4. Execute actions (e.g. Send template) + const actions = workflow.actions || []; + const templateAction = actions.find((act) => act.type === 'send_template'); + + if (!templateAction) { + throw new Error('No template action defined for this workflow.'); + } + + const templateName = templateAction.template_name; + const language = templateAction.language || 'en_US'; + + // Load template row + const { data: templateRow } = await db + .from('message_templates') + .select('*') + .eq('account_id', workflow.account_id) + .eq('name', templateName) + .eq('language', language) + .maybeSingle(); + + // Resolve template parameters from payload mapping + const mappings = templateAction.mappings || {}; + + // Meta requires non-empty strings for all parameter values. If a mapped field is missing/empty, fallback to a space ' ' + const bodyParams = Array.isArray(mappings.body) + ? mappings.body.map((m) => { + const val = resolveMapping(payload, m); + return val ? val.trim() : ' '; + }) + : []; + + const headerText = mappings.headerText + ? resolveMapping(payload, mappings.headerText)?.trim() || ' ' + : undefined; + + const headerMediaUrl = mappings.headerMedia + ? resolveMapping(payload, mappings.headerMedia)?.trim() || ' ' + : undefined; + + const buttonParams: Record = {}; + if (mappings.buttons) { + for (const idx in mappings.buttons) { + const numIdx = parseInt(idx, 10); + if (!isNaN(numIdx) && mappings.buttons[idx]) { + buttonParams[numIdx] = resolveMapping(payload, mappings.buttons[idx])?.trim() || ' '; + } + } + } + + const templateParams = { + body: bodyParams, + headerText, + headerMediaUrl, + buttonParams + }; + + const adminUserId = config.user_id; + + // 5. Resolve / create Contact & Conversation based on toggle + let contact: Contact | null = null; + let conversation: Conversation | null = null; + const shouldCreateContacts = workflow.create_contacts !== false; + + if (shouldCreateContacts) { + const contactOutcome = await findOrCreateContact( + workflow.account_id, + adminUserId, + sanitizedPhone, + customerName, + customerEmail + ); + if (contactOutcome && contactOutcome.contact) { + contact = contactOutcome.contact; + conversation = await findOrCreateConversation( + workflow.account_id, + adminUserId, + contactOutcome.contact.id + ); + } + } else { + // Look up existing contact only + const existingContact = await findExistingContact(db, workflow.account_id, sanitizedPhone); + if (existingContact) { + contact = existingContact; + const updateFields: Partial & { updated_at?: string } = {}; + if (customerName && customerName !== existingContact.name) { + updateFields.name = customerName; + } + if (customerEmail && customerEmail !== existingContact.email) { + updateFields.email = customerEmail; + } + if (Object.keys(updateFields).length > 0) { + updateFields.updated_at = new Date().toISOString(); + await db.from('contacts').update(updateFields).eq('id', existingContact.id); + Object.assign(existingContact, updateFields); + } + conversation = await findOrCreateConversation( + workflow.account_id, + adminUserId, + existingContact.id + ); + } + } + + // 6. Send via Meta API with variant retries + let waMessageId = ''; + let workingPhone = sanitizedPhone; + let lastError: unknown = null; + const variants = phoneVariants(sanitizedPhone); + + const attempt = async (phone: string): Promise => { + const result = await sendTemplateMessage({ + phoneNumberId: config.phone_number_id, + accessToken, + to: phone, + templateName, + language, + template: templateRow ?? undefined, + messageParams: templateParams, + useMarketingEndpoint: config.use_marketing_endpoint + }); + return result.messageId; + }; + + for (const variant of variants) { + try { + waMessageId = await attempt(variant); + workingPhone = variant; + lastError = null; + break; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!isRecipientNotAllowedError(msg)) { + throw err; + } + lastError = err; + } + } + + if (lastError) { + throw lastError; + } + + // If a contact was created/found and conversation resolved, log in history + if (contact && conversation) { + if (workingPhone !== contact.phone) { + await db.from('contacts').update({ phone: workingPhone }).eq('id', contact.id); + } + + const { error: msgErr } = await db.from('messages').insert({ + conversation_id: conversation.id, + sender_type: 'bot', + content_type: 'template', + template_name: templateName, + message_id: waMessageId, + status: 'sent' + }); + if (msgErr) { + console.error('[webhooks] db message insert failed:', msgErr.message); + } + + await db + .from('conversations') + .update({ + last_message_text: `[template:${templateName}]`, + last_message_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }) + .eq('id', conversation.id); + } + + // 7. Create success log entry + await db.from('webhook_logs').insert({ + account_id: workflow.account_id, + integration_id: integration.id, + workflow_id: workflow.id, + workflow_name: workflow.name, + customer_name: customerName, + customer_phone: workingPhone, + status: 'success', + payload: payload + }); + + return { success: true, message_id: waMessageId }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[webhooks] executeWorkflow ${workflow.name} failed:`, errorMsg); + + // Log failure + try { + await db.from('webhook_logs').insert({ + account_id: workflow.account_id, + integration_id: integration.id, + workflow_id: workflow.id, + workflow_name: workflow.name, + customer_name: customerName || null, + customer_phone: customerPhone || null, + status: 'failed', + error_message: errorMsg, + payload: payload + }); + } catch (logErr) { + console.error('[webhooks] executeWorkflow failed to insert error log:', logErr); + } + + throw error; + } +} + +// Local findOrCreateContact & findOrCreateConversation helpers mirrors webhook/route.ts +async function findOrCreateContact( + accountId: string, + configOwnerUserId: string, + phone: string, + name: string, + email?: string | null +) { + const db = supabaseAdmin(); + const existingContact = await findExistingContact(db, accountId, phone); + + if (existingContact) { + const updateFields: Partial & { updated_at?: string } = {}; + if (name && name !== existingContact.name) { + updateFields.name = name; + } + if (email && email !== existingContact.email) { + updateFields.email = email; + } + if (Object.keys(updateFields).length > 0) { + updateFields.updated_at = new Date().toISOString(); + await db.from('contacts').update(updateFields).eq('id', existingContact.id); + Object.assign(existingContact, updateFields); + } + return { contact: existingContact, wasCreated: false }; + } + + const { data: newContact, error: createError } = await db + .from('contacts') + .insert({ + account_id: accountId, + user_id: configOwnerUserId, + phone, + name: name || phone, + email: email || null + }) + .select() + .single(); + + if (createError) { + console.error('Error creating webhook contact:', createError); + return null; + } + + return { contact: newContact, wasCreated: true }; +} + +async function findOrCreateConversation( + accountId: string, + configOwnerUserId: string, + contactId: string +) { + const db = supabaseAdmin(); + const { data: existing, error: findError } = await db + .from('conversations') + .select('*') + .eq('account_id', accountId) + .eq('contact_id', contactId) + .single(); + + if (!findError && existing) { + return existing; + } + + const { data: newConv, error: createError } = await db + .from('conversations') + .insert({ + account_id: accountId, + user_id: configOwnerUserId, + contact_id: contactId + }) + .select() + .single(); + + if (createError) { + console.error('Error creating webhook conversation:', createError); + return null; + } + + return newConv; +} diff --git a/src/app/api/webhooks/logs/route.ts b/src/app/api/webhooks/logs/route.ts new file mode 100644 index 0000000000..e280a7d4d6 --- /dev/null +++ b/src/app/api/webhooks/logs/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +export async function GET() { + try { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { data: profile } = await supabase.from('profiles').select('account_id').eq('user_id', user.id).maybeSingle(); + if (!profile?.account_id) return NextResponse.json({ error: 'No account linked' }, { status: 403 }); + + const { data: logs, error } = await supabase + .from('webhook_logs') + .select('*') + .eq('account_id', profile.account_id) + .order('created_at', { ascending: false }) + .limit(100); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ logs }); + } catch (e) { + return NextResponse.json({ error: e instanceof Error ? e.message : 'Unknown error' }, { status: 500 }); + } +} diff --git a/src/app/api/webhooks/workflows/[id]/route.ts b/src/app/api/webhooks/workflows/[id]/route.ts new file mode 100644 index 0000000000..c758ef9f04 --- /dev/null +++ b/src/app/api/webhooks/workflows/[id]/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +export async function PUT( + request: Request, + context: { params: Promise<{ id: string }> } +) { + try { + const { id } = await context.params; + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { data: profile } = await supabase.from('profiles').select('account_id').eq('user_id', user.id).maybeSingle(); + if (!profile?.account_id) return NextResponse.json({ error: 'No account linked' }, { status: 403 }); + + const body = await request.json(); + const { name, recipient_name_field, recipient_phone_field, recipient_email_field, conditions, actions, is_active, create_contacts } = body; + + if (!name) return NextResponse.json({ error: 'Workflow Name is required' }, { status: 400 }); + if (!recipient_phone_field) return NextResponse.json({ error: 'Recipient phone mapping is required' }, { status: 400 }); + if (!recipient_name_field) return NextResponse.json({ error: 'Recipient name mapping is required' }, { status: 400 }); + + const { data: workflow, error } = await supabase + .from('webhook_workflows') + .update({ + name, + recipient_name_field, + recipient_phone_field, + recipient_email_field: recipient_email_field || null, + conditions: conditions || { matchType: 'all', rules: [] }, + actions: actions || [], + is_active: is_active !== false, + create_contacts: create_contacts !== false, + updated_at: new Date().toISOString() + }) + .eq('id', id) + .eq('account_id', profile.account_id) + .select() + .single(); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ workflow }); + } catch (e) { + return NextResponse.json({ error: e instanceof Error ? e.message : 'Unknown error' }, { status: 500 }); + } +} + +export async function DELETE( + request: Request, + context: { params: Promise<{ id: string }> } +) { + try { + const { id } = await context.params; + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { data: profile } = await supabase.from('profiles').select('account_id').eq('user_id', user.id).maybeSingle(); + if (!profile?.account_id) return NextResponse.json({ error: 'No account linked' }, { status: 403 }); + + const { error } = await supabase + .from('webhook_workflows') + .delete() + .eq('id', id) + .eq('account_id', profile.account_id); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ success: true }); + } catch (e) { + return NextResponse.json({ error: e instanceof Error ? e.message : 'Unknown error' }, { status: 500 }); + } +} diff --git a/src/app/api/webhooks/workflows/route.ts b/src/app/api/webhooks/workflows/route.ts new file mode 100644 index 0000000000..aeefce8068 --- /dev/null +++ b/src/app/api/webhooks/workflows/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +export async function GET() { + try { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { data: profile } = await supabase.from('profiles').select('account_id').eq('user_id', user.id).maybeSingle(); + if (!profile?.account_id) return NextResponse.json({ error: 'No account linked' }, { status: 403 }); + + const { data: workflows, error } = await supabase + .from('webhook_workflows') + .select('*') + .eq('account_id', profile.account_id) + .order('created_at', { ascending: false }); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ workflows }); + } catch (e) { + return NextResponse.json({ error: e instanceof Error ? e.message : 'Unknown error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { data: profile } = await supabase.from('profiles').select('account_id').eq('user_id', user.id).maybeSingle(); + if (!profile?.account_id) return NextResponse.json({ error: 'No account linked' }, { status: 403 }); + + // Check count limit + const { count, error: countErr } = await supabase + .from('webhook_workflows') + .select('*', { count: 'exact', head: true }) + .eq('account_id', profile.account_id); + + if (countErr) return NextResponse.json({ error: countErr.message }, { status: 500 }); + if (count !== null && count >= 5) { + return NextResponse.json({ error: 'Maximum limit of 5 workflows reached.' }, { status: 400 }); + } + + // Fetch integration id + const { data: integration } = await supabase + .from('webhook_integrations') + .select('id') + .eq('account_id', profile.account_id) + .maybeSingle(); + + if (!integration) { + return NextResponse.json({ error: 'Please configure the integration settings first.' }, { status: 400 }); + } + + const body = await request.json(); + const { name, recipient_name_field, recipient_phone_field, recipient_email_field, conditions, actions, is_active, create_contacts } = body; + + if (!name) return NextResponse.json({ error: 'Workflow Name is required' }, { status: 400 }); + if (!recipient_phone_field) return NextResponse.json({ error: 'Recipient phone mapping is required' }, { status: 400 }); + if (!recipient_name_field) return NextResponse.json({ error: 'Recipient name mapping is required' }, { status: 400 }); + + const { data: workflow, error } = await supabase + .from('webhook_workflows') + .insert({ + account_id: profile.account_id, + integration_id: integration.id, + name, + recipient_name_field, + recipient_phone_field, + recipient_email_field: recipient_email_field || null, + conditions: conditions || { matchType: 'all', rules: [] }, + actions: actions || [], + is_active: is_active !== false, + create_contacts: create_contacts !== false + }) + .select() + .single(); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ workflow }); + } catch (e) { + return NextResponse.json({ error: e instanceof Error ? e.message : 'Unknown error' }, { status: 500 }); + } +} diff --git a/src/app/api/whatsapp/broadcast/route.ts b/src/app/api/whatsapp/broadcast/route.ts index e4205a3d5a..9920f76ffd 100644 --- a/src/app/api/whatsapp/broadcast/route.ts +++ b/src/app/api/whatsapp/broadcast/route.ts @@ -209,6 +209,7 @@ export async function POST(request: Request) { template: templateRow ?? undefined, messageParams: recipient.messageParams, params: recipient.params ?? [], + useMarketingEndpoint: config.use_marketing_endpoint, }) sentMessageId = result.messageId lastError = null diff --git a/src/app/api/whatsapp/config/route.ts b/src/app/api/whatsapp/config/route.ts index 399699c4ed..1d334caa5c 100644 --- a/src/app/api/whatsapp/config/route.ts +++ b/src/app/api/whatsapp/config/route.ts @@ -185,7 +185,7 @@ export async function POST(request: Request) { } const body = await request.json() - const { phone_number_id, waba_id, access_token, verify_token, pin } = body + const { phone_number_id, waba_id, access_token, verify_token, pin, use_marketing_endpoint } = body if (!access_token || !phone_number_id) { return NextResponse.json( @@ -363,6 +363,7 @@ export async function POST(request: Request) { registered_at: registrationError ? null : registeredAt, subscribed_apps_at: subscribedAppsAt ?? null, last_registration_error: registrationError, + use_marketing_endpoint: use_marketing_endpoint ?? false, updated_at: new Date().toISOString(), } diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 5db7488056..425caed2c1 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -22,6 +22,7 @@ import { UserCog, Users, UsersRound, + Webhook, Workflow, X, Zap, @@ -99,6 +100,7 @@ const navItems: NavItem[] = [ { href: "/automations", labelKey: "automations", icon: Zap }, { href: "/flows", labelKey: "flows", icon: Workflow, beta: true }, { href: "/agents", labelKey: "aiAgents", icon: Bot }, + { href: "/webhooks", labelKey: "webhooks", icon: Webhook }, ]; const bottomNavItems = [ @@ -240,7 +242,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) { {item.beta && ( {t("beta")} diff --git a/src/components/settings/settings-overview.tsx b/src/components/settings/settings-overview.tsx index ce992f8936..24183ca4ed 100644 --- a/src/components/settings/settings-overview.tsx +++ b/src/components/settings/settings-overview.tsx @@ -40,7 +40,7 @@ export function SettingsOverview({ useAuth(); const { mode, theme } = useTheme(); const t = useTranslations('Settings.overview'); - const tRoles = useTranslations('roles'); + const tRoles = useTranslations('Settings.roles'); const tSections = useTranslations('Settings.sections'); const [counts, setCounts] = useState(null); diff --git a/src/components/settings/settings-rail.tsx b/src/components/settings/settings-rail.tsx index 1aa5041e64..d714493063 100644 --- a/src/components/settings/settings-rail.tsx +++ b/src/components/settings/settings-rail.tsx @@ -81,7 +81,7 @@ export function SettingsRail({ onClick={() => onSelect(s)} aria-current={isActive ? 'page' : undefined} className={cn( - 'flex shrink-0 items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium whitespace-nowrap transition-colors', + 'flex shrink-0 items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium whitespace-nowrap transition-colors cursor-pointer', 'lg:w-full', isActive ? 'bg-primary-soft text-primary' diff --git a/src/components/settings/whatsapp-config.tsx b/src/components/settings/whatsapp-config.tsx index 85b7a3b819..61ad090a39 100644 --- a/src/components/settings/whatsapp-config.tsx +++ b/src/components/settings/whatsapp-config.tsx @@ -69,6 +69,7 @@ export function WhatsAppConfig() { const [verifyToken, setVerifyToken] = useState(''); const [pin, setPin] = useState(''); const [tokenEdited, setTokenEdited] = useState(false); + const [useMarketingEndpoint, setUseMarketingEndpoint] = useState(false); // True once /register has succeeded on Meta's side (timestamp set // in the row). When false, the saved config is metadata-only and @@ -121,6 +122,7 @@ export function WhatsAppConfig() { setVerifyToken(''); setPin(''); setTokenEdited(false); + setUseMarketingEndpoint(data.use_marketing_endpoint || false); } else { setConfig(null); setPhoneNumberId(''); @@ -129,6 +131,7 @@ export function WhatsAppConfig() { setVerifyToken(''); setPin(''); setTokenEdited(false); + setUseMarketingEndpoint(false); } // Clear any stale probe result when reloading the row. setRegistrationProbe(null); @@ -207,6 +210,7 @@ export function WhatsAppConfig() { // requires it on first save or when changing numbers; for a // simple token rotation, leaving it blank skips re-register. pin: pin.trim() || null, + use_marketing_endpoint: useMarketingEndpoint, }; if (tokenEdited && accessToken !== MASKED_TOKEN && accessToken.trim()) { @@ -650,6 +654,25 @@ export function WhatsAppConfig() {

+ +
+ setUseMarketingEndpoint(e.target.checked)} + className="size-4 rounded border-border bg-muted text-primary focus:ring-primary cursor-pointer disabled:opacity-50" + /> +
+ +

+ Enable this if your account is enrolled in Meta's Marketing Messages Lite API (routes category Marketing templates to /marketing_messages instead of /messages). +

+
+
diff --git a/src/components/webhooks/edit-workflow-modal.tsx b/src/components/webhooks/edit-workflow-modal.tsx new file mode 100644 index 0000000000..c6b803d186 --- /dev/null +++ b/src/components/webhooks/edit-workflow-modal.tsx @@ -0,0 +1,1228 @@ +"use client"; + +import { useEffect, useState, useRef } from 'react'; +import { toast } from 'sonner'; +import { Plus, Trash2, X, Info, Paperclip, Upload, Loader2, ArrowLeft, Image as ImageIcon } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { createClient } from '@/lib/supabase/client'; +import { extractJsonPaths, getNestedValue, type WebhookCondition } from '@/lib/generic-webhooks/utils'; + +import type { MessageTemplate } from '@/types'; + +export interface WebhookWorkflow { + id?: string; + name: string; + is_active: boolean; + recipient_name_field: string; + recipient_phone_field: string; + recipient_email_field: string | null; + create_contacts: boolean; + conditions?: { + matchType?: 'all' | 'any'; + rules?: WebhookCondition[]; + } | null; + actions?: { + type: string; + template_name: string; + language?: string; + mappings?: { + body?: { type: 'payload' | 'static' | 'upload'; value: string }[]; + headerText?: { type: 'payload' | 'static' | 'upload'; value: string }; + headerMedia?: { type: 'payload' | 'static' | 'upload'; value: string; filename?: string | null }; + buttons?: Record; + }; + }[] | null; +} + +interface EditWorkflowModalProps { + workflow: WebhookWorkflow | null; // Null if creating a new workflow + lastPayload: Record | null; + onClose: () => void; + onSave: () => void; +} + +export function EditWorkflowModal({ workflow, lastPayload, onClose, onSave }: EditWorkflowModalProps) { + const isEditing = !!workflow; + const [name, setName] = useState(''); + const [isActive, setIsActive] = useState(true); + const [recipientNameField, setRecipientNameField] = useState(''); + const [recipientPhoneField, setRecipientPhoneField] = useState(''); + const [recipientEmailField, setRecipientEmailField] = useState(''); + const [createContacts, setCreateContacts] = useState(true); + + // Conditions + const [matchType, setMatchType] = useState<'all' | 'any'>('all'); + const [conditions, setConditions] = useState([]); + + // Templates & Action + const [templates, setTemplates] = useState([]); + const [selectedTemplateId, setSelectedTemplateId] = useState(''); + const [bodyMappings, setBodyMappings] = useState<{ index: number; type: 'payload' | 'static' | 'upload'; value: string }[]>([]); // [{ index: 1, type: 'payload', value: '' }] + const [headerMapping, setHeaderMapping] = useState<{ type: 'payload' | 'static' | 'upload'; value: string } | null>(null); // { type: 'payload', value: '' } + + // Sub tabs state + const [activeSubTab, setActiveSubTab] = useState<'map' | 'media' | 'advance'>('map'); + + // Media Mapping fields + const [mediaSourceType, setMediaSourceType] = useState<'static' | 'payload' | 'upload'>('static'); + const [mediaFileName, setMediaFileName] = useState(''); + const [mediaLink, setMediaLink] = useState(''); + const [mediaPayloadPath, setMediaPayloadPath] = useState(''); + const [mediaUploadUrl, setMediaUploadUrl] = useState(''); + const [mediaUploading, setMediaUploading] = useState(false); + const fileInputRef = useRef(null); + + // Button mapping (for Advance tab) + const [buttonMappings, setButtonMappings] = useState>({}); + + const [saving, setSaving] = useState(false); + + const supabase = createClient(); + const availablePaths = lastPayload ? extractJsonPaths(lastPayload) : []; + + useEffect(() => { + // Load approved templates + async function loadTemplates() { + const { data } = await supabase + .from('message_templates') + .select('*') + .eq('status', 'APPROVED') + .order('name'); + setTemplates(data || []); + } + loadTemplates(); + + if (workflow) { + setName(workflow.name); + setIsActive(workflow.is_active); + setRecipientNameField(workflow.recipient_name_field); + setRecipientPhoneField(workflow.recipient_phone_field); + setRecipientEmailField(workflow.recipient_email_field || ''); + setCreateContacts(workflow.create_contacts !== false); + + const condsObj = workflow.conditions || {}; + setMatchType(condsObj.matchType === 'any' ? 'any' : 'all'); + setConditions(Array.isArray(condsObj.rules) ? condsObj.rules : []); + + const actionsList = workflow.actions || []; + const templateAct = actionsList.find((a) => a.type === 'send_template'); + if (templateAct) { + // Find template by name + setSelectedTemplateId(templateAct.template_name || ''); + + // Load mappings + const bodyM = templateAct.mappings?.body || []; + setBodyMappings( + bodyM.map((m, idx: number) => ({ + index: idx + 1, + type: m.type || 'payload', + value: m.value || '' + })) + ); + + if (templateAct.mappings?.headerText) { + setHeaderMapping({ + type: templateAct.mappings.headerText.type || 'payload', + value: templateAct.mappings.headerText.value || '' + }); + } + + // Load media mapping + const headerMedia = templateAct.mappings?.headerMedia; + if (headerMedia) { + setMediaSourceType(headerMedia.type || 'static'); + setMediaFileName(headerMedia.filename || ''); + if (headerMedia.type === 'payload') { + setMediaPayloadPath(headerMedia.value || ''); + } else if (headerMedia.type === 'upload') { + setMediaUploadUrl(headerMedia.value || ''); + } else { + setMediaLink(headerMedia.value || ''); + } + } + + // Load button mappings + const buttonsMap = templateAct.mappings?.buttons || {}; + const bMappings: Record = {}; + for (const key in buttonsMap) { + const idx = parseInt(key, 10); + if (!isNaN(idx)) { + bMappings[idx] = { + type: buttonsMap[key].type || 'static', + value: buttonsMap[key].value || '' + }; + } + } + setButtonMappings(bMappings); + } + } + }, [workflow, supabase]); + + // Extracts variable indices like {{1}}, {{2}} from body text + const parseTemplateVariables = (bodyText: string): number[] => { + const regex = /\{\{(\d+)\}\}/g; + const indices: number[] = []; + let match; + while ((match = regex.exec(bodyText)) !== null) { + const idx = parseInt(match[1]); + if (!indices.includes(idx)) indices.push(idx); + } + return indices.sort((a, b) => a - b); + }; + + const handleTemplateChange = (templateName: string) => { + setSelectedTemplateId(templateName); + const tmpl = templates.find((t) => t.name === templateName); + if (!tmpl) { + setBodyMappings([]); + setHeaderMapping(null); + return; + } + + // Body variables + const vars = parseTemplateVariables(tmpl.body_text); + setBodyMappings( + vars.map((v) => ({ + index: v, + type: 'payload', + value: '' + })) + ); + + // Header variable check + if (tmpl.header_type === 'text' && tmpl.header_content?.includes('{{1}}')) { + setHeaderMapping({ type: 'payload', value: '' }); + } else { + setHeaderMapping(null); + } + + // Reset media mapping & buttons + setActiveSubTab('map'); + setMediaSourceType('static'); + setMediaFileName(''); + setMediaLink(''); + setMediaPayloadPath(''); + setMediaUploadUrl(''); + + const bMappings: Record = {}; + if (tmpl.buttons && Array.isArray(tmpl.buttons)) { + tmpl.buttons.forEach((_, idx: number) => { + bMappings[idx] = { type: 'static', value: '' }; + }); + } + setButtonMappings(bMappings); + }; + + const addCondition = () => { + setConditions([...conditions, { field: '', operator: 'equals', value: '' }]); + }; + + const removeCondition = (index: number) => { + setConditions(conditions.filter((_, i) => i !== index)); + }; + + const updateCondition = (index: number, fieldKey: keyof WebhookCondition, val: string) => { + const updated = [...conditions]; + updated[index] = { ...updated[index], [fieldKey]: val } as WebhookCondition; + setConditions(updated); + }; + + const updateBodyMapping = (index: number, key: 'type' | 'value', val: string) => { + setBodyMappings( + bodyMappings.map((m) => (m.index === index ? { ...m, [key]: val } : m)) + ); + }; + + const updateButtonMapping = (idx: number, key: 'type' | 'value', val: string) => { + setButtonMappings(prev => ({ + ...prev, + [idx]: { + ...prev[idx], + [key]: val + } + })); + }; + + const handleMediaUpload = async (file: File) => { + const headerType = selectedTmpl?.header_type || 'image'; + if (headerType === 'image') { + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png']; + if (!allowedTypes.includes(file.type)) { + toast.error('Unsupported image type. Use PNG, JPG, or JPEG.'); + return; + } + if (file.size > 5 * 1024 * 1024) { + toast.error('Image is too large. Maximum 5 MB.'); + return; + } + } else if (headerType === 'video') { + const allowedTypes = ['video/mp4']; + if (!allowedTypes.includes(file.type)) { + toast.error('Unsupported video type. Use MP4.'); + return; + } + if (file.size > 10 * 1024 * 1024) { + toast.error('Video is too large. Maximum 10 MB.'); + return; + } + } else if (headerType === 'document') { + const allowedTypes = ['application/pdf']; + if (!allowedTypes.includes(file.type)) { + toast.error('Unsupported document type. Use PDF.'); + return; + } + if (file.size > 10 * 1024 * 1024) { + toast.error('Document is too large. Maximum 10 MB.'); + return; + } + } + setMediaUploading(true); + try { + const { data: { user }, error: userErr } = await supabase.auth.getUser(); + if (userErr || !user) throw new Error('Not signed in.'); + + const { data: profile, error: profileErr } = await supabase + .from('profiles') + .select('account_id') + .eq('user_id', user.id) + .maybeSingle(); + if (profileErr || !profile?.account_id) { + throw new Error('Could not resolve your account.'); + } + + const ext = file.name.split('.').pop()?.toLowerCase() ?? 'png'; + const safeBase = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 40) || 'file'; + const path = `account-${profile.account_id}/${Date.now()}-${safeBase}.${ext}`; + + const { error: upErr } = await supabase.storage + .from('flow-media') + .upload(path, file, { + cacheControl: '3600', + upsert: false, + contentType: file.type + }); + if (upErr) throw new Error(upErr.message); + + const { data: { publicUrl } } = supabase.storage.from('flow-media').getPublicUrl(path); + setMediaUploadUrl(publicUrl); + setMediaFileName(file.name); + toast.success('File uploaded successfully.'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Upload failed.'); + } finally { + setMediaUploading(false); + } + }; + + const handleSave = async () => { + if (!name) { + toast.error('Workflow Name is required.'); + return; + } + if (!recipientPhoneField) { + toast.error('Recipient Phone Mapping is required.'); + return; + } + if (!recipientNameField) { + toast.error('Recipient Name Mapping is required.'); + return; + } + if (!selectedTemplateId) { + toast.error('Please select a template action.'); + return; + } + + setSaving(true); + try { + const selectedTmpl = templates.find((t) => t.name === selectedTemplateId); + + const payloadData = { + name, + is_active: isActive, + recipient_name_field: recipientNameField, + recipient_phone_field: recipientPhoneField, + recipient_email_field: recipientEmailField || null, + create_contacts: createContacts, + conditions: { + matchType, + rules: conditions + }, + actions: [ + { + type: 'send_template', + template_name: selectedTemplateId, + language: selectedTmpl?.language || 'en_US', + mappings: { + body: bodyMappings.map((m) => ({ type: m.type, value: m.value })), + headerText: headerMapping ? { type: headerMapping.type, value: headerMapping.value } : null, + headerMedia: ['image', 'video', 'document'].includes(selectedTmpl?.header_type || '') ? { + type: mediaSourceType, + filename: mediaFileName || null, + value: mediaSourceType === 'payload' + ? mediaPayloadPath + : mediaSourceType === 'upload' + ? mediaUploadUrl + : mediaLink + } : null, + buttons: selectedTmpl?.buttons?.length ? Object.fromEntries( + selectedTmpl.buttons.map((btn, idx: number) => [ + idx, + { + type: buttonMappings[idx]?.type || 'static', + value: buttonMappings[idx]?.value || '' + } + ]) + ) : null + } + } + ] + }; + + const url = isEditing ? `/api/webhooks/workflows/${workflow.id}` : '/api/webhooks/workflows'; + const method = isEditing ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payloadData) + }); + + if (!res.ok) { + const errData = await res.json(); + throw new Error(errData.error || 'Failed to save workflow'); + } + + toast.success(isEditing ? 'Workflow updated.' : 'Workflow created.'); + onSave(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'An error occurred.'); + } finally { + setSaving(false); + } + }; + + const selectedTmpl = templates.find((t) => t.name === selectedTemplateId); + + const getSubstitutedText = (text: string, mappings: { index: number; type: string; value: string }[]) => { + if (!text) return ''; + return text.replace(/\{\{(\d+)\}\}/g, (_, numStr) => { + const index = parseInt(numStr, 10); + const mapping = mappings.find((m) => m.index === index); + if (!mapping) return `{{${numStr}}}`; + if (mapping.type === 'static') { + const val = mapping.value || ''; + // Substitute variables inside static text e.g. {{customer.name}} + return val.replace(/\{\{([^}]+)\}\}/g, (__: string, path: string) => { + if (lastPayload) { + const resolvedVal = getNestedValue(lastPayload, path.trim()); + if (resolvedVal !== undefined && resolvedVal !== null) return String(resolvedVal); + } + return `[${path}]`; + }); + } + if (mapping.value) { + if (lastPayload) { + const val = getNestedValue(lastPayload, mapping.value); + if (val !== undefined && val !== null) return String(val); + } + return `[${mapping.value}]`; + } + return `{{${numStr}}}`; + }); + }; + + const getSubstitutedHeader = (headerText: string, mapping: { type: string; value: string } | null) => { + if (!headerText) return ''; + if (!mapping) return headerText; + return headerText.replace(/\{\{1\}\}/g, () => { + if (mapping.type === 'static') { + const val = mapping.value || ''; + return val.replace(/\{\{([^}]+)\}\}/g, (__: string, path: string) => { + if (lastPayload) { + const resolvedVal = getNestedValue(lastPayload, path.trim()); + if (resolvedVal !== undefined && resolvedVal !== null) return String(resolvedVal); + } + return `[${path}]`; + }); + } + if (mapping.value) { + if (lastPayload) { + const val = getNestedValue(lastPayload, mapping.value); + if (val !== undefined && val !== null) return String(val); + } + return `[${mapping.value}]`; + } + return '{{1}}'; + }); + }; + + return ( +
+ {/* Form Header */} +
+
+ +

+ {isEditing ? `Edit Workflow: ${workflow.name}` : 'Create New Workflow'} +

+
+
+ + {/* Form Body */} +
+ {/* General Section */} +
+
+
+ + setName(e.target.value)} + placeholder="e.g. Lead Alert Notification" + className="mt-1 bg-white dark:bg-slate-950 text-slate-900 dark:text-white border-slate-200 dark:border-slate-800 focus:border-primary" + /> +
+ +
+ Is Active + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ +

+ If enabled, unrecognized phone numbers are saved to the database. Existing contacts are never duplicated. +

+
+ +
+
+ + {/* Conditions Section */} +
+
+
+

Conditions

+

Determine if the payload triggers this workflow.

+
+ +
+ + + +
+
+ + {conditions.length > 0 ? ( +
+ {conditions.map((cond, idx) => ( +
+ + + + + {!['exists', 'not_exists'].includes(cond.operator) && ( + updateCondition(idx, 'value', e.target.value)} + placeholder="Compare value" + className="w-36 bg-white dark:bg-slate-950 text-slate-900 dark:text-white border-slate-200 dark:border-slate-800 py-1 h-8 text-xs focus:border-primary" + /> + )} + + +
+ ))} +
+ ) : ( +
+ No conditions configured. Trigger on any payload. +
+ )} +
+ + {/* Actions / Template Mapping */} +
+
+

Action: Send Template

+

Choose which WhatsApp Template to send and map its variables.

+
+ +
+
+ + +
+ + {selectedTmpl && (() => { + const hasMediaHeader = ['image', 'video', 'document'].includes(selectedTmpl.header_type || ''); + const hasButtons = !!(selectedTmpl.buttons && selectedTmpl.buttons.length > 0); + + let previewImageUrl = ''; + if (selectedTmpl.header_type === 'image') { + if (mediaSourceType === 'upload' && mediaUploadUrl) { + previewImageUrl = mediaUploadUrl; + } else if (mediaSourceType === 'static' && mediaLink) { + previewImageUrl = mediaLink; + } else if (mediaSourceType === 'payload' && mediaPayloadPath) { + if (lastPayload) { + const val = getNestedValue(lastPayload, mediaPayloadPath); + if (val && typeof val === 'string' && val.startsWith('http')) { + previewImageUrl = val; + } + } + } + if (!previewImageUrl && selectedTmpl.header_media_url) { + previewImageUrl = selectedTmpl.header_media_url; + } + } + + return ( +
+ {/* Left Column: Live WhatsApp Preview */} +
+
+ Live Preview + Simulated WhatsApp +
+ +
+ {/* Chat Body */} +
+ {/* Chat bubble header context */} +
+
WA
+
+
WhatsApp Bot
+
Business Account
+
+
+ + {/* Message Bubble */} +
+ {/* Header Media Image Preview */} + {selectedTmpl.header_type === 'image' && ( +
+ {previewImageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Header preview + ) : ( +
+ + No image mapped +
+ )} +
+ )} + + {/* Header Text Preview */} + {selectedTmpl.header_type === 'text' && selectedTmpl.header_content && ( +
+ {getSubstitutedHeader(selectedTmpl.header_content, headerMapping)} +
+ )} + + {/* Body Text Preview */} +
+ {getSubstitutedText(selectedTmpl.body_text, bodyMappings)} +
+ + {/* Footer text */} + {selectedTmpl.footer_text && ( +
+ {selectedTmpl.footer_text} +
+ )} + + {/* Timestamp */} +
+ {new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
+
+ + {/* Interactive Buttons Preview */} + {hasButtons && ( +
+ {selectedTmpl.buttons?.map((btn: import('@/types').TemplateButton, idx: number) => { + const btnMapping = buttonMappings[idx]; + let payloadPreview = ''; + if (btnMapping) { + payloadPreview = btnMapping.type === 'static' + ? btnMapping.value + : btnMapping.value + ? `[payload: ${btnMapping.value}]` + : ''; + } + + return ( +
+ {btn.text} + {payloadPreview && ( + + {`Payload: ${payloadPreview}`} + + )} +
+ ); + })} +
+ )} +
+
+
+ + {/* Right Column: Tabbed Configurations */} +
+ {/* Tab Navigation */} +
+ + + +
+ + {/* Map Tab Content */} + {activeSubTab === 'map' && ( +
+ {/* Header parameters */} + {headerMapping && ( +
+
+ + Header Text Parameter +
+
+ + {headerMapping.type === 'payload' ? ( + + ) : ( +
+ setHeaderMapping({ ...headerMapping, value: e.target.value })} + placeholder="Static text (use {{path}} for variables)" + className="flex-1 bg-white dark:bg-slate-900 text-slate-900 dark:text-white border-slate-200 dark:border-slate-800 py-1 h-8 text-xs focus:border-primary" + /> + +
+ )} +
+
+ )} + + {/* Body parameters */} + {bodyMappings.length > 0 && ( +
+ Body Parameters Variables + {bodyMappings.map((m) => ( +
+ {`{{${m.index}}}`} + + {m.type === 'payload' ? ( + + ) : ( +
+ updateBodyMapping(m.index, 'value', e.target.value)} + placeholder="Static text (use {{path}} for variables)" + className="flex-1 bg-white dark:bg-slate-900 text-slate-900 dark:text-white border-slate-200 dark:border-slate-800 py-1 h-8 text-xs focus:border-primary" + /> + +
+ )} +
+ ))} +
+ )} +
+ )} + + {/* Media Tab Content */} + {activeSubTab === 'media' && hasMediaHeader && ( +
+

+ You can personalise this template with new media (images, videos, or PDFs) or we will use the existing ones you uploaded as sample by default. +

+ +
+ + setMediaFileName(e.target.value)} + placeholder="e.g. image.jpg" + className="bg-white dark:bg-slate-900 text-slate-900 dark:text-white border-slate-200 dark:border-slate-800 py-1 h-8 text-xs focus:border-primary" + /> +
+ +
+ + +
+ + {mediaSourceType === 'static' && ( +
+ + setMediaLink(e.target.value)} + placeholder={ + selectedTmpl.header_type === 'video' + ? "https://example.com/video.mp4" + : selectedTmpl.header_type === 'document' + ? "https://example.com/document.pdf" + : "https://example.com/image.jpg" + } + className="bg-white dark:bg-slate-900 text-slate-900 dark:text-white border-slate-200 dark:border-slate-800 py-1 h-8 text-xs focus:border-primary" + /> +
+ )} + + {mediaSourceType === 'payload' && ( +
+ + +
+ )} + + {mediaSourceType === 'upload' && ( +
+ + {mediaUploadUrl ? ( +
+ + + {mediaFileName || mediaUploadUrl} + + +
+ ) : ( + + )} + { + const f = e.target.files?.[0]; + if (f) void handleMediaUpload(f); + e.target.value = ''; + }} + /> +

+ {selectedTmpl.header_type === 'video' + ? "(Allowed file types: .mp4. Max file size: 10 MB)" + : selectedTmpl.header_type === 'document' + ? "(Allowed file types: .pdf. Max file size: 10 MB)" + : "(Allowed file types: .jpeg, .jpg, .png. Max file size: 5 MB)"} +

+
+ )} +
+ )} + + {/* Advance Tab Content */} + {activeSubTab === 'advance' && hasButtons && ( +
+
+ Button Payloads +

+ In your WhatsApp Templates, if you've incorporated buttons, the payload serves as a trigger or intent for a flow, activating the flow upon selection. +

+
+ +
+ {selectedTmpl.buttons?.map((btn: import('@/types').TemplateButton, idx: number) => ( +
+
+ {`Button ${idx + 1}: "${btn.text}" (${btn.type})`} + Optional +
+ +
+ + {buttonMappings[idx]?.type === 'payload' ? ( + + ) : ( +
+ updateButtonMapping(idx, 'value', e.target.value)} + placeholder="Static payload value (use {{path}} for variables)" + className="flex-1 bg-white dark:bg-slate-900 text-slate-900 dark:text-white border-slate-200 dark:border-slate-800 py-1 h-8 text-xs focus:border-primary" + /> + +
+ )} +
+
+ ))} +
+
+ )} +
+
+ ); + })()} +
+
+
+ + {/* Form Footer */} +
+ + + +
+
+ ); +} diff --git a/src/components/webhooks/webhook-configuration.tsx b/src/components/webhooks/webhook-configuration.tsx new file mode 100644 index 0000000000..06833e95b6 --- /dev/null +++ b/src/components/webhooks/webhook-configuration.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { useEffect, useState, useRef } from 'react'; +import { toast } from 'sonner'; +import { Copy, RefreshCw, Radio, CheckCircle, AlertCircle, Save } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { createClient } from '@/lib/supabase/client'; +import { extractJsonPaths } from '@/lib/generic-webhooks/utils'; + +export function WebhookConfiguration() { + const [integrationName, setIntegrationName] = useState(''); + const [selectedChannel, setSelectedChannel] = useState(''); + const [channels, setChannels] = useState<{ id: string; display_phone_number: string }[]>([]); + const [integrationId, setIntegrationId] = useState(null); + const [lastPayload, setLastPayload] = useState | null>(null); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + const [hmacSecret, setHmacSecret] = useState(''); + const [defaultPhonePrefix, setDefaultPhonePrefix] = useState('91'); + + // Payload listening state + const [isListening, setIsListening] = useState(false); + const timerRef = useRef(null); + + const supabase = createClient(); + + useEffect(() => { + async function loadData() { + try { + const { data: configs } = await supabase + .from('whatsapp_config') + .select('id, phone_number_id'); + + const channelsList = (configs || []).map((c) => ({ + id: c.id, + display_phone_number: c.phone_number_id + })); + setChannels(channelsList); + + const res = await fetch('/api/webhooks/config'); + if (res.ok) { + const data = await res.json(); + if (data.config) { + setIntegrationName(data.config.name); + setSelectedChannel(data.config.whatsapp_config_id || ''); + setIntegrationId(data.config.id); + setLastPayload(data.config.last_payload); + setHmacSecret(data.config.hmac_secret || ''); + setDefaultPhonePrefix(data.config.default_phone_prefix || '91'); + } + } + } catch (err) { + console.error('Failed to load webhook configuration:', err); + } finally { + setLoading(false); + } + } + loadData(); + + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [supabase]); + + const handleSave = async () => { + if (!integrationName) { + toast.error('Integration Name is required.'); + return; + } + setSaving(true); + try { + const res = await fetch('/api/webhooks/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: integrationName, + whatsapp_config_id: selectedChannel || null, + hmac_secret: hmacSecret || null, + default_phone_prefix: defaultPhonePrefix || '91' + }) + }); + + if (!res.ok) { + const errData = await res.json(); + throw new Error(errData.error || 'Failed to save configuration'); + } + + const data = await res.json(); + setIntegrationId(data.config.id); + setLastPayload(data.config.last_payload); + toast.success('Webhook settings updated.'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'An error occurred.'); + } finally { + setSaving(false); + } + }; + + const getWebhookUrl = () => { + if (!integrationId) return ''; + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + return `${origin}/api/webhooks/incoming/${integrationId}`; + }; + + const copyUrl = () => { + const url = getWebhookUrl(); + if (!url) return; + navigator.clipboard.writeText(url); + toast.success('Webhook URL copied to clipboard.'); + }; + + const startListening = () => { + if (!integrationId) { + toast.error('Please save your configuration first to generate a Webhook URL.'); + return; + } + setIsListening(true); + toast.info('Listening for incoming payloads. Send a request to your Webhook URL now.'); + + let count = 0; + timerRef.current = setInterval(async () => { + count++; + if (count > 20) { + stopListening(); + toast.warning('Listening timed out. No payload received.'); + return; + } + + try { + const res = await fetch('/api/webhooks/config'); + if (res.ok) { + const data = await res.json(); + if (data.config && JSON.stringify(data.config.last_payload) !== JSON.stringify(lastPayload)) { + setLastPayload(data.config.last_payload); + stopListening(); + toast.success('Sample payload captured successfully!'); + } + } + } catch (err) { + console.error('Polling error:', err); + } + }, 3000); + }; + + const stopListening = () => { + setIsListening(false); + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + + const flattenedPaths = lastPayload ? extractJsonPaths(lastPayload) : []; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Settings Form */} +
+

Integration Settings

+
+
+ + setIntegrationName(e.target.value)} + placeholder="e.g. HubSpot Integration" + /> +
+ +
+ + +
+ +
+ +
+ setHmacSecret(e.target.value)} + placeholder="Leave empty to disable signature check" + className="font-mono text-sm" + /> + +
+

+ If set, incoming requests must include a valid signature in the X-Webhook-Signature (or X-Signature) header. +

+
+ +
+ + setDefaultPhonePrefix(e.target.value.replace(/\D/g, ''))} + placeholder="e.g. 91" + /> +

+ Fallback country prefix prepended to 10-digit numbers (defaults to 91). +

+
+
+ +
+ +
+
+ + {integrationId && ( + <> + {/* Webhook URL Display */} +
+

Webhook URL

+

+ Copy this URL and add it under the webhook section of the application you are integrating with. +

+
+ + +
+
+ + {/* Test Payload Capture */} +
+
+
+

Response Received

+

+ Send a test request from your external system to capture the JSON schema. +

+
+ + +
+ + {lastPayload ? ( +
+
+
+ + Captured Schema Paths ({flattenedPaths.length} fields) +
+
+ {flattenedPaths.map((path) => ( + + {path} + + ))} +
+
+ +
+ Payload Sample: +
+                    {JSON.stringify(lastPayload, null, 2)}
+                  
+
+
+ ) : ( +
+ +

+ No sample payload captured yet. Start listening and submit a request from your system. +

+
+ )} +
+ + )} +
+ ); +} diff --git a/src/components/webhooks/webhook-logs.tsx b/src/components/webhooks/webhook-logs.tsx new file mode 100644 index 0000000000..e501bf889d --- /dev/null +++ b/src/components/webhooks/webhook-logs.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import { format } from 'date-fns'; +import { RefreshCw, Play, CheckCircle, XCircle, AlertCircle, Eye, EyeOff } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface WebhookLog { + id: string; + workflow_name: string; + status: 'success' | 'failed' | 'no_match'; + created_at: string; + customer_name?: string | null; + customer_phone?: string | null; + error_message?: string | null; + payload?: unknown; +} + +export function WebhookLogs() { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedLogId, setExpandedLogId] = useState(null); + + useEffect(() => { + fetchLogs(); + }, []); + + const fetchLogs = async () => { + setLoading(true); + try { + const res = await fetch('/api/webhooks/logs'); + if (res.ok) { + const data = await res.json(); + setLogs(data.logs || []); + } + } catch (err) { + console.error('Failed to fetch logs:', err); + } finally { + setLoading(false); + } + }; + + const toggleExpandLog = (id: string) => { + setExpandedLogId(expandedLogId === id ? null : id); + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'success': + return ( + + + Success + + ); + case 'failed': + return ( + + + Failed + + ); + case 'no_match': + return ( + + + No Match + + ); + default: + return ( + + {status} + + ); + } + }; + + return ( +
+
+
+

Execution Logs

+

+ View history of webhook trigger execution results and diagnostic payloads. +

+
+ + +
+ + {loading && logs.length === 0 ? ( +
+ +
+ ) : logs.length === 0 ? ( +
+ +

No Logs Found

+

+ Webhook triggers will log execution details here when payloads hit your workflow URL. +

+
+ ) : ( +
+
+ + + + + + + + + + + + + {logs.map((log) => { + const isExpanded = expandedLogId === log.id; + return ( + + + + + + + + + + {isExpanded && ( + + + + )} + + ); + })} + +
DateWorkflow NameCustomer PhoneCustomer NameStatusActions
+ {format(new Date(log.created_at), 'yyyy-MM-dd HH:mm:ss')} + {log.workflow_name}{log.customer_phone || '—'}{log.customer_name || '—'}{getStatusBadge(log.status)} + +
+
+ {log.error_message && ( +
+ Error Message +

{log.error_message}

+
+ )} +
+ Payload JSON +
+                                  {JSON.stringify(log.payload, null, 2)}
+                                
+
+
+
+
+
+ )} +
+ ); +} + +// React import helper inside component file for Next.js compile safety +import React from 'react'; diff --git a/src/components/webhooks/webhook-overview.tsx b/src/components/webhooks/webhook-overview.tsx new file mode 100644 index 0000000000..1395fc48cf --- /dev/null +++ b/src/components/webhooks/webhook-overview.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import { CheckCircle2, XCircle, ArrowRight, Webhook, Zap, FileText } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface WebhookOverviewProps { + onTabChange: (tab: string) => void; +} + +export function WebhookOverview({ onTabChange }: WebhookOverviewProps) { + const [isConnected, setIsConnected] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchConfig() { + try { + const res = await fetch('/api/webhooks/config'); + if (res.ok) { + const data = await res.json(); + setIsConnected(data.config?.is_connected ?? false); + } + } catch (err) { + console.error('Failed to load webhook connection state:', err); + } finally { + setLoading(false); + } + } + fetchConfig(); + }, []); + + return ( +
+ {/* Hero Header */} +
+
+
+
+ +
+
+

Generic Webhooks

+

+ Connect your account to various systems and applications effortlessly with Generic Webhooks. Utilize real-time updates to send customized WhatsApp messages to your customers. +

+
+
+ +
+ Last Updated: + {loading ? ( + + ) : isConnected ? ( + + + Connected + + ) : ( + + + Not Connected + + )} +
+
+ +
+

What can I do?

+
+
+
+ +
+
+

Send customized template messages

+

+ Automatically match incoming payloads to approved WhatsApp templates and inject variables like customer name or order number. +

+
+
+ +
+
+ +
+
+

Trigger real-time triggers

+

+ Set up matching rules on fields like event types or lead status to fire messages precisely when users take actions. +

+
+
+
+
+
+ + {/* Guide Cards */} +
+

How it works

+
    +
  1. +

    Configure settings

    +

    + Provide an integration name, pick your WhatsApp number/channel, and grab your unique webhook URL. +

    +
  2. +
  3. +

    Capture sample payload

    +

    + Trigger a test submission from HubSpot or Shopify to your webhook URL. This parses the JSON schema for easy mapping. +

    +
  4. +
  5. +

    Create workflows

    +

    + Specify conditions, map recipient fields, and choose which templates to send with matching variables. +

    +
  6. +
+ +
+ +
+
+
+ ); +} diff --git a/src/components/webhooks/webhook-tabs.tsx b/src/components/webhooks/webhook-tabs.tsx new file mode 100644 index 0000000000..a2bf9b3991 --- /dev/null +++ b/src/components/webhooks/webhook-tabs.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState } from 'react'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { HelpCircle, Settings, Workflow, History } from 'lucide-react'; +import { WebhookOverview } from './webhook-overview'; +import { WebhookConfiguration } from './webhook-configuration'; +import { WebhookWorkflows } from './webhook-workflows'; +import { WebhookLogs } from './webhook-logs'; + +export function WebhookTabs() { + const [activeTab, setActiveTab] = useState('overview'); + + return ( +
+ + + + + Overview + + + + + Configuration + + + + + Workflows + + + + + Logs + + + +
+ + + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/src/components/webhooks/webhook-workflows.tsx b/src/components/webhooks/webhook-workflows.tsx new file mode 100644 index 0000000000..b3f6849245 --- /dev/null +++ b/src/components/webhooks/webhook-workflows.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { Plus, Edit2, Trash2, Copy, ToggleLeft, ToggleRight, Settings2, AlertCircle, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { EditWorkflowModal, type WebhookWorkflow } from './edit-workflow-modal'; + +export function WebhookWorkflows() { + const [workflows, setWorkflows] = useState([]); + const [lastPayload, setLastPayload] = useState | null>(null); + const [isConfigured, setIsConfigured] = useState(false); + const [loading, setLoading] = useState(true); + + const [view, setView] = useState<'list' | 'edit' | 'create'>('list'); + const [selectedWorkflow, setSelectedWorkflow] = useState(null); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + const configRes = await fetch('/api/webhooks/config'); + if (configRes.ok) { + const configData = await configRes.json(); + if (configData.config) { + setIsConfigured(true); + setLastPayload(configData.config.last_payload); + } + } + + const workflowsRes = await fetch('/api/webhooks/workflows'); + if (workflowsRes.ok) { + const workflowsData = await workflowsRes.json(); + setWorkflows(workflowsData.workflows || []); + } + } catch (err) { + console.error('Failed to load workflows data:', err); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('Are you sure you want to delete this workflow?')) return; + try { + const res = await fetch(`/api/webhooks/workflows/${id}`, { method: 'DELETE' }); + if (res.ok) { + toast.success('Workflow deleted.'); + loadData(); + } else { + throw new Error('Failed to delete'); + } + } catch (err) { + console.error('Failed to delete workflow:', err); + toast.error('Failed to delete workflow.'); + } + }; + + const handleToggleActive = async (workflow: WebhookWorkflow) => { + try { + const res = await fetch(`/api/webhooks/workflows/${workflow.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: workflow.name, + recipient_name_field: workflow.recipient_name_field, + recipient_phone_field: workflow.recipient_phone_field, + conditions: workflow.conditions, + actions: workflow.actions, + is_active: !workflow.is_active + }) + }); + if (res.ok) { + toast.success(workflow.is_active ? 'Workflow deactivated.' : 'Workflow activated.'); + loadData(); + } else { + throw new Error('Failed to update status.'); + } + } catch (err) { + console.error('Failed to update active state:', err); + toast.error('Failed to update active state.'); + } + }; + + const getWorkflowUrl = (workflowId: string) => { + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + return `${origin}/api/webhooks/incoming/${workflowId}`; + }; + + const copyUrl = (id: string) => { + const url = getWorkflowUrl(id); + navigator.clipboard.writeText(url); + toast.success('Workflow Webhook URL copied.'); + }; + + const openCreateModal = () => { + if (!isConfigured) { + toast.error('Please configure your webhook settings and select a WhatsApp channel first.'); + return; + } + if (!lastPayload) { + toast.error('Please submit a test payload in the Configuration tab before creating workflows.'); + return; + } + if (workflows.length >= 5) { + toast.error('Maximum limit of 5 workflows reached.'); + return; + } + setSelectedWorkflow(null); + setView('create'); + }; + + const openEditModal = (workflow: WebhookWorkflow) => { + setSelectedWorkflow(workflow); + setView('edit'); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (view !== 'list') { + return ( + setView('list')} + onSave={() => { + setView('list'); + loadData(); + }} + /> + ); + } + + return ( +
+
+
+

Workflows

+

+ Define triggers and actions to send WhatsApp messages automatically ({workflows.length}/5 allowed). +

+
+ + +
+ + {!isConfigured ? ( +
+ +

Integration Not Configured

+

+ Configure integration settings and save under the Configuration tab before building workflows. +

+
+ ) : !lastPayload ? ( +
+ +

Test Payload Required

+

+ Go to the Configuration tab and capture a test payload to map variables before setting up workflows. +

+
+ ) : workflows.length === 0 ? ( +
+ +

No Workflows Configured

+

+ Build your first workflow to trigger WhatsApp templates on incoming JSON payloads. +

+
+ ) : ( +
+ {workflows.map((wf, idx) => { + const templateAction = wf.actions?.find((a) => a.type === 'send_template'); + return ( +
+
+
+
+

{wf.name}

+

{getWorkflowUrl(wf.id || '')}

+
+ + +
+ +
+
+ Action details +
+
+ Trigger: + On JSON Post +
+
+ WhatsApp Template: + {templateAction?.template_name || 'None'} +
+
+
+ +
+ + +
+ + + +
+
+
+ ); + })} +
+ )} + +
+ ); +} diff --git a/src/hooks/use-auth.tsx b/src/hooks/use-auth.tsx index c14434b7e4..b3ba01c20c 100644 --- a/src/hooks/use-auth.tsx +++ b/src/hooks/use-auth.tsx @@ -250,6 +250,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { const currentUser = session?.user ?? null; setUser(currentUser); + if (session?.access_token) { + supabase.realtime.setAuth(session.access_token); + } + if (currentUser) { // Don't block session loading on profile fetch — chrome // (header, sidebar) can render from the user object alone, @@ -279,6 +283,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { const currentUser = session?.user ?? null; setUser(currentUser); + if (session?.access_token) { + supabase.realtime.setAuth(session.access_token); + } else { + supabase.realtime.setAuth(null); + } + if (currentUser) { if (currentUser.id !== lastFetchedUserIdRef.current) { fetchProfile(currentUser.id); diff --git a/src/hooks/use-realtime.ts b/src/hooks/use-realtime.ts index c123d423cd..c13f941707 100644 --- a/src/hooks/use-realtime.ts +++ b/src/hooks/use-realtime.ts @@ -44,8 +44,10 @@ export function useRealtime({ const supabase = createClient(); + const uniqueChannelName = `${channelName}-${Math.random().toString(36).substring(2, 9)}`; + const channel = supabase - .channel(channelName) + .channel(uniqueChannelName) .on( "postgres_changes", { event: "*", schema: "public", table: "messages" }, diff --git a/src/hooks/use-total-unread.ts b/src/hooks/use-total-unread.ts index ff30ec875d..9e25998381 100644 --- a/src/hooks/use-total-unread.ts +++ b/src/hooks/use-total-unread.ts @@ -12,7 +12,7 @@ import type { Conversation } from "@/types"; * Lives on its own realtime channel (distinct from the inbox page's * "inbox-realtime") so both can coexist without sharing state. */ -export function useTotalUnread(): number { +export function useTotalUnread({ enabled = true }: { enabled?: boolean } = {}): number { const [total, setTotal] = useState(0); // Keep a live local mirror of {id: unread_count} so INSERT/UPDATE/DELETE @@ -20,6 +20,8 @@ export function useTotalUnread(): number { const countsRef = useRef>(new Map()); useEffect(() => { + if (!enabled) return; + const supabase = createClient(); let cancelled = false; @@ -42,8 +44,9 @@ export function useTotalUnread(): number { setTotal(sum); })(); + const uniqueChannelName = `total-unread-realtime-${Math.random().toString(36).substring(2, 9)}`; const channel = supabase - .channel("total-unread-realtime") + .channel(uniqueChannelName) .on( "postgres_changes", { event: "*", schema: "public", table: "conversations" }, diff --git a/src/lib/automations/meta-send.ts b/src/lib/automations/meta-send.ts index 8907790fa3..d35139c817 100644 --- a/src/lib/automations/meta-send.ts +++ b/src/lib/automations/meta-send.ts @@ -1,5 +1,6 @@ import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api' import type { InteractiveMessagePayload } from '@/lib/whatsapp/interactive' +import type { MessageTemplate } from '@/types' import { engineSendInteractiveButtons, engineSendInteractiveList, @@ -142,6 +143,22 @@ async function sendViaMeta(input: SendInput): Promise<{ whatsapp_message_id: str const accessToken = decrypt(config.access_token) + // Load the template row once so sendTemplateMessage can build + // components and dynamically route marketing templates correctly. + let templateRow: MessageTemplate | undefined = undefined + if (input.kind === 'template') { + const { data } = await db + .from('message_templates') + .select('*') + .eq('account_id', input.accountId) + .eq('name', input.templateName) + .eq('language', input.language || 'en_US') + .maybeSingle() + if (data) { + templateRow = data as MessageTemplate + } + } + const attempt = async (phone: string): Promise => { if (input.kind === 'template') { const r = await sendTemplateMessage({ @@ -150,7 +167,9 @@ async function sendViaMeta(input: SendInput): Promise<{ whatsapp_message_id: str to: phone, templateName: input.templateName, language: input.language, + template: templateRow, params: input.params, + useMarketingEndpoint: config.use_marketing_endpoint, }) return r.messageId } diff --git a/src/lib/generic-webhooks/utils.test.ts b/src/lib/generic-webhooks/utils.test.ts new file mode 100644 index 0000000000..1efb3c799c --- /dev/null +++ b/src/lib/generic-webhooks/utils.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { + getNestedValue, + extractJsonPaths, + evaluateConditions, + resolveMapping, + type WebhookCondition, + type WebhookMapping +} from './utils'; + +describe('Generic Webhooks Utilities', () => { + describe('getNestedValue', () => { + it('resolves simple keys', () => { + const obj = { name: 'Alice' }; + expect(getNestedValue(obj, 'name')).toBe('Alice'); + }); + + it('resolves nested keys', () => { + const obj = { customer: { profile: { email: 'alice@example.com' } } }; + expect(getNestedValue(obj, 'customer.profile.email')).toBe('alice@example.com'); + }); + + it('returns undefined for non-existent paths', () => { + const obj = { customer: { name: 'Alice' } }; + expect(getNestedValue(obj, 'customer.age')).toBeUndefined(); + expect(getNestedValue(obj, 'order.id')).toBeUndefined(); + }); + + it('handles null and undefined targets gracefully', () => { + expect(getNestedValue(null, 'name')).toBeUndefined(); + expect(getNestedValue(undefined, 'name')).toBeUndefined(); + }); + }); + + describe('extractJsonPaths', () => { + it('flattens simple object keys', () => { + const obj = { name: 'Alice', age: 30 }; + expect(extractJsonPaths(obj)).toEqual(['name', 'age']); + }); + + it('flattens nested object keys omitting arrays from recursion', () => { + const obj = { + id: 1, + customer: { + name: 'Alice', + address: { + city: 'New York' + } + }, + items: [{ id: 10 }] + }; + expect(extractJsonPaths(obj)).toEqual([ + 'id', + 'customer.name', + 'customer.address.city', + 'items' + ]); + }); + + it('handles empty structures gracefully', () => { + expect(extractJsonPaths({})).toEqual([]); + expect(extractJsonPaths(null)).toEqual([]); + }); + }); + + describe('evaluateConditions', () => { + const payload = { + event: 'lead.created', + lead: { + status: 'VIP', + score: 95 + } + }; + + it('returns true when no conditions exist', () => { + expect(evaluateConditions(payload, [])).toBe(true); + }); + + it('evaluates equals and not_equals operators correctly', () => { + const conds: WebhookCondition[] = [ + { field: 'event', operator: 'equals', value: 'lead.created' }, + { field: 'lead.status', operator: 'not_equals', value: 'Regular' } + ]; + expect(evaluateConditions(payload, conds)).toBe(true); + }); + + it('evaluates contains and not_contains operators correctly', () => { + const conds: WebhookCondition[] = [ + { field: 'lead.status', operator: 'contains', value: 'vi' } + ]; + expect(evaluateConditions(payload, conds)).toBe(true); + + const failConds: WebhookCondition[] = [ + { field: 'lead.status', operator: 'not_contains', value: 'VIP' } + ]; + expect(evaluateConditions(payload, failConds)).toBe(false); + }); + + it('evaluates exists and not_exists operators correctly', () => { + const conds: WebhookCondition[] = [ + { field: 'lead.status', operator: 'exists', value: '' }, + { field: 'lead.address', operator: 'not_exists', value: '' } + ]; + expect(evaluateConditions(payload, conds)).toBe(true); + }); + + it('supports matchType any', () => { + const conds: WebhookCondition[] = [ + { field: 'event', operator: 'equals', value: 'lead.deleted' }, + { field: 'lead.status', operator: 'equals', value: 'VIP' } + ]; + expect(evaluateConditions(payload, conds, 'any')).toBe(true); + expect(evaluateConditions(payload, conds, 'all')).toBe(false); + }); + }); + + describe('resolveMapping', () => { + const payload = { name: 'Alice', details: { code: 'VIP-99' } }; + + it('resolves static values', () => { + const mapping: WebhookMapping = { type: 'static', value: 'Hello World' }; + expect(resolveMapping(payload, mapping)).toBe('Hello World'); + }); + + it('resolves payload paths', () => { + const mapping: WebhookMapping = { type: 'payload', value: 'details.code' }; + expect(resolveMapping(payload, mapping)).toBe('VIP-99'); + }); + + it('returns empty string for missing path values', () => { + const mapping: WebhookMapping = { type: 'payload', value: 'details.missing' }; + expect(resolveMapping(payload, mapping)).toBe(''); + }); + }); +}); diff --git a/src/lib/generic-webhooks/utils.ts b/src/lib/generic-webhooks/utils.ts new file mode 100644 index 0000000000..ece1906578 --- /dev/null +++ b/src/lib/generic-webhooks/utils.ts @@ -0,0 +1,114 @@ +/** + * Generic Webhooks Utility Helpers + */ + +export interface WebhookCondition { + field: string; + operator: 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'exists' | 'not_exists'; + value: string; +} + +export interface WebhookMapping { + type: 'payload' | 'static' | 'upload'; + value: string; +} + +/** + * Resolves a nested key in a JSON object using dot notation. + * e.g., getNestedValue({ customer: { name: 'John' } }, 'customer.name') => 'John' + */ +export function getNestedValue(obj: unknown, path: string): unknown { + if (!obj || !path) return undefined; + const parts = path.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current === null || current === undefined || typeof current !== 'object') return undefined; + current = (current as Record)[part]; + } + return current; +} + +/** + * Flattens a nested JSON object into a list of dotted paths. + * e.g., { a: { b: 1 } } => ['a.b'] + */ +export function extractJsonPaths(obj: unknown, prefix = ''): string[] { + if (obj === null || obj === undefined) return []; + if (typeof obj !== 'object') return []; + + const paths: string[] = []; + + const record = obj as Record; + for (const key in record) { + if (!Object.prototype.hasOwnProperty.call(record, key)) continue; + const path = prefix ? `${prefix}.${key}` : key; + const value = record[key]; + + // Check if the current value is a nested non-array object + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + paths.push(...extractJsonPaths(value, path)); + } else { + paths.push(path); + } + } + + return paths; +} + +/** + * Evaluates whether an incoming payload matches the specified conditions. + */ +export function evaluateConditions( + payload: unknown, + conditions: WebhookCondition[], + matchType: 'all' | 'any' = 'all' +): boolean { + if (!conditions || conditions.length === 0) return true; + + const results = conditions.map((cond) => { + const val = getNestedValue(payload, cond.field); + const target = cond.value; + + switch (cond.operator) { + case 'equals': + return String(val) === String(target); + case 'not_equals': + return String(val) !== String(target); + case 'contains': + return val !== undefined && val !== null && String(val).toLowerCase().includes(String(target).toLowerCase()); + case 'not_contains': + return val === undefined || val === null || !String(val).toLowerCase().includes(String(target).toLowerCase()); + case 'exists': + return val !== undefined && val !== null; + case 'not_exists': + return val === undefined || val === null; + default: + return false; + } + }); + + if (matchType === 'any') { + return results.some((r) => r === true); + } + return results.every((r) => r === true); +} + +/** + * Resolves template parameter mappings against an incoming payload. + */ +export function resolveMapping(payload: unknown, mapping: WebhookMapping): string { + if (!mapping) return ''; + if (mapping.type === 'upload') { + return mapping.value; + } + if (mapping.type === 'static') { + // Replace dynamic variables in static text like {{customer.name}} or {{lead.name}} + return mapping.value.replace(/\{\{([^}]+)\}\}/g, (_, path) => { + const trimmedPath = path.trim(); + const val = getNestedValue(payload, trimmedPath); + return val !== undefined && val !== null ? String(val) : ''; + }); + } + const val = getNestedValue(payload, mapping.value); + return val !== undefined && val !== null ? String(val) : ''; +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 6c3bfea004..8c0c3b4a44 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -167,6 +167,10 @@ export const RATE_LIMITS = { * capping a stampede; excess inbounds simply don't get an auto-reply * (they still land in the inbox for a human). */ aiAutoReplyAccount: { limit: 30, windowMs: 60_000 }, + /** Generic webhook incoming execution, keyed per workflow ID. + * 60/min (1/s) bounds a runaway trigger source from driving + * excessive sends, while leaving enough room for natural webhook bursts. */ + incomingWebhook: { limit: 60, windowMs: 60_000 }, } as const; /** Test-only helper. Clears the in-memory state so unit tests don't diff --git a/src/lib/whatsapp/broadcast-core.ts b/src/lib/whatsapp/broadcast-core.ts index 672ce6ada6..5f57583946 100644 --- a/src/lib/whatsapp/broadcast-core.ts +++ b/src/lib/whatsapp/broadcast-core.ts @@ -72,6 +72,7 @@ export interface BroadcastPlan { planned: PlannedRecipient[]; /** Phones rejected up front (invalid E.164) — counted as failed. */ rejected: number; + useMarketingEndpoint?: boolean; } const MAX_RECIPIENTS = 1000; @@ -243,6 +244,7 @@ export async function createBroadcast( templateRow, planned, rejected, + useMarketingEndpoint: config.use_marketing_endpoint, }; } @@ -280,6 +282,7 @@ export async function deliverBroadcast( language: plan.templateLanguage, template: plan.templateRow ?? undefined, params: recipient.params, + useMarketingEndpoint: plan.useMarketingEndpoint, }); sentMessageId = result.messageId; lastError = null; diff --git a/src/lib/whatsapp/meta-api.test.ts b/src/lib/whatsapp/meta-api.test.ts index 62e365657c..d69c9bb316 100644 --- a/src/lib/whatsapp/meta-api.test.ts +++ b/src/lib/whatsapp/meta-api.test.ts @@ -3,6 +3,7 @@ import { INTERACTIVE_LIMITS, sendInteractiveButtons, sendInteractiveList, + sendTemplateMessage, } from "./meta-api"; // All assertions in this file run BEFORE the network call. We stub fetch @@ -267,3 +268,132 @@ describe("sendInteractiveList — validation", () => { }); }); }); + +describe("sendTemplateMessage — dynamic routing", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn(neverFetch)); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const BASE_TEMPLATE_ARGS = { + phoneNumberId: "test-phone", + accessToken: "test-token", + to: "1234567890", + templateName: "my_template", + language: "en_US", + }; + + it("routes to standard /messages when template is undefined", async () => { + let capturedUrl: string | null = null; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + capturedUrl = url; + return new Response( + JSON.stringify({ messages: [{ id: "wamid.TEMPLATE_STANDARD" }] }), + { status: 200 }, + ); + }), + ); + + const result = await sendTemplateMessage(BASE_TEMPLATE_ARGS); + + expect(result).toEqual({ messageId: "wamid.TEMPLATE_STANDARD" }); + expect(capturedUrl).toContain("test-phone/messages"); + expect(capturedUrl).not.toContain("marketing_messages"); + }); + + it("routes to standard /messages when template category is Utility", async () => { + let capturedUrl: string | null = null; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + capturedUrl = url; + return new Response( + JSON.stringify({ messages: [{ id: "wamid.TEMPLATE_UTILITY" }] }), + { status: 200 }, + ); + }), + ); + + const result = await sendTemplateMessage({ + ...BASE_TEMPLATE_ARGS, + template: { + id: "tpl-123", + user_id: "user-123", + name: "my_template", + category: "Utility", + body_text: "Hi", + created_at: "2026-01-01T00:00:00Z", + }, + }); + + expect(result).toEqual({ messageId: "wamid.TEMPLATE_UTILITY" }); + expect(capturedUrl).toContain("test-phone/messages"); + expect(capturedUrl).not.toContain("marketing_messages"); + }); + + it("routes to /marketing_messages when template category is Marketing and useMarketingEndpoint is true", async () => { + let capturedUrl: string | null = null; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + capturedUrl = url; + return new Response( + JSON.stringify({ messages: [{ id: "wamid.TEMPLATE_MARKETING" }] }), + { status: 200 }, + ); + }), + ); + + const result = await sendTemplateMessage({ + ...BASE_TEMPLATE_ARGS, + useMarketingEndpoint: true, + template: { + id: "tpl-123", + user_id: "user-123", + name: "my_template", + category: "Marketing", + body_text: "Hi", + created_at: "2026-01-01T00:00:00Z", + }, + }); + + expect(result).toEqual({ messageId: "wamid.TEMPLATE_MARKETING" }); + expect(capturedUrl).toContain("test-phone/marketing_messages"); + expect(capturedUrl).not.toContain("test-phone/messages"); + }); + + it("routes to /messages when template category is Marketing but useMarketingEndpoint is false", async () => { + let capturedUrl: string | null = null; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + capturedUrl = url; + return new Response( + JSON.stringify({ messages: [{ id: "wamid.TEMPLATE_MARKETING_FALLBACK" }] }), + { status: 200 }, + ); + }), + ); + + const result = await sendTemplateMessage({ + ...BASE_TEMPLATE_ARGS, + useMarketingEndpoint: false, + template: { + id: "tpl-123", + user_id: "user-123", + name: "my_template", + category: "Marketing", + body_text: "Hi", + created_at: "2026-01-01T00:00:00Z", + }, + }); + + expect(result).toEqual({ messageId: "wamid.TEMPLATE_MARKETING_FALLBACK" }); + expect(capturedUrl).toContain("test-phone/messages"); + expect(capturedUrl).not.toContain("test-phone/marketing_messages"); + }); +}); diff --git a/src/lib/whatsapp/meta-api.ts b/src/lib/whatsapp/meta-api.ts index 80518df7a4..1e0ad49ec1 100644 --- a/src/lib/whatsapp/meta-api.ts +++ b/src/lib/whatsapp/meta-api.ts @@ -324,7 +324,6 @@ export async function sendMediaMessage( const data = await response.json() return { messageId: data.messages[0].id } } - import type { MessageTemplate } from '@/types' import { buildSendComponents, @@ -360,6 +359,8 @@ export interface SendTemplateMessageArgs { messageParams?: SendTimeParams /** Meta's message_id of the message being replied to. */ contextMessageId?: string + /** If true, uses the marketing_messages endpoint for templates in the Marketing category. */ + useMarketingEndpoint?: boolean } /** @@ -386,8 +387,14 @@ export async function sendTemplateMessage( template, messageParams, contextMessageId, + useMarketingEndpoint, } = args - const url = `${META_API_BASE}/${phoneNumberId}/messages` + const isMarketing = + (template?.category === 'Marketing' || + template?.category?.toUpperCase() === 'MARKETING') && + useMarketingEndpoint + const endpoint = isMarketing ? 'marketing_messages' : 'messages' + const url = `${META_API_BASE}/${phoneNumberId}/${endpoint}` const templatePayload: Record = { name: templateName, diff --git a/src/lib/whatsapp/send-message.ts b/src/lib/whatsapp/send-message.ts index 5b5ca32dd0..bdf9858f97 100644 --- a/src/lib/whatsapp/send-message.ts +++ b/src/lib/whatsapp/send-message.ts @@ -341,6 +341,7 @@ export async function sendMessageToConversation( messageParams: templateMessageParams ?? undefined, params: templateParams || [], contextMessageId, + useMarketingEndpoint: config.use_marketing_endpoint, }); return result.messageId; } @@ -455,7 +456,7 @@ export async function sendMessageToConversation( sender_type: 'agent', content_type: messageType, content_text: interactiveBody ?? contentText ?? null, - media_url: mediaUrl || null, + media_url: mediaUrl || ((templateMessageParams as Record)?.headerMediaUrl as string | undefined) || null, template_name: templateName || null, interactive_payload: messageType === 'interactive' ? interactivePayload : null, diff --git a/src/middleware.ts b/src/middleware.ts index 2f48469d6d..347b01a4e1 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -70,7 +70,7 @@ export async function middleware(request: NextRequest) { } // Protected pages - redirect to login if not authenticated - const protectedPaths = ['/dashboard', '/inbox', '/contacts', '/pipelines', '/broadcasts', '/automations', '/settings'] + const protectedPaths = ['/dashboard', '/inbox', '/contacts', '/pipelines', '/broadcasts', '/automations', '/settings', '/webhooks'] if (!user && protectedPaths.some(path => request.nextUrl.pathname.startsWith(path))) { const url = request.nextUrl.clone() url.pathname = '/login' diff --git a/src/types/index.ts b/src/types/index.ts index 2b2e156bbf..a8dd34ab55 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -285,6 +285,8 @@ export interface WhatsAppConfig { subscribed_apps_at?: string; /** Last error from /register; cleared on success. */ last_registration_error?: string; + /** If true, uses the marketing_messages endpoint for templates in the Marketing category. */ + use_marketing_endpoint?: boolean; } // Raw Meta status enum. We persist this verbatim from Meta (sync + webhook) diff --git a/supabase/migrations/038_generic_webhooks.sql b/supabase/migrations/038_generic_webhooks.sql new file mode 100644 index 0000000000..3633c77559 --- /dev/null +++ b/supabase/migrations/038_generic_webhooks.sql @@ -0,0 +1,75 @@ +-- ============================================================ +-- 023_generic_webhooks.sql — Generic Webhooks feature +-- ============================================================ + +-- Table for generic webhook integrations/configurations +CREATE TABLE IF NOT EXISTS webhook_integrations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + name TEXT NOT NULL, + whatsapp_config_id UUID REFERENCES whatsapp_config(id) ON DELETE SET NULL, + is_connected BOOLEAN NOT NULL DEFAULT FALSE, + last_payload JSONB DEFAULT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(account_id) +); + +CREATE INDEX IF NOT EXISTS idx_webhook_integrations_account ON webhook_integrations(account_id); + +ALTER TABLE webhook_integrations ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "Members can manage webhook integrations" ON webhook_integrations; +CREATE POLICY "Members can manage webhook integrations" ON webhook_integrations FOR ALL + USING (is_account_member(account_id)); + +-- Table for webhook workflows (up to 5 per integration) +CREATE TABLE IF NOT EXISTS webhook_workflows ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + integration_id UUID NOT NULL REFERENCES webhook_integrations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + recipient_name_field TEXT NOT NULL, -- JSON path, e.g. "customer.name" + recipient_phone_field TEXT NOT NULL, -- JSON path, e.g. "customer.phone" + conditions JSONB NOT NULL DEFAULT '[]'::jsonb, -- Array of condition objects + actions JSONB NOT NULL DEFAULT '[]'::jsonb, -- Array of action objects (e.g. send template) + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_webhook_workflows_integration ON webhook_workflows(integration_id); + +ALTER TABLE webhook_workflows ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "Members can manage webhook workflows" ON webhook_workflows; +CREATE POLICY "Members can manage webhook workflows" ON webhook_workflows FOR ALL + USING (is_account_member(account_id)); + +-- Table for webhook execution logs +CREATE TABLE IF NOT EXISTS webhook_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + integration_id UUID REFERENCES webhook_integrations(id) ON DELETE SET NULL, + workflow_id UUID REFERENCES webhook_workflows(id) ON DELETE SET NULL, + workflow_name TEXT NOT NULL, + customer_name TEXT, + customer_phone TEXT, + status TEXT NOT NULL CHECK (status IN ('success', 'failed', 'no_match')), + error_message TEXT, + payload JSONB DEFAULT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_webhook_logs_account ON webhook_logs(account_id); +CREATE INDEX IF NOT EXISTS idx_webhook_logs_workflow ON webhook_logs(workflow_id); + +ALTER TABLE webhook_logs ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "Members can view webhook logs" ON webhook_logs; +CREATE POLICY "Members can view webhook logs" ON webhook_logs FOR SELECT + USING (is_account_member(account_id)); + +-- Triggers for updated_at +DROP TRIGGER IF EXISTS set_updated_at ON webhook_integrations; +CREATE TRIGGER set_updated_at BEFORE UPDATE ON webhook_integrations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS set_updated_at ON webhook_workflows; +CREATE TRIGGER set_updated_at BEFORE UPDATE ON webhook_workflows FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/supabase/migrations/039_webhook_workflows_create_contacts.sql b/supabase/migrations/039_webhook_workflows_create_contacts.sql new file mode 100644 index 0000000000..8e1351722a --- /dev/null +++ b/supabase/migrations/039_webhook_workflows_create_contacts.sql @@ -0,0 +1,3 @@ +-- Add create_contacts column to webhook_workflows +ALTER TABLE webhook_workflows +ADD COLUMN IF NOT EXISTS create_contacts BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/supabase/migrations/040_webhook_workflows_recipient_email.sql b/supabase/migrations/040_webhook_workflows_recipient_email.sql new file mode 100644 index 0000000000..ba9593c69d --- /dev/null +++ b/supabase/migrations/040_webhook_workflows_recipient_email.sql @@ -0,0 +1,3 @@ +-- Add recipient_email_field column to webhook_workflows +ALTER TABLE webhook_workflows +ADD COLUMN IF NOT EXISTS recipient_email_field TEXT; diff --git a/supabase/migrations/041_add_marketing_gating_and_webhook_fields.sql b/supabase/migrations/041_add_marketing_gating_and_webhook_fields.sql new file mode 100644 index 0000000000..11bb3e7657 --- /dev/null +++ b/supabase/migrations/041_add_marketing_gating_and_webhook_fields.sql @@ -0,0 +1,6 @@ +-- Add use_marketing_endpoint to whatsapp_config +ALTER TABLE whatsapp_config ADD COLUMN IF NOT EXISTS use_marketing_endpoint BOOLEAN DEFAULT FALSE; + +-- Add hmac_secret and default_phone_prefix to webhook_integrations +ALTER TABLE webhook_integrations ADD COLUMN IF NOT EXISTS hmac_secret TEXT DEFAULT NULL; +ALTER TABLE webhook_integrations ADD COLUMN IF NOT EXISTS default_phone_prefix TEXT DEFAULT '91';