From 896e0e92b2fbc0d97fed4171c2e362afcfeca876 Mon Sep 17 00:00:00 2001 From: Dibash Date: Sat, 18 Jul 2026 02:00:21 +0530 Subject: [PATCH 1/7] feat(theme): add support for light theme and mode selector --- src/app/globals.css | 17 ++++++++++++ src/app/layout.tsx | 2 +- src/app/light.css | 50 ++++++++++++++++++++++++++++++++++ src/components/ui/checkbox.tsx | 2 +- src/lib/themes.ts | 7 +++++ 5 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/app/light.css diff --git a/src/app/globals.css b/src/app/globals.css index ee8f58dac7..703e4c9261 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; +@import "./light.css"; @custom-variant dark (&:is(.dark *)); @@ -55,6 +56,19 @@ --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); + + /* Slate colors mapped to CSS variables for light mode overrides */ + --color-slate-950: var(--slate-950, oklch(0.04 0.015 260)); + --color-slate-900: var(--slate-900, oklch(0.13 0.015 260)); + --color-slate-800: var(--slate-800, oklch(0.20 0.015 260)); + --color-slate-700: var(--slate-700, oklch(0.28 0.015 260)); + --color-slate-600: var(--slate-600, oklch(0.35 0.015 260)); + --color-slate-500: var(--slate-500, oklch(0.45 0.015 260)); + --color-slate-400: var(--slate-400, oklch(0.55 0.015 260)); + --color-slate-300: var(--slate-300, oklch(0.65 0.015 260)); + --color-slate-200: var(--slate-200, oklch(0.75 0.015 260)); + --color-slate-100: var(--slate-100, oklch(0.85 0.015 260)); + --color-slate-50: var(--slate-50, oklch(0.92 0.015 260)); } /* ============================================================ @@ -227,4 +241,7 @@ html[data-theme="rose"] { html { @apply font-sans; } + button { + @apply cursor-pointer; + } } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4a8c05a344..abc2862bed 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -25,7 +25,7 @@ export const metadata: Metadata = { default: "wacrm", template: "%s — wacrm", }, - description: "Self-hostable CRM template for WhatsApp.", + description: "Self-hostable Journey Tank WP CRM.", robots: { index: false, follow: false, diff --git a/src/app/light.css b/src/app/light.css new file mode 100644 index 0000000000..6d18e455fb --- /dev/null +++ b/src/app/light.css @@ -0,0 +1,50 @@ +/* ============================================================ + * LIGHT MODE THEME + * ============================================================ */ + +html[data-mode="light"] { + --background: oklch(0.99 0.005 260); /* Clean white background */ + --foreground: oklch(0.12 0.01 260); /* Very dark gray text */ + --card: oklch(0.96 0.008 260); /* Light gray card */ + --card-foreground: oklch(0.12 0.01 260); + --popover: oklch(0.97 0.008 260); + --popover-foreground: oklch(0.12 0.01 260); + --primary: oklch(0.526 0.247 293); /* Violet primary */ + --primary-foreground: oklch(0.99 0.005 260); + --primary-hover: oklch(0.45 0.22 293); + --primary-soft: oklch(0.526 0.247 293 / 0.08); + --primary-soft-2: oklch(0.526 0.247 293 / 0.15); + --secondary: oklch(0.90 0.01 260); + --secondary-foreground: oklch(0.12 0.01 260); + --muted: oklch(0.93 0.008 260); + --muted-foreground: oklch(0.45 0.01 260); + --accent: oklch(0.90 0.01 260); + --accent-foreground: oklch(0.12 0.01 260); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.88 0.01 260); + --input: oklch(0.88 0.01 260); + --ring: oklch(0.526 0.247 293); + --radius: 0.625rem; + + --sidebar: oklch(0.95 0.008 260); /* Light sidebar */ + --sidebar-foreground: oklch(0.12 0.01 260); + --sidebar-primary: oklch(0.526 0.247 293); + --sidebar-primary-foreground: oklch(0.99 0.005 260); + --sidebar-accent: oklch(0.90 0.01 260); + --sidebar-accent-foreground: oklch(0.12 0.01 260); + --sidebar-border: oklch(0.88 0.01 260); + --sidebar-ring: oklch(0.526 0.247 293); + + /* Map standard slate colors to light mode equivalents */ + --slate-950: oklch(0.99 0.005 260); /* Main background */ + --slate-900: oklch(0.95 0.008 260); /* Sidebar & panels */ + --slate-800: oklch(0.88 0.01 260); /* Borders & secondary buttons */ + --slate-700: oklch(0.78 0.01 260); /* Inputs & dividers */ + --slate-600: oklch(0.60 0.01 260); /* Muted borders/icons */ + --slate-500: oklch(0.48 0.01 260); /* Muted labels */ + --slate-400: oklch(0.38 0.01 260); /* Subtext */ + --slate-300: oklch(0.25 0.01 260); /* Normal text secondary */ + --slate-200: oklch(0.18 0.01 260); /* Text heading primary */ + --slate-100: oklch(0.12 0.01 260); + --slate-50: oklch(0.08 0.01 260); +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index c369fb81fd..fbdd510bcb 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -16,7 +16,7 @@ function Checkbox({ = [ tagline: "Bold and modern — D2C, creator-economy, lifestyle.", swatch: "oklch(0.645 0.22 16)", }, + { + id: "light", + name: "Light Mode", + tagline: "Clean, crisp, and high-contrast light theme.", + swatch: "oklch(0.95 0.008 260)", + }, ]; export function isThemeId(value: unknown): value is ThemeId { From b6c7800a29f6fae36ed6576cc3c39487a08e7d12 Mon Sep 17 00:00:00 2001 From: Dibash Date: Sat, 18 Jul 2026 02:00:31 +0530 Subject: [PATCH 2/7] feat(auth): restrict template operations to admins and enforce WhatsApp config roles --- src/app/api/whatsapp/config/route.ts | 106 +----- .../config/verify-registration/route.ts | 199 +++++----- src/components/settings/template-manager.tsx | 354 ++++++++++++------ src/components/settings/whatsapp-config.tsx | 55 ++- src/hooks/use-can.ts | 4 + src/lib/auth/roles.test.ts | 8 + src/lib/auth/roles.ts | 11 +- .../037_agent_templates_permission.sql | 10 + 8 files changed, 405 insertions(+), 342 deletions(-) create mode 100644 supabase/migrations/037_agent_templates_permission.sql diff --git a/src/app/api/whatsapp/config/route.ts b/src/app/api/whatsapp/config/route.ts index 399699c4ed..8db34f1d22 100644 --- a/src/app/api/whatsapp/config/route.ts +++ b/src/app/api/whatsapp/config/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { registerPhoneNumber, @@ -7,29 +6,9 @@ import { verifyPhoneNumber, } from '@/lib/whatsapp/meta-api' import { encrypt, decrypt } from '@/lib/whatsapp/encryption' +import { getCurrentAccount, requireRole, toErrorResponse } from '@/lib/auth/account' + -/** - * Resolve the caller's account_id from their profile. Inlined here - * (rather than going through `@/lib/auth/account.getCurrentAccount`) - * because the GET handler wants to return shaped 200s for every - * non-auth failure mode, not throw — keeping the helper minimal lets - * the existing response branches stay as-is. - * - * Returns null if the user has no profile or no account; callers - * should treat that the same as "not connected". - */ -async function resolveAccountId( - supabase: Awaited>, - userId: string, -): Promise { - const { data, error } = await supabase - .from('profiles') - .select('account_id') - .eq('user_id', userId) - .maybeSingle() - if (error || !data?.account_id) return null - return data.account_id as string -} // Lazy-initialised service-role client. We need it to detect a // phone_number_id already claimed by a *different* user — under RLS, @@ -60,30 +39,11 @@ function supabaseAdmin() { * { connected: false, reason: 'token_corrupted', message: '...', needs_reset: true } * { connected: false, reason: 'meta_api_error', message: '...' } */ -export async function GET() { +export async function GET(request: Request) { try { - const supabase = await createClient() - - const { - data: { user }, - error: authError, - } = await supabase.auth.getUser() - - if (authError || !user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accountId = await resolveAccountId(supabase, user.id) - if (!accountId) { - return NextResponse.json( - { - connected: false, - reason: 'no_account', - message: 'Your profile is not linked to an account.', - }, - { status: 200 }, - ) - } + const { searchParams } = new URL(request.url) + const quick = searchParams.get('quick') === 'true' + const { supabase, accountId } = await getCurrentAccount() const { data: config, error: configError } = await supabase .from('whatsapp_config') @@ -129,6 +89,11 @@ export async function GET() { ) } + // If quick validation requested, return connected status early to save Meta API rate limits + if (quick) { + return NextResponse.json({ connected: config.status === 'connected', quick: true }) + } + // Validate credentials against Meta try { const phoneInfo = await verifyPhoneNumber({ @@ -150,10 +115,7 @@ export async function GET() { } } catch (error) { console.error('Error in WhatsApp config GET:', error) - return NextResponse.json( - { connected: false, reason: 'unknown', message: 'Internal server error' }, - { status: 500 } - ) + return toErrorResponse(error) } } @@ -165,24 +127,7 @@ export async function GET() { */ export async function POST(request: Request) { try { - const supabase = await createClient() - - const { - data: { user }, - error: authError, - } = await supabase.auth.getUser() - - if (authError || !user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accountId = await resolveAccountId(supabase, user.id) - if (!accountId) { - return NextResponse.json( - { error: 'Your profile is not linked to an account.' }, - { status: 403 }, - ) - } + const { supabase, accountId, userId } = await requireRole('admin') const body = await request.json() const { phone_number_id, waba_id, access_token, verify_token, pin } = body @@ -388,7 +333,7 @@ export async function POST(request: Request) { .from('whatsapp_config') .insert({ account_id: accountId, - user_id: user.id, + user_id: userId, ...baseRow, }) @@ -427,7 +372,7 @@ export async function POST(request: Request) { }) } catch (error) { console.error('Error in WhatsApp config POST:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return toErrorResponse(error) } } @@ -440,24 +385,7 @@ export async function POST(request: Request) { */ export async function DELETE() { try { - const supabase = await createClient() - - const { - data: { user }, - error: authError, - } = await supabase.auth.getUser() - - if (authError || !user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accountId = await resolveAccountId(supabase, user.id) - if (!accountId) { - return NextResponse.json( - { error: 'Your profile is not linked to an account.' }, - { status: 403 }, - ) - } + const { supabase, accountId } = await requireRole('admin') const { error: deleteError } = await supabase .from('whatsapp_config') @@ -475,6 +403,6 @@ export async function DELETE() { return NextResponse.json({ success: true }) } catch (error) { console.error('Error in WhatsApp config DELETE:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return toErrorResponse(error) } } diff --git a/src/app/api/whatsapp/config/verify-registration/route.ts b/src/app/api/whatsapp/config/verify-registration/route.ts index 3dea7210aa..a11287daaa 100644 --- a/src/app/api/whatsapp/config/verify-registration/route.ts +++ b/src/app/api/whatsapp/config/verify-registration/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { decrypt } from '@/lib/whatsapp/encryption' import { getSubscribedApps, verifyPhoneNumber, } from '@/lib/whatsapp/meta-api' +import { requireRole, toErrorResponse } from '@/lib/auth/account' /** * GET /api/whatsapp/config/verify-registration @@ -29,128 +29,109 @@ import { * what the UI badges on. */ export async function GET() { - const supabase = await createClient() - const { - data: { user }, - error: authError, - } = await supabase.auth.getUser() - if (authError || !user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // whatsapp_config is one-row-per-account post-017. Resolve the - // caller's account_id so a teammate who joined an existing account - // sees the same registration state as the admin who set it up. - 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) { - return NextResponse.json({ - live: false, - checks: { config_exists: false }, - message: 'Your profile is not linked to an account.', - }) - } + try { + const { supabase, accountId } = await requireRole('admin') - const { data: config } = await supabase - .from('whatsapp_config') - .select('*') - .eq('account_id', accountId) - .maybeSingle() + const { data: config } = await supabase + .from('whatsapp_config') + .select('*') + .eq('account_id', accountId) + .maybeSingle() - if (!config) { - return NextResponse.json({ - live: false, - checks: { config_exists: false }, - message: 'No WhatsApp configuration saved yet.', - }) - } - - let accessToken: string - try { - accessToken = decrypt(config.access_token) - } catch { - return NextResponse.json({ - live: false, - checks: { - config_exists: true, - token_decryptable: false, - }, - message: - 'Stored access token can\'t be decrypted — likely ENCRYPTION_KEY changed. Re-enter the token to repair.', - }) - } + if (!config) { + return NextResponse.json({ + live: false, + checks: { config_exists: false }, + message: 'No WhatsApp configuration saved yet.', + }) + } - const checks: { - config_exists: boolean - token_decryptable: boolean - phone_metadata_ok: boolean - waba_subscribed_to_app: boolean | null - locally_marked_registered: boolean - } = { - config_exists: true, - token_decryptable: true, - phone_metadata_ok: false, - waba_subscribed_to_app: null, - locally_marked_registered: config.registered_at != null, - } - const errors: string[] = [] + let accessToken: string + try { + accessToken = decrypt(config.access_token) + } catch { + return NextResponse.json({ + live: false, + checks: { + config_exists: true, + token_decryptable: false, + }, + message: + 'Stored access token can\'t be decrypted — likely ENCRYPTION_KEY changed. Re-enter the token to repair.', + }) + } - // 1. Phone metadata - try { - await verifyPhoneNumber({ - phoneNumberId: config.phone_number_id, - accessToken, - }) - checks.phone_metadata_ok = true - } catch (err) { - errors.push( - `Phone metadata check failed: ${err instanceof Error ? err.message : String(err)}`, - ) - } + const checks: { + config_exists: boolean + token_decryptable: boolean + phone_metadata_ok: boolean + waba_subscribed_to_app: boolean | null + locally_marked_registered: boolean + } = { + config_exists: true, + token_decryptable: true, + phone_metadata_ok: false, + waba_subscribed_to_app: null, + locally_marked_registered: config.registered_at != null, + } + const errors: string[] = [] - // 2. WABA subscription — only meaningful if we have a waba_id - if (config.waba_id) { + // 1. Phone metadata try { - const subs = await getSubscribedApps({ - wabaId: config.waba_id, + await verifyPhoneNumber({ + phoneNumberId: config.phone_number_id, accessToken, }) - // Meta returns the apps subscribed to this WABA. If the list - // is non-empty, OUR app is in there (the access_token we used - // belongs to our app — Meta wouldn't return data for an app - // the token can't see). Treat any entry as success. - checks.waba_subscribed_to_app = subs.length > 0 - if (!checks.waba_subscribed_to_app) { + checks.phone_metadata_ok = true + } catch (err) { + errors.push( + `Phone metadata check failed: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + // 2. WABA subscription — only meaningful if we have a waba_id + if (config.waba_id) { + try { + const subs = await getSubscribedApps({ + wabaId: config.waba_id, + accessToken, + }) + // Meta returns the apps subscribed to this WABA. If the list + // is non-empty, OUR app is in there (the access_token we used + // belongs to our app — Meta wouldn't return data for an app + // the token can't see). Treat any entry as success. + checks.waba_subscribed_to_app = subs.length > 0 + if (!checks.waba_subscribed_to_app) { + errors.push( + 'WABA has no subscribed apps. Re-save the configuration to subscribe.', + ) + } + } catch (err) { errors.push( - 'WABA has no subscribed apps. Re-save the configuration to subscribe.', + `WABA subscription check failed: ${err instanceof Error ? err.message : String(err)}`, ) } - } catch (err) { + } else { errors.push( - `WABA subscription check failed: ${err instanceof Error ? err.message : String(err)}`, + 'No WABA ID on file — webhooks can\'t be wired without it. Add it in the form and re-save.', ) } - } else { - errors.push( - 'No WABA ID on file — webhooks can\'t be wired without it. Add it in the form and re-save.', - ) - } - const live = - checks.phone_metadata_ok && - (checks.waba_subscribed_to_app ?? false) && - checks.locally_marked_registered + const live = + checks.phone_metadata_ok && + (checks.waba_subscribed_to_app ?? false) && + checks.locally_marked_registered - return NextResponse.json({ - live, - checks, - errors, - last_registration_error: config.last_registration_error ?? null, - registered_at: config.registered_at ?? null, - subscribed_apps_at: config.subscribed_apps_at ?? null, - }) + return NextResponse.json({ + live, + checks, + errors, + last_registration_error: config.last_registration_error ?? null, + registered_at: config.registered_at ?? null, + subscribed_apps_at: config.subscribed_apps_at ?? null, + }) + } catch (error) { + console.error('Error in WhatsApp verify-registration GET:', error) + return toErrorResponse(error) + } } diff --git a/src/components/settings/template-manager.tsx b/src/components/settings/template-manager.tsx index 827cb12496..c96f3e3b87 100644 --- a/src/components/settings/template-manager.tsx +++ b/src/components/settings/template-manager.tsx @@ -12,6 +12,8 @@ import { Pencil, RotateCcw, Upload, + Eye, + Copy, } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; import { @@ -127,11 +129,12 @@ function emptyButton(type: TemplateButton['type']): TemplateButton { export function TemplateManager() { const t = useTranslations('Settings.templates'); const supabase = createClient(); - const { user, loading: authLoading } = useAuth(); + const { user, accountId, canManageTemplates, loading: authLoading } = useAuth(); const [loading, setLoading] = useState(true); const [templates, setTemplates] = useState([]); const [dialogOpen, setDialogOpen] = useState(false); + const [viewingOnly, setViewingOnly] = useState(false); const [submitting, setSubmitting] = useState(false); const [syncing, setSyncing] = useState(false); const [form, setForm] = useState(emptyForm); @@ -179,21 +182,21 @@ export function TemplateManager() { useEffect(() => { if (authLoading) return; - if (!user) { + if (!accountId) { setLoading(false); return; } - fetchTemplates(user.id); + fetchTemplates(accountId); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [authLoading, user?.id]); + }, [authLoading, accountId]); - async function fetchTemplates(userId: string) { + async function fetchTemplates(accountId: string) { try { setLoading(true); const { data, error } = await supabase .from('message_templates') .select('*') - .eq('user_id', userId) + .eq('account_id', accountId) .order('created_at', { ascending: false }); if (error) throw error; setTemplates(data || []); @@ -235,6 +238,7 @@ export function TemplateManager() { function openEdit(template: MessageTemplate) { setEditingId(template.id); + setViewingOnly(false); setForm({ name: template.name, category: template.category, @@ -251,8 +255,51 @@ export function TemplateManager() { setDialogOpen(true); } + function openView(template: MessageTemplate) { + setEditingId(template.id); + setViewingOnly(true); + setForm({ + name: template.name, + category: template.category, + language: template.language || 'en_US', + header_format: (template.header_type ?? 'none') as HeaderFormat, + header_content: template.header_content ?? '', + header_media_url: template.header_media_url ?? '', + header_sample: template.sample_values?.header?.[0] ?? '', + body_text: template.body_text, + body_samples: template.sample_values?.body ?? [], + footer_text: template.footer_text ?? '', + buttons: template.buttons ?? [], + }); + setDialogOpen(true); + } + + function openClone(template: MessageTemplate) { + setEditingId(null); + setViewingOnly(false); + + let cloneName = template.name + '_copy'; + cloneName = cloneName.toLowerCase().replace(/[^a-z0-9_]/g, ''); + + setForm({ + name: cloneName, + category: template.category, + language: template.language || 'en_US', + header_format: (template.header_type ?? 'none') as HeaderFormat, + header_content: template.header_content ?? '', + header_media_url: template.header_media_url ?? '', + header_sample: template.sample_values?.header?.[0] ?? '', + body_text: template.body_text, + body_samples: template.sample_values?.body ?? [], + footer_text: template.footer_text ?? '', + buttons: template.buttons ?? [], + }); + setDialogOpen(true); + } + function openCreate() { setEditingId(null); + setViewingOnly(false); setForm(emptyForm); setDialogOpen(true); } @@ -280,7 +327,7 @@ export function TemplateManager() { } // Refresh first, then close — re-opening the dialog // immediately should not show a stale list. - if (user) await fetchTemplates(user.id); + if (accountId) await fetchTemplates(accountId); toast.success( data.dry_run ? isEdit @@ -334,7 +381,7 @@ export function TemplateManager() { { duration: 10000 }, ); } - await fetchTemplates(user.id); + if (accountId) await fetchTemplates(accountId); } catch (err) { console.error('Template sync error:', err); toast.error(err instanceof Error ? err.message : t('toastSyncError')); @@ -491,13 +538,13 @@ export function TemplateManager() { - @@ -570,56 +617,82 @@ export function TemplateManager() { )} -
- {statusKey === 'APPROVED' && ( - - )} - {(statusKey === 'REJECTED' || statusKey === 'PAUSED') && ( - - )} +
+ {canManageTemplates && ( + <> + + {statusKey === 'APPROVED' && ( + + )} + {(statusKey === 'REJECTED' || statusKey === 'PAUSED') && ( + + )} + + + )}
@@ -634,6 +707,7 @@ export function TemplateManager() { setDialogOpen(open); if (!open) { setEditingId(null); + setViewingOnly(false); setForm(emptyForm); } }} @@ -641,12 +715,18 @@ export function TemplateManager() { - {editingId ? t('dialogEditTitle') : t('dialogNewTitle')} + {viewingOnly + ? t('dialogViewTitle') + : editingId + ? t('dialogEditTitle') + : t('dialogNewTitle')} - {editingId - ? t('dialogEditDesc') - : t('dialogNewDesc')} + {viewingOnly + ? t('dialogViewDesc') + : editingId + ? t('dialogEditDesc') + : t('dialogNewDesc')} @@ -664,7 +744,7 @@ export function TemplateManager() { placeholder={t('namePlaceholder')} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} - disabled={editingId !== null} + disabled={editingId !== null || viewingOnly} className="bg-muted border-border text-foreground placeholder:text-muted-foreground disabled:opacity-60 disabled:cursor-not-allowed" />

@@ -679,6 +759,7 @@ export function TemplateManager() { // Preserve header_content, header_media_url, and // header_sample across format switches. The submit @@ -747,7 +829,7 @@ export function TemplateManager() { }) } > - + @@ -781,8 +863,9 @@ export function TemplateManager() { onChange={(e) => setForm({ ...form, header_content: e.target.value }) } + disabled={viewingOnly} maxLength={TEMPLATE_LIMITS.headerTextMaxLength} - className="bg-muted border-border text-foreground placeholder:text-muted-foreground" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground disabled:opacity-60 disabled:cursor-not-allowed" /> {headerVarCount > 0 && ( setForm({ ...form, header_sample: e.target.value }) } - className="bg-muted border-border text-foreground placeholder:text-muted-foreground" + disabled={viewingOnly} + className="bg-muted border-border text-foreground placeholder:text-muted-foreground disabled:opacity-60 disabled:cursor-not-allowed" /> )}

@@ -801,7 +885,7 @@ export function TemplateManager() { {headerNeedsMedia && (
- {form.header_format === 'image' && ( + {form.header_format === 'image' && !viewingOnly && (
headerFileRef.current?.click()} > {uploadingHeader ? ( @@ -839,7 +923,8 @@ export function TemplateManager() { onChange={(e) => setForm({ ...form, header_media_url: e.target.value }) } - className="bg-muted border-border text-foreground placeholder:text-muted-foreground" + disabled={viewingOnly} + className="bg-muted border-border text-foreground placeholder:text-muted-foreground disabled:opacity-60 disabled:cursor-not-allowed" /> {form.header_format === 'image' && form.header_media_url && ( // eslint-disable-next-line @next/next/no-img-element @@ -870,14 +955,15 @@ export function TemplateManager() { onChange={(e) => setForm({ ...form, body_text: e.target.value }) } + disabled={viewingOnly} rows={4} maxLength={TEMPLATE_LIMITS.bodyMaxLength} - className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none disabled:opacity-60 disabled:cursor-not-allowed" />

{t('bodyHint')}

- + {bodyVarCount > 0 && (
- + {!viewingOnly && ( + + )}
{form.buttons.length === 0 ? (

@@ -947,6 +1037,7 @@ export function TemplateManager() {

updateButton(i, { text: e.target.value }) } - className="flex-1 bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs" + className="flex-1 bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs disabled:opacity-60 disabled:cursor-not-allowed" /> - + {!viewingOnly && ( + + )}
{btn.type === 'URL' && (
updateButton(i, { url: e.target.value }) } - className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs disabled:opacity-60 disabled:cursor-not-allowed" /> {extractVariableIndices(btn.url).length > 0 && ( updateButton(i, { example: e.target.value }) } - className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs disabled:opacity-60 disabled:cursor-not-allowed" /> )}
@@ -1030,20 +1127,22 @@ export function TemplateManager() { updateButton(i, { phone_number: e.target.value }) } - className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs disabled:opacity-60 disabled:cursor-not-allowed" /> )} {btn.type === 'COPY_CODE' && ( updateButton(i, { example: e.target.value }) } - className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs disabled:opacity-60 disabled:cursor-not-allowed" /> )}
@@ -1054,29 +1153,40 @@ export function TemplateManager() {
- - + {viewingOnly ? ( + + ) : ( + <> + + + + )} diff --git a/src/components/settings/whatsapp-config.tsx b/src/components/settings/whatsapp-config.tsx index 85b7a3b819..a9b4a1fca5 100644 --- a/src/components/settings/whatsapp-config.tsx +++ b/src/components/settings/whatsapp-config.tsx @@ -39,12 +39,7 @@ type ResetReason = 'token_corrupted' | 'meta_api_error' | null; export function WhatsAppConfig() { const t = useTranslations('Settings.whatsapp'); const supabase = createClient(); - // After multi-user, whatsapp_config is one-row-per-account, not - // one-row-per-user. We pull `accountId` straight off the auth - // context and key every read off it — so a teammate who just - // joined an account sees the inviter's saved config without - // having to re-enter anything. - const { user, accountId, loading: authLoading, profileLoading } = useAuth(); + const { user, accountId, canEditSettings, loading: authLoading, profileLoading } = useAuth(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -183,6 +178,7 @@ export function WhatsAppConfig() { }, [authLoading, profileLoading, user?.id, accountId, fetchConfig]); async function handleSave() { + if (!canEditSettings) return; if (!phoneNumberId.trim()) { toast.error('Phone Number ID is required'); return; @@ -278,6 +274,7 @@ export function WhatsAppConfig() { } async function handleTestConnection() { + if (!canEditSettings) return; try { setTesting(true); const res = await fetch('/api/whatsapp/config', { method: 'GET' }); @@ -308,6 +305,7 @@ export function WhatsAppConfig() { } async function handleVerifyRegistration() { + if (!canEditSettings) return; setVerifyingRegistration(true); setRegistrationProbe(null); try { @@ -334,6 +332,7 @@ export function WhatsAppConfig() { } async function handleReset() { + if (!canEditSettings) return; if (!confirm('This will delete the current WhatsApp config so you can re-enter it. Continue?')) { return; } @@ -396,16 +395,26 @@ export function WhatsAppConfig() {
{/* Main config form */}
+ {!canEditSettings && ( + + + Read-only access + + You must be an Owner or Admin to configure the WhatsApp connection. + + + )} + {/* Corrupted-token reset banner */} {showResetBanner && ( - +
- +
- + Stored token can't be decrypted - + {statusMessage} @@ -620,6 +633,7 @@ export function WhatsAppConfig() {
setVerifyToken(e.target.value)} @@ -636,6 +650,7 @@ export function WhatsAppConfig() { {t('optional')} + )} +
+ + + {selected ? selected.name : t("sendTemplate")} + + {selected && ( + + {selected.category} + + )} +
+ {selected ? t("fillPlaceholders") : t("pickTemplate")} - {!selected ? ( -
- {loading ? ( -
- + {/* Body */} +
+ {/* ── Step 1: Template list ── */} + {!selected ? ( +
+
+ setSearch(e.target.value)} + placeholder="Search templates…" + className="h-8 text-sm" + />
- ) : templates.length === 0 ? ( -
-

{t("noApprovedTemplates")}

-

- {t("noApprovedTemplatesHint")} -

+
+ {loading ? ( +
+ +
+ ) : filteredTemplates.length === 0 ? ( +
+

+ {search ? "No templates match your search" : t("noApprovedTemplates")} +

+ {!search && ( +

+ {t("noApprovedTemplatesHint")} +

+ )} +
+ ) : ( + filteredTemplates.map((tpl) => ( + + )) + )}
- ) : ( - templates.map((t) => ( - +
+
+ ) : ( + /* ── Step 2: Two-column config ── */ +
+ {/* Left column: Live preview */} +
+
+ + Live Preview + +
+
+ {/* WhatsApp-style chat bubble */} +
+ {/* Chat header bar */} +
+
+ WA +
+
+
Business
+
WhatsApp
+
+
+ {/* Message bubble */} +
+
+ {/* Media header preview */} + {selected.header_type === "image" && ( +
+ {previewImageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Header + ) : ( +
+ + No image set +
+ )} +
+ )} + {selected.header_type === "video" && ( +
+ {(mediaSourceType === "upload" && mediaUploadUrl) || (mediaSourceType === "static" && mediaLink) ? ( +
+
+ ) : ( +
+
+ )} +
+ )} + {selected.header_type === "document" && ( +
+ + + {mediaFileName || + ((mediaSourceType === "static" && mediaLink) ? "Document ready" : + templateHasDefaultMedia ? "Default document" : "No document set")} + +
+ )} + {/* Text header */} + {selected.header_type === "text" && previewHeader && ( +
+ {previewHeader} +
)} + {/* Body text */} +
+ {previewBody || Body text preview} +
+ {/* Footer */} + {selected.footer_text && ( +
+ {selected.footer_text} +
+ )} + {/* Timestamp */} +
+ {new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} +
-

- {t.body_text} -

+ {/* Buttons */} + {hasButtons && ( +
+ {selected.buttons!.map((btn, idx) => ( +
+ {btn.text} +
+ ))} +
+ )}
-
- - )) - )} -
- ) : ( -
-
-

{t("preview")}

-

- {renderBodyPreview(selected.body_text, params)} -

- {selected.footer_text && ( -

- {selected.footer_text} -

- )} -
- {slots && slots.headerVarCount > 0 && ( -
- - setHeaderText(e.target.value)} - placeholder={t("headerValuePlaceholder")} - className="border-border bg-muted text-foreground placeholder:text-muted-foreground" - /> -
- )} - {slots?.bodyVars.map((v, i) => ( -
- - { - const next = [...params]; - next[i] = e.target.value; - setParams(next); - }} - placeholder={t("bodyValuePlaceholder", { val: `{{${v}}}` })} - className="border-border bg-muted text-foreground placeholder:text-muted-foreground" - /> + {/* Template meta */} +
+
+ {selected.name} + · + {selected.language} +
+
+
- ))} - {slots?.urlButtonSlots.map((slot) => ( -
- - - setButtonParams((prev) => ({ - ...prev, - [slot.index]: e.target.value, - })) - } - placeholder={t("urlSuffixValuePlaceholder")} - className="border-border bg-muted text-foreground placeholder:text-muted-foreground" - /> -

- {t("finalUrl", { url: slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}") })} -

+ + {/* Right column: Input tabs */} +
+ {/* Tab nav */} +
+ setActiveTab("map")}> + Map Variables + + hasMediaHeader && setActiveTab("media")} + title={!hasMediaHeader ? "This template has no media header" : "Configure media"} + > + Media {hasMediaHeader && ( + + {mediaProvided ? "✓" : "!"} + + )} + + hasButtons && setActiveTab("advance")} + title={!hasButtons ? "This template has no buttons" : "Configure button params"} + > + Buttons {hasButtons && ( + ({selected!.buttons!.length}) + )} + +
+ + {/* Tab content */} +
+ {/* ── Map Tab ── */} + {activeTab === "map" && ( +
+ {slots && slots.headerVarCount === 0 && slots.bodyVars.length === 0 && ( +
+

This template has no body variables.

+

+ {hasMediaHeader + ? "Switch to the Media tab to set a header image/video/document." + : "You can send it directly."} +

+
+ )} + + {/* Header text var */} + {slots && slots.headerVarCount > 0 && ( +
+ + setHeaderText(e.target.value)} + placeholder={t("headerValuePlaceholder") || "Value for the header variable"} + className="" + /> +
+ )} + + {/* Body vars */} + {slots?.bodyVars.map((v, i) => ( +
+ + { + const next = [...bodyParams]; + next[i] = e.target.value; + setBodyParams(next); + }} + placeholder={t("bodyValuePlaceholder", { val: `{{${v}}}` }) || `Value for {{${v}}}`} + className="" + /> +
+ ))} +
+ )} + + {/* ── Media Tab ── */} + {activeTab === "media" && hasMediaHeader && ( +
+

+ {templateHasDefaultMedia + ? "This template already has a default media file. You can optionally override it with a different file for this send." + : "This template requires a media file. Please provide one below."} +

+ + {/* Source type */} +
+ +
+ {(["static", "upload"] as const).map((type) => ( + + ))} +
+
+ + {/* Static URL */} + {mediaSourceType === "static" && ( +
+ + setMediaLink(e.target.value)} + placeholder={ + selected.header_type === "video" + ? "https://example.com/video.mp4" + : selected.header_type === "document" + ? "https://example.com/document.pdf" + : "https://example.com/image.jpg" + } + className="" + /> +

+ Must be a publicly accessible HTTPS URL. +

+
+ )} + + {/* Upload */} + {mediaSourceType === "upload" && ( +
+ + {mediaUploadUrl ? ( +
+ + + {mediaFileName || mediaUploadUrl} + + +
+ ) : ( + + )} + { + const f = e.target.files?.[0]; + if (f) void handleMediaUpload(f); + e.target.value = ""; + }} + /> +

+ {selected.header_type === "video" + ? "Allowed: .mp4 · Max 10 MB" + : selected.header_type === "document" + ? "Allowed: .pdf · Max 10 MB" + : "Allowed: .jpeg, .jpg, .png · Max 5 MB"} +

+
+ )} +
+ )} + + {/* ── Advance / Buttons Tab ── */} + {activeTab === "advance" && hasButtons && ( +
+

+ URL buttons with a {"{{1}}"} variable + require a suffix value. Quick-reply and phone buttons don't need input. +

+ {selected!.buttons!.map((btn, idx) => { + const needsParam = + btn.type === "URL" && + extractVariableIndices(btn.url).length > 0; + return ( +
+
+ + {`Button ${idx + 1}: "${btn.text}"`} + + + {btn.type} + +
+ {needsParam ? ( + <> +

+ Base URL: {btn.url} +

+ + setButtonParams((prev) => ({ + ...prev, + [idx]: e.target.value, + })) + } + placeholder={t("urlSuffixValuePlaceholder") || "URL suffix value for {{1}}"} + className="text-xs h-8" + /> + {buttonParams[idx] && ( +

+ Final: {btn.url.replace(/\{\{1\}\}/g, buttonParams[idx])} +

+ )} + + ) : ( +

+ No input required for this button type. +

+ )} +
+ ); + })} +
+ )} +
+ + {/* Footer */} +
+
+ {!canConfirm && selected && ( + + {slots?.bodyVars.some((_, i) => !(bodyParams[i] ?? "").trim()) + ? "Fill in all body variables to send." + : slots!.headerVarCount > 0 && !headerText.trim() + ? "Header variable required." + : !mediaProvided + ? "Media file required — go to the Media tab." + : slots!.urlButtonSlots.some((s) => !(buttonParams[s.index] ?? "").trim()) + ? "Button URL suffix required — go to the Buttons tab." + : ""} + + )} +
+ +
- ))} -
- )} - - - {selected ? ( - <> - - - - ) : ( - +
)} - +
); } + +// ─── Small helpers ──────────────────────────────────────────────────────────── + +function TabButton({ + active, + disabled, + onClick, + children, + title, +}: { + active: boolean; + disabled?: boolean; + onClick: () => void; + children: React.ReactNode; + title?: string; +}) { + return ( + + ); +} diff --git a/src/lib/whatsapp/send-message.ts b/src/lib/whatsapp/send-message.ts index 5b5ca32dd0..b4075e425d 100644 --- a/src/lib/whatsapp/send-message.ts +++ b/src/lib/whatsapp/send-message.ts @@ -455,7 +455,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/lib/whatsapp/template-header-handle.test.ts b/src/lib/whatsapp/template-header-handle.test.ts index f73af6dd34..a8476facc1 100644 --- a/src/lib/whatsapp/template-header-handle.test.ts +++ b/src/lib/whatsapp/template-header-handle.test.ts @@ -3,10 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Stub the Meta resumable upload so the helper is tested in isolation. vi.mock('./meta-api', () => ({ uploadResumableMedia: vi.fn(async () => ({ handle: 'HANDLE123' })), + getMetaAppId: vi.fn(async () => 'app-1'), })); import { ensureImageHeaderHandle } from './template-header-handle'; -import { uploadResumableMedia } from './meta-api'; +import { uploadResumableMedia, getMetaAppId } from './meta-api'; import type { TemplatePayload } from './template-validators'; function payload(over: Partial = {}): TemplatePayload { @@ -33,6 +34,7 @@ function imgResponse(type = 'image/jpeg', size = 1024, ok = true, status = 200): describe('ensureImageHeaderHandle', () => { beforeEach(() => { vi.mocked(uploadResumableMedia).mockClear(); + vi.mocked(getMetaAppId).mockClear(); }); afterEach(() => { vi.unstubAllGlobals(); @@ -53,9 +55,20 @@ describe('ensureImageHeaderHandle', () => { expect(p.header_handle).toBe('existing'); }); - it('throws an actionable error when META_APP_ID is unset', async () => { + it('resolves App ID dynamically when META_APP_ID is unset', async () => { + vi.stubGlobal('fetch', vi.fn(async () => imgResponse('image/jpeg', 2048))); + vi.mocked(getMetaAppId).mockResolvedValueOnce('resolved-app-id'); + const p = payload(); + await ensureImageHeaderHandle(p, 'tok'); + expect(getMetaAppId).toHaveBeenCalledWith('tok'); + expect(uploadResumableMedia).toHaveBeenCalledOnce(); + expect(p.header_handle).toBe('HANDLE123'); + }); + + it('throws an actionable error when META_APP_ID is unset and dynamic resolution fails', async () => { + vi.mocked(getMetaAppId).mockRejectedValueOnce(new Error('API Error')); const p = payload(); - await expect(ensureImageHeaderHandle(p, 'tok')).rejects.toThrow(/META_APP_ID/); + await expect(ensureImageHeaderHandle(p, 'tok')).rejects.toThrow(/dynamic resolution failed/); }); it('derives + sets header_handle from a valid image URL', async () => { diff --git a/src/lib/whatsapp/template-header-handle.ts b/src/lib/whatsapp/template-header-handle.ts index eb051830cf..17f4886415 100644 --- a/src/lib/whatsapp/template-header-handle.ts +++ b/src/lib/whatsapp/template-header-handle.ts @@ -1,4 +1,4 @@ -import { uploadResumableMedia } from '@/lib/whatsapp/meta-api' +import { uploadResumableMedia, getMetaAppId } from '@/lib/whatsapp/meta-api' import type { TemplatePayload } from '@/lib/whatsapp/template-validators' /** @@ -26,11 +26,16 @@ export async function ensureImageHeaderHandle( if (payload.header_handle) return // already have one if (!payload.header_media_url) return // validator already requires url-or-handle - const appId = process.env.META_APP_ID + let appId = process.env.META_APP_ID if (!appId) { - throw new Error( - 'Image-header templates need META_APP_ID set (used for Meta’s Resumable Upload). Add it to your environment, or remove the image header.', - ) + try { + appId = await getMetaAppId(accessToken) + } catch (e) { + console.error('Failed to dynamically resolve Meta App ID:', e) + throw new Error( + 'Image-header templates need META_APP_ID set (used for Meta’s Resumable Upload), and dynamic resolution failed. Add it to your environment, or remove the image header.', + ) + } } // Fetch the sample image bytes (works for our uploaded chat-media URL diff --git a/src/lib/whatsapp/template-send-builder.ts b/src/lib/whatsapp/template-send-builder.ts index 8e72237f67..808341be51 100644 --- a/src/lib/whatsapp/template-send-builder.ts +++ b/src/lib/whatsapp/template-send-builder.ts @@ -63,16 +63,16 @@ export type MetaSendComponent = type MetaSendParameter = | { type: 'text'; text: string } - | { type: 'image'; image: { link?: string; id?: string } } - | { type: 'video'; video: { link?: string; id?: string } } - | { type: 'document'; document: { link?: string; id?: string } } + | { type: 'image'; image: { link?: string; id?: string | number } } + | { type: 'video'; video: { link?: string; id?: string | number } } + | { type: 'document'; document: { link?: string; id?: string | number } } | { type: 'coupon_code'; coupon_code: string } | { type: 'payload'; payload: string }; function buildHeaderComponent( template: MessageTemplate, params: SendTimeParams, -): MetaSendComponent | null { + ): MetaSendComponent | null { const headerType = template.header_type; if (!headerType) return null; @@ -103,14 +103,26 @@ function buildHeaderComponent( // sample (`example.header_handle`); it is NOT a reusable send-time // media id, and passing it as `{ id }` makes Meta reject the send. Only // an explicit `headerMediaId` (a real /media upload id) is honored. - const link = params.headerMediaUrl ?? template.header_media_url; - const id = params.headerMediaId; - if (!link && !id) { - throw new Error( - `${headerType} header requires a media link or id at send time — set header_media_url on the template or pass headerMediaUrl/headerMediaId.`, - ); + let mediaPayload: { link?: string; id?: string | number } = {}; + + if (params.headerMediaId) { + const id = params.headerMediaId; + if (typeof id === 'string' && /^\d+$/.test(id)) { + mediaPayload = { id: parseInt(id, 10) }; + } else { + mediaPayload = { id }; + } + } else { + const link = params.headerMediaUrl ?? template.header_media_url; + if (link) { + mediaPayload = { link }; + } else { + throw new Error( + `${headerType} header requires a media link or id at send time — set header_media_url on the template or pass headerMediaUrl/headerMediaId.`, + ); + } } - const mediaPayload: { link?: string; id?: string } = id ? { id } : { link }; + return { type: 'header', parameters: [ From 3a4686ece1c56a11ba87a1794a4ee43707a20c59 Mon Sep 17 00:00:00 2001 From: Dibash Date: Sat, 18 Jul 2026 02:07:04 +0530 Subject: [PATCH 4/7] feat(webhooks): add generic webhooks engine with rate limiting, signature verification, and configurable marketing/prefix options --- src/app/(dashboard)/contacts/page.tsx | 117 ++ src/app/(dashboard)/inbox/page.tsx | 96 +- src/app/(dashboard)/webhooks/page.tsx | 18 + src/app/api/webhooks/config/route.ts | 59 + src/app/api/webhooks/incoming/[id]/route.ts | 592 ++++++++ src/app/api/webhooks/logs/route.ts | 25 + src/app/api/webhooks/workflows/[id]/route.ts | 73 + src/app/api/webhooks/workflows/route.ts | 86 ++ src/app/api/whatsapp/broadcast/route.ts | 1 + src/app/api/whatsapp/config/route.ts | 3 +- src/components/layout/sidebar.tsx | 4 +- src/components/settings/settings-overview.tsx | 2 +- src/components/settings/settings-rail.tsx | 2 +- src/components/settings/whatsapp-config.tsx | 23 + .../webhooks/edit-workflow-modal.tsx | 1228 +++++++++++++++++ .../webhooks/webhook-configuration.tsx | 340 +++++ src/components/webhooks/webhook-logs.tsx | 187 +++ src/components/webhooks/webhook-overview.tsx | 133 ++ src/components/webhooks/webhook-tabs.tsx | 71 + src/components/webhooks/webhook-workflows.tsx | 263 ++++ src/hooks/use-auth.tsx | 10 + src/hooks/use-realtime.ts | 4 +- src/hooks/use-total-unread.ts | 7 +- src/lib/automations/meta-send.ts | 19 + src/lib/generic-webhooks/utils.test.ts | 135 ++ src/lib/generic-webhooks/utils.ts | 114 ++ src/lib/rate-limit.ts | 4 + src/lib/whatsapp/broadcast-core.ts | 3 + src/lib/whatsapp/meta-api.test.ts | 130 ++ src/lib/whatsapp/meta-api.ts | 11 +- src/lib/whatsapp/send-message.ts | 3 +- src/middleware.ts | 2 +- src/types/index.ts | 2 + supabase/migrations/038_generic_webhooks.sql | 75 + .../039_webhook_workflows_create_contacts.sql | 3 + .../040_webhook_workflows_recipient_email.sql | 3 + ...dd_marketing_gating_and_webhook_fields.sql | 6 + 37 files changed, 3792 insertions(+), 62 deletions(-) create mode 100644 src/app/(dashboard)/webhooks/page.tsx create mode 100644 src/app/api/webhooks/config/route.ts create mode 100644 src/app/api/webhooks/incoming/[id]/route.ts create mode 100644 src/app/api/webhooks/logs/route.ts create mode 100644 src/app/api/webhooks/workflows/[id]/route.ts create mode 100644 src/app/api/webhooks/workflows/route.ts create mode 100644 src/components/webhooks/edit-workflow-modal.tsx create mode 100644 src/components/webhooks/webhook-configuration.tsx create mode 100644 src/components/webhooks/webhook-logs.tsx create mode 100644 src/components/webhooks/webhook-overview.tsx create mode 100644 src/components/webhooks/webhook-tabs.tsx create mode 100644 src/components/webhooks/webhook-workflows.tsx create mode 100644 src/lib/generic-webhooks/utils.test.ts create mode 100644 src/lib/generic-webhooks/utils.ts create mode 100644 supabase/migrations/038_generic_webhooks.sql create mode 100644 supabase/migrations/039_webhook_workflows_create_contacts.sql create mode 100644 supabase/migrations/040_webhook_workflows_recipient_email.sql create mode 100644 supabase/migrations/041_add_marketing_gating_and_webhook_fields.sql 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..671e7767c7 --- /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 */} +
+
JT
+
+
Journey Tank 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'; From e95353fe148b692d184d9d3cde9f185bde25c77d Mon Sep 17 00:00:00 2001 From: Dibash Date: Sat, 18 Jul 2026 02:09:43 +0530 Subject: [PATCH 5/7] feat(auth): add canManageTemplates permission to useAuth hook --- src/hooks/use-auth.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/hooks/use-auth.tsx b/src/hooks/use-auth.tsx index c14434b7e4..00e012f4e6 100644 --- a/src/hooks/use-auth.tsx +++ b/src/hooks/use-auth.tsx @@ -16,6 +16,7 @@ import { DEFAULT_CURRENCY } from "@/lib/currency"; import { canEditSettings as canEditSettingsFor, canManageMembers as canManageMembersFor, + canManageTemplates as canManageTemplatesFor, canSendMessages as canSendMessagesFor, isAccountRole, type AccountRole, @@ -102,6 +103,8 @@ interface AuthContextValue { canEditSettings: boolean; /** True if the caller can send messages and edit operational data (agent+). */ canSendMessages: boolean; + /** True if the caller can manage WhatsApp message templates (agent+). */ + canManageTemplates: boolean; } const AuthContext = createContext(null); @@ -250,6 +253,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 +286,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); @@ -330,6 +343,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { canManageMembers: role ? canManageMembersFor(role) : false, canEditSettings: role ? canEditSettingsFor(role) : false, canSendMessages: role ? canSendMessagesFor(role) : false, + canManageTemplates: role ? canManageTemplatesFor(role) : false, }; }, [profile?.account_role, profile?.account_id]); @@ -383,6 +397,7 @@ export function useAuth(): AuthContextValue { canManageMembers: false, canEditSettings: false, canSendMessages: false, + canManageTemplates: false, }; } return ctx; From d6bbb7ea33b095b4a4acd833f44bcd90f176aa72 Mon Sep 17 00:00:00 2001 From: Dibash Date: Sat, 18 Jul 2026 02:20:56 +0530 Subject: [PATCH 6/7] chore(webhooks): rename hardcoded bot name in preview to generic WhatsApp Bot --- src/components/webhooks/edit-workflow-modal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/webhooks/edit-workflow-modal.tsx b/src/components/webhooks/edit-workflow-modal.tsx index 671e7767c7..c6b803d186 100644 --- a/src/components/webhooks/edit-workflow-modal.tsx +++ b/src/components/webhooks/edit-workflow-modal.tsx @@ -697,9 +697,9 @@ export function EditWorkflowModal({ workflow, lastPayload, onClose, onSave }: Ed
{/* Chat bubble header context */}
-
JT
+
WA
-
Journey Tank Bot
+
WhatsApp Bot
Business Account
From 55224d2a9b755cd371dafbe23d39d4e0ff6dc9d9 Mon Sep 17 00:00:00 2001 From: Dibash Date: Sat, 18 Jul 2026 02:30:10 +0530 Subject: [PATCH 7/7] fix(whatsapp): revert hallucinated dynamic META_APP_ID resolution --- .../whatsapp/template-header-handle.test.ts | 19 +------------------ src/lib/whatsapp/template-header-handle.ts | 15 +++++---------- 2 files changed, 6 insertions(+), 28 deletions(-) diff --git a/src/lib/whatsapp/template-header-handle.test.ts b/src/lib/whatsapp/template-header-handle.test.ts index a8476facc1..2d20630e0e 100644 --- a/src/lib/whatsapp/template-header-handle.test.ts +++ b/src/lib/whatsapp/template-header-handle.test.ts @@ -3,11 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Stub the Meta resumable upload so the helper is tested in isolation. vi.mock('./meta-api', () => ({ uploadResumableMedia: vi.fn(async () => ({ handle: 'HANDLE123' })), - getMetaAppId: vi.fn(async () => 'app-1'), })); import { ensureImageHeaderHandle } from './template-header-handle'; -import { uploadResumableMedia, getMetaAppId } from './meta-api'; +import { uploadResumableMedia } from './meta-api'; import type { TemplatePayload } from './template-validators'; function payload(over: Partial = {}): TemplatePayload { @@ -34,7 +33,6 @@ function imgResponse(type = 'image/jpeg', size = 1024, ok = true, status = 200): describe('ensureImageHeaderHandle', () => { beforeEach(() => { vi.mocked(uploadResumableMedia).mockClear(); - vi.mocked(getMetaAppId).mockClear(); }); afterEach(() => { vi.unstubAllGlobals(); @@ -55,21 +53,6 @@ describe('ensureImageHeaderHandle', () => { expect(p.header_handle).toBe('existing'); }); - it('resolves App ID dynamically when META_APP_ID is unset', async () => { - vi.stubGlobal('fetch', vi.fn(async () => imgResponse('image/jpeg', 2048))); - vi.mocked(getMetaAppId).mockResolvedValueOnce('resolved-app-id'); - const p = payload(); - await ensureImageHeaderHandle(p, 'tok'); - expect(getMetaAppId).toHaveBeenCalledWith('tok'); - expect(uploadResumableMedia).toHaveBeenCalledOnce(); - expect(p.header_handle).toBe('HANDLE123'); - }); - - it('throws an actionable error when META_APP_ID is unset and dynamic resolution fails', async () => { - vi.mocked(getMetaAppId).mockRejectedValueOnce(new Error('API Error')); - const p = payload(); - await expect(ensureImageHeaderHandle(p, 'tok')).rejects.toThrow(/dynamic resolution failed/); - }); it('derives + sets header_handle from a valid image URL', async () => { vi.stubEnv('META_APP_ID', 'app-1'); diff --git a/src/lib/whatsapp/template-header-handle.ts b/src/lib/whatsapp/template-header-handle.ts index 17f4886415..eb051830cf 100644 --- a/src/lib/whatsapp/template-header-handle.ts +++ b/src/lib/whatsapp/template-header-handle.ts @@ -1,4 +1,4 @@ -import { uploadResumableMedia, getMetaAppId } from '@/lib/whatsapp/meta-api' +import { uploadResumableMedia } from '@/lib/whatsapp/meta-api' import type { TemplatePayload } from '@/lib/whatsapp/template-validators' /** @@ -26,16 +26,11 @@ export async function ensureImageHeaderHandle( if (payload.header_handle) return // already have one if (!payload.header_media_url) return // validator already requires url-or-handle - let appId = process.env.META_APP_ID + const appId = process.env.META_APP_ID if (!appId) { - try { - appId = await getMetaAppId(accessToken) - } catch (e) { - console.error('Failed to dynamically resolve Meta App ID:', e) - throw new Error( - 'Image-header templates need META_APP_ID set (used for Meta’s Resumable Upload), and dynamic resolution failed. Add it to your environment, or remove the image header.', - ) - } + throw new Error( + 'Image-header templates need META_APP_ID set (used for Meta’s Resumable Upload). Add it to your environment, or remove the image header.', + ) } // Fetch the sample image bytes (works for our uploaded chat-media URL