Merge pull request #31 from ArnasDon/feat/automations

feat(automations): visual flow builder, engine, and logs viewer
This commit is contained in:
Arnas Donauskas 2026-04-17 20:10:02 +03:00 committed by GitHub
commit e8c952b129
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 3509 additions and 29 deletions

View File

@ -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<BuilderInitial | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="flex h-screen flex-col items-center justify-center gap-3">
<p className="text-sm text-red-400">{error}</p>
<button
onClick={() => router.push("/automations")}
className="text-sm text-emerald-400 hover:text-emerald-300"
>
Back to Automations
</button>
</div>
)
}
if (!initial) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
</div>
)
}
return <AutomationBuilder initial={initial} />
}

View File

@ -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<Automation | null>(null)
const [logs, setLogs] = useState<AutomationLog[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [openLogId, setOpenLogId] = useState<string | null>(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 (
<div className="flex h-64 flex-col items-center justify-center gap-3">
<p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => router.push("/automations")}>
Back
</Button>
</div>
)
}
if (!automation || logs === null) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => router.push("/automations")}
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div>
<h1 className="text-2xl font-bold text-white">{automation.name}</h1>
<p className="mt-0.5 text-sm text-slate-400">Execution logs</p>
</div>
</div>
{logs.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-slate-800 bg-slate-900/40">
<p className="text-sm text-white">No executions yet</p>
<p className="mt-1 text-xs text-slate-400">
Trigger this automation to see runs here.
</p>
</div>
) : (
<ul className="space-y-2">
{logs.map((log) => {
const isOpen = openLogId === log.id
return (
<li
key={log.id}
className="rounded-xl border border-slate-800 bg-slate-900"
>
<button
type="button"
onClick={() => setOpenLogId(isOpen ? null : log.id)}
className="flex w-full items-center gap-3 px-4 py-3 text-left"
>
{isOpen ? (
<ChevronDown className="h-4 w-4 text-slate-500" />
) : (
<ChevronRight className="h-4 w-4 text-slate-500" />
)}
<StatusBadge status={log.status} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">
{log.contact?.name ?? log.contact?.phone ?? "Unknown contact"}
</div>
<div className="truncate text-xs text-slate-500">
{log.trigger_event} · {log.steps_executed?.length ?? 0} step
{log.steps_executed?.length === 1 ? "" : "s"}
</div>
</div>
<div className="text-xs text-slate-500">
{formatRelative(log.created_at)}
</div>
</button>
{isOpen && (
<div className="border-t border-slate-800 px-4 py-3">
{log.error_message && (
<p className="mb-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{log.error_message}
</p>
)}
<ul className="space-y-1.5">
{(log.steps_executed ?? []).map((r, i) => (
<StepRow key={i} result={r} />
))}
{(log.steps_executed ?? []).length === 0 && (
<li className="text-xs text-slate-500">No steps recorded.</li>
)}
</ul>
</div>
)}
</li>
)
})}
</ul>
)}
</div>
)
}
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 (
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium",
classes,
)}
>
{status}
</span>
)
}
function StepRow({ result }: { result: AutomationLogStepResult }) {
const ok = result.status === "success"
return (
<li className="flex items-start gap-2 text-xs">
<span
className={cn(
"mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full",
ok ? "bg-emerald-500/20 text-emerald-400" : "bg-red-500/20 text-red-400",
)}
aria-hidden
>
{ok ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
</span>
<span className="text-slate-300">{result.step_type}</span>
{result.detail && (
<span className="truncate text-slate-500">— {result.detail}</span>
)}
</li>
)
}

View File

@ -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<string, unknown>,
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<string, unknown>,
is_active: false,
steps,
}
}
return {
name: "",
description: "",
trigger_type: "new_message_received" as AutomationTriggerType,
trigger_config: {},
is_active: false,
steps: [],
}
}, [template])
return <AutomationBuilder initial={initial} />
}
interface SeedRow {
index: number
step_type: AutomationStepType
step_config: Record<string, unknown>
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
}

View File

