Merge pull request #117 from ArnasDon/feat/flows-templates-and-v1.5

feat(flows): templates + v1.5 nodes + run-history viewer (PR 4/4)
This commit is contained in:
Arnas Donauskas 2026-05-22 14:29:41 +03:00 committed by GitHub
commit 4cb6849291
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 2105 additions and 32 deletions

View File

@ -0,0 +1,350 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import {
ArrowLeft,
Loader2,
CircleCheck,
CircleAlert,
Clock,
UserPlus,
PlayCircle,
PauseCircle,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { toast } from "sonner";
import { format, formatDistanceToNow } from "date-fns";
import { useAuth } from "@/hooks/use-auth";
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
/**
* Run history viewer.
*
* Lists the 50 most recent runs for a flow, newest first. Each row
* collapses to a one-liner (contact + status + time); expanding shows
* the full `flow_run_events` timeline for that run — useful for
* debugging "why didn't my flow advance?" by surfacing the engine's
* own log.
*/
interface RunRow {
id: string;
status:
| "active"
| "completed"
| "handed_off"
| "timed_out"
| "paused_by_agent"
| "failed";
current_node_key: string | null;
started_at: string;
last_advanced_at: string;
ended_at: string | null;
end_reason: string | null;
vars: Record<string, unknown>;
reprompt_count: number;
contact: { id: string; name: string | null; phone: string } | null;
}
interface EventRow {
flow_run_id: string;
event_type: string;
node_key: string | null;
payload: Record<string, unknown>;
created_at: string;
}
const STATUS_META: Record<
RunRow["status"],
{ label: string; classes: string; icon: typeof Clock }
> = {
active: {
label: "Active",
classes: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
icon: PlayCircle,
},
completed: {
label: "Completed",
classes: "border-slate-700 bg-slate-800 text-slate-300",
icon: CircleCheck,
},
handed_off: {
label: "Handed off",
classes: "border-amber-600/40 bg-amber-500/10 text-amber-300",
icon: UserPlus,
},
timed_out: {
label: "Timed out",
classes: "border-slate-700 bg-slate-800/60 text-slate-400",
icon: Clock,
},
paused_by_agent: {
label: "Paused by agent",
classes: "border-slate-700 bg-slate-800 text-slate-300",
icon: PauseCircle,
},
failed: {
label: "Failed",
classes: "border-red-600/40 bg-red-500/10 text-red-300",
icon: CircleAlert,
},
};
export default function FlowRunsPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const { profile, loading: authLoading } = useAuth();
const [flow, setFlow] = useState<{ id: string; name: string } | null>(null);
const [runs, setRuns] = useState<RunRow[]>([]);
const [events, setEvents] = useState<EventRow[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [notFound, setNotFound] = useState(false);
const allowed = isFlowsEnabled(profile);
useEffect(() => {
if (authLoading) return;
if (!allowed) {
router.replace("/dashboard");
return;
}
if (!params.id) return;
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/flows/${params.id}/runs`);
if (res.status === 404) {
if (!cancelled) setNotFound(true);
return;
}
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const json = (await res.json()) as {
flow: { id: string; name: string };
runs: RunRow[];
events: EventRow[];
};
if (!cancelled) {
setFlow(json.flow);
setRuns(json.runs ?? []);
setEvents(json.events ?? []);
}
} catch (err) {
if (!cancelled) {
console.error(err);
toast.error("Couldn't load runs.");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [allowed, authLoading, params.id, router]);
function toggle(runId: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(runId)) next.delete(runId);
else next.add(runId);
return next;
});
}
if (authLoading || (allowed && loading)) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
</div>
);
}
if (notFound || !flow) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-slate-400">Flow not found.</p>
<button
type="button"
onClick={() => router.push("/flows")}
className="text-sm text-violet-400 hover:text-violet-300"
>
← Back to flows
</button>
</div>
);
}
return (
<div className="mx-auto max-w-4xl p-6">
<button
type="button"
onClick={() => router.push(`/flows/${flow.id}`)}
className="mb-2 inline-flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300"
>
<ArrowLeft className="h-3 w-3" />
{flow.name}
</button>
<h1 className="text-xl font-semibold text-white">Runs</h1>
<p className="mt-1 text-sm text-slate-400">
The 50 most recent times this flow ran. Expand a row to see the engine&apos;s
per-step log.
</p>
{runs.length === 0 ? (
<div className="mt-6 rounded-lg border border-dashed border-slate-700 bg-slate-900/50 px-6 py-12 text-center text-sm text-slate-400">
No runs yet. Trigger the flow from a personal WhatsApp number to see
it appear here.
</div>
) : (
<div className="mt-6 flex flex-col gap-2">
{runs.map((run) => (
<RunCard
key={run.id}
run={run}
events={events.filter((e) => e.flow_run_id === run.id)}
expanded={expanded.has(run.id)}
onToggle={() => toggle(run.id)}
/>
))}
</div>
)}
</div>
);
}
function RunCard({
run,
events,
expanded,
onToggle,
}: {
run: RunRow;
events: EventRow[];
expanded: boolean;
onToggle: () => void;
}) {
const meta = STATUS_META[run.status];
const StatusIcon = meta.icon;
const contactLabel =
run.contact?.name?.trim() || run.contact?.phone || "Unknown contact";
const duration = run.ended_at
? formatDistanceToNow(new Date(run.ended_at), {
addSuffix: false,
})
: null;
return (
<div className="rounded-lg border border-slate-800 bg-slate-900">
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-3 px-4 py-3 text-left"
>
{expanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-slate-500" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-slate-500" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-white">
{contactLabel}
</span>
<Badge variant="outline" className={cn("gap-1", meta.classes)}>
<StatusIcon className="h-3 w-3" />
{meta.label}
</Badge>
{run.status === "active" && run.current_node_key && (
<code className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400">
at {run.current_node_key}
</code>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
<span>Started {format(new Date(run.started_at), "PP p")}</span>
{run.reprompt_count > 0 && (
<span>· {run.reprompt_count} re-prompts</span>
)}
{duration && <span>· ran for {duration}</span>}
</div>
</div>
</button>
{expanded && (
<div className="border-t border-slate-800 px-4 py-3">
{Object.keys(run.vars).length > 0 && (
<details className="mb-3">
<summary className="cursor-pointer text-xs text-slate-400">
Captured vars ({Object.keys(run.vars).length})
</summary>
<pre className="mt-2 overflow-x-auto rounded-md bg-slate-950 p-2 text-[11px] text-slate-300">
{JSON.stringify(run.vars, null, 2)}
</pre>
</details>
)}
<div className="flex flex-col gap-1">
{events.length === 0 ? (
<p className="text-xs text-slate-500">
No events recorded for this run.
</p>
) : (
events.map((ev, ix) => <EventLine key={ix} ev={ev} />)
)}
</div>
</div>
)}
</div>
);
}
const EVENT_COLOR: Record<string, string> = {
started: "text-emerald-300",
node_entered: "text-slate-300",
message_sent: "text-sky-300",
reply_received: "text-violet-300",
fallback_fired: "text-amber-300",
handoff: "text-amber-300",
timeout: "text-slate-500",
error: "text-red-300",
completed: "text-emerald-300",
};
function EventLine({ ev }: { ev: EventRow }) {
const cls = EVENT_COLOR[ev.event_type] ?? "text-slate-400";
return (
<div className="flex items-start gap-2 rounded-md px-2 py-1 text-xs">
<span className="w-32 shrink-0 text-[10px] text-slate-500">
{format(new Date(ev.created_at), "HH:mm:ss")}
</span>
<span className={cn("w-32 shrink-0 font-mono text-[10px]", cls)}>
{ev.event_type}
</span>
{ev.node_key && (
<code className="shrink-0 rounded bg-slate-800 px-1 py-0.5 text-[10px] text-slate-400">
{ev.node_key}
</code>
)}
{Object.keys(ev.payload).length > 0 && (
<span className="min-w-0 truncate text-[10px] text-slate-500">
{summarizePayload(ev.payload)}
</span>
)}
</div>
);
}
function summarizePayload(payload: Record<string, unknown>): string {
// Show the keys that matter most to a human debugger; full JSON is
// available via the "Captured vars" details panel for the run.
const keys = ["reply_id", "captured_key", "reason", "advancing_to"];
for (const k of keys) {
if (k in payload && payload[k] !== null && payload[k] !== undefined) {
return `${k}=${String(payload[k]).slice(0, 80)}`;
}
}
return "";
}

View File

@ -13,6 +13,9 @@ import {
PlayCircle, PlayCircle,
PauseCircle, PauseCircle,
Archive, Archive,
HelpCircle,
UserPlus,
FileText,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
@ -64,6 +67,21 @@ const STATUS_COLORS: Record<FlowRow["status"], string> = {
archived: "border-slate-700 bg-slate-800/50 text-slate-500", archived: "border-slate-700 bg-slate-800/50 text-slate-500",
}; };
interface TemplateSummary {
slug: string;
name: string;
description: string;
icon: "MessageSquare" | "HelpCircle" | "UserPlus";
trigger_type: string;
node_count: number;
}
const TEMPLATE_ICONS = {
MessageSquare,
HelpCircle,
UserPlus,
} as const;
export default function FlowsPage() { export default function FlowsPage() {
const router = useRouter(); const router = useRouter();
const { profile, loading: authLoading } = useAuth(); const { profile, loading: authLoading } = useAuth();
@ -72,6 +90,7 @@ export default function FlowsPage() {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [templates, setTemplates] = useState<TemplateSummary[]>([]);
const flowsAccessAllowed = isFlowsEnabled(profile); const flowsAccessAllowed = isFlowsEnabled(profile);
@ -85,10 +104,23 @@ export default function FlowsPage() {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const res = await fetch("/api/flows"); const [flowsRes, tmplRes] = await Promise.all([
if (!res.ok) throw new Error(`Failed to load flows: ${res.status}`); fetch("/api/flows"),
const json = (await res.json()) as { flows: FlowRow[] }; fetch("/api/flows/templates"),
if (!cancelled) setFlows(json.flows ?? []); ]);
if (!flowsRes.ok) {
throw new Error(`Failed to load flows: ${flowsRes.status}`);
}
const flowsJson = (await flowsRes.json()) as { flows: FlowRow[] };
if (!cancelled) setFlows(flowsJson.flows ?? []);
// Templates endpoint is forward-looking — if it 404s on an
// older deployment, gracefully fall through.
if (tmplRes.ok) {
const tmplJson = (await tmplRes.json()) as {
templates: TemplateSummary[];
};
if (!cancelled) setTemplates(tmplJson.templates ?? []);
}
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
console.error(err); console.error(err);
@ -129,6 +161,29 @@ export default function FlowsPage() {
} }
} }
async function handleUseTemplate(slug: string) {
setCreating(true);
try {
const res = await fetch("/api/flows", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template_slug: slug }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.error ?? `Clone failed: ${res.status}`);
}
const json = (await res.json()) as { flow: FlowRow };
setCreateOpen(false);
router.push(`/flows/${json.flow.id}`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Clone failed";
toast.error(msg);
} finally {
setCreating(false);
}
}
async function handleDelete(flow: FlowRow) { async function handleDelete(flow: FlowRow) {
const yes = window.confirm( const yes = window.confirm(
`Delete "${flow.name}"? Any active runs will end immediately.`, `Delete "${flow.name}"? Any active runs will end immediately.`,
@ -185,14 +240,51 @@ export default function FlowsPage() {
)} )}
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="bg-slate-900 text-slate-100"> <DialogContent className="max-w-2xl bg-slate-900 text-slate-100">
<DialogHeader> <DialogHeader>
<DialogTitle>Create a new flow</DialogTitle> <DialogTitle>Create a new flow</DialogTitle>
<DialogDescription className="text-slate-400"> <DialogDescription className="text-slate-400">
Give it a name. You can configure the trigger and nodes on the Start from a template or build from scratch.
next screen.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{templates.length > 0 && (
<div className="space-y-2">
<p className="text-xs uppercase tracking-wide text-slate-500">
Start from a template
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{templates.map((t) => {
const Icon = TEMPLATE_ICONS[t.icon] ?? FileText;
return (
<button
key={t.slug}
type="button"
onClick={() => handleUseTemplate(t.slug)}
disabled={creating}
className="flex flex-col gap-2 rounded-lg border border-slate-800 bg-slate-950 p-3 text-left transition-colors hover:border-violet-500/40 hover:bg-slate-800 disabled:opacity-50"
>
<Icon className="h-4 w-4 text-violet-400" />
<span className="text-sm font-medium text-white">
{t.name}
</span>
<span className="line-clamp-3 text-xs text-slate-400">
{t.description}
</span>
<span className="mt-auto text-[10px] text-slate-500">
{t.node_count} nodes
</span>
</button>
);
})}
</div>
</div>
)}
<div className="space-y-2">
<p className="text-xs uppercase tracking-wide text-slate-500">
Or start blank
</p>
<Input <Input
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
@ -202,6 +294,8 @@ export default function FlowsPage() {
if (e.key === "Enter") handleCreate(); if (e.key === "Enter") handleCreate();
}} }}
/> />
</div>
<DialogFooter> <DialogFooter>
<Button <Button
variant="ghost" variant="ghost"
@ -212,7 +306,7 @@ export default function FlowsPage() {
</Button> </Button>
<Button onClick={handleCreate} disabled={!newName.trim() || creating}> <Button onClick={handleCreate} disabled={!newName.trim() || creating}>
{creating && <Loader2 className="h-4 w-4 animate-spin" />} {creating && <Loader2 className="h-4 w-4 animate-spin" />}
Create Create blank flow
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -0,0 +1,95 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
/**
* GET /api/flows/[id]/runs
*
* Newest-first list of flow runs for a single flow, with the latest
* event timeline embedded for each. Used by the run-history viewer
* page (`/flows/[id]/runs`) to give the owner end-to-end visibility
* into what the bot did with each customer.
*
* RLS does the ownership check (flow_runs has a `user_id` policy);
* we also gate on the per-account beta flag so the route 404s for
* non-beta accounts matching the rest of /api/flows.
*
* Limited to the 50 most recent runs. Pagination can come later;
* the dashboard surface here is for debugging, not heavy querying.
*/
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: profile } = await supabase
.from('profiles')
.select('beta_features')
.eq('user_id', user.id)
.maybeSingle()
if (!isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
// Confirm flow exists + caller owns it (RLS does this) before doing
// the run query — gives us a clean 404 instead of empty array.
const { data: flow } = await supabase
.from('flows')
.select('id, name')
.eq('id', id)
.maybeSingle()
if (!flow) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
// Pull runs + each run's contact name + each run's events. Two
// joined selects keep the round-trip count to the runs query + one
// per-run events query.
const { data: runs, error: runsErr } = await supabase
.from('flow_runs')
.select(
'id, status, current_node_key, started_at, last_advanced_at, ended_at, end_reason, vars, reprompt_count, contact:contacts(id, name, phone)',
)
.eq('flow_id', id)
.order('started_at', { ascending: false })
.limit(50)
if (runsErr) {
return NextResponse.json({ error: runsErr.message }, { status: 500 })
}
const runIds = (runs ?? []).map((r) => (r as { id: string }).id)
let events: Array<{
flow_run_id: string
event_type: string
node_key: string | null
payload: Record<string, unknown>
created_at: string
}> = []
if (runIds.length > 0) {
const { data: evs, error: evsErr } = await supabase
.from('flow_run_events')
.select('flow_run_id, event_type, node_key, payload, created_at')
.in('flow_run_id', runIds)
.order('created_at', { ascending: true })
if (evsErr) {
// Non-fatal — the page can still show runs without timelines.
console.error('[flows-runs] events fetch failed:', evsErr.message)
} else if (evs) {
events = evs as typeof events
}
}
return NextResponse.json({
flow,
runs: runs ?? [],
events,
})
}

View File

@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/flows/admin-client' import { supabaseAdmin } from '@/lib/flows/admin-client'
import { isFlowsEnabled } from '@/lib/flows/feature-flag' import { isFlowsEnabled } from '@/lib/flows/feature-flag'
import { getFlowTemplate } from '@/lib/flows/templates'
/** /**
* GET /api/flows — list the caller's flows. * GET /api/flows — list the caller's flows.
@ -68,17 +69,78 @@ export async function POST(request: Request) {
description?: string | null description?: string | null
trigger_type?: 'keyword' | 'first_inbound_message' | 'manual' trigger_type?: 'keyword' | 'first_inbound_message' | 'manual'
trigger_config?: Record<string, unknown> trigger_config?: Record<string, unknown>
/**
* If set, clone the matching template's name + trigger +
* entry_node_id + nodes[] into a fresh draft for this user.
* `name` from the body overrides the template default if
* provided.
*/
template_slug?: string
} }
| null | null
if (!body) { if (!body) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
} }
const admin = supabaseAdmin()
// -------- Template clone path --------
if (body.template_slug) {
const template = getFlowTemplate(body.template_slug)
if (!template) {
return NextResponse.json(
{ error: `Unknown template_slug "${body.template_slug}"` },
{ status: 400 },
)
}
const { data: flow, error: flowErr } = await admin
.from('flows')
.insert({
user_id: userId,
name: body.name?.trim() || template.name,
description: template.description,
status: 'draft',
trigger_type: template.trigger_type,
trigger_config: template.trigger_config,
entry_node_id: template.entry_node_id,
})
.select()
.single()
if (flowErr || !flow) {
return NextResponse.json(
{ error: flowErr?.message ?? 'flow insert failed' },
{ status: 500 },
)
}
if (template.nodes.length > 0) {
const { error: nodesErr } = await admin.from('flow_nodes').insert(
template.nodes.map((n) => ({
flow_id: flow.id,
node_key: n.node_key,
node_type: n.node_type,
config: n.config,
})),
)
if (nodesErr) {
// Roll back the parent flow so a half-cloned template doesn't
// sit as an empty draft. CASCADE on flow_id removes the
// (probably zero) nodes too.
await admin.from('flows').delete().eq('id', flow.id)
return NextResponse.json(
{ error: nodesErr.message },
{ status: 500 },
)
}
}
return NextResponse.json({ flow }, { status: 201 })
}
// -------- Plain (empty) create path --------
if (!body.name?.trim()) { if (!body.name?.trim()) {
return NextResponse.json({ error: 'name is required' }, { status: 400 }) return NextResponse.json({ error: 'name is required' }, { status: 400 })
} }
const trigger_type = body.trigger_type ?? 'keyword' const trigger_type = body.trigger_type ?? 'keyword'
const admin = supabaseAdmin()
const { data, error } = await admin const { data, error } = await admin
.from('flows') .from('flows')
.insert({ .insert({

View File

@ -0,0 +1,44 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
import { listFlowTemplates } from '@/lib/flows/templates'
/**
* GET /api/flows/templates
*
* Returns the static template gallery (slug + name + description +
* icon hint + node_count) so the New-flow dialog can render cards
* without bundling the full template payloads client-side. Bodies
* are fetched only on actual clone via POST /api/flows.
*
* 404 to non-beta accounts (matches the rest of the /api/flows
* surface).
*/
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: profile } = await supabase
.from('profiles')
.select('beta_features')
.eq('user_id', user.id)
.maybeSingle()
if (!isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
// Shallow shape so the client gallery doesn't have to know about
// the full node tree.
const templates = listFlowTemplates().map((t) => ({
slug: t.slug,
name: t.name,
description: t.description,
icon: t.icon,
trigger_type: t.trigger_type,
node_count: t.nodes.length,
}))
return NextResponse.json({ templates })
}

View File

@ -15,12 +15,13 @@
* the same file as small components rather than separate modules. * the same file as small components rather than separate modules.
*/ */
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
ArrowLeft, ArrowLeft,
CircleCheck, CircleCheck,
CircleAlert, CircleAlert,
History,
Loader2, Loader2,
Plus, Plus,
Save, Save,
@ -36,6 +37,9 @@ import {
Flag, Flag,
PlayCircle, PlayCircle,
PauseCircle, PauseCircle,
Inbox,
GitFork,
Tag,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -79,6 +83,9 @@ type NodeType =
| "send_message" | "send_message"
| "send_buttons" | "send_buttons"
| "send_list" | "send_list"
| "collect_input"
| "condition"
| "set_tag"
| "handoff" | "handoff"
| "end"; | "end";
@ -123,6 +130,21 @@ const NODE_META: Record<
icon: ListPlus, icon: ListPlus,
color: "text-indigo-400", color: "text-indigo-400",
}, },
collect_input: {
label: "Collect input",
icon: Inbox,
color: "text-teal-400",
},
condition: {
label: "If / else",
icon: GitFork,
color: "text-fuchsia-400",
},
set_tag: {
label: "Tag contact",
icon: Tag,
color: "text-pink-400",
},
handoff: { handoff: {
label: "Handoff to agent", label: "Handoff to agent",
icon: UserPlus, icon: UserPlus,
@ -175,6 +197,23 @@ function defaultConfigFor(type: NodeType): Record<string, unknown> {
}, },
], ],
}; };
case "collect_input":
return {
prompt_text: "",
var_key: "answer",
next_node_key: "",
};
case "condition":
return {
subject: "var",
subject_key: "",
operator: "equals",
value: "",
true_next: "",
false_next: "",
};
case "set_tag":
return { mode: "add", tag_id: "", next_node_key: "" };
case "handoff": case "handoff":
return { note: "" }; return { note: "" };
case "end": case "end":
@ -399,6 +438,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
onDelete={handleDelete} onDelete={handleDelete}
canActivate={canActivate} canActivate={canActivate}
onBack={() => router.push("/flows")} onBack={() => router.push("/flows")}
onViewRuns={() => router.push(`/flows/${initialFlow.id}/runs`)}
/> />
<TriggerPanel <TriggerPanel
@ -465,6 +505,7 @@ function Header({
onDelete, onDelete,
canActivate, canActivate,
onBack, onBack,
onViewRuns,
}: { }: {
state: BuilderState; state: BuilderState;
setState: React.Dispatch<React.SetStateAction<BuilderState>>; setState: React.Dispatch<React.SetStateAction<BuilderState>>;
@ -475,6 +516,7 @@ function Header({
onDelete: () => void; onDelete: () => void;
canActivate: boolean; canActivate: boolean;
onBack: () => void; onBack: () => void;
onViewRuns: () => void;
}) { }) {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@ -502,6 +544,14 @@ function Header({
<StatusBadge status={state.status} /> <StatusBadge status={state.status} />
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onViewRuns()}
>
<History className="h-3.5 w-3.5" />
Runs
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -883,6 +933,66 @@ function NodeConfigForm({
/> />
)} )}
{node.node_type === "collect_input" && (
<>
<TextRow
label="Prompt sent to the customer"
value={(cfg as { prompt_text?: string }).prompt_text ?? ""}
onChange={(v) => onUpdateConfig({ prompt_text: v })}
rows={2}
/>
<div>
<label className="mb-1 block text-xs text-slate-400">
Variable key (stored in flow_runs.vars; alphanumeric + underscore)
</label>
<Input
value={(cfg as { var_key?: string }).var_key ?? ""}
onChange={(e) =>
onUpdateConfig({
var_key: e.target.value.replace(/[^a-zA-Z0-9_]/g, ""),
})
}
placeholder="e.g. name, email, company"
className="bg-slate-800 font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-slate-500">
Interpolate in downstream prompts and handoff notes with{" "}
<code className="rounded bg-slate-800 px-1">
{"{{vars."}
{(cfg as { var_key?: string }).var_key || "name"}
{"}}"}
</code>
.
</p>
</div>
<NextNodeRow
value={(cfg as { next_node_key?: string }).next_node_key ?? ""}
allNodes={allNodes}
currentKey={node.node_key}
onChange={(v) => onUpdateConfig({ next_node_key: v })}
label="After capturing, advance to"
/>
</>
)}
{node.node_type === "condition" && (
<ConditionForm
cfg={cfg as ConditionCfg}
allNodes={allNodes}
currentKey={node.node_key}
onUpdateConfig={onUpdateConfig}
/>
)}
{node.node_type === "set_tag" && (
<SetTagForm
cfg={cfg as SetTagCfg}
allNodes={allNodes}
currentKey={node.node_key}
onUpdateConfig={onUpdateConfig}
/>
)}
{node.node_type === "handoff" && ( {node.node_type === "handoff" && (
<TextRow <TextRow
label="Internal note (for the agent picking up)" label="Internal note (for the agent picking up)"
@ -1206,6 +1316,281 @@ function SendListForm({
); );
} }
// ---- condition form ----
interface ConditionCfg {
subject?: "var" | "tag" | "contact_field";
subject_key?: string;
operator?: "equals" | "contains" | "present" | "absent";
value?: string;
true_next?: string;
false_next?: string;
}
interface UserTag {
id: string;
name: string;
color?: string;
}
function ConditionForm({
cfg,
allNodes,
currentKey,
onUpdateConfig,
}: {
cfg: ConditionCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void;
}) {
const [tags, setTags] = useState<UserTag[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/tags").catch(() => null);
if (!res || !res.ok) return;
const json = (await res.json()) as { tags?: UserTag[] };
if (!cancelled) setTags(json.tags ?? []);
} catch {
// Tags endpoint absent on older deployments — fall back to a
// plain text input so the condition is still authorable.
}
})();
return () => {
cancelled = true;
};
}, []);
const subject = cfg.subject ?? "var";
const operator = cfg.operator ?? "equals";
const showValue = operator === "equals" || operator === "contains";
return (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<label className="mb-1 block text-xs text-slate-400">If</label>
<Select
value={subject}
onValueChange={(v) =>
onUpdateConfig({ subject: v as ConditionCfg["subject"] })
}
>
<SelectTrigger className="bg-slate-800">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="var">Captured variable</SelectItem>
<SelectItem value="tag">Contact has tag</SelectItem>
<SelectItem value="contact_field">Contact field</SelectItem>
</SelectContent>
</Select>
</div>
<div className="md:col-span-2">
<label className="mb-1 block text-xs text-slate-400">
{subject === "var"
? "var name"
: subject === "tag"
? "Tag"
: "Field"}
</label>
{subject === "tag" && tags.length > 0 ? (
<Select
value={cfg.subject_key ?? ""}
onValueChange={(v) => onUpdateConfig({ subject_key: v })}
>
<SelectTrigger className="bg-slate-800">
<SelectValue placeholder="Pick a tag…" />
</SelectTrigger>
<SelectContent>
{tags.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : subject === "contact_field" ? (
<Select
value={cfg.subject_key ?? ""}
onValueChange={(v) => onUpdateConfig({ subject_key: v })}
>
<SelectTrigger className="bg-slate-800">
<SelectValue placeholder="Pick a field…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">name</SelectItem>
<SelectItem value="email">email</SelectItem>
<SelectItem value="phone">phone</SelectItem>
<SelectItem value="company">company</SelectItem>
</SelectContent>
</Select>
) : (
<Input
value={cfg.subject_key ?? ""}
onChange={(e) => onUpdateConfig({ subject_key: e.target.value })}
placeholder={subject === "var" ? "e.g. email" : "tag UUID"}
className="bg-slate-800 font-mono text-xs"
/>
)}
</div>
</div>
<div
className={cn(
"grid grid-cols-1 gap-3",
showValue ? "md:grid-cols-2" : "",
)}
>
<div>
<label className="mb-1 block text-xs text-slate-400">Operator</label>
<Select
value={operator}
onValueChange={(v) =>
onUpdateConfig({ operator: v as ConditionCfg["operator"] })
}
>
<SelectTrigger className="bg-slate-800">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="present">is present</SelectItem>
<SelectItem value="absent">is absent</SelectItem>
<SelectItem value="equals">equals</SelectItem>
<SelectItem value="contains">contains</SelectItem>
</SelectContent>
</Select>
</div>
{showValue && (
<div>
<label className="mb-1 block text-xs text-slate-400">Value</label>
<Input
value={cfg.value ?? ""}
onChange={(e) => onUpdateConfig({ value: e.target.value })}
className="bg-slate-800"
/>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<NextNodeRow
value={cfg.true_next ?? ""}
allNodes={allNodes}
currentKey={currentKey}
onChange={(v) => onUpdateConfig({ true_next: v })}
label="If true → advance to"
/>
<NextNodeRow
value={cfg.false_next ?? ""}
allNodes={allNodes}
currentKey={currentKey}
onChange={(v) => onUpdateConfig({ false_next: v })}
label="If false → advance to"
/>
</div>
</>
);
}
// ---- set_tag form ----
interface SetTagCfg {
mode?: "add" | "remove";
tag_id?: string;
next_node_key?: string;
}
function SetTagForm({
cfg,
allNodes,
currentKey,
onUpdateConfig,
}: {
cfg: SetTagCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void;
}) {
const [tags, setTags] = useState<UserTag[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/tags").catch(() => null);
if (!res || !res.ok) return;
const json = (await res.json()) as { tags?: UserTag[] };
if (!cancelled) setTags(json.tags ?? []);
} catch {
// No tags endpoint — fall back to raw UUID input.
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-slate-400">Action</label>
<Select
value={cfg.mode ?? "add"}
onValueChange={(v) =>
onUpdateConfig({ mode: v as SetTagCfg["mode"] })
}
>
<SelectTrigger className="bg-slate-800">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="add">Add tag</SelectItem>
<SelectItem value="remove">Remove tag</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1 block text-xs text-slate-400">Tag</label>
{tags.length > 0 ? (
<Select
value={cfg.tag_id ?? ""}
onValueChange={(v) => onUpdateConfig({ tag_id: v })}
>
<SelectTrigger className="bg-slate-800">
<SelectValue placeholder="Pick a tag…" />
</SelectTrigger>
<SelectContent>
{tags.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={cfg.tag_id ?? ""}
onChange={(e) => onUpdateConfig({ tag_id: e.target.value })}
placeholder="Tag UUID"
className="bg-slate-800 font-mono text-xs"
/>
)}
</div>
</div>
<NextNodeRow
value={cfg.next_node_key ?? ""}
allNodes={allNodes}
currentKey={currentKey}
onChange={(v) => onUpdateConfig({ next_node_key: v })}
label="Then advance to"
/>
</>
);
}
// ---- Smaller field components ---- // ---- Smaller field components ----
function TextRow({ function TextRow({
@ -1321,6 +1706,9 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
"send_buttons", "send_buttons",
"send_list", "send_list",
"send_message", "send_message",
"collect_input",
"condition",
"set_tag",
"handoff", "handoff",
"end", "end",
]; ];

View File

@ -5,6 +5,7 @@ import {
isAutoAdvancing, isAutoAdvancing,
isSuspending, isSuspending,
isTerminal, isTerminal,
evaluateConditionPredicate,
} from "./engine"; } from "./engine";
describe("matchReplyId", () => { describe("matchReplyId", () => {
@ -143,11 +144,14 @@ describe("matchesKeywordTrigger", () => {
}); });
describe("node classification helpers", () => { describe("node classification helpers", () => {
it("isAutoAdvancing covers start + send_message", () => { it("isAutoAdvancing covers start + send_message + condition + set_tag", () => {
expect(isAutoAdvancing("start")).toBe(true); expect(isAutoAdvancing("start")).toBe(true);
expect(isAutoAdvancing("send_message")).toBe(true); expect(isAutoAdvancing("send_message")).toBe(true);
expect(isAutoAdvancing("condition")).toBe(true);
expect(isAutoAdvancing("set_tag")).toBe(true);
expect(isAutoAdvancing("send_buttons")).toBe(false); expect(isAutoAdvancing("send_buttons")).toBe(false);
expect(isAutoAdvancing("send_list")).toBe(false); expect(isAutoAdvancing("send_list")).toBe(false);
expect(isAutoAdvancing("collect_input")).toBe(false);
expect(isAutoAdvancing("handoff")).toBe(false); expect(isAutoAdvancing("handoff")).toBe(false);
expect(isAutoAdvancing("end")).toBe(false); expect(isAutoAdvancing("end")).toBe(false);
}); });
@ -155,8 +159,11 @@ describe("node classification helpers", () => {
it("isSuspending covers the input-requiring nodes", () => { it("isSuspending covers the input-requiring nodes", () => {
expect(isSuspending("send_buttons")).toBe(true); expect(isSuspending("send_buttons")).toBe(true);
expect(isSuspending("send_list")).toBe(true); expect(isSuspending("send_list")).toBe(true);
expect(isSuspending("collect_input")).toBe(true);
expect(isSuspending("start")).toBe(false); expect(isSuspending("start")).toBe(false);
expect(isSuspending("send_message")).toBe(false); expect(isSuspending("send_message")).toBe(false);
expect(isSuspending("condition")).toBe(false);
expect(isSuspending("set_tag")).toBe(false);
expect(isSuspending("handoff")).toBe(false); expect(isSuspending("handoff")).toBe(false);
expect(isSuspending("end")).toBe(false); expect(isSuspending("end")).toBe(false);
}); });
@ -166,6 +173,7 @@ describe("node classification helpers", () => {
expect(isTerminal("end")).toBe(true); expect(isTerminal("end")).toBe(true);
expect(isTerminal("start")).toBe(false); expect(isTerminal("start")).toBe(false);
expect(isTerminal("send_buttons")).toBe(false); expect(isTerminal("send_buttons")).toBe(false);
expect(isTerminal("condition")).toBe(false);
}); });
it("the three classifications are mutually exclusive for known node types", () => { it("the three classifications are mutually exclusive for known node types", () => {
@ -174,6 +182,9 @@ describe("node classification helpers", () => {
"send_message", "send_message",
"send_buttons", "send_buttons",
"send_list", "send_list",
"collect_input",
"condition",
"set_tag",
"handoff", "handoff",
"end", "end",
]; ];
@ -184,3 +195,103 @@ describe("node classification helpers", () => {
} }
}); });
}); });
describe("evaluateConditionPredicate", () => {
it("present: true when subject has a value", () => {
expect(
evaluateConditionPredicate({
operator: "present",
subjectValue: "alice@example.com",
configValue: undefined,
}),
).toBe(true);
});
it("present: false when subject is undefined or empty", () => {
expect(
evaluateConditionPredicate({
operator: "present",
subjectValue: undefined,
configValue: undefined,
}),
).toBe(false);
expect(
evaluateConditionPredicate({
operator: "present",
subjectValue: "",
configValue: undefined,
}),
).toBe(false);
});
it("absent: inverse of present", () => {
expect(
evaluateConditionPredicate({
operator: "absent",
subjectValue: undefined,
configValue: undefined,
}),
).toBe(true);
expect(
evaluateConditionPredicate({
operator: "absent",
subjectValue: "x",
configValue: undefined,
}),
).toBe(false);
});
it("equals: exact string comparison; case-sensitive", () => {
expect(
evaluateConditionPredicate({
operator: "equals",
subjectValue: "VIP",
configValue: "VIP",
}),
).toBe(true);
expect(
evaluateConditionPredicate({
operator: "equals",
subjectValue: "vip",
configValue: "VIP",
}),
).toBe(false);
});
it("equals: undefined subject never matches (even against empty)", () => {
expect(
evaluateConditionPredicate({
operator: "equals",
subjectValue: undefined,
configValue: "",
}),
).toBe(false);
});
it("contains: substring match", () => {
expect(
evaluateConditionPredicate({
operator: "contains",
subjectValue: "support@example.com",
configValue: "@example.com",
}),
).toBe(true);
expect(
evaluateConditionPredicate({
operator: "contains",
subjectValue: "support@other.com",
configValue: "@example.com",
}),
).toBe(false);
});
it("contains: undefined subject never matches", () => {
expect(
evaluateConditionPredicate({
operator: "contains",
subjectValue: undefined,
configValue: "anything",
}),
).toBe(false);
});
});

View File

@ -36,9 +36,12 @@ import { supabaseAdmin } from "./admin-client";
import { import {
engineSendInteractiveButtons, engineSendInteractiveButtons,
engineSendInteractiveList, engineSendInteractiveList,
engineSendText,
} from "./meta-send"; } from "./meta-send";
import { decideFallback, resolveFallbackPolicy } from "./fallback"; import { decideFallback, resolveFallbackPolicy } from "./fallback";
import { import {
type CollectInputNodeConfig,
type ConditionNodeConfig,
type DispatchInboundInput, type DispatchInboundInput,
type DispatchInboundResult, type DispatchInboundResult,
type FlowNodeRow, type FlowNodeRow,
@ -48,6 +51,7 @@ import {
type SendButtonsNodeConfig, type SendButtonsNodeConfig,
type SendListNodeConfig, type SendListNodeConfig,
type SendMessageNodeConfig, type SendMessageNodeConfig,
type SetTagNodeConfig,
type StartNodeConfig, type StartNodeConfig,
type KeywordTriggerConfig, type KeywordTriggerConfig,
} from "./types"; } from "./types";
@ -105,12 +109,21 @@ export function matchesKeywordTrigger(
/** Nodes that advance to a next_node_key without waiting for input. */ /** Nodes that advance to a next_node_key without waiting for input. */
export function isAutoAdvancing(node_type: string): boolean { export function isAutoAdvancing(node_type: string): boolean {
return node_type === "start" || node_type === "send_message"; return (
node_type === "start" ||
node_type === "send_message" ||
node_type === "condition" ||
node_type === "set_tag"
);
} }
/** Nodes that send a prompt and suspend awaiting a customer reply. */ /** Nodes that send a prompt and suspend awaiting a customer reply. */
export function isSuspending(node_type: string): boolean { export function isSuspending(node_type: string): boolean {
return node_type === "send_buttons" || node_type === "send_list"; return (
node_type === "send_buttons" ||
node_type === "send_list" ||
node_type === "collect_input"
);
} }
/** Nodes that end the run. */ /** Nodes that end the run. */
@ -118,6 +131,36 @@ export function isTerminal(node_type: string): boolean {
return node_type === "handoff" || node_type === "end"; return node_type === "handoff" || node_type === "end";
} }
/**
* Evaluate a `condition` node's predicate against the current run
* state. Exported pure for unit testing — the engine wraps it with a
* DB lookup for `tag` / `contact_field` subjects.
*/
export function evaluateConditionPredicate(args: {
operator: ConditionNodeConfig["operator"];
/**
* Resolved value of the subject. `undefined` means the subject is
* absent (no var with that key / no such tag / contact field is
* null). Pure function: caller does the DB lookup.
*/
subjectValue: string | undefined;
/** The configured comparison value, when applicable. */
configValue: string | undefined;
}): boolean {
switch (args.operator) {
case "present":
return args.subjectValue !== undefined && args.subjectValue !== "";
case "absent":
return args.subjectValue === undefined || args.subjectValue === "";
case "equals":
if (args.subjectValue === undefined) return false;
return args.subjectValue === (args.configValue ?? "");
case "contains":
if (args.subjectValue === undefined) return false;
return args.subjectValue.includes(args.configValue ?? "");
}
}
// ============================================================ // ============================================================
// DB I/O — wrapped in tiny helpers so the dispatch flow stays // DB I/O — wrapped in tiny helpers so the dispatch flow stays
// readable. Errors surface as thrown — the entry point catches. // readable. Errors surface as thrown — the entry point catches.
@ -385,6 +428,73 @@ async function executeHandoff(
await endRun(db, run.id, "handed_off", "handoff_node"); await endRun(db, run.id, "handed_off", "handoff_node");
} }
/**
* Resolve a condition node's subject value from DB / run state, then
* call the pure `evaluateConditionPredicate`. Splits out so the
* predicate itself stays unit-testable without a Supabase mock.
*
* Subject sources:
* - `var` → `flow_runs.vars[subject_key]` (captured by collect_input
* or http_fetch in v2).
* - `tag` → present iff `contact_tags(contact_id, tag_id)` exists.
* `subject_key` IS the tag UUID; the SELECT returns 1 row or 0.
* - `contact_field` → one of name/email/phone/company on `contacts`.
*/
async function evaluateConditionNode(
db: AdminClient,
run: FlowRunRow,
cfg: ConditionNodeConfig,
): Promise<boolean> {
let subjectValue: string | undefined;
if (cfg.subject === "var") {
const v = run.vars[cfg.subject_key];
subjectValue = typeof v === "string" ? v : v === undefined ? undefined : String(v);
} else if (cfg.subject === "tag") {
const { count } = await db
.from("contact_tags")
.select("contact_id", { count: "exact", head: true })
.eq("contact_id", run.contact_id!)
.eq("tag_id", cfg.subject_key);
// For tags, "present" really is the only meaningful test — the
// `present`/`absent` operators are the natural fit. equals/contains
// against a tag UUID would still work mechanically (compare its
// existence to the value).
subjectValue = (count ?? 0) > 0 ? cfg.subject_key : undefined;
} else {
const ALLOWED = ["name", "email", "phone", "company"] as const;
type AllowedField = (typeof ALLOWED)[number];
if (!ALLOWED.includes(cfg.subject_key as AllowedField)) {
throw new Error(`unsupported contact_field: ${cfg.subject_key}`);
}
const { data } = await db
.from("contacts")
.select(cfg.subject_key)
.eq("id", run.contact_id!)
.maybeSingle();
const raw = (data as Record<string, unknown> | null)?.[cfg.subject_key];
subjectValue = typeof raw === "string" && raw.length > 0 ? raw : undefined;
}
return evaluateConditionPredicate({
operator: cfg.operator,
subjectValue,
configValue: cfg.value,
});
}
/**
* Tiny `{{vars.foo}}` interpolation. Used by send_message + collect_input
* prompt text so a captured `name` can show up in the next prompt
* ("Thanks {{vars.name}}, what's your email?"). Missing vars render as
* empty string — the same behavior as the automations engine.
*/
function interpolateVars(template: string, vars: Record<string, unknown>): string {
if (!template) return "";
return template.replace(/\{\{vars\.([a-zA-Z0-9_]+)\}\}/g, (_, key) => {
const v = vars[key];
return v === undefined || v === null ? "" : String(v);
});
}
async function endRun( async function endRun(
db: AdminClient, db: AdminClient,
runId: string, runId: string,
@ -441,13 +551,124 @@ async function advanceFromNodeKey(
continue; continue;
} }
if (node.node_type === "send_message") { if (node.node_type === "send_message") {
// v1 send_message is a plain text without input. The dedicated
// text-only sender lives in automations/meta-send.ts; we'll
// wire it in PR #4 when the collect_input node lands. For v1,
// a flow that needs to send plain text uses a send_buttons
// node with a single "OK" button as the gating affordance.
// Until then, treat send_message as auto-advance with no send.
const cfg = node.config as unknown as SendMessageNodeConfig; const cfg = node.config as unknown as SendMessageNodeConfig;
try {
const { whatsapp_message_id } = await engineSendText({
userId: run.user_id,
conversationId: run.conversation_id!,
contactId: run.contact_id!,
text: interpolateVars(cfg.text, run.vars),
});
await logEvent(db, run.id, "message_sent", node.node_key, {
node_type: "send_message",
whatsapp_message_id,
});
} catch (err) {
await logEvent(db, run.id, "error", node.node_key, {
reason: "send_text_failed",
detail: err instanceof Error ? err.message : String(err),
});
await endRun(db, run.id, "failed", "send_text_failed");
return { outcome: "completed" };
}
currentKey = cfg.next_node_key;
continue;
}
if (node.node_type === "collect_input") {
// Send the prompt and suspend. Customer's next TEXT reply will
// wake us up via handleReplyForActiveRun's collect_input branch.
const cfg = node.config as unknown as CollectInputNodeConfig;
try {
const { whatsapp_message_id } = await engineSendText({
userId: run.user_id,
conversationId: run.conversation_id!,
contactId: run.contact_id!,
text: interpolateVars(cfg.prompt_text, run.vars),
});
await logEvent(db, run.id, "message_sent", node.node_key, {
node_type: "collect_input",
whatsapp_message_id,
});
const { data: msg } = await db
.from("messages")
.select("id")
.eq("message_id", whatsapp_message_id)
.maybeSingle();
await db
.from("flow_runs")
.update({
last_prompt_message_id: (msg as { id: string } | null)?.id ?? null,
})
.eq("id", run.id);
} catch (err) {
await logEvent(db, run.id, "error", node.node_key, {
reason: "collect_input_prompt_failed",
detail: err instanceof Error ? err.message : String(err),
});
await endRun(db, run.id, "failed", "collect_input_prompt_failed");
return { outcome: "completed" };
}
const advanced = await advanceCurrentNodeKey(
db,
run.id,
run.current_node_key,
node.node_key,
);
if (!advanced) {
await logEvent(db, run.id, "error", node.node_key, {
reason: "lost_race_during_advance",
});
}
return { outcome: "advanced" };
}
if (node.node_type === "condition") {
const cfg = node.config as unknown as ConditionNodeConfig;
let branch: "true" | "false";
try {
branch = (await evaluateConditionNode(db, run, cfg))
? "true"
: "false";
} catch (err) {
await logEvent(db, run.id, "error", node.node_key, {
reason: "condition_evaluation_failed",
detail: err instanceof Error ? err.message : String(err),
});
await endRun(db, run.id, "failed", "condition_evaluation_failed");
return { outcome: "completed" };
}
currentKey =
branch === "true" ? cfg.true_next : cfg.false_next;
await logEvent(db, run.id, "node_entered", node.node_key, {
condition_result: branch,
advancing_to: currentKey,
});
continue;
}
if (node.node_type === "set_tag") {
const cfg = node.config as unknown as SetTagNodeConfig;
try {
if (cfg.mode === "add") {
await db
.from("contact_tags")
.upsert(
{ contact_id: run.contact_id!, tag_id: cfg.tag_id },
{ onConflict: "contact_id,tag_id" },
);
} else {
await db
.from("contact_tags")
.delete()
.eq("contact_id", run.contact_id!)
.eq("tag_id", cfg.tag_id);
}
} catch (err) {
// Non-fatal — log + advance. A tag-write failure shouldn't
// strand the customer mid-flow.
await logEvent(db, run.id, "error", node.node_key, {
reason: "set_tag_failed",
detail: err instanceof Error ? err.message : String(err),
});
}
currentKey = cfg.next_node_key; currentKey = cfg.next_node_key;
continue; continue;
} }
@ -625,11 +846,41 @@ async function handleReplyForActiveRun(
return { consumed: true, flow_run_id: run.id, outcome: "no_match" }; return { consumed: true, flow_run_id: run.id, outcome: "no_match" };
} }
// Only interactive replies can advance a v1 flow. Text replies fall // Two ways a reply can advance:
// through to the fallback policy below (collect_input arrives in v1.5). // 1. Interactive button/list tap on a send_buttons/send_list node.
// 2. Text reply on a collect_input node — capture into vars.
//
// Everything else falls through to the fallback policy below.
let matched: string | null = null; let matched: string | null = null;
if (message.kind === "interactive_reply") { if (
message.kind === "interactive_reply" &&
(currentNode.node_type === "send_buttons" ||
currentNode.node_type === "send_list")
) {
matched = matchReplyId(currentNode, message.reply_id); matched = matchReplyId(currentNode, message.reply_id);
} else if (
message.kind === "text" &&
currentNode.node_type === "collect_input"
) {
const cfg = currentNode.config as unknown as CollectInputNodeConfig;
const captured = message.text.trim();
if (captured.length > 0 && cfg.var_key) {
// Persist captured value + reset reprompt count atomically.
const { error: capErr } = await db
.from("flow_runs")
.update({
vars: { ...run.vars, [cfg.var_key]: captured },
reprompt_count: 0,
})
.eq("id", run.id);
if (!capErr) {
await logEvent(db, run.id, "node_entered", currentNode.node_key, {
captured_key: cfg.var_key,
captured_length: captured.length,
});
matched = cfg.next_node_key;
}
}
} }
if (matched) { if (matched) {
@ -678,6 +929,23 @@ async function handleReplyForActiveRun(
await sendButtonsAndSuspend(db, run, currentNode); await sendButtonsAndSuspend(db, run, currentNode);
} else if (currentNode.node_type === "send_list") { } else if (currentNode.node_type === "send_list") {
await sendListAndSuspend(db, run, currentNode); await sendListAndSuspend(db, run, currentNode);
} else if (currentNode.node_type === "collect_input") {
// Customer typed something we couldn't accept (empty after trim,
// or var_key missing — rare). Re-send the prompt so they try again.
const cfg = currentNode.config as unknown as CollectInputNodeConfig;
try {
await engineSendText({
userId: run.user_id,
conversationId: run.conversation_id!,
contactId: run.contact_id!,
text: interpolateVars(cfg.prompt_text, run.vars),
});
} catch (err) {
await logEvent(db, run.id, "error", currentNode.node_key, {
reason: "reprompt_send_failed",
detail: err instanceof Error ? err.message : String(err),
});
}
} }
return { consumed: true, flow_run_id: run.id, outcome: "fallback_fired" }; return { consumed: true, flow_run_id: run.id, outcome: "fallback_fired" };
} }

View File

@ -1,6 +1,7 @@
import { import {
sendInteractiveButtons, sendInteractiveButtons,
sendInteractiveList, sendInteractiveList,
sendTextMessage,
type InteractiveButton, type InteractiveButton,
type InteractiveListSection, type InteractiveListSection,
} from '@/lib/whatsapp/meta-api' } from '@/lib/whatsapp/meta-api'
@ -28,6 +29,112 @@ import { supabaseAdmin } from './admin-client'
// keeps the foundation PR self-contained and unit-testable. // keeps the foundation PR self-contained and unit-testable.
// ------------------------------------------------------------ // ------------------------------------------------------------
interface SendTextEngineArgs {
userId: string
conversationId: string
contactId: string
text: string
}
/**
* Send a plain-text WhatsApp message from the Flows engine.
*
* Used by the runner's `send_message` and `collect_input` nodes —
* both prompt the customer with text and either auto-advance (the
* send_message case) or suspend awaiting a text reply (collect_input).
*
* Wraps the same phone-variant retry + DB persistence pattern as the
* interactive senders; the duplication will be DRY'd into a shared
* `engineSendBase` once the v2 features (templates with variables,
* media sends) settle.
*/
export async function engineSendText(
args: SendTextEngineArgs,
): Promise<{ whatsapp_message_id: string }> {
const db = supabaseAdmin()
const { data: contact, error: contactErr } = await db
.from('contacts')
.select('id, phone')
.eq('id', args.contactId)
.eq('user_id', args.userId)
.maybeSingle()
if (contactErr || !contact?.phone) {
throw new Error('contact not found for this user')
}
const sanitized = sanitizePhoneForMeta(contact.phone)
if (!isValidE164(sanitized)) {
throw new Error(`contact phone invalid: ${contact.phone}`)
}
const { data: config, error: configErr } = await db
.from('whatsapp_config')
.select('*')
.eq('user_id', args.userId)
.single()
if (configErr || !config) {
throw new Error('WhatsApp not configured for this account')
}
const accessToken = decrypt(config.access_token)
const attempt = async (phone: string): Promise<string> => {
const r = await sendTextMessage({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
text: args.text,
})
return r.messageId
}
const variants = phoneVariants(sanitized)
let workingPhone = sanitized
let waMessageId = ''
let lastError: unknown = null
for (const v of variants) {
try {
waMessageId = await attempt(v)
workingPhone = v
lastError = null
break
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!isRecipientNotAllowedError(msg)) throw err
lastError = err
}
}
if (lastError) throw lastError
if (workingPhone !== sanitized) {
await db.from('contacts').update({ phone: workingPhone }).eq('id', contact.id)
}
const { error: msgErr } = await db.from('messages').insert({
conversation_id: args.conversationId,
sender_type: 'bot',
content_type: 'text',
content_text: args.text,
message_id: waMessageId,
status: 'sent',
})
if (msgErr) {
throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`)
}
await db
.from('conversations')
.update({
last_message_text: args.text,
last_message_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', args.conversationId)
return { whatsapp_message_id: waMessageId }
}
interface SendInteractiveButtonsEngineArgs { interface SendInteractiveButtonsEngineArgs {
userId: string userId: string
conversationId: string conversationId: string

304
src/lib/flows/templates.ts Normal file
View File

@ -0,0 +1,304 @@
/**
* Starter flow templates.
*
* Three pre-canned flows users can clone with one click instead of
* building from scratch. Each template is a plain JS object describing
* the same shape `/api/flows` PUT accepts — name, trigger config,
* entry_node_id, fallback_policy, nodes[] — keyed by a stable
* `slug`.
*
* The clone path (`/api/flows` POST with `template_slug`) creates a
* NEW flow_row + flow_nodes rows for the user. `node_key`s are kept
* verbatim (they're stable strings, not UUIDs, so cloning never
* needs to rewrite edge references).
*
* Choosing a single static module over a DB-backed gallery for v1
* because: (a) the set is small and changes with code releases, not
* data; (b) keeps templates portable across self-hosted instances
* without migrations; (c) editing in source is the lowest-friction
* way to add the next template.
*/
import type {
CollectInputNodeConfig,
ConditionNodeConfig,
HandoffNodeConfig,
KeywordTriggerConfig,
SendButtonsNodeConfig,
SendListNodeConfig,
SendMessageNodeConfig,
StartNodeConfig,
} from "./types";
export type FlowTemplateNodeType =
| "start"
| "send_message"
| "send_buttons"
| "send_list"
| "collect_input"
| "condition"
| "set_tag"
| "handoff"
| "end";
export interface FlowTemplateNode {
node_key: string;
node_type: FlowTemplateNodeType;
config:
| StartNodeConfig
| SendMessageNodeConfig
| SendButtonsNodeConfig
| SendListNodeConfig
| CollectInputNodeConfig
| ConditionNodeConfig
| HandoffNodeConfig
| Record<string, unknown>;
}
export interface FlowTemplate {
slug: string;
name: string;
description: string;
/** Used by the gallery to surface a relevant icon. lucide-react name. */
icon: "MessageSquare" | "HelpCircle" | "UserPlus";
trigger_type: "keyword" | "first_inbound_message" | "manual";
trigger_config: KeywordTriggerConfig | Record<string, unknown>;
entry_node_id: string;
nodes: FlowTemplateNode[];
}
// ============================================================
// 1. Welcome menu — the example from the owner's brief
// ============================================================
const WELCOME_MENU: FlowTemplate = {
slug: "welcome_menu",
name: "Welcome menu",
description:
"Greet customers who type a keyword and route them to the right agent based on whether they're new or existing.",
icon: "MessageSquare",
trigger_type: "keyword",
trigger_config: { keywords: ["support", "help", "hi"], match_type: "contains" },
entry_node_id: "start",
nodes: [
{
node_key: "start",
node_type: "start",
config: { next_node_key: "welcome" },
},
{
node_key: "welcome",
node_type: "send_buttons",
config: {
text: "Hi! 👋 Welcome to support. Are you an existing customer or new here?",
footer_text: "Tap a button below to continue.",
buttons: [
{
reply_id: "existing",
title: "Existing customer",
next_node_key: "existing_handoff",
},
{
reply_id: "new",
title: "New customer",
next_node_key: "new_handoff",
},
],
} as SendButtonsNodeConfig,
},
{
node_key: "existing_handoff",
node_type: "handoff",
config: {
note: "Existing customer needs assistance — please check account history before replying.",
} as HandoffNodeConfig,
},
{
node_key: "new_handoff",
node_type: "handoff",
config: {
note: "New customer — share pricing + onboarding link.",
} as HandoffNodeConfig,
},
],
};
// ============================================================
// 2. FAQ bot — list-message answers, fully automated
// ============================================================
const FAQ_BOT: FlowTemplate = {
slug: "faq_bot",
name: "FAQ bot",
description:
"Answer common questions automatically. Customer picks a topic from a list; the bot replies with the answer and ends.",
icon: "HelpCircle",
trigger_type: "keyword",
trigger_config: {
keywords: ["faq", "question", "info"],
match_type: "contains",
},
entry_node_id: "start",
nodes: [
{
node_key: "start",
node_type: "start",
config: { next_node_key: "topics" },
},
{
node_key: "topics",
node_type: "send_list",
config: {
text: "What can I help you with?",
button_label: "View topics",
sections: [
{
title: "Common questions",
rows: [
{
reply_id: "hours",
title: "Opening hours",
next_node_key: "answer_hours",
},
{
reply_id: "pricing",
title: "Pricing",
next_node_key: "answer_pricing",
},
{
reply_id: "refunds",
title: "Refund policy",
next_node_key: "answer_refunds",
},
],
},
{
title: "Other",
rows: [
{
reply_id: "human",
title: "Talk to a human",
next_node_key: "human_handoff",
},
],
},
],
} as SendListNodeConfig,
},
{
node_key: "answer_hours",
node_type: "send_message",
config: {
text: "We're open Mon–Fri, 9am–6pm local time. Weekend support is limited to urgent issues.",
next_node_key: "end",
} as SendMessageNodeConfig,
},
{
node_key: "answer_pricing",
node_type: "send_message",
config: {
text: "Our pricing starts at $9/mo. Visit https://example.com/pricing for the full breakdown.",
next_node_key: "end",
} as SendMessageNodeConfig,
},
{
node_key: "answer_refunds",
node_type: "send_message",
config: {
text: "Refunds are honored within 30 days of purchase. Reply with your order number and we'll process it.",
next_node_key: "end",
} as SendMessageNodeConfig,
},
{
node_key: "human_handoff",
node_type: "handoff",
config: {
note: "Customer asked to talk to a human from the FAQ bot.",
} as HandoffNodeConfig,
},
{
node_key: "end",
node_type: "end",
config: {},
},
],
};
// ============================================================
// 3. Lead capture — collect_input chain, ends in a handoff
// ============================================================
const LEAD_CAPTURE: FlowTemplate = {
slug: "lead_capture",
name: "Lead capture",
description:
"Greet first-time inbounds, capture name + email + company, then hand off to sales with the answers in the note.",
icon: "UserPlus",
trigger_type: "first_inbound_message",
trigger_config: {},
entry_node_id: "start",
nodes: [
{
node_key: "start",
node_type: "start",
config: { next_node_key: "intro" },
},
{
node_key: "intro",
node_type: "send_message",
config: {
text: "Welcome! 👋 I'll ask a few quick questions so we can get you to the right person.",
next_node_key: "ask_name",
} as SendMessageNodeConfig,
},
{
node_key: "ask_name",
node_type: "collect_input",
config: {
prompt_text: "What's your name?",
var_key: "name",
next_node_key: "ask_email",
} as CollectInputNodeConfig,
},
{
node_key: "ask_email",
node_type: "collect_input",
config: {
prompt_text: "Thanks {{vars.name}}! What's your work email?",
var_key: "email",
next_node_key: "ask_company",
} as CollectInputNodeConfig,
},
{
node_key: "ask_company",
node_type: "collect_input",
config: {
prompt_text: "Almost done — what's your company name?",
var_key: "company",
next_node_key: "handoff",
} as CollectInputNodeConfig,
},
{
node_key: "handoff",
node_type: "handoff",
config: {
note: "New lead — name={{vars.name}}, email={{vars.email}}, company={{vars.company}}.",
} as HandoffNodeConfig,
},
],
};
// ============================================================
// Registry
// ============================================================
const TEMPLATES: Record<string, FlowTemplate> = {
welcome_menu: WELCOME_MENU,
faq_bot: FAQ_BOT,
lead_capture: LEAD_CAPTURE,
};
export function getFlowTemplate(slug: string): FlowTemplate | null {
return TEMPLATES[slug] ?? null;
}
export function listFlowTemplates(): FlowTemplate[] {
return Object.values(TEMPLATES);
}

View File

@ -78,6 +78,72 @@ export interface HandoffNodeConfig {
assign_to?: string; assign_to?: string;
} }
/**
* Captures the customer's next free-text reply into
* `flow_runs.vars[var_key]`, then advances.
*
* v1.5 ships without runtime validation (`validation` is accepted on
* the config for forward compat but ignored by the runner); the
* builder still surfaces the field so users can author flows that
* v2 will start enforcing.
*/
export interface CollectInputNodeConfig {
/** Prompt text sent to the customer before they reply. */
prompt_text: string;
/**
* Key under which to store the captured text in
* `flow_runs.vars`. Stable identifier — used by downstream
* `condition` nodes and `handoff` notes via interpolation.
*/
var_key: string;
/**
* Reserved for v2. Accepted on the config but ignored by the v1.5
* runner — captures any non-empty text.
*/
validation?: "any" | "email" | "phone" | "regex";
/** Used only when `validation === 'regex'`. */
regex?: string;
/** Node to advance to after capture. */
next_node_key: string;
}
export type ConditionOperator =
| "equals"
| "contains"
| "present"
| "absent";
export type ConditionSubject = "var" | "tag" | "contact_field";
/**
* Routes the run based on a predicate over the contact's tags,
* profile fields, or stored vars. Always auto-advances — no Meta
* call, no customer-side input.
*/
export interface ConditionNodeConfig {
subject: ConditionSubject;
/**
* For `var`: the key in flow_runs.vars.
* For `tag`: the tag UUID (matched against contact_tags).
* For `contact_field`: one of 'name' | 'email' | 'phone' | 'company'.
*/
subject_key: string;
operator: ConditionOperator;
/** Compared against `subject` for `equals`/`contains`. Ignored for `present`/`absent`. */
value?: string;
/** Node to advance to when the predicate evaluates true. */
true_next: string;
/** Node to advance to when it evaluates false. */
false_next: string;
}
export interface SetTagNodeConfig {
mode: "add" | "remove";
/** Tag UUID. The builder picks from the user's existing tags. */
tag_id: string;
next_node_key: string;
}
// Terminal nodes carry no config — they just stop the run. // Terminal nodes carry no config — they just stop the run.
export type EndNodeConfig = Record<string, never>; export type EndNodeConfig = Record<string, never>;
@ -94,6 +160,9 @@ export type FlowNodeConfig =
| { node_type: "send_message"; config: SendMessageNodeConfig } | { node_type: "send_message"; config: SendMessageNodeConfig }
| { node_type: "send_buttons"; config: SendButtonsNodeConfig } | { node_type: "send_buttons"; config: SendButtonsNodeConfig }
| { node_type: "send_list"; config: SendListNodeConfig } | { node_type: "send_list"; config: SendListNodeConfig }
| { node_type: "collect_input"; config: CollectInputNodeConfig }
| { node_type: "condition"; config: ConditionNodeConfig }
| { node_type: "set_tag"; config: SetTagNodeConfig }
| { node_type: "handoff"; config: HandoffNodeConfig } | { node_type: "handoff"; config: HandoffNodeConfig }
| { node_type: "end"; config: EndNodeConfig }; | { node_type: "end"; config: EndNodeConfig };

View File

@ -473,6 +473,175 @@ function validateNode(
break; break;
} }
case "collect_input": {
const cfg = node.config as {
prompt_text?: string;
var_key?: string;
next_node_key?: string;
};
if (!cfg.prompt_text?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "prompt_text",
message: "Collect-input needs a prompt to send the customer.",
});
}
if (!cfg.var_key?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "var_key",
message: "Collect-input needs a var_key to store the answer under.",
});
} else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cfg.var_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "var_key",
message: `var_key "${cfg.var_key}" must be alphanumeric+underscore and start with a letter or underscore.`,
});
}
if (!cfg.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: "Collect-input must point to a next node.",
});
} else if (!knownKeys.has(cfg.next_node_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: `Collect-input points to non-existent node "${cfg.next_node_key}".`,
});
}
break;
}
case "condition": {
const cfg = node.config as {
subject?: "var" | "tag" | "contact_field";
subject_key?: string;
operator?: "equals" | "contains" | "present" | "absent";
value?: string;
true_next?: string;
false_next?: string;
};
if (!cfg.subject || !["var", "tag", "contact_field"].includes(cfg.subject)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "subject",
message: "Condition needs a subject (var / tag / contact_field).",
});
}
if (!cfg.subject_key?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "subject_key",
message: "Condition needs a subject_key (var name, tag id, or field name).",
});
}
if (
!cfg.operator ||
!["equals", "contains", "present", "absent"].includes(cfg.operator)
) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "operator",
message: "Condition needs an operator.",
});
} else if (
(cfg.operator === "equals" || cfg.operator === "contains") &&
(cfg.value === undefined || cfg.value === "")
) {
issues.push({
severity: "warning",
scope: "node",
node_key: node.node_key,
field: "value",
message: `Operator "${cfg.operator}" usually expects a comparison value — empty value will only match empty subjects.`,
});
}
for (const branch of ["true_next", "false_next"] as const) {
const key = cfg[branch];
if (!key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: branch,
message: `Condition needs a node for the "${branch === "true_next" ? "true" : "false"}" branch.`,
});
} else if (!knownKeys.has(key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: branch,
message: `Condition's "${branch}" points to non-existent node "${key}".`,
});
}
}
break;
}
case "set_tag": {
const cfg = node.config as {
mode?: "add" | "remove";
tag_id?: string;
next_node_key?: string;
};
if (!cfg.mode || !["add", "remove"].includes(cfg.mode)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "mode",
message: "Set-tag needs a mode (add or remove).",
});
}
if (!cfg.tag_id) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "tag_id",
message: "Set-tag needs a tag to apply.",
});
}
if (!cfg.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: "Set-tag must point to a next node.",
});
} else if (!knownKeys.has(cfg.next_node_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: `Set-tag points to non-existent node "${cfg.next_node_key}".`,
});
}
break;
}
case "handoff": case "handoff":
case "end": case "end":
// Terminal nodes have no outgoing edges; nothing to validate // Terminal nodes have no outgoing edges; nothing to validate
@ -520,10 +689,22 @@ export function reachableFromEntry(
function outgoingEdges(node: NodeInput): string[] { function outgoingEdges(node: NodeInput): string[] {
switch (node.node_type) { switch (node.node_type) {
case "start": case "start":
case "send_message": { case "send_message":
case "collect_input":
case "set_tag": {
const cfg = node.config as { next_node_key?: string }; const cfg = node.config as { next_node_key?: string };
return cfg.next_node_key ? [cfg.next_node_key] : []; return cfg.next_node_key ? [cfg.next_node_key] : [];
} }
case "condition": {
const cfg = node.config as {
true_next?: string;
false_next?: string;
};
const out: string[] = [];
if (cfg.true_next) out.push(cfg.true_next);
if (cfg.false_next) out.push(cfg.false_next);
return out;
}
case "send_buttons": { case "send_buttons": {
const cfg = node.config as { const cfg = node.config as {
buttons?: Array<{ next_node_key?: string }>; buttons?: Array<{ next_node_key?: string }>;