mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
commit
4cb6849291
350
src/app/(dashboard)/flows/[id]/runs/page.tsx
Normal file
350
src/app/(dashboard)/flows/[id]/runs/page.tsx
Normal 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'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 "";
|
||||||
|
}
|
||||||
@ -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,23 +240,62 @@ 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>
|
||||||
<Input
|
|
||||||
value={newName}
|
{templates.length > 0 && (
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
<div className="space-y-2">
|
||||||
placeholder="e.g. Welcome menu"
|
<p className="text-xs uppercase tracking-wide text-slate-500">
|
||||||
className="bg-slate-800"
|
Start from a template
|
||||||
onKeyDown={(e) => {
|
</p>
|
||||||
if (e.key === "Enter") handleCreate();
|
<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
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="e.g. Welcome menu"
|
||||||
|
className="bg-slate-800"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
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>
|
||||||
|
|||||||
95
src/app/api/flows/[id]/runs/route.ts
Normal file
95
src/app/api/flows/[id]/runs/route.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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({
|
||||||
|
|||||||
44
src/app/api/flows/templates/route.ts
Normal file
44
src/app/api/flows/templates/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
@ -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",
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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" };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
304
src/lib/flows/templates.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
|
|
||||||
|
|||||||
@ -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 }>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user