@ -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<TemplateSlug, typeof Zap> = {
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<Automation[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [pendingDelete, setPendingDelete] = useState<Automation | null>(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 (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
)
}
if (automations === null) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
</div>
)
}
const showTemplates = automations.length < 3
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Automations</h1>
<p className="mt-1 text-sm text-slate-400">
Automate your WhatsApp messaging workflows
Build workflows that react to WhatsApp events automatically.
</p>
</div>
<Button
onClick={() => router.push("/automations/new")}
className="bg-emerald-600 text-white hover:bg-emerald-700"
>
<Plus className="h-4 w-4" />
Create Automation
</Button>
</div>
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-slate-700 bg-slate-900/50 py-20">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10">
<Zap className="h-8 w-8 text-emerald-500" />
{showTemplates && (
<section>
<h2 className="mb-3 text-sm font-semibold text-slate-300">Quick-start templates</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{TEMPLATE_ORDER.map((slug) => {
const t = AUTOMATION_TEMPLATES[slug]
const Icon = TEMPLATE_ICON[slug]
return (
<button
key={slug}
onClick={() => startFromTemplate(slug)}
className="group flex flex-col items-start rounded-xl border border-slate-800 bg-slate-900 p-4 text-left transition-colors hover:border-emerald-500/50 hover:bg-slate-900/80"
>
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-400 group-hover:bg-emerald-500/15">
<Icon className="h-5 w-5" />
</div>
<h3 className="mt-6 text-lg font-semibold text-white">Coming Soon</h3>
<p className="mt-2 max-w-sm text-center text-sm text-slate-400">
Set up automated responses, workflows, and triggers for your WhatsApp
messages.
</p>
<div className="mt-6 rounded-lg border border-slate-700 bg-slate-800/50 px-4 py-2.5">
<p className="text-xs text-slate-400">
This feature is under development
<div className="text-sm font-semibold text-white">{t.name}</div>
<p className="mt-1 text-xs text-slate-400">{t.description}</p>
</button>
)
})}
</div>
</section>
)}
{automations.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-slate-800 bg-slate-900/40">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500/10">
<Zap className="h-6 w-6 text-emerald-500" />
</div>
<p className="mt-3 text-sm font-medium text-white">No automations yet</p>
<p className="mt-1 text-xs text-slate-400">
Pick a template above or create one from scratch.
</p>
</div>
) : (
<ul className="space-y-3">
{automations.map((a) => (
<AutomationCard
key={a.id}
automation={a}
onToggle={(next) => toggleActive(a, next)}
onEdit={() => router.push(`/automations/${a.id}/edit`)}
onDuplicate={() => duplicate(a)}
onLogs={() => router.push(`/automations/${a.id}/logs`)}
onDelete={() => setPendingDelete(a)}
/>
))}
</ul>
)}
<Dialog open={!!pendingDelete} onOpenChange={(v) => !v && setPendingDelete(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete automation</DialogTitle>
<DialogDescription>
This permanently removes{" "}
<span className="text-white">{pendingDelete?.name}</span> and its execution
history. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setPendingDelete(null)}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={deleting}
>
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
)
}
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 (
<li className="rounded-xl border border-slate-800 bg-slate-900 transition-colors hover:border-slate-700">
<div className="flex items-center gap-4 p-4">
<div
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-500/10"
aria-hidden
>
<Zap className="h-5 w-5 text-emerald-400" />
</div>
<button
type="button"
onClick={onEdit}
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-white">
{automation.name}
</span>
{automation.is_active && (
<span className="relative flex h-2 w-2" aria-label="active">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
)}
</div>
{automation.description && (
<p className="mt-0.5 truncate text-xs text-slate-400">{automation.description}</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium",
meta.pillClass,
)}
>
{meta.label}
</span>
<span className="tabular-nums">
{automation.execution_count} run{automation.execution_count === 1 ? "" : "s"}
</span>
<span aria-hidden>·</span>
<span>last {formatRelative(automation.last_executed_at)}</span>
</div>
</button>
<div className="flex items-center gap-3">
<Switch
checked={automation.is_active}
onCheckedChange={(v) => onToggle(!!v)}
aria-label={automation.is_active ? "Deactivate" : "Activate"}
/>
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open menu"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white data-[popup-open]:bg-slate-800"
>
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicate}>
<Copy className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={onLogs}>
<FileText className="h-4 w-4" />
View Logs
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</li>
)
}

View File

@ -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<string, string>()
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 })
}

View File

@ -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<string, unknown> = {}
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 })
}

View File

@ -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 })
}

View File

@ -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 })
}

View File

@ -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 })
}

View File

