diff --git a/src/app/(dashboard)/automations/[id]/edit/page.tsx b/src/app/(dashboard)/automations/[id]/edit/page.tsx new file mode 100644 index 0000000..8eed3ef --- /dev/null +++ b/src/app/(dashboard)/automations/[id]/edit/page.tsx @@ -0,0 +1,74 @@ +"use client" + +import { use, useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { Loader2 } from "lucide-react" + +import { + AutomationBuilder, + fromServerSteps, + type BuilderInitial, + type ServerStepNode, +} from "@/components/automations/automation-builder" +import type { AutomationTriggerType } from "@/types" + +export default function EditAutomationPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = use(params) + const router = useRouter() + const [initial, setInitial] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + async function load() { + const res = await fetch(`/api/automations/${id}`) + if (!res.ok) { + if (!cancelled) setError(`Failed to load (${res.status})`) + return + } + const body = await res.json() + if (cancelled) return + setInitial({ + id: body.automation.id, + name: body.automation.name ?? "", + description: body.automation.description ?? "", + trigger_type: body.automation.trigger_type as AutomationTriggerType, + trigger_config: body.automation.trigger_config ?? {}, + is_active: !!body.automation.is_active, + steps: fromServerSteps((body.steps ?? []) as ServerStepNode[]), + }) + } + load() + return () => { + cancelled = true + } + }, [id]) + + if (error) { + return ( +
+

{error}

+ +
+ ) + } + + if (!initial) { + return ( +
+ +
+ ) + } + + return +} diff --git a/src/app/(dashboard)/automations/[id]/logs/page.tsx b/src/app/(dashboard)/automations/[id]/logs/page.tsx new file mode 100644 index 0000000..d5124ed --- /dev/null +++ b/src/app/(dashboard)/automations/[id]/logs/page.tsx @@ -0,0 +1,205 @@ +"use client" + +import { use, useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { + ArrowLeft, + Check, + Loader2, + X, + ChevronDown, + ChevronRight, +} from "lucide-react" + +import { createClient } from "@/lib/supabase/client" +import type { + Automation, + AutomationLog, + AutomationLogStepResult, +} from "@/types" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { formatRelative } from "@/lib/automations/trigger-meta" + +export default function AutomationLogsPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = use(params) + const router = useRouter() + + const [automation, setAutomation] = useState(null) + const [logs, setLogs] = useState(null) + const [error, setError] = useState(null) + const [openLogId, setOpenLogId] = useState(null) + + useEffect(() => { + async function load() { + try { + const supabase = createClient() + const [autRes, logRes] = await Promise.all([ + supabase + .from("automations") + .select("*") + .eq("id", id) + .maybeSingle(), + supabase + .from("automation_logs") + .select("*, contact:contacts(id, name, phone)") + .eq("automation_id", id) + .order("created_at", { ascending: false }) + .limit(100), + ]) + if (autRes.error) throw autRes.error + if (logRes.error) throw logRes.error + setAutomation(autRes.data as Automation | null) + setLogs((logRes.data ?? []) as AutomationLog[]) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load logs") + } + } + load() + }, [id]) + + if (error) { + return ( +
+

{error}

+ +
+ ) + } + + if (!automation || logs === null) { + return ( +
+ +
+ ) + } + + return ( +
+
+ +
+

{automation.name}

+

Execution logs

+
+
+ + {logs.length === 0 ? ( +
+

No executions yet

+

+ Trigger this automation to see runs here. +

+
+ ) : ( +
    + {logs.map((log) => { + const isOpen = openLogId === log.id + return ( +
  • + + {isOpen && ( +
    + {log.error_message && ( +

    + {log.error_message} +

    + )} +
      + {(log.steps_executed ?? []).map((r, i) => ( + + ))} + {(log.steps_executed ?? []).length === 0 && ( +
    • No steps recorded.
    • + )} +
    +
    + )} +
  • + ) + })} +
+ )} +
+ ) +} + +function StatusBadge({ status }: { status: AutomationLog["status"] }) { + const classes = + status === "success" + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-300" + : status === "partial" + ? "border-amber-500/30 bg-amber-500/10 text-amber-300" + : "border-red-500/30 bg-red-500/10 text-red-300" + return ( + + {status} + + ) +} + +function StepRow({ result }: { result: AutomationLogStepResult }) { + const ok = result.status === "success" + return ( +
  • + + {ok ? : } + + {result.step_type} + {result.detail && ( + — {result.detail} + )} +
  • + ) +} diff --git a/src/app/(dashboard)/automations/new/page.tsx b/src/app/(dashboard)/automations/new/page.tsx new file mode 100644 index 0000000..d707984 --- /dev/null +++ b/src/app/(dashboard)/automations/new/page.tsx @@ -0,0 +1,90 @@ +"use client" + +import { useMemo } from "react" +import { useSearchParams } from "next/navigation" + +import { + AutomationBuilder, + type BuilderInitial, + type BuilderStep, +} from "@/components/automations/automation-builder" +import { AUTOMATION_TEMPLATES, type TemplateSlug } from "@/lib/automations/templates" +import type { AutomationStepType, AutomationTriggerType } from "@/types" + +export default function NewAutomationPage() { + const params = useSearchParams() + const template = params.get("template") as TemplateSlug | null + + const initial: BuilderInitial = useMemo(() => { + if (template && AUTOMATION_TEMPLATES[template]) { + const t = AUTOMATION_TEMPLATES[template] + const steps = expandFromSeeds( + t.steps.map((seed, idx) => ({ + index: idx, + step_type: seed.step_type, + step_config: seed.step_config as Record, + branch: seed.branch ?? null, + parent_index: seed.parent_index ?? null, + })), + ) + return { + name: t.name, + description: t.description, + trigger_type: t.trigger_type, + trigger_config: t.trigger_config as Record, + is_active: false, + steps, + } + } + return { + name: "", + description: "", + trigger_type: "new_message_received" as AutomationTriggerType, + trigger_config: {}, + is_active: false, + steps: [], + } + }, [template]) + + return +} + +interface SeedRow { + index: number + step_type: AutomationStepType + step_config: Record + branch: "yes" | "no" | null + parent_index: number | null +} + +function uid(): string { + return ( + "c_" + + (typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : Math.random().toString(36).slice(2) + Date.now().toString(36)) + ) +} + +/** Template seeds are flat with parent_index references. Expand into the + * builder's nested tree, preserving order within each scope. */ +function expandFromSeeds(rows: SeedRow[]): BuilderStep[] { + const nodes: BuilderStep[] = rows.map((r) => ({ + cid: uid(), + step_type: r.step_type, + step_config: r.step_config, + branches: + r.step_type === "condition" ? { yes: [], no: [] } : undefined, + })) + const roots: BuilderStep[] = [] + rows.forEach((r, i) => { + if (r.parent_index == null) { + roots.push(nodes[i]) + return + } + const parent = nodes[r.parent_index] + if (!parent.branches) parent.branches = { yes: [], no: [] } + parent.branches[r.branch ?? "yes"].push(nodes[i]) + }) + return roots +} diff --git a/src/app/(dashboard)/automations/page.tsx b/src/app/(dashboard)/automations/page.tsx index c4ba314..021ae6e 100644 --- a/src/app/(dashboard)/automations/page.tsx +++ b/src/app/(dashboard)/automations/page.tsx @@ -1,32 +1,358 @@ -"use client"; +"use client" -import { Zap } from "lucide-react"; +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { + Zap, + Plus, + MoreVertical, + Copy, + Pencil, + Trash2, + FileText, + MessageCircle, + Clock, + Users, + PhoneCall, + Loader2, +} from "lucide-react" + +import { createClient } from "@/lib/supabase/client" +import type { Automation } from "@/types" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { AUTOMATION_TEMPLATES, type TemplateSlug } from "@/lib/automations/templates" +import { triggerMeta, formatRelative } from "@/lib/automations/trigger-meta" +import { cn } from "@/lib/utils" + +const TEMPLATE_ORDER: TemplateSlug[] = [ + "welcome_message", + "out_of_office", + "lead_qualifier", + "follow_up_reminder", +] + +const TEMPLATE_ICON: Record = { + welcome_message: MessageCircle, + out_of_office: Clock, + lead_qualifier: Users, + follow_up_reminder: PhoneCall, +} export default function AutomationsPage() { + const router = useRouter() + const [automations, setAutomations] = useState(null) + const [error, setError] = useState(null) + const [pendingDelete, setPendingDelete] = useState(null) + const [deleting, setDeleting] = useState(false) + + async function load() { + try { + const supabase = createClient() + const { data, error: fetchErr } = await supabase + .from("automations") + .select("*") + .order("created_at", { ascending: false }) + if (fetchErr) throw fetchErr + setAutomations((data ?? []) as Automation[]) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load automations") + } + } + + useEffect(() => { + load() + }, []) + + async function toggleActive(a: Automation, next: boolean) { + // Optimistic flip so the switch feels instant. + setAutomations((prev) => + prev?.map((x) => (x.id === a.id ? { ...x, is_active: next } : x)) ?? prev, + ) + const res = await fetch(`/api/automations/${a.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ is_active: next }), + }) + if (!res.ok) { + // Roll back on error. + setAutomations((prev) => + prev?.map((x) => (x.id === a.id ? { ...x, is_active: !next } : x)) ?? prev, + ) + const body = await res.json().catch(() => ({})) + toast.error(body?.error ?? "Failed to update") + return + } + toast.success(next ? "Automation activated" : "Automation paused") + } + + async function duplicate(a: Automation) { + const res = await fetch(`/api/automations/${a.id}/duplicate`, { method: "POST" }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + toast.error(body?.error ?? "Failed to duplicate") + return + } + toast.success("Automation duplicated") + load() + } + + async function confirmDelete() { + if (!pendingDelete) return + setDeleting(true) + const res = await fetch(`/api/automations/${pendingDelete.id}`, { method: "DELETE" }) + setDeleting(false) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + toast.error(body?.error ?? "Failed to delete") + return + } + toast.success("Automation deleted") + setPendingDelete(null) + load() + } + + async function startFromTemplate(slug: TemplateSlug) { + router.push(`/automations/new?template=${slug}`) + } + + if (error) { + return ( +
    +

    {error}

    + +
    + ) + } + + if (automations === null) { + return ( +
    + +
    + ) + } + + const showTemplates = automations.length < 3 + return (
    -
    -

    Automations

    -

    - Automate your WhatsApp messaging workflows -

    -
    - -
    -
    - -
    -

    Coming Soon

    -

    - Set up automated responses, workflows, and triggers for your WhatsApp - messages. -

    -
    -

    - This feature is under development +

    +
    +

    Automations

    +

    + Build workflows that react to WhatsApp events automatically.

    +
    + + {showTemplates && ( +
    +

    Quick-start templates

    +
    + {TEMPLATE_ORDER.map((slug) => { + const t = AUTOMATION_TEMPLATES[slug] + const Icon = TEMPLATE_ICON[slug] + return ( + + ) + })} +
    +
    + )} + + {automations.length === 0 ? ( +
    +
    + +
    +

    No automations yet

    +

    + Pick a template above or create one from scratch. +

    +
    + ) : ( +
      + {automations.map((a) => ( + toggleActive(a, next)} + onEdit={() => router.push(`/automations/${a.id}/edit`)} + onDuplicate={() => duplicate(a)} + onLogs={() => router.push(`/automations/${a.id}/logs`)} + onDelete={() => setPendingDelete(a)} + /> + ))} +
    + )} + + !v && setPendingDelete(null)}> + + + Delete automation + + This permanently removes{" "} + {pendingDelete?.name} and its execution + history. This cannot be undone. + + + + + + + +
    - ); + ) +} + +function AutomationCard({ + automation, + onToggle, + onEdit, + onDuplicate, + onLogs, + onDelete, +}: { + automation: Automation + onToggle: (next: boolean) => void + onEdit: () => void + onDuplicate: () => void + onLogs: () => void + onDelete: () => void +}) { + const meta = triggerMeta(automation.trigger_type) + return ( +
  • +
    +
    + +
    + + + +
    + onToggle(!!v)} + aria-label={automation.is_active ? "Deactivate" : "Activate"} + /> + + + + + + + + + Edit + + + + Duplicate + + + + View Logs + + + + + Delete + + + +
    +
    +
  • + ) } diff --git a/src/app/api/automations/[id]/duplicate/route.ts b/src/app/api/automations/[id]/duplicate/route.ts new file mode 100644 index 0000000..0c41158 --- /dev/null +++ b/src/app/api/automations/[id]/duplicate/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { supabaseAdmin } from '@/lib/automations/admin-client' + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const admin = supabaseAdmin() + const { data: original, error: origErr } = await admin + .from('automations') + .select('*') + .eq('id', id) + .eq('user_id', user.id) + .maybeSingle() + if (origErr) return NextResponse.json({ error: origErr.message }, { status: 500 }) + if (!original) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + const { data: copy, error: copyErr } = await admin + .from('automations') + .insert({ + user_id: user.id, + name: `${original.name} (Copy)`, + description: original.description, + trigger_type: original.trigger_type, + trigger_config: original.trigger_config, + is_active: false, + }) + .select() + .single() + if (copyErr || !copy) { + return NextResponse.json({ error: copyErr?.message ?? 'copy failed' }, { status: 500 }) + } + + const { data: steps } = await admin + .from('automation_steps') + .select('id, parent_step_id, branch, step_type, step_config, position') + .eq('automation_id', id) + .order('position', { ascending: true }) + + if (steps && steps.length > 0) { + // Re-map parent_step_id: build old→new id map first so the second + // pass inserts rows with correct parent references. + const idMap = new Map() + const uid = () => + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : Math.random().toString(36).slice(2) + Date.now().toString(36) + for (const row of steps) idMap.set(row.id as string, uid()) + + const rows = steps.map((row) => ({ + id: idMap.get(row.id as string)!, + automation_id: copy.id, + parent_step_id: row.parent_step_id ? idMap.get(row.parent_step_id as string) : null, + branch: row.branch, + step_type: row.step_type, + step_config: row.step_config, + position: row.position, + })) + const { error: insErr } = await admin.from('automation_steps').insert(rows) + if (insErr) return NextResponse.json({ error: insErr.message }, { status: 500 }) + } + + return NextResponse.json({ automation: copy }, { status: 201 }) +} diff --git a/src/app/api/automations/[id]/route.ts b/src/app/api/automations/[id]/route.ts new file mode 100644 index 0000000..8e905fa --- /dev/null +++ b/src/app/api/automations/[id]/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { supabaseAdmin } from '@/lib/automations/admin-client' +import { + loadStepsTree, + replaceSteps, + type BuilderStepInput, +} from '@/lib/automations/steps-tree' + +async function requireUser() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + return user +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const user = await requireUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const admin = supabaseAdmin() + const { data: automation, error } = await admin + .from('automations') + .select('*') + .eq('id', id) + .eq('user_id', user.id) + .maybeSingle() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (!automation) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + const steps = await loadStepsTree(id) + return NextResponse.json({ automation, steps }) +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const user = await requireUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body = await request.json().catch(() => null) + if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + + const admin = supabaseAdmin() + + // Ownership check before we touch anything. + const { data: existing } = await admin + .from('automations') + .select('id, user_id') + .eq('id', id) + .maybeSingle() + if (!existing || existing.user_id !== user.id) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const update: Record = {} + for (const k of [ + 'name', + 'description', + 'trigger_type', + 'trigger_config', + 'is_active', + ] as const) { + if (k in body) update[k] = body[k] + } + + if (Object.keys(update).length > 0) { + const { error: updErr } = await admin + .from('automations') + .update(update) + .eq('id', id) + if (updErr) return NextResponse.json({ error: updErr.message }, { status: 500 }) + } + + if (Array.isArray(body.steps)) { + const err = await replaceSteps(id, body.steps as BuilderStepInput[]) + if (err) return NextResponse.json({ error: err }, { status: 500 }) + } + + return NextResponse.json({ ok: true }) +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const user = await requireUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { error } = await supabaseAdmin() + .from('automations') + .delete() + .eq('id', id) + .eq('user_id', user.id) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ ok: true }) +} diff --git a/src/app/api/automations/cron/route.ts b/src/app/api/automations/cron/route.ts new file mode 100644 index 0000000..e7527c3 --- /dev/null +++ b/src/app/api/automations/cron/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server' +import { supabaseAdmin } from '@/lib/automations/admin-client' +import { resumePendingExecution } from '@/lib/automations/engine' +import type { AutomationContext } from '@/lib/automations/engine' + +/** + * Drain due `automation_pending_executions` rows. Meant to be hit + * on a schedule (Vercel Cron / external pinger) — requires a shared + * secret via the `x-cron-secret` header to match + * `AUTOMATION_CRON_SECRET`. + * + * The claim step (status = 'running') serves as a simple lock so + * overlapping invocations don't double-process rows. Best-effort + * only; expensive SELECT ... FOR UPDATE is avoided in favor of a + * two-step UPDATE-by-id. + */ +export async function GET(request: Request) { + const expected = process.env.AUTOMATION_CRON_SECRET + if (!expected) { + return NextResponse.json({ error: 'cron not configured' }, { status: 503 }) + } + const supplied = request.headers.get('x-cron-secret') + if (supplied !== expected) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const admin = supabaseAdmin() + const { data: due, error } = await admin + .from('automation_pending_executions') + .select('*') + .eq('status', 'pending') + .lte('run_at', new Date().toISOString()) + .order('run_at', { ascending: true }) + .limit(50) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (!due || due.length === 0) return NextResponse.json({ processed: 0 }) + + let processed = 0 + for (const row of due) { + const { data: claim } = await admin + .from('automation_pending_executions') + .update({ status: 'running' }) + .eq('id', row.id) + .eq('status', 'pending') + .select('id') + .maybeSingle() + if (!claim) continue + + await resumePendingExecution({ + id: row.id as string, + automation_id: row.automation_id as string, + user_id: row.user_id as string, + contact_id: (row.contact_id as string | null) ?? null, + log_id: (row.log_id as string | null) ?? null, + parent_step_id: (row.parent_step_id as string | null) ?? null, + branch: (row.branch as 'yes' | 'no' | null) ?? null, + next_step_position: row.next_step_position as number, + context: (row.context as AutomationContext) ?? {}, + }) + processed++ + } + + return NextResponse.json({ processed }) +} diff --git a/src/app/api/automations/engine/route.ts b/src/app/api/automations/engine/route.ts new file mode 100644 index 0000000..1be8d99 --- /dev/null +++ b/src/app/api/automations/engine/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { runAutomationsForTrigger } from '@/lib/automations/engine' +import type { AutomationTriggerType } from '@/types' + +/** + * Manual trigger for testing or for external integrations that want + * to fire automations. Auth is required — the caller's user_id is + * used so RLS-safe data remains per-user. + */ +export async function POST(request: Request) { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body = await request.json().catch(() => null) + if (!body?.trigger_type) { + return NextResponse.json({ error: 'trigger_type required' }, { status: 400 }) + } + + await runAutomationsForTrigger({ + userId: user.id, + triggerType: body.trigger_type as AutomationTriggerType, + contactId: body.contact_id ?? null, + context: body.context ?? {}, + }) + + return NextResponse.json({ ok: true }) +} diff --git a/src/app/api/automations/route.ts b/src/app/api/automations/route.ts new file mode 100644 index 0000000..63e914a --- /dev/null +++ b/src/app/api/automations/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { supabaseAdmin } from '@/lib/automations/admin-client' +import { getTemplate } from '@/lib/automations/templates' +import { insertSteps, type BuilderStepInput } from '@/lib/automations/steps-tree' + +export async function GET() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { data, error } = await supabase + .from('automations') + .select('*') + .order('created_at', { ascending: false }) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ automations: data ?? [] }) +} + +export async function POST(request: Request) { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body = await request.json().catch(() => null) + if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + + const { name, description, trigger_type, trigger_config, is_active, steps, template } = body + + let effectiveSteps: BuilderStepInput[] | undefined = steps + let effectiveName = name + let effectiveDescription = description + let effectiveTriggerType = trigger_type + let effectiveTriggerConfig = trigger_config + + if (template && (!steps || steps.length === 0)) { + const t = getTemplate(template) + if (t) { + effectiveName = effectiveName ?? t.name + effectiveDescription = effectiveDescription ?? t.description + effectiveTriggerType = effectiveTriggerType ?? t.trigger_type + effectiveTriggerConfig = effectiveTriggerConfig ?? t.trigger_config + effectiveSteps = t.steps as unknown as BuilderStepInput[] + } + } + + if (!effectiveName || !effectiveTriggerType) { + return NextResponse.json( + { error: 'name and trigger_type are required' }, + { status: 400 }, + ) + } + + const admin = supabaseAdmin() + const { data: automation, error: insertErr } = await admin + .from('automations') + .insert({ + user_id: user.id, + name: effectiveName, + description: effectiveDescription ?? null, + trigger_type: effectiveTriggerType, + trigger_config: effectiveTriggerConfig ?? {}, + is_active: !!is_active, + }) + .select() + .single() + + if (insertErr || !automation) { + return NextResponse.json( + { error: insertErr?.message ?? 'insert failed' }, + { status: 500 }, + ) + } + + if (effectiveSteps && effectiveSteps.length > 0) { + const err = await insertSteps(automation.id, effectiveSteps) + if (err) return NextResponse.json({ error: err }, { status: 500 }) + } + + return NextResponse.json({ automation }, { status: 201 }) +} diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 37c0d2a..0ae975e 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -4,6 +4,7 @@ import { decrypt } from '@/lib/whatsapp/encryption' import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api' import { normalizePhone, phonesMatch } from '@/lib/whatsapp/phone-utils' import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature' +import { runAutomationsForTrigger } from '@/lib/automations/engine' // Lazy-initialized to avoid build-time crash when env vars are missing // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -345,12 +346,24 @@ async function processMessage( ) // Find or create contact - const contactRecord = await findOrCreateContact( + const contactOutcome = await findOrCreateContact( userId, senderPhone, contactName ) - if (!contactRecord) return + if (!contactOutcome) return + const contactRecord = contactOutcome.contact + // Fire new_contact_created automations for genuinely-new contacts. + // Fire-and-forget — the engine never throws, and webhook latency + // must not be tied to automation execution. + if (contactOutcome.wasCreated) { + runAutomationsForTrigger({ + userId, + triggerType: 'new_contact_created', + contactId: contactRecord.id, + context: { message_text: message.text?.body }, + }).catch((err) => console.error('[automations] dispatch failed:', err)) + } // Find or create conversation const conversation = await findOrCreateConversation( @@ -416,6 +429,22 @@ async function processMessage( // so the broadcast's `replied_count` advances (via the aggregate // trigger installed in migration 003). await flagBroadcastReplyIfAny(userId, contactRecord.id) + + // Fire any automations that watch inbound messages (new_message_received + // + keyword_match — the engine filters by keyword internally). Kept + // fire-and-forget so a slow/failing automation never blocks the webhook. + const inboundText = contentText ?? message.text?.body ?? '' + for (const triggerType of ['new_message_received', 'keyword_match'] as const) { + runAutomationsForTrigger({ + userId, + triggerType, + contactId: contactRecord.id, + context: { + message_text: inboundText, + conversation_id: conversation.id, + }, + }).catch((err) => console.error('[automations] dispatch failed:', err)) + } } async function parseMessageContent( @@ -537,11 +566,21 @@ async function parseMessageContent( } } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ContactRow = any + +interface ContactOutcome { + contact: ContactRow + /** True when this call created the row; drives new_contact_created + * automation dispatch in processMessage. */ + wasCreated: boolean +} + async function findOrCreateContact( userId: string, phone: string, name: string -) { +): Promise { // Look up existing contacts for this user const { data: contacts, error: contactsError } = await supabaseAdmin() .from('contacts') @@ -554,8 +593,7 @@ async function findOrCreateContact( } // Use phonesMatch for flexible matching - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const existingContact = contacts?.find((c: any) => phonesMatch(c.phone, phone)) + const existingContact = contacts?.find((c: ContactRow) => phonesMatch(c.phone, phone)) if (existingContact) { // Update name if it changed @@ -565,7 +603,7 @@ async function findOrCreateContact( .update({ name, updated_at: new Date().toISOString() }) .eq('id', existingContact.id) } - return existingContact + return { contact: existingContact, wasCreated: false } } // Create new contact @@ -584,7 +622,7 @@ async function findOrCreateContact( return null } - return newContact + return { contact: newContact, wasCreated: true } } async function findOrCreateConversation(userId: string, contactId: string) { diff --git a/src/components/automations/automation-builder.tsx b/src/components/automations/automation-builder.tsx new file mode 100644 index 0000000..7371e01 --- /dev/null +++ b/src/components/automations/automation-builder.tsx @@ -0,0 +1,1144 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { + ArrowLeft, + ChevronDown, + Plus, + Trash2, + GripVertical, + MessageSquare, + FileText, + Tag, + TagIcon, + UserCheck, + PencilLine, + Briefcase, + Hourglass, + GitBranch, + Webhook, + CircleSlash, + Zap, + Loader2, + ArrowDown, + ArrowUp, +} from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Switch } from "@/components/ui/switch" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import type { + AutomationStepType, + AutomationTriggerType, + KeywordMatchTriggerConfig, +} from "@/types" +import { cn } from "@/lib/utils" + +// ------------------------------------------------------------ +// Types (builder-local — mirror the flattened rows we POST) +// ------------------------------------------------------------ + +export interface BuilderStep { + /** Client id; the API assigns real UUIDs server-side. */ + cid: string + step_type: AutomationStepType + step_config: Record + branches?: { yes: BuilderStep[]; no: BuilderStep[] } +} + +export interface BuilderInitial { + id?: string + name: string + description: string + trigger_type: AutomationTriggerType + trigger_config: Record + is_active: boolean + steps: BuilderStep[] +} + +// ------------------------------------------------------------ +// Step metadata — one source of truth for icon + label + border color +// ------------------------------------------------------------ + +interface StepMeta { + label: string + icon: typeof Zap + /** Left-border accent color per spec. */ + border: string +} + +const STEP_META: Record = { + send_message: { label: "Send Message", icon: MessageSquare, border: "border-l-emerald-500" }, + send_template: { label: "Send Template", icon: FileText, border: "border-l-emerald-500" }, + add_tag: { label: "Add Tag", icon: Tag, border: "border-l-emerald-500" }, + remove_tag: { label: "Remove Tag", icon: TagIcon, border: "border-l-emerald-500" }, + assign_conversation: { label: "Assign Conversation", icon: UserCheck, border: "border-l-emerald-500" }, + update_contact_field: { label: "Update Contact Field", icon: PencilLine, border: "border-l-emerald-500" }, + create_deal: { label: "Create Deal", icon: Briefcase, border: "border-l-emerald-500" }, + wait: { label: "Wait", icon: Hourglass, border: "border-l-slate-500" }, + condition: { label: "Condition (If/Else)", icon: GitBranch, border: "border-l-amber-500" }, + send_webhook: { label: "Send Webhook", icon: Webhook, border: "border-l-emerald-500" }, + close_conversation: { label: "Close Conversation", icon: CircleSlash, border: "border-l-emerald-500" }, +} + +const ADDABLE_STEPS: AutomationStepType[] = [ + "send_message", + "send_template", + "add_tag", + "remove_tag", + "assign_conversation", + "update_contact_field", + "create_deal", + "wait", + "condition", + "send_webhook", + "close_conversation", +] + +const TRIGGER_OPTIONS: { value: AutomationTriggerType; label: string; hint: string }[] = [ + { value: "new_message_received", label: "New Message Received", hint: "Any incoming message" }, + { value: "keyword_match", label: "Keyword Match", hint: "Message contains specific keyword(s)" }, + { value: "new_contact_created", label: "New Contact Created", hint: "When a contact is auto-created" }, + { value: "conversation_assigned", label: "Conversation Assigned", hint: "When assigned to an agent" }, + { value: "tag_added", label: "Tag Added", hint: "When a tag is added to a contact" }, + { value: "time_based", label: "Time-Based", hint: "On a recurring schedule" }, +] + +function cid(): string { + return ( + "c_" + + (typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : Math.random().toString(36).slice(2) + Date.now().toString(36)) + ) +} + +function blankConfig(type: AutomationStepType): Record { + switch (type) { + case "send_message": + return { text: "" } + case "send_template": + return { template_name: "", language: "en_US" } + case "add_tag": + case "remove_tag": + return { tag_id: "" } + case "assign_conversation": + return { mode: "round_robin" } + case "update_contact_field": + return { field: "name", value: "" } + case "create_deal": + return { pipeline_id: "", stage_id: "", title: "", value: 0 } + case "wait": + return { amount: 1, unit: "hours" } + case "condition": + return { subject: "tag_presence", operand: "", value: "" } + case "send_webhook": + return { url: "", headers: {}, body_template: "" } + case "close_conversation": + return {} + default: + return {} + } +} + +// ------------------------------------------------------------ +// Main builder component +// ------------------------------------------------------------ + +export function AutomationBuilder({ initial }: { initial: BuilderInitial }) { + const router = useRouter() + const isEditing = !!initial.id + const [state, setState] = useState(initial) + const [saving, setSaving] = useState(false) + const [expandedId, setExpandedId] = useState(null) + + function patchTop(key: K, value: BuilderInitial[K]) { + setState((s) => ({ ...s, [key]: value })) + } + + // --- Step tree mutations (immutable) --- + + function updateStep(path: StepPath, updater: (s: BuilderStep) => BuilderStep) { + setState((s) => ({ ...s, steps: mapAtPath(s.steps, path, updater) })) + } + + function addStepAt(parent: ParentScope, index: number, type: AutomationStepType) { + const node: BuilderStep = { + cid: cid(), + step_type: type, + step_config: blankConfig(type), + branches: type === "condition" ? { yes: [], no: [] } : undefined, + } + setState((s) => ({ ...s, steps: insertAt(s.steps, parent, index, node) })) + setExpandedId(node.cid) + } + + function deleteStepAt(path: StepPath) { + setState((s) => ({ ...s, steps: removeAt(s.steps, path) })) + } + + function moveStepAt(path: StepPath, direction: -1 | 1) { + setState((s) => ({ ...s, steps: moveAt(s.steps, path, direction) })) + } + + async function save() { + setSaving(true) + try { + const payload = { + name: state.name || "Untitled automation", + description: state.description || null, + trigger_type: state.trigger_type, + trigger_config: state.trigger_config, + is_active: state.is_active, + steps: toApiSteps(state.steps), + } + + const res = isEditing + ? await fetch(`/api/automations/${initial.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }) + : await fetch(`/api/automations`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }) + + const body = await res.json().catch(() => ({})) + if (!res.ok) { + toast.error(body?.error ?? "Save failed") + return + } + toast.success(isEditing ? "Automation saved" : "Automation created") + if (!isEditing && body?.automation?.id) { + router.replace(`/automations/${body.automation.id}/edit`) + } + } finally { + setSaving(false) + } + } + + return ( +
    + {/* Top bar */} +
    + + patchTop("name", e.target.value)} + placeholder="Untitled automation" + className="flex-1 rounded-md bg-transparent px-2 py-1 text-base font-semibold text-white placeholder:text-slate-500 focus:bg-slate-800 focus:outline-none" + /> +
    + Active + patchTop("is_active", !!v)} + /> +
    + +
    + + {/* Canvas */} +
    +
    +
    + patchTop("trigger_type", t)} + onConfigChange={(c) => patchTop("trigger_config", c)} + /> + +
    +
    +
    + ) +} + +// ------------------------------------------------------------ +// Trigger card +// ------------------------------------------------------------ + +function TriggerCard({ + type, + config, + onTypeChange, + onConfigChange, +}: { + type: AutomationTriggerType + config: Record + onTypeChange: (t: AutomationTriggerType) => void + onConfigChange: (c: Record) => void +}) { + const [open, setOpen] = useState(false) + return ( +
    +
    + + {open && ( +
    +
    + + +

    + {TRIGGER_OPTIONS.find((o) => o.value === type)?.hint} +

    +
    + {type === "keyword_match" && ( + + )} + {type === "tag_added" && ( + + onConfigChange({ ...config, tag_id: e.target.value }) + } + className="bg-slate-800 text-white" + /> + )} + {type === "time_based" && ( + + onConfigChange({ ...config, schedule: e.target.value }) + } + className="bg-slate-800 text-white" + /> + )} +
    + )} +
    +
    + ) +} + +function KeywordMatchConfig({ + config, + onChange, +}: { + config: KeywordMatchTriggerConfig + onChange: (c: Record) => void +}) { + const keywords = config?.keywords ?? [] + return ( +
    +
    + + + onChange({ + ...config, + keywords: e.target.value + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + }) + } + className="bg-slate-800 text-white" + /> +
    +
    + + +
    +
    + ) +} + +// ------------------------------------------------------------ +// Step list + card + connectors +// ------------------------------------------------------------ + +type ParentScope = + | { kind: "root" } + | { kind: "branch"; parentCid: string; branch: "yes" | "no" } + +type StepPath = ( + | { kind: "root"; index: number } + | { kind: "branch"; parentCid: string; branch: "yes" | "no"; index: number } +)[] + +interface StepListProps { + steps: BuilderStep[] + parentPath: StepPath + expandedId: string | null + setExpandedId: (id: string | null) => void + updateStep: (path: StepPath, updater: (s: BuilderStep) => BuilderStep) => void + addStepAt: (parent: ParentScope, index: number, type: AutomationStepType) => void + deleteStepAt: (path: StepPath) => void + moveStepAt: (path: StepPath, direction: -1 | 1) => void +} + +function StepList(props: StepListProps) { + const { steps, parentPath, ...rest } = props + const parentScope: ParentScope = + parentPath.length === 0 + ? { kind: "root" } + : (() => { + const last = parentPath[parentPath.length - 1] + if (last.kind !== "branch") return { kind: "root" } as const + return { kind: "branch", parentCid: last.parentCid, branch: last.branch } as const + })() + + return ( +
    + props.addStepAt(parentScope, 0, t)} /> + {steps.map((step, idx) => ( + + ))} +
    + ) +} + +function StepRenderer({ + step, + index, + total, + parentScope, + parentPath, + ...props +}: { + step: BuilderStep + index: number + total: number + parentScope: ParentScope + parentPath: StepPath +} & Omit) { + const path: StepPath = [ + ...parentPath, + parentScope.kind === "root" + ? { kind: "root", index } + : { kind: "branch", parentCid: parentScope.parentCid, branch: parentScope.branch, index }, + ] + const meta = STEP_META[step.step_type] + const Icon = meta.icon + const expanded = props.expandedId === step.cid + const isCondition = step.step_type === "condition" + const width = isCondition ? "w-[400px]" : "w-80" + + return ( + <> +
    +
    + + {expanded && ( +
    + props.updateStep(path, () => next)} + /> +
    +
    + + +
    + +
    +
    + )} +
    + + {isCondition && ( + + )} +
    + + props.addStepAt(parentScope, index + 1, t)} + /> + + ) +} + +function ConditionBranches({ + step, + parentPath, + ...props +}: { + step: BuilderStep + parentPath: StepPath +} & Omit) { + const yes = step.branches?.yes ?? [] + const no = step.branches?.no ?? [] + // Build the child scope by appending a branch marker. The scope the + // StepList uses is driven by the LAST element of parentPath, so the + // tail's `index` doesn't matter — it's replaced per child during walks. + const yesPath: StepPath = [ + ...parentPath, + { kind: "branch", parentCid: step.cid, branch: "yes", index: 0 }, + ] + const noPath: StepPath = [ + ...parentPath, + { kind: "branch", parentCid: step.cid, branch: "no", index: 0 }, + ] + return ( +
    + + + + + + +
    + ) +} + +function BranchColumn({ + label, + color, + children, +}: { + label: string + color: string + children: React.ReactNode +}) { + return ( +
    +
    {label}
    + {children} +
    + ) +} + +function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) { + return ( +
    +
    + + + + + + {ADDABLE_STEPS.map((t) => { + const Icon = STEP_META[t].icon + return ( + onPick(t)}> + + {STEP_META[t].label} + + ) + })} + + +
    +
    + ) +} + +// ------------------------------------------------------------ +// Per-step config editor +// ------------------------------------------------------------ + +function StepEditor({ + step, + onChange, +}: { + step: BuilderStep + onChange: (s: BuilderStep) => void +}) { + const cfg = step.step_config + const set = (patch: Record) => + onChange({ ...step, step_config: { ...cfg, ...patch } }) + + switch (step.step_type) { + case "send_message": + return ( + +