mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(flows): templates + v1.5 nodes (collect_input/condition/set_tag) + run-history viewer (PR 4/4)
Final feature PR before GA cleanup. Closes the gap between "flow
runner works" (PR 2) and "users have everything they need to build
real automation" — three new node types, plain-text sends actually
sending, three starter templates, a run-history page for debugging.
What landed:
1. engineSendText helper (src/lib/flows/meta-send.ts) — wraps
sendTextMessage with the phone-variant retry + DB persistence
pattern. Unblocks send_message + collect_input nodes that
previously auto-advanced without sending.
2. Three new node types in the engine:
- collect_input: sends a prompt, suspends, captures the next
text reply into flow_runs.vars[var_key], advances. Supports
{{vars.X}} interpolation in downstream prompts + handoff
notes.
- condition: branches on subject (var / tag / contact_field)
with operators (equals / contains / present / absent).
Auto-advancing, no Meta call. Pure predicate logic exported
as evaluateConditionPredicate with 7 new unit tests.
- set_tag: upsert/delete on contact_tags then advance. Non-
fatal on write failure — logged + advances anyway so a tag
glitch doesn't strand the customer.
3. Real send_message — calls engineSendText, logs the result,
handles errors by ending the run with status='failed'. Same
{{vars.X}} interpolation.
4. Three starter templates (src/lib/flows/templates.ts):
- Welcome menu (the owner's brief example)
- FAQ bot (keyword → list → answers → end)
- Lead capture (first_inbound → collect_input chain → handoff
with vars in the note)
POST /api/flows now accepts {template_slug} to clone a
template into a fresh draft. GET /api/flows/templates exposes
the gallery shape (no nodes payload — fetched on actual clone).
5. Builder UI extensions:
- NODE_META + config sub-forms for collect_input, condition,
set_tag.
- Template gallery on the /flows New-flow dialog (loads from
/api/flows/templates, falls back gracefully if absent).
- "Runs" button in the editor header → /flows/[id]/runs.
6. Run-history viewer at /flows/[id]/runs — newest-first list of
the 50 most recent runs, with expandable per-run event
timeline (started, node_entered, message_sent,
reply_received, fallback_fired, handoff, timeout, error,
completed). Useful for debugging "why didn't my flow advance?"
from the brief's use case.
7. Validator updates: new error rules for each new node type
(prompt_text required, var_key alphanumeric, operator valid,
true_next/false_next exist, etc.). outgoingEdges helper now
covers collect_input/condition/set_tag for reachability.
All gated behind isFlowsEnabled(profile). Non-beta accounts see no
change. Run-history API is also 404 for non-beta.
Validation: 173/173 tests pass (added 7 new for the predicate);
typecheck + lint + build all clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f9d13c42a7
commit
ed15493f95
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,
|
||||
PauseCircle,
|
||||
Archive,
|
||||
HelpCircle,
|
||||
UserPlus,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const { profile, loading: authLoading } = useAuth();
|
||||
@ -72,6 +90,7 @@ export default function FlowsPage() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [templates, setTemplates] = useState<TemplateSummary[]>([]);
|
||||
|
||||
const flowsAccessAllowed = isFlowsEnabled(profile);
|
||||
|
||||
@ -85,10 +104,23 @@ export default function FlowsPage() {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/flows");
|
||||
if (!res.ok) throw new Error(`Failed to load flows: ${res.status}`);
|
||||
const json = (await res.json()) as { flows: FlowRow[] };
|
||||
if (!cancelled) setFlows(json.flows ?? []);
|
||||
const [flowsRes, tmplRes] = await Promise.all([
|
||||
fetch("/api/flows"),
|
||||
fetch("/api/flows/templates"),
|
||||
]);
|
||||
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) {
|
||||
if (!cancelled) {
|
||||
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) {
|
||||
const yes = window.confirm(
|
||||
`Delete "${flow.name}"? Any active runs will end immediately.`,
|
||||
@ -185,23 +240,62 @@ export default function FlowsPage() {
|
||||
)}
|
||||
|
||||
<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>
|
||||
<DialogTitle>Create a new flow</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Give it a name. You can configure the trigger and nodes on the
|
||||
next screen.
|
||||
Start from a template or build from scratch.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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();
|
||||
}}
|
||||
/>
|
||||
|
||||
{templates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">
|
||||
Start from a template
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{templates.map((t) => {
|
||||
const Icon = TEMPLATE_ICONS[t.icon] ?? FileText;
|
||||
return (
|
||||
<button
|
||||
key={t.slug}
|
||||
type="button"
|
||||
onClick={() => handleUseTemplate(t.slug)}
|
||||
disabled={creating}
|
||||
className="flex flex-col gap-2 rounded-lg border border-slate-800 bg-slate-950 p-3 text-left transition-colors hover:border-violet-500/40 hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-violet-400" />
|
||||
<span className="text-sm font-medium text-white">
|
||||
{t.name}
|
||||
</span>
|
||||
<span className="line-clamp-3 text-xs text-slate-400">
|
||||
{t.description}
|
||||
</span>
|
||||
<span className="mt-auto text-[10px] text-slate-500">
|
||||
{t.node_count} nodes
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">
|
||||
Or start blank
|
||||
</p>
|
||||
<Input
|
||||
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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -212,7 +306,7 @@ export default function FlowsPage() {
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!newName.trim() || creating}>
|
||||
{creating && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Create
|
||||
Create blank flow
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</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 { supabaseAdmin } from '@/lib/flows/admin-client'
|
||||
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
|
||||
import { getFlowTemplate } from '@/lib/flows/templates'
|
||||
|
||||
/**
|
||||
* GET /api/flows — list the caller's flows.
|
||||
@ -68,17 +69,78 @@ export async function POST(request: Request) {
|
||||
description?: string | null
|
||||
trigger_type?: 'keyword' | 'first_inbound_message' | 'manual'
|
||||
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
|
||||
if (!body) {
|
||||
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()) {
|
||||
return NextResponse.json({ error: 'name is required' }, { status: 400 })
|
||||
}
|
||||
const trigger_type = body.trigger_type ?? 'keyword'
|
||||
|
||||
const admin = supabaseAdmin()
|
||||
const { data, error } = await admin
|
||||
.from('flows')
|
||||
.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.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CircleCheck,
|
||||
CircleAlert,
|
||||
History,
|
||||
Loader2,
|
||||
Plus,
|
||||
Save,
|
||||
@ -36,6 +37,9 @@ import {
|
||||
Flag,
|
||||
PlayCircle,
|
||||
PauseCircle,
|
||||
Inbox,
|
||||
GitFork,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@ -79,6 +83,9 @@ type NodeType =
|
||||
| "send_message"
|
||||
| "send_buttons"
|
||||
| "send_list"
|
||||
| "collect_input"
|
||||
| "condition"
|
||||
| "set_tag"
|
||||
| "handoff"
|
||||
| "end";
|
||||
|
||||
@ -123,6 +130,21 @@ const NODE_META: Record<
|
||||
icon: ListPlus,
|
||||
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: {
|
||||
label: "Handoff to agent",
|
||||
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":
|
||||
return { note: "" };
|
||||
case "end":
|
||||
@ -399,6 +438,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
|
||||
onDelete={handleDelete}
|
||||
canActivate={canActivate}
|
||||
onBack={() => router.push("/flows")}
|
||||
onViewRuns={() => router.push(`/flows/${initialFlow.id}/runs`)}
|
||||
/>
|
||||
|
||||
<TriggerPanel
|
||||
@ -465,6 +505,7 @@ function Header({
|
||||
onDelete,
|
||||
canActivate,
|
||||
onBack,
|
||||
onViewRuns,
|
||||
}: {
|
||||
state: BuilderState;
|
||||
setState: React.Dispatch<React.SetStateAction<BuilderState>>;
|
||||
@ -475,6 +516,7 @@ function Header({
|
||||
onDelete: () => void;
|
||||
canActivate: boolean;
|
||||
onBack: () => void;
|
||||
onViewRuns: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
@ -502,6 +544,14 @@ function Header({
|
||||
<StatusBadge status={state.status} />
|
||||
</div>
|
||||
<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
|
||||
variant="ghost"
|
||||
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" && (
|
||||
<TextRow
|
||||
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 ----
|
||||
|
||||
function TextRow({
|
||||
@ -1321,6 +1706,9 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
|
||||
"send_buttons",
|
||||
"send_list",
|
||||
"send_message",
|
||||
"collect_input",
|
||||
"condition",
|
||||
"set_tag",
|
||||
"handoff",
|
||||
"end",
|
||||
];
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
isAutoAdvancing,
|
||||
isSuspending,
|
||||
isTerminal,
|
||||
evaluateConditionPredicate,
|
||||
} from "./engine";
|
||||
|
||||
describe("matchReplyId", () => {
|
||||
@ -143,11 +144,14 @@ describe("matchesKeywordTrigger", () => {
|
||||
});
|
||||
|
||||
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("send_message")).toBe(true);
|
||||
expect(isAutoAdvancing("condition")).toBe(true);
|
||||
expect(isAutoAdvancing("set_tag")).toBe(true);
|
||||
expect(isAutoAdvancing("send_buttons")).toBe(false);
|
||||
expect(isAutoAdvancing("send_list")).toBe(false);
|
||||
expect(isAutoAdvancing("collect_input")).toBe(false);
|
||||
expect(isAutoAdvancing("handoff")).toBe(false);
|
||||
expect(isAutoAdvancing("end")).toBe(false);
|
||||
});
|
||||
@ -155,8 +159,11 @@ describe("node classification helpers", () => {
|
||||
it("isSuspending covers the input-requiring nodes", () => {
|
||||
expect(isSuspending("send_buttons")).toBe(true);
|
||||
expect(isSuspending("send_list")).toBe(true);
|
||||
expect(isSuspending("collect_input")).toBe(true);
|
||||
expect(isSuspending("start")).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("end")).toBe(false);
|
||||
});
|
||||
@ -166,6 +173,7 @@ describe("node classification helpers", () => {
|
||||
expect(isTerminal("end")).toBe(true);
|
||||
expect(isTerminal("start")).toBe(false);
|
||||
expect(isTerminal("send_buttons")).toBe(false);
|
||||
expect(isTerminal("condition")).toBe(false);
|
||||
});
|
||||
|
||||
it("the three classifications are mutually exclusive for known node types", () => {
|
||||
@ -174,6 +182,9 @@ describe("node classification helpers", () => {
|
||||
"send_message",
|
||||
"send_buttons",
|
||||
"send_list",
|
||||
"collect_input",
|
||||
"condition",
|
||||
"set_tag",
|
||||
"handoff",
|
||||
"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 {
|
||||
engineSendInteractiveButtons,
|
||||
engineSendInteractiveList,
|
||||
engineSendText,
|
||||
} from "./meta-send";
|
||||
import { decideFallback, resolveFallbackPolicy } from "./fallback";
|
||||
import {
|
||||
type CollectInputNodeConfig,
|
||||
type ConditionNodeConfig,
|
||||
type DispatchInboundInput,
|
||||
type DispatchInboundResult,
|
||||
type FlowNodeRow,
|
||||
@ -48,6 +51,7 @@ import {
|
||||
type SendButtonsNodeConfig,
|
||||
type SendListNodeConfig,
|
||||
type SendMessageNodeConfig,
|
||||
type SetTagNodeConfig,
|
||||
type StartNodeConfig,
|
||||
type KeywordTriggerConfig,
|
||||
} from "./types";
|
||||
@ -105,12 +109,21 @@ export function matchesKeywordTrigger(
|
||||
|
||||
/** Nodes that advance to a next_node_key without waiting for input. */
|
||||
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. */
|
||||
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. */
|
||||
@ -118,6 +131,36 @@ export function isTerminal(node_type: string): boolean {
|
||||
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
|
||||
// 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
db: AdminClient,
|
||||
runId: string,
|
||||
@ -441,13 +551,124 @@ async function advanceFromNodeKey(
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
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;
|
||||
continue;
|
||||
}
|
||||
@ -625,11 +846,41 @@ async function handleReplyForActiveRun(
|
||||
return { consumed: true, flow_run_id: run.id, outcome: "no_match" };
|
||||
}
|
||||
|
||||
// Only interactive replies can advance a v1 flow. Text replies fall
|
||||
// through to the fallback policy below (collect_input arrives in v1.5).
|
||||
// Two ways a reply can advance:
|
||||
// 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;
|
||||
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);
|
||||
} 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) {
|
||||
@ -678,6 +929,23 @@ async function handleReplyForActiveRun(
|
||||
await sendButtonsAndSuspend(db, run, currentNode);
|
||||
} else if (currentNode.node_type === "send_list") {
|
||||
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" };
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
sendInteractiveButtons,
|
||||
sendInteractiveList,
|
||||
sendTextMessage,
|
||||
type InteractiveButton,
|
||||
type InteractiveListSection,
|
||||
} from '@/lib/whatsapp/meta-api'
|
||||
@ -28,6 +29,112 @@ import { supabaseAdmin } from './admin-client'
|
||||
// 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 {
|
||||
userId: 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
export type EndNodeConfig = Record<string, never>;
|
||||
|
||||
@ -94,6 +160,9 @@ export type FlowNodeConfig =
|
||||
| { node_type: "send_message"; config: SendMessageNodeConfig }
|
||||
| { node_type: "send_buttons"; config: SendButtonsNodeConfig }
|
||||
| { 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: "end"; config: EndNodeConfig };
|
||||
|
||||
|
||||
@ -473,6 +473,175 @@ function validateNode(
|
||||
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 "end":
|
||||
// Terminal nodes have no outgoing edges; nothing to validate
|
||||
@ -520,10 +689,22 @@ export function reachableFromEntry(
|
||||
function outgoingEdges(node: NodeInput): string[] {
|
||||
switch (node.node_type) {
|
||||
case "start":
|
||||
case "send_message": {
|
||||
case "send_message":
|
||||
case "collect_input":
|
||||
case "set_tag": {
|
||||
const cfg = node.config as { next_node_key?: string };
|
||||
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": {
|
||||
const cfg = node.config as {
|
||||
buttons?: Array<{ next_node_key?: string }>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user