@ -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<ContactOutcome | null> {
// 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) {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
// Root: emerald when checked, slate when unchecked. Matches app theme.
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950",
"disabled:cursor-not-allowed disabled:opacity-50",
"data-[checked]:bg-emerald-500 data-[unchecked]:bg-slate-700",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform",
"data-[checked]:translate-x-4 data-[unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@ -0,0 +1,16 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
// Lazy, shared service-role client for automation engine work.
// Mirrors the pattern used by the webhook handler
// (src/app/api/whatsapp/webhook/route.ts).
let _adminClient: SupabaseClient | null = null
export function supabaseAdmin(): SupabaseClient {
if (!_adminClient) {
_adminClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
}
return _adminClient
}

View File

@ -0,0 +1,544 @@
import type {
Automation,
AutomationLogStepResult,
AutomationStep,
AutomationTriggerType,
ConditionStepConfig,
KeywordMatchTriggerConfig,
SendMessageStepConfig,
SendWebhookStepConfig,
TagStepConfig,
UpdateContactFieldStepConfig,
WaitStepConfig,
CreateDealStepConfig,
AssignConversationStepConfig,
} from '@/types'
import { supabaseAdmin } from './admin-client'
// ------------------------------------------------------------
// Public API
// ------------------------------------------------------------
export interface AutomationContext {
/** Raw message text, for keyword_match + message_content conditions. */
message_text?: string
/** Conversation the event belongs to, if any. */
conversation_id?: string
/** Arbitrary variables accumulated during execution. */
vars?: Record<string, unknown>
/** The tag id that was added, for tag_added trigger. */
tag_id?: string
/** Agent the conversation was assigned to, for conversation_assigned. */
agent_id?: string
}
export interface DispatchInput {
userId: string
triggerType: AutomationTriggerType
contactId?: string | null
context?: AutomationContext
}
/**
* Fire all active automations matching the given trigger for a user.
*
* Must never throw — callers use fire-and-forget from the webhook.
* All errors are caught and logged; per-automation failures are
* recorded into automation_logs with status='failed'.
*/
export async function runAutomationsForTrigger(input: DispatchInput): Promise<void> {
try {
const db = supabaseAdmin()
const { data: automations, error } = await db
.from('automations')
.select('*')
.eq('user_id', input.userId)
.eq('trigger_type', input.triggerType)
.eq('is_active', true)
if (error) {
console.error('[automations] fetch failed:', error)
return
}
if (!automations || automations.length === 0) return
for (const automation of automations as Automation[]) {
if (!triggerMatches(automation, input.context)) continue
try {
await executeAutomation(automation, input)
} catch (err) {
console.error('[automations] execute failed:', automation.id, err)
}
}
} catch (err) {
console.error('[automations] dispatch failed:', err)
}
}
/**
* Resume a run that was parked at a wait step. Called from the cron
* endpoint after it grabs a due `automation_pending_executions` row.
*/
export async function resumePendingExecution(pending: {
id: string
automation_id: string
user_id: string
contact_id: string | null
log_id: string | null
parent_step_id: string | null
branch: 'yes' | 'no' | null
next_step_position: number
context: AutomationContext
}): Promise<void> {
const db = supabaseAdmin()
const { data: automation, error } = await db
.from('automations')
.select('*')
.eq('id', pending.automation_id)
.single()
if (error || !automation) {
console.error('[automations] resume: missing automation', pending.automation_id, error)
await markPending(pending.id, 'failed')
return
}
try {
await executeStepsFrom({
automation: automation as Automation,
contactId: pending.contact_id,
context: pending.context ?? {},
parentStepId: pending.parent_step_id,
branch: pending.branch,
startPosition: pending.next_step_position,
logId: pending.log_id,
triggerEvent: 'resumed_wait',
})
await markPending(pending.id, 'done')
} catch (err) {
console.error('[automations] resume failed:', err)
await markPending(pending.id, 'failed')
}
}
// ------------------------------------------------------------
// Internal execution
// ------------------------------------------------------------
async function executeAutomation(automation: Automation, input: DispatchInput) {
const db = supabaseAdmin()
const { data: log, error: logErr } = await db
.from('automation_logs')
.insert({
automation_id: automation.id,
user_id: automation.user_id,
contact_id: input.contactId ?? null,
trigger_event: input.triggerType,
steps_executed: [],
status: 'success',
})
.select()
.single()
if (logErr || !log) {
console.error('[automations] cannot create log:', logErr)
return
}
await executeStepsFrom({
automation,
contactId: input.contactId ?? null,
context: input.context ?? {},
parentStepId: null,
branch: null,
startPosition: 0,
logId: log.id,
triggerEvent: input.triggerType,
})
await db
.from('automations')
.update({
execution_count: (automation.execution_count ?? 0) + 1,
last_executed_at: new Date().toISOString(),
})
.eq('id', automation.id)
}
interface ExecuteArgs {
automation: Automation
contactId: string | null
context: AutomationContext
parentStepId: string | null
branch: 'yes' | 'no' | null
startPosition: number
logId: string | null
triggerEvent: string
}
async function executeStepsFrom(args: ExecuteArgs): Promise<void> {
const db = supabaseAdmin()
const baseQuery = db
.from('automation_steps')
.select('*')
.eq('automation_id', args.automation.id)
.gte('position', args.startPosition)
.order('position', { ascending: true })
const scoped =
args.parentStepId === null
? baseQuery.is('parent_step_id', null)
: baseQuery.eq('parent_step_id', args.parentStepId).eq('branch', args.branch ?? 'yes')
const { data: steps, error: stepsErr } = await scoped
if (stepsErr) {
await finalizeLog(args.logId, 'failed', stepsErr.message)
return
}
if (!steps || steps.length === 0) {
if (args.parentStepId === null && args.logId) {
await finalizeLog(args.logId, 'success', null)
}
return
}
const results: AutomationLogStepResult[] = []
let status: 'success' | 'partial' | 'failed' = 'success'
let errorMessage: string | null = null
for (const step of steps as AutomationStep[]) {
// `wait` is the suspension point: enqueue and stop processing this
// scope. The cron endpoint will pick it up later.
if (step.step_type === 'wait') {
const cfg = step.step_config as WaitStepConfig
const ms = waitMs(cfg)
await db.from('automation_pending_executions').insert({
automation_id: args.automation.id,
user_id: args.automation.user_id,
contact_id: args.contactId,
log_id: args.logId,
parent_step_id: args.parentStepId,
branch: args.branch,
next_step_position: step.position + 1,
context: args.context,
run_at: new Date(Date.now() + ms).toISOString(),
status: 'pending',
})
results.push({
step_id: step.id,
step_type: step.step_type,
status: 'success',
detail: `waiting ${cfg.amount} ${cfg.unit}`,
})
status = 'partial'
await appendResults(args.logId, results, status, errorMessage)
return
}
try {
if (step.step_type === 'condition') {
const cfg = step.step_config as ConditionStepConfig
const taken = await evaluateCondition(cfg, args)
results.push({
step_id: step.id,
step_type: 'condition',
status: 'success',
detail: `branch=${taken ? 'yes' : 'no'}`,
})
// Recurse into the chosen branch at position 0 (children use their
// own ordering within the branch scope).
await executeStepsFrom({
...args,
parentStepId: step.id,
branch: taken ? 'yes' : 'no',
startPosition: 0,
logId: args.logId,
})
continue
}
const detail = await runStep(step, args)
results.push({
step_id: step.id,
step_type: step.step_type,
status: 'success',
detail,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
results.push({
step_id: step.id,
step_type: step.step_type,
status: 'failed',
detail: msg,
})
status = 'failed'
errorMessage = msg
break
}
}
if (args.parentStepId === null) {
await appendResults(args.logId, results, status, errorMessage)
} else {
// Nested branch — just append results; parent scope decides final status.
await appendResults(args.logId, results, null, errorMessage)
}
}
async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string> {
const db = supabaseAdmin()
switch (step.step_type) {
case 'send_message': {
const cfg = step.step_config as SendMessageStepConfig
if (!args.contactId) throw new Error('send_message needs a contact')
const { data: conv } = await db
.from('conversations')
.select('id, user_id')
.eq('user_id', args.automation.user_id)
.eq('contact_id', args.contactId)
.maybeSingle()
if (!conv) throw new Error('no conversation for contact')
await db.from('messages').insert({
conversation_id: conv.id,
sender_type: 'bot',
content_type: 'text',
content_text: interpolate(cfg.text, args),
status: 'sending',
})
return 'message queued'
}
case 'send_template':
// Template sending requires Meta API credentials; recorded but
// not actually dispatched here — the dedicated broadcasts flow
// owns Meta calls. Leave as a noop success so the automation
// progresses; real send-via-Meta is tracked as follow-up work.
return 'template send recorded'
case 'add_tag': {
const cfg = step.step_config as TagStepConfig
if (!args.contactId || !cfg.tag_id) throw new Error('add_tag needs contact + tag_id')
await db
.from('contact_tags')
.upsert(
{ contact_id: args.contactId, tag_id: cfg.tag_id },
{ onConflict: 'contact_id,tag_id', ignoreDuplicates: true },
)
return `tag ${cfg.tag_id} added`
}
case 'remove_tag': {
const cfg = step.step_config as TagStepConfig
if (!args.contactId || !cfg.tag_id) throw new Error('remove_tag needs contact + tag_id')
await db
.from('contact_tags')
.delete()
.eq('contact_id', args.contactId)
.eq('tag_id', cfg.tag_id)
return `tag ${cfg.tag_id} removed`
}
case 'assign_conversation': {
const cfg = step.step_config as AssignConversationStepConfig
if (!args.contactId) throw new Error('assign_conversation needs a contact')
let agentId = cfg.agent_id
if (cfg.mode === 'round_robin') {
const { data: profiles } = await db
.from('profiles')
.select('user_id')
.eq('user_id', args.automation.user_id)
.limit(1)
agentId = profiles?.[0]?.user_id
}
if (!agentId) return 'no agent resolved'
await db
.from('conversations')
.update({ assigned_agent_id: agentId })
.eq('user_id', args.automation.user_id)
.eq('contact_id', args.contactId)
return `assigned to ${agentId}`
}
case 'update_contact_field': {
const cfg = step.step_config as UpdateContactFieldStepConfig
if (!args.contactId) throw new Error('update_contact_field needs a contact')
const allowed = new Set(['name', 'email', 'company'])
if (!allowed.has(cfg.field)) {
return `field ${cfg.field} not writable from automations`
}
await db
.from('contacts')
.update({ [cfg.field]: cfg.value, updated_at: new Date().toISOString() })
.eq('id', args.contactId)
return `${cfg.field} updated`
}
case 'create_deal': {
const cfg = step.step_config as CreateDealStepConfig
if (!cfg.pipeline_id || !cfg.stage_id) throw new Error('create_deal needs pipeline + stage')
await db.from('deals').insert({
user_id: args.automation.user_id,
pipeline_id: cfg.pipeline_id,
stage_id: cfg.stage_id,
contact_id: args.contactId,
title: interpolate(cfg.title, args),
value: cfg.value ?? 0,
status: 'open',
})
return 'deal created'
}
case 'send_webhook': {
const cfg = step.step_config as SendWebhookStepConfig
if (!cfg.url) throw new Error('send_webhook needs url')
const body = cfg.body_template ? interpolate(cfg.body_template, args) : JSON.stringify(args.context)
const res = await fetch(cfg.url, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(cfg.headers ?? {}) },
body,
})
if (!res.ok) throw new Error(`webhook returned ${res.status}`)
return `webhook ${res.status}`
}
case 'close_conversation': {
if (!args.contactId) throw new Error('close_conversation needs a contact')
await db
.from('conversations')
.update({ status: 'closed', updated_at: new Date().toISOString() })
.eq('user_id', args.automation.user_id)
.eq('contact_id', args.contactId)
return 'conversation closed'
}
default:
return `unknown step: ${step.step_type}`
}
}
// ------------------------------------------------------------
// Helpers
// ------------------------------------------------------------
function triggerMatches(automation: Automation, ctx: AutomationContext | undefined): boolean {
if (automation.trigger_type !== 'keyword_match') return true
const cfg = automation.trigger_config as KeywordMatchTriggerConfig
if (!cfg?.keywords || cfg.keywords.length === 0) return false
const text = (ctx?.message_text ?? '').toString()
if (!text) return false
const haystack = cfg.case_sensitive ? text : text.toLowerCase()
return cfg.keywords.some((raw) => {
const k = cfg.case_sensitive ? raw : raw.toLowerCase()
return cfg.match_type === 'exact' ? haystack === k : haystack.includes(k)
})
}
async function evaluateCondition(cfg: ConditionStepConfig, args: ExecuteArgs): Promise<boolean> {
const db = supabaseAdmin()
switch (cfg.subject) {
case 'tag_presence': {
if (!args.contactId || !cfg.operand) return false
const { count } = await db
.from('contact_tags')
.select('id', { count: 'exact', head: true })
.eq('contact_id', args.contactId)
.eq('tag_id', cfg.operand)
return (count ?? 0) > 0
}
case 'contact_field': {
if (!args.contactId || !cfg.operand) return false
const { data } = await db
.from('contacts')
.select(cfg.operand)
.eq('id', args.contactId)
.maybeSingle()
const v = (data as Record<string, unknown> | null)?.[cfg.operand]
return v != null && String(v) === String(cfg.value ?? '')
}
case 'message_content': {
const text = (args.context.message_text ?? '').toString()
return text.toLowerCase().includes((cfg.value ?? '').toLowerCase())
}
case 'time_of_day': {
// operand form "HH:mm-HH:mm" — true if now is within that window
// (supports over-midnight ranges like "18:00-09:00").
const [from, to] = (cfg.operand ?? '').split('-')
if (!from || !to) return false
const now = new Date()
const mins = now.getHours() * 60 + now.getMinutes()
const parse = (s: string) => {
const [h, m] = s.split(':').map(Number)
return (h || 0) * 60 + (m || 0)
}
const f = parse(from)
const t = parse(to)
return f <= t ? mins >= f && mins < t : mins >= f || mins < t
}
default:
return false
}
}
function waitMs(cfg: WaitStepConfig): number {
const unitMs = cfg.unit === 'days' ? 86_400_000 : cfg.unit === 'hours' ? 3_600_000 : 60_000
return Math.max(1_000, cfg.amount * unitMs)
}
function interpolate(s: string, args: ExecuteArgs): string {
return s.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => {
const [ns, prop] = String(key).split('.')
if (ns === 'message' && prop === 'text') return String(args.context.message_text ?? '')
if (ns === 'vars' && prop) return String(args.context.vars?.[prop] ?? '')
return ''
})
}
async function appendResults(
logId: string | null,
newItems: AutomationLogStepResult[],
status: 'success' | 'partial' | 'failed' | null,
errorMessage: string | null,
) {
if (!logId) return
const db = supabaseAdmin()
const { data: existing } = await db
.from('automation_logs')
.select('steps_executed, status')
.eq('id', logId)
.single()
const merged = [
...((existing?.steps_executed as AutomationLogStepResult[] | undefined) ?? []),
...newItems,
]
const update: Record<string, unknown> = { steps_executed: merged }
// Only overwrite status on the outermost scope — nested branches pass null.
if (status !== null) {
update.status = status
}
if (errorMessage) update.error_message = errorMessage
await db.from('automation_logs').update(update).eq('id', logId)
}
async function finalizeLog(
logId: string | null,
status: 'success' | 'partial' | 'failed',
errorMessage: string | null,
) {
if (!logId) return
await supabaseAdmin()
.from('automation_logs')
.update({ status, error_message: errorMessage })
.eq('id', logId)
}
async function markPending(id: string, status: 'done' | 'failed') {
await supabaseAdmin()
.from('automation_pending_executions')
.update({ status })
.eq('id', id)
}

