mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(automations): visual flow builder, engine, and logs viewer
Replaces the /automations placeholder with a full feature: - Migration 006 adds automations, automation_steps, automation_logs, and automation_pending_executions with RLS matching existing tables. - List page with active/inactive switch, quick-start templates, duplicate, delete, and View Logs actions. - Full-screen vertical flow builder with Trigger + Action + Condition (Yes/No branches) + Wait cards. Inline edit forms, up/down reorder, dotted-grid canvas. - Execution engine (src/lib/automations/engine.ts) dispatched from the WhatsApp webhook for new_message_received, new_contact_created, and keyword_match. Wait steps enqueue to automation_pending_executions; /api/automations/cron drains them behind AUTOMATION_CRON_SECRET. - Logs viewer with per-step checkmarks and expandable detail. Existing files touched only in webhook/route.ts (fire-and-forget trigger dispatch) and types/index.ts (appended automation types). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
66dd4ef97e
commit
b7b362ae42
74
src/app/(dashboard)/automations/[id]/edit/page.tsx
Normal file
74
src/app/(dashboard)/automations/[id]/edit/page.tsx
Normal 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} />
|
||||
}
|
||||
205
src/app/(dashboard)/automations/[id]/logs/page.tsx
Normal file
205
src/app/(dashboard)/automations/[id]/logs/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
src/app/(dashboard)/automations/new/page.tsx
Normal file
90
src/app/(dashboard)/automations/new/page.tsx
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
72
src/app/api/automations/[id]/duplicate/route.ts
Normal file
72
src/app/api/automations/[id]/duplicate/route.ts
Normal 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 })
|
||||
}
|
||||
106
src/app/api/automations/[id]/route.ts
Normal file
106
src/app/api/automations/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
65
src/app/api/automations/cron/route.ts
Normal file
65
src/app/api/automations/cron/route.ts
Normal 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 })
|
||||
}
|
||||
31
src/app/api/automations/engine/route.ts
Normal file
31
src/app/api/automations/engine/route.ts
Normal 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 })
|
||||
}
|
||||
85
src/app/api/automations/route.ts
Normal file
85
src/app/api/automations/route.ts
Normal 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 })
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
1144
src/components/automations/automation-builder.tsx
Normal file
1144
src/components/automations/automation-builder.tsx
Normal file
File diff suppressed because it is too large
Load Diff
36
src/components/ui/switch.tsx
Normal file
36
src/components/ui/switch.tsx
Normal 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 }
|
||||
16
src/lib/automations/admin-client.ts
Normal file
16
src/lib/automations/admin-client.ts
Normal 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
|
||||
}
|
||||
544
src/lib/automations/engine.ts
Normal file
544
src/lib/automations/engine.ts
Normal 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)
|
||||
}
|
||||
162
src/lib/automations/steps-tree.ts
Normal file
162
src/lib/automations/steps-tree.ts
Normal 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
|
||||
}
|
||||
127
src/lib/automations/templates.ts
Normal file
127
src/lib/automations/templates.ts
Normal 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
|
||||
}
|
||||
55
src/lib/automations/trigger-meta.ts
Normal file
55
src/lib/automations/trigger-meta.ts
Normal 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()
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
140
supabase/migrations/006_automations.sql
Normal file
140
supabase/migrations/006_automations.sql
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user