View File

@ -0,0 +1,162 @@
import { supabaseAdmin } from './admin-client'
// ------------------------------------------------------------
// Builder payload → flat rows for automation_steps.
// Root steps arrive in order. A Condition step carries its children
// under `branches: { yes: [...], no: [...] }`. We walk the tree and
// assign stable UUIDs so parent_step_id references resolve in a
// single INSERT.
// ------------------------------------------------------------
export interface BuilderStepInput {
id?: string
step_type: string
step_config: Record<string, unknown>
branches?: { yes?: BuilderStepInput[]; no?: BuilderStepInput[] }
// Legacy flat form (from template seeds):
branch?: 'yes' | 'no' | null
parent_index?: number | null
}
interface InsertRow {
id: string
automation_id: string
parent_step_id: string | null
branch: 'yes' | 'no' | null
step_type: string
step_config: Record<string, unknown>
position: number
}
const uid = () =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: Math.random().toString(36).slice(2) + Date.now().toString(36)
export async function replaceSteps(
automationId: string,
input: BuilderStepInput[],
): Promise<string | null> {
const admin = supabaseAdmin()
const { error: delErr } = await admin
.from('automation_steps')
.delete()
.eq('automation_id', automationId)
if (delErr) return delErr.message
return insertSteps(automationId, input)
}
export async function insertSteps(
automationId: string,
input: BuilderStepInput[],
): Promise<string | null> {
if (!input || input.length === 0) return null
const looksFlat = input.some(
(s) => s.branch !== undefined || s.parent_index !== undefined,
)
const tree = looksFlat ? seedsToTree(input) : input
const rows: InsertRow[] = []
function walk(
steps: BuilderStepInput[],
parentId: string | null,
branch: 'yes' | 'no' | null,
) {
steps.forEach((s, idx) => {
const id = s.id ?? uid()
rows.push({
id,
automation_id: automationId,
parent_step_id: parentId,
branch,
step_type: s.step_type,
step_config: s.step_config ?? {},
position: idx,
})
if (s.step_type === 'condition' && s.branches) {
if (s.branches.yes) walk(s.branches.yes, id, 'yes')
if (s.branches.no) walk(s.branches.no, id, 'no')
}
})
}
walk(tree, null, null)
if (rows.length === 0) return null
const { error } = await supabaseAdmin().from('automation_steps').insert(rows)
return error?.message ?? null
}
function seedsToTree(seeds: BuilderStepInput[]): BuilderStepInput[] {
const nodes: BuilderStepInput[] = seeds.map((s) => ({
...s,
branches: { yes: [], no: [] },
}))
const roots: BuilderStepInput[] = []
nodes.forEach((n, i) => {
const seed = seeds[i]
if (seed.parent_index == null) {
roots.push(n)
} else {
const parent = nodes[seed.parent_index]
parent.branches = parent.branches ?? { yes: [], no: [] }
const bucket = (seed.branch ?? 'yes') as 'yes' | 'no'
;(parent.branches[bucket] ??= []).push(n)
}
})
return roots
}
/**
* Load the steps for an automation and rebuild the nested tree shape
* the builder UI expects. One query, O(n) assembly.
*/
export interface BuilderStepNode extends BuilderStepInput {
id: string
branches: { yes: BuilderStepNode[]; no: BuilderStepNode[] }
}
interface DbStep {
id: string
parent_step_id: string | null
branch: 'yes' | 'no' | null
step_type: string
step_config: Record<string, unknown>
position: number
}
export async function loadStepsTree(automationId: string): Promise<BuilderStepNode[]> {
const { data, error } = await supabaseAdmin()
.from('automation_steps')
.select('*')
.eq('automation_id', automationId)
.order('position', { ascending: true })
if (error) throw new Error(error.message)
const rows = (data ?? []) as DbStep[]
const byId = new Map<string, BuilderStepNode>()
for (const row of rows) {
byId.set(row.id, {
id: row.id,
step_type: row.step_type,
step_config: row.step_config ?? {},
branches: { yes: [], no: [] },
})
}
const roots: BuilderStepNode[] = []
for (const row of rows) {
const node = byId.get(row.id)!
if (row.parent_step_id) {
const parent = byId.get(row.parent_step_id)
if (parent) {
const bucket = (row.branch ?? 'yes') as 'yes' | 'no'
parent.branches[bucket].push(node)
}
} else {
roots.push(node)
}
}
return roots
}

View File

@ -0,0 +1,127 @@
import type {
AutomationStepConfig,
AutomationStepType,
AutomationTriggerConfig,
AutomationTriggerType,
} from '@/types'
export type TemplateSlug =
| 'welcome_message'
| 'out_of_office'
| 'lead_qualifier'
| 'follow_up_reminder'
export interface TemplateStepSeed {
step_type: AutomationStepType
step_config: AutomationStepConfig
branch?: 'yes' | 'no' | null
/** Index (within this seed list) of the Condition parent, if nested. */
parent_index?: number | null
}
export interface AutomationTemplateDefinition {
slug: TemplateSlug
name: string
description: string
trigger_type: AutomationTriggerType
trigger_config: AutomationTriggerConfig
steps: TemplateStepSeed[]
}
export const AUTOMATION_TEMPLATES: Record<TemplateSlug, AutomationTemplateDefinition> = {
welcome_message: {
slug: 'welcome_message',
name: 'Welcome Message',
description: 'Auto-reply to first-time contacts with a greeting.',
trigger_type: 'new_contact_created',
trigger_config: {},
steps: [
{
step_type: 'send_message',
step_config: {
text: "Hi! 👋 Thanks for reaching out. We'll get back to you shortly.",
},
},
{
step_type: 'add_tag',
step_config: { tag_id: '' },
},
],
},
out_of_office: {
slug: 'out_of_office',
name: 'Out of Office',
description: 'Auto-reply during off-hours so nobody is left waiting.',
trigger_type: 'new_message_received',
trigger_config: {},
steps: [
{
step_type: 'condition',
step_config: {
subject: 'time_of_day',
operand: '18:00-09:00',
},
},
{
step_type: 'send_message',
step_config: {
text:
"Thanks for your message! Our team is offline right now (9am–6pm) and will reply first thing tomorrow.",
},
parent_index: 0,
branch: 'yes',
},
],
},
lead_qualifier: {
slug: 'lead_qualifier',
name: 'Lead Qualifier',
description: 'Ask qualification questions to filter inbound leads.',
trigger_type: 'keyword_match',
trigger_config: {
keywords: ['pricing', 'quote', 'buy'],
match_type: 'contains',
},
steps: [
{
step_type: 'send_message',
step_config: {
text:
"Great — happy to help with pricing! Quick question: roughly how many seats are you looking for?",
},
},
{
step_type: 'wait',
step_config: { amount: 10, unit: 'minutes' },
},
{
step_type: 'assign_conversation',
step_config: { mode: 'round_robin' },
},
],
},
follow_up_reminder: {
slug: 'follow_up_reminder',
name: 'Follow-up Reminder',
description: 'Send a nudge if a contact has not replied within 24 hours.',
trigger_type: 'new_message_received',
trigger_config: {},
steps: [
{
step_type: 'wait',
step_config: { amount: 1, unit: 'days' },
},
{
step_type: 'send_message',
step_config: {
text:
"Just circling back — did you have any other questions for us? Happy to help!",
},
},
],
},
}
export function getTemplate(slug: string): AutomationTemplateDefinition | null {
return AUTOMATION_TEMPLATES[slug as TemplateSlug] ?? null
}

View File

@ -0,0 +1,55 @@
import type { AutomationTriggerType } from '@/types'
export interface TriggerMeta {
label: string
/** Tailwind classes for the Badge pill on the list row. */
pillClass: string
}
export const TRIGGER_META: Record<AutomationTriggerType, TriggerMeta> = {
new_message_received: {
label: 'New Message',
pillClass: 'border-blue-500/30 bg-blue-500/10 text-blue-300',
},
keyword_match: {
label: 'Keyword Match',
pillClass: 'border-purple-500/30 bg-purple-500/10 text-purple-300',
},
new_contact_created: {
label: 'New Contact',
pillClass: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300',
},
conversation_assigned: {
label: 'Conversation Assigned',
pillClass: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-300',
},
tag_added: {
label: 'Tag Added',
pillClass: 'border-amber-500/30 bg-amber-500/10 text-amber-300',
},
time_based: {
label: 'Time-Based',
pillClass: 'border-slate-500/30 bg-slate-500/10 text-slate-300',
},
}
export function triggerMeta(t: AutomationTriggerType | string): TriggerMeta {
return (
TRIGGER_META[t as AutomationTriggerType] ?? {
label: t,
pillClass: 'border-slate-500/30 bg-slate-500/10 text-slate-300',
}
)
}
export function formatRelative(iso: string | null | undefined): string {
if (!iso) return 'never'
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return 'never'
const diffSec = Math.round((Date.now() - then) / 1000)
if (diffSec < 60) return 'just now'
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
if (diffSec < 2_592_000) return `${Math.floor(diffSec / 86400)}d ago`
return new Date(iso).toLocaleDateString()
}

View File

@ -207,3 +207,167 @@ export interface BroadcastRecipient {
created_at: string;
contact?: Contact;
}
// ============================================================
// Automations (migration 006)
// ============================================================
export type AutomationTriggerType =
| 'new_message_received'
| 'keyword_match'
| 'new_contact_created'
| 'conversation_assigned'
| 'tag_added'
| 'time_based';
export type AutomationStepType =
| 'send_message'
| 'send_template'
| 'add_tag'
| 'remove_tag'
| 'assign_conversation'
| 'update_contact_field'
| 'create_deal'
| 'wait'
| 'condition'
| 'send_webhook'
| 'close_conversation';
export type AutomationLogStatus = 'success' | 'partial' | 'failed';
export interface KeywordMatchTriggerConfig {
keywords: string[];
match_type: 'exact' | 'contains';
case_sensitive?: boolean;
}
export interface TagTriggerConfig {
tag_id: string;
}
export interface TimeBasedTriggerConfig {
/** Cron expression or simple HH:mm string; engine can accept either. */
schedule: string;
timezone?: string;
}
export type AutomationTriggerConfig =
| Record<string, never>
| KeywordMatchTriggerConfig
| TagTriggerConfig
| TimeBasedTriggerConfig
| Record<string, unknown>;
export interface SendMessageStepConfig {
text: string;
}
export interface SendTemplateStepConfig {
template_name: string;
language?: string;
variables?: Record<string, string>;
}
export interface TagStepConfig {
tag_id: string;
}
export interface AssignConversationStepConfig {
mode: 'specific' | 'round_robin';
agent_id?: string;
}
export interface UpdateContactFieldStepConfig {
field: string;
value: string;
}
export interface CreateDealStepConfig {
pipeline_id: string;
stage_id: string;
title: string;
value?: number;
}
export interface WaitStepConfig {
amount: number;
unit: 'minutes' | 'hours' | 'days';
}
export type ConditionSubject =
| 'contact_field'
| 'tag_presence'
| 'message_content'
| 'time_of_day';
export interface ConditionStepConfig {
subject: ConditionSubject;
/** e.g. field name, tag id, substring, or "HH:mm-HH:mm" depending on subject */
operand?: string;
/** For contact_field equals / message_content contains — comparison value */
value?: string;
}
export interface SendWebhookStepConfig {
url: string;
headers?: Record<string, string>;
body_template?: string;
}
export type AutomationStepConfig =
| SendMessageStepConfig
| SendTemplateStepConfig
| TagStepConfig
| AssignConversationStepConfig
| UpdateContactFieldStepConfig
| CreateDealStepConfig
| WaitStepConfig
| ConditionStepConfig
| SendWebhookStepConfig
| Record<string, never>
| Record<string, unknown>;
export interface Automation {
id: string;
user_id: string;
name: string;
description?: string;
trigger_type: AutomationTriggerType;
trigger_config: AutomationTriggerConfig;
is_active: boolean;
execution_count: number;
last_executed_at?: string | null;
created_at: string;
updated_at: string;
}
export interface AutomationStep {
id: string;
automation_id: string;
parent_step_id?: string | null;
branch?: 'yes' | 'no' | null;
step_type: AutomationStepType;
step_config: AutomationStepConfig;
position: number;
created_at: string;
}
export interface AutomationLogStepResult {
step_id: string;
step_type: AutomationStepType;
status: 'success' | 'skipped' | 'failed';
detail?: string;
}
export interface AutomationLog {
id: string;
automation_id: string;
user_id: string;
contact_id: string | null;
trigger_event: string;
steps_executed: AutomationLogStepResult[];
status: AutomationLogStatus;
error_message?: string | null;
created_at: string;
contact?: Contact;
}

View File

@ -0,0 +1,140 @@
-- ============================================================
-- 006_automations.sql — Automations feature
--
-- Idempotent migration — safe to run multiple times.
-- Follows the same conventions as 001_initial_schema.sql:
-- IF NOT EXISTS on tables/indexes, DROP IF EXISTS before
-- re-creating policies/triggers (Postgres has no
-- CREATE POLICY IF NOT EXISTS).
-- ============================================================
-- ============================================================
-- AUTOMATIONS
-- ============================================================
CREATE TABLE IF NOT EXISTS automations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
trigger_type TEXT NOT NULL,
trigger_config JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
execution_count INTEGER NOT NULL DEFAULT 0,
last_executed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_automations_user_id ON automations(user_id);
-- Partial index tuned for the engine's hot path: find active automations
-- whose trigger_type matches the fired event. RLS then narrows by user_id.
CREATE INDEX IF NOT EXISTS idx_automations_active_trigger
ON automations(trigger_type) WHERE is_active = TRUE;
ALTER TABLE automations ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can manage own automations" ON automations;
CREATE POLICY "Users can manage own automations" ON automations FOR ALL
USING (auth.uid() = user_id);
DROP TRIGGER IF EXISTS set_updated_at ON automations;
CREATE TRIGGER set_updated_at BEFORE UPDATE ON automations
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- AUTOMATION_STEPS
--
-- `position` — order within parent scope (root scope or a branch).
-- `parent_step_id` — NULL for root-level steps; set to the Condition
-- step's id for steps that live inside one of its
-- branches.
-- `branch` — NULL for root steps. For children of a Condition,
-- 'yes' or 'no' identifying which path.
-- ============================================================
CREATE TABLE IF NOT EXISTS automation_steps (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
automation_id UUID NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
parent_step_id UUID REFERENCES automation_steps(id) ON DELETE CASCADE,
branch TEXT CHECK (branch IN ('yes', 'no')),
step_type TEXT NOT NULL,
step_config JSONB NOT NULL DEFAULT '{}'::jsonb,
position INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_automation_steps_automation_id
ON automation_steps(automation_id, position);
CREATE INDEX IF NOT EXISTS idx_automation_steps_parent
ON automation_steps(parent_step_id) WHERE parent_step_id IS NOT NULL;
ALTER TABLE automation_steps ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can manage steps of own automations" ON automation_steps;
CREATE POLICY "Users can manage steps of own automations" ON automation_steps FOR ALL
USING (
EXISTS (
SELECT 1 FROM automations a
WHERE a.id = automation_steps.automation_id
AND a.user_id = auth.uid()
)
);
-- ============================================================
-- AUTOMATION_LOGS
--
-- user_id is denormalized for simple RLS; contact_id is nullable so
-- history survives contact deletion (mirrors migration 004's pattern
-- on broadcast_recipients / deals).
-- ============================================================
CREATE TABLE IF NOT EXISTS automation_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
automation_id UUID NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
trigger_event TEXT NOT NULL,
steps_executed JSONB NOT NULL DEFAULT '[]'::jsonb,
status TEXT NOT NULL CHECK (status IN ('success', 'partial', 'failed')),
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_automation_logs_automation
ON automation_logs(automation_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_automation_logs_user ON automation_logs(user_id);
ALTER TABLE automation_logs ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can view own automation logs" ON automation_logs;
CREATE POLICY "Users can view own automation logs" ON automation_logs FOR ALL
USING (auth.uid() = user_id);
-- ============================================================
-- AUTOMATION_PENDING_EXECUTIONS
--
-- Queue row created when a running automation hits a `wait` step.
-- The cron endpoint drains rows where run_at <= now() and status =
-- 'pending', flips them to 'running', and resumes the automation
-- from `next_step_position` with the saved `context` jsonb.
--
-- Service-role only — writes never originate from the browser, and
-- the engine uses the service-role client. No user policy exposed.
-- ============================================================
CREATE TABLE IF NOT EXISTS automation_pending_executions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
automation_id UUID NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
log_id UUID REFERENCES automation_logs(id) ON DELETE CASCADE,
parent_step_id UUID REFERENCES automation_steps(id) ON DELETE SET NULL,
branch TEXT CHECK (branch IN ('yes', 'no')),
next_step_position INTEGER NOT NULL,
context JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'running', 'done', 'failed')),
run_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_automation_pending_due
ON automation_pending_executions(run_at) WHERE status = 'pending';
ALTER TABLE automation_pending_executions ENABLE ROW LEVEL SECURITY;
-- No SELECT/INSERT/UPDATE/DELETE policy for authenticated users — all
-- access is server-side via the service-role key.