Merge pull request #115 from ArnasDon/feat/flows-builder-ui

feat(flows): no-code builder UI + flows API + validator (PR 3/4)
This commit is contained in:
Arnas Donauskas 2026-05-22 14:00:38 +03:00 committed by GitHub
commit f9d13c42a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 3281 additions and 1 deletions

View File

@ -0,0 +1,96 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/hooks/use-auth";
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
import { FlowBuilder } from "@/components/flows/flow-builder";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
/**
* Flow editor shell.
*
* Loads `{flow, nodes}` from `/api/flows/[id]` and hands it to
* `<FlowBuilder>`. Owns the loading/error state so the builder can
* focus purely on editing.
*
* Beta gate: client-side bounce for the snappy redirect, server-side
* 404 on the API as the real security boundary.
*/
export default function FlowEditorPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const { profile, loading: authLoading } = useAuth();
const [flow, setFlow] = useState<FlowRow | null>(null);
const [nodes, setNodes] = useState<FlowNodeRow[]>([]);
const [loading, setLoading] = useState(true);
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}`);
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: FlowRow;
nodes: FlowNodeRow[];
};
if (!cancelled) {
setFlow(json.flow);
setNodes(json.nodes ?? []);
}
} catch (err) {
if (!cancelled) {
console.error(err);
toast.error("Couldn't load flow.");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [allowed, authLoading, params.id, router]);
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 <FlowBuilder initialFlow={flow} initialNodes={nodes} />;
}

View File

@ -0,0 +1,325 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
Workflow,
Plus,
Trash2,
Pencil,
Loader2,
MessageSquare,
PlayCircle,
PauseCircle,
Archive,
} from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
/**
* Flows list page.
*
* Gated client-side on `isFlowsEnabled(profile)` AND server-side by
* every /api/flows endpoint returning 404 to non-beta accounts. The
* client gate hides the page from non-beta users who land here via
* URL-typing; the server gate is the real security boundary.
*/
interface FlowRow {
id: string;
name: string;
description: string | null;
status: "draft" | "active" | "archived";
trigger_type: "keyword" | "first_inbound_message" | "manual";
trigger_config: { keywords?: string[] } | Record<string, unknown>;
execution_count: number;
last_executed_at: string | null;
created_at: string;
updated_at: string;
}
const STATUS_LABELS: Record<FlowRow["status"], string> = {
draft: "Draft",
active: "Active",
archived: "Archived",
};
const STATUS_COLORS: Record<FlowRow["status"], string> = {
draft: "border-slate-700 bg-slate-800 text-slate-300",
active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
archived: "border-slate-700 bg-slate-800/50 text-slate-500",
};
export default function FlowsPage() {
const router = useRouter();
const { profile, loading: authLoading } = useAuth();
const [flows, setFlows] = useState<FlowRow[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const flowsAccessAllowed = isFlowsEnabled(profile);
useEffect(() => {
if (authLoading) return;
if (!flowsAccessAllowed) {
// Bounce non-beta users — the API would 404 every call anyway.
router.replace("/dashboard");
return;
}
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 ?? []);
} catch (err) {
if (!cancelled) {
console.error(err);
toast.error("Couldn't load flows.");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [authLoading, flowsAccessAllowed, router]);
async function handleCreate() {
if (!newName.trim()) return;
setCreating(true);
try {
const res = await fetch("/api/flows", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: newName.trim(),
trigger_type: "keyword",
trigger_config: { keywords: [] },
}),
});
if (!res.ok) throw new Error(`Create failed: ${res.status}`);
const json = (await res.json()) as { flow: FlowRow };
setCreateOpen(false);
setNewName("");
router.push(`/flows/${json.flow.id}`);
} catch (err) {
console.error(err);
toast.error("Couldn't create flow.");
} finally {
setCreating(false);
}
}
async function handleDelete(flow: FlowRow) {
const yes = window.confirm(
`Delete "${flow.name}"? Any active runs will end immediately.`,
);
if (!yes) return;
try {
const res = await fetch(`/api/flows/${flow.id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
setFlows((prev) => prev.filter((f) => f.id !== flow.id));
toast.success("Flow deleted.");
} catch (err) {
console.error(err);
toast.error("Couldn't delete flow.");
}
}
if (authLoading || (flowsAccessAllowed && loading)) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
</div>
);
}
return (
<div className="space-y-6 p-6">
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-white">Flows</h1>
<p className="mt-1 text-sm text-slate-400">
Build branching, button-driven WhatsApp conversations. Useful for
menus, FAQs, and triage before a human steps in.
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
New flow
</Button>
</header>
{flows.length === 0 ? (
<EmptyState onCreate={() => setCreateOpen(true)} />
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{flows.map((flow) => (
<FlowCard
key={flow.id}
flow={flow}
onEdit={() => router.push(`/flows/${flow.id}`)}
onDelete={() => handleDelete(flow)}
/>
))}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="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.
</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();
}}
/>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setCreateOpen(false)}
disabled={creating}
>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!newName.trim() || creating}>
{creating && <Loader2 className="h-4 w-4 animate-spin" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-slate-700 bg-slate-900/50 px-6 py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-slate-800">
<Workflow className="h-6 w-6 text-slate-500" />
</div>
<h2 className="mt-4 text-base font-medium text-white">
No flows yet
</h2>
<p className="mt-1 max-w-md text-sm text-slate-400">
Build your first conversation — a welcome menu, an order lookup, an FAQ
bot. Customers tap buttons; the bot routes them to the right answer (or
the right agent).
</p>
<Button onClick={onCreate} className="mt-5">
<Plus className="h-4 w-4" />
Create your first flow
</Button>
</div>
);
}
function FlowCard({
flow,
onEdit,
onDelete,
}: {
flow: FlowRow;
onEdit: () => void;
onDelete: () => void;
}) {
const triggerSummary = describeTrigger(flow);
const StatusIcon =
flow.status === "active"
? PlayCircle
: flow.status === "archived"
? Archive
: PauseCircle;
return (
<div className="flex flex-col rounded-lg border border-slate-800 bg-slate-900 p-4 transition-colors hover:border-slate-700">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Workflow className="h-4 w-4 shrink-0 text-violet-400" />
<h3 className="truncate text-sm font-semibold text-white">
{flow.name}
</h3>
</div>
<Badge
variant="outline"
className={cn(
"shrink-0 gap-1 text-[10px]",
STATUS_COLORS[flow.status],
)}
>
<StatusIcon className="h-3 w-3" />
{STATUS_LABELS[flow.status]}
</Badge>
</div>
<p className="mt-2 line-clamp-2 text-xs text-slate-400">
{flow.description || triggerSummary}
</p>
<div className="mt-4 flex items-center gap-3 text-[11px] text-slate-500">
<span className="inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{flow.execution_count} {flow.execution_count === 1 ? "run" : "runs"}
</span>
</div>
<div className="mt-4 flex items-center justify-end gap-2 border-t border-slate-800 pt-3">
<Button variant="ghost" size="sm" onClick={onEdit}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
</div>
</div>
);
}
function describeTrigger(flow: FlowRow): string {
if (flow.trigger_type === "keyword") {
const keywords = Array.isArray(flow.trigger_config.keywords)
? (flow.trigger_config.keywords as string[])
: [];
if (keywords.length === 0) return "Triggers on keyword (none set)";
return `Triggers on: ${keywords.join(", ")}`;
}
if (flow.trigger_type === "first_inbound_message") {
return "Triggers on a contact's first-ever inbound message";
}
return "Manual trigger";
}

View File

@ -0,0 +1,117 @@
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 { validateFlowForActivation } from '@/lib/flows/validate'
/**
* POST /api/flows/[id]/activate
*
* Body: { status: 'draft' | 'active' | 'archived' }
*
* Activating runs the full validator and refuses on any 'error'
* severity issue. Drafts and archives are unconditional — users
* need to be able to save broken-work-in-progress and pause flows
* without first fixing them.
*
* Returns the updated flow on success; on validation failure returns
* the full issue list so the builder can highlight each problem.
*/
export async function POST(
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 })
}
const body = (await request.json().catch(() => null)) as
| { status?: 'draft' | 'active' | 'archived' }
| null
const status = body?.status
if (!status || !['draft', 'active', 'archived'].includes(status)) {
return NextResponse.json(
{ error: "status must be one of 'draft' | 'active' | 'archived'" },
{ status: 400 },
)
}
// Ownership via RLS — caller's client.
const { data: existing } = await supabase
.from('flows')
.select('id')
.eq('id', id)
.maybeSingle()
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const admin = supabaseAdmin()
if (status === 'active') {
// Re-load with the full payload the validator needs.
const [{ data: flow }, { data: nodes }] = await Promise.all([
admin
.from('flows')
.select('name, trigger_type, trigger_config, entry_node_id')
.eq('id', id)
.maybeSingle(),
admin
.from('flow_nodes')
.select('node_key, node_type, config')
.eq('flow_id', id),
])
if (!flow) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const issues = validateFlowForActivation(
flow as {
name: string
trigger_type: 'keyword' | 'first_inbound_message' | 'manual'
trigger_config: Record<string, unknown>
entry_node_id: string | null
},
(nodes ?? []) as Array<{
node_key: string
node_type: string
config: Record<string, unknown>
}>,
)
const blockers = issues.filter((i) => i.severity === 'error')
if (blockers.length > 0) {
return NextResponse.json(
{
error: 'Cannot activate flow — fix the issues below first.',
issues,
},
{ status: 422 },
)
}
}
const { data: updated, error } = await admin
.from('flows')
.update({ status, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.maybeSingle()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ flow: updated })
}

View File

@ -0,0 +1,201 @@
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'
/**
* GET /api/flows/[id] — fetch one flow with its nodes.
* PUT /api/flows/[id] — replace name/trigger/entry/fallback + the
* full node graph (delete-then-insert under
* the hood; not atomic, but the runner is
* resilient to mid-edit reads — node_not_found
* gracefully ends the run).
* DELETE /api/flows/[id] — hard delete (RLS+CASCADE clean up nodes,
* runs, events).
*
* All three are 404 for non-beta accounts.
*/
async function requireFlowsBetaAndOwnership(
flowId: string,
): Promise<
| {
ok: true
userId: string
supabase: Awaited<ReturnType<typeof createClient>>
}
| { ok: false; status: number; body: { error: string } }
> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { ok: false, status: 401, body: { error: 'Unauthorized' } }
}
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 { ok: false, status: 404, body: { error: 'Not found' } }
}
// RLS scopes this to the caller — a flow owned by another user
// returns null (404 below).
const { data: flow } = await supabase
.from('flows')
.select('id')
.eq('id', flowId)
.maybeSingle()
if (!flow) {
return { ok: false, status: 404, body: { error: 'Not found' } }
}
return { ok: true, userId: user.id, supabase }
}
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const guard = await requireFlowsBetaAndOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })
const { supabase } = guard
const [{ data: flow }, { data: nodes }] = await Promise.all([
supabase.from('flows').select('*').eq('id', id).maybeSingle(),
supabase
.from('flow_nodes')
.select('*')
.eq('flow_id', id)
.order('created_at', { ascending: true }),
])
if (!flow) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json({ flow, nodes: nodes ?? [] })
}
interface PutBody {
name?: string
description?: string | null
trigger_type?: 'keyword' | 'first_inbound_message' | 'manual'
trigger_config?: Record<string, unknown>
entry_node_id?: string | null
fallback_policy?: Record<string, unknown>
nodes?: Array<{
node_key: string
node_type: string
config: Record<string, unknown>
position_x?: number
position_y?: number
}>
}
export async function PUT(
request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const guard = await requireFlowsBetaAndOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })
const body = (await request.json().catch(() => null)) as PutBody | null
if (!body) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
if (body.name !== undefined && !body.name.trim()) {
return NextResponse.json(
{ error: 'name cannot be empty' },
{ status: 400 },
)
}
const admin = supabaseAdmin()
// Update the flow row first — the body may not include `nodes` (a
// header-only save for editing the trigger config without touching
// the graph). Skip node replacement in that case.
const flowPatch: Record<string, unknown> = {
updated_at: new Date().toISOString(),
}
if (body.name !== undefined) flowPatch.name = body.name.trim()
if (body.description !== undefined)
flowPatch.description = body.description
if (body.trigger_type !== undefined) flowPatch.trigger_type = body.trigger_type
if (body.trigger_config !== undefined)
flowPatch.trigger_config = body.trigger_config
if (body.entry_node_id !== undefined)
flowPatch.entry_node_id = body.entry_node_id
if (body.fallback_policy !== undefined)
flowPatch.fallback_policy = body.fallback_policy
const { error: updErr } = await admin
.from('flows')
.update(flowPatch)
.eq('id', id)
if (updErr) {
return NextResponse.json({ error: updErr.message }, { status: 500 })
}
if (body.nodes !== undefined) {
// Delete-then-insert. Not transactional but the runner handles
// mid-edit reads safely (a node_not_found ends the run cleanly).
const { error: delErr } = await admin
.from('flow_nodes')
.delete()
.eq('flow_id', id)
if (delErr) {
return NextResponse.json({ error: delErr.message }, { status: 500 })
}
if (body.nodes.length > 0) {
const { error: insErr } = await admin.from('flow_nodes').insert(
body.nodes.map((n) => ({
flow_id: id,
node_key: n.node_key,
node_type: n.node_type,
config: n.config,
position_x: n.position_x ?? 0,
position_y: n.position_y ?? 0,
})),
)
if (insErr) {
return NextResponse.json({ error: insErr.message }, { status: 500 })
}
}
}
// Re-fetch and return the new state — the editor uses the response
// to reconcile its local form state.
const [{ data: flow }, { data: nodes }] = await Promise.all([
admin.from('flows').select('*').eq('id', id).maybeSingle(),
admin
.from('flow_nodes')
.select('*')
.eq('flow_id', id)
.order('created_at', { ascending: true }),
])
return NextResponse.json({ flow, nodes: nodes ?? [] })
}
export async function DELETE(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const guard = await requireFlowsBetaAndOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })
// CASCADE on flow_nodes / flow_runs / flow_run_events handles the
// children. Active runs end abruptly — there's no graceful "drain"
// mechanism in v1, but that's intentional: deleting a flow is a
// deliberate destructive action and the partial unique index will
// free up the contact for new triggers immediately.
const { error } = await supabaseAdmin().from('flows').delete().eq('id', id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ ok: true })
}

101
src/app/api/flows/route.ts Normal file
View File

@ -0,0 +1,101 @@
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'
/**
* GET /api/flows — list the caller's flows.
* POST /api/flows — create a new (draft) flow.
*
* Both endpoints gate on the per-account Flows beta flag. Non-beta
* accounts get a 404 so the UI / a curious user can't discover the
* surface ahead of GA.
*/
async function requireFlowsBeta(): Promise<
| { ok: true; userId: string; supabase: Awaited<ReturnType<typeof createClient>> }
| { ok: false; status: number; body: { error: string } }
> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { ok: false, status: 401, body: { error: 'Unauthorized' } }
}
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)
) {
// 404 (not 403) so the route looks like it doesn't exist to
// non-beta accounts — keeps the UI invisible.
return { ok: false, status: 404, body: { error: 'Not found' } }
}
return { ok: true, userId: user.id, supabase }
}
export async function GET() {
const guard = await requireFlowsBeta()
if (!guard.ok) {
return NextResponse.json(guard.body, { status: guard.status })
}
const { supabase } = guard
const { data, error } = await supabase
.from('flows')
.select('*')
.order('created_at', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ flows: data ?? [] })
}
export async function POST(request: Request) {
const guard = await requireFlowsBeta()
if (!guard.ok) {
return NextResponse.json(guard.body, { status: guard.status })
}
const { userId } = guard
const body = (await request.json().catch(() => null)) as
| {
name?: string
description?: string | null
trigger_type?: 'keyword' | 'first_inbound_message' | 'manual'
trigger_config?: Record<string, unknown>
}
| null
if (!body) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
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({
user_id: userId,
name: body.name.trim(),
description: body.description ?? null,
status: 'draft',
trigger_type,
trigger_config: body.trigger_config ?? {},
})
.select()
.single()
if (error || !data) {
return NextResponse.json(
{ error: error?.message ?? 'insert failed' },
{ status: 500 },
)
}
return NextResponse.json({ flow: data }, { status: 201 })
}

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ import { useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useTotalUnread } from "@/hooks/use-total-unread"; import { useTotalUnread } from "@/hooks/use-total-unread";
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
import { import {
LayoutDashboard, LayoutDashboard,
MessageSquare, MessageSquare,
@ -13,6 +14,7 @@ import {
GitBranch, GitBranch,
Radio, Radio,
Zap, Zap,
Workflow,
Settings, Settings,
LogOut, LogOut,
User, User,
@ -31,13 +33,29 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
const navItems = [ interface NavItem {
href: string;
label: string;
icon: typeof LayoutDashboard;
/**
* If set, only included in the rendered nav when the predicate
* returns true. Used to gate beta-only entries (e.g. /flows) on
* `profiles.beta_features`.
*/
betaKey?: string;
}
const navItems: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/inbox", label: "Inbox", icon: MessageSquare }, { href: "/inbox", label: "Inbox", icon: MessageSquare },
{ href: "/contacts", label: "Contacts", icon: Users }, { href: "/contacts", label: "Contacts", icon: Users },
{ href: "/pipelines", label: "Pipelines", icon: GitBranch }, { href: "/pipelines", label: "Pipelines", icon: GitBranch },
{ href: "/broadcasts", label: "Broadcasts", icon: Radio }, { href: "/broadcasts", label: "Broadcasts", icon: Radio },
{ href: "/automations", label: "Automations", icon: Zap }, { href: "/automations", label: "Automations", icon: Zap },
// Flows is gated on the per-account beta flag; the entry is invisible
// for accounts that haven't opted in. Server-side route guards make
// this a UX choice, not a security one — the API + pages 404 anyway.
{ href: "/flows", label: "Flows", icon: Workflow, betaKey: "flows" },
]; ];
const bottomNavItems = [ const bottomNavItems = [
@ -132,6 +150,13 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
<nav className="flex-1 overflow-y-auto px-3 py-4"> <nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
{navItems.map((item) => { {navItems.map((item) => {
// Per-account beta gate. Today only 'flows' uses this;
// extending the navItems list with new betaKeys is a
// single-line change.
if (item.betaKey === "flows" && !isFlowsEnabled(profile)) {
return null;
}
const isActive = const isActive =
pathname === item.href || pathname === item.href ||
(item.href !== "/dashboard" && pathname.startsWith(item.href)); (item.href !== "/dashboard" && pathname.startsWith(item.href));

View File

@ -0,0 +1,453 @@
import { describe, it, expect } from "vitest";
import { validateFlowForActivation, reachableFromEntry } from "./validate";
const validFlow = {
name: "Welcome",
trigger_type: "keyword" as const,
trigger_config: { keywords: ["support"] },
entry_node_id: "start",
};
const validNodes = [
{ node_key: "start", node_type: "start", config: { next_node_key: "menu" } },
{
node_key: "menu",
node_type: "send_buttons",
config: {
text: "How can we help?",
buttons: [
{ reply_id: "a", title: "A", next_node_key: "ho" },
{ reply_id: "b", title: "B", next_node_key: "ho" },
],
},
},
{ node_key: "ho", node_type: "handoff", config: {} },
];
describe("validateFlowForActivation — happy path", () => {
it("produces no issues on a well-formed flow", () => {
expect(validateFlowForActivation(validFlow, validNodes)).toEqual([]);
});
});
describe("validateFlowForActivation — flow-level", () => {
it("flags empty name", () => {
expect(
validateFlowForActivation({ ...validFlow, name: "" }, validNodes),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ scope: "flow", field: "name" }),
]),
);
});
it("flags whitespace-only name", () => {
const issues = validateFlowForActivation(
{ ...validFlow, name: " " },
validNodes,
);
expect(issues.some((i) => i.field === "name")).toBe(true);
});
it("flags missing entry_node_id", () => {
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: null },
validNodes,
);
expect(issues.some((i) => i.field === "entry_node_id")).toBe(true);
});
it("flags entry_node_id that doesn't exist in nodes", () => {
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "ghost" },
validNodes,
);
expect(
issues.some(
(i) =>
i.field === "entry_node_id" &&
i.message.includes('"ghost"'),
),
).toBe(true);
});
it("flags empty node list", () => {
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: null },
[],
);
expect(
issues.some((i) => i.message.includes("at least one node")),
).toBe(true);
});
it("flags duplicate node_key", () => {
const dupes = [
{ node_key: "a", node_type: "start", config: { next_node_key: "b" } },
{ node_key: "a", node_type: "end", config: {} },
{ node_key: "b", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "a" },
dupes,
);
expect(
issues.some(
(i) =>
i.message.includes("Duplicate node_key") &&
i.node_key === "a",
),
).toBe(true);
});
});
describe("validateFlowForActivation — trigger", () => {
it("flags keyword trigger with no keywords", () => {
const issues = validateFlowForActivation(
{
...validFlow,
trigger_config: { keywords: [] },
},
validNodes,
);
expect(
issues.some(
(i) =>
i.scope === "trigger" &&
i.message.includes("at least one keyword"),
),
).toBe(true);
});
it("flags keyword trigger missing keywords field entirely", () => {
const issues = validateFlowForActivation(
{ ...validFlow, trigger_config: {} },
validNodes,
);
expect(issues.some((i) => i.scope === "trigger")).toBe(true);
});
it("warns when keywords contain blanks", () => {
const issues = validateFlowForActivation(
{
...validFlow,
trigger_config: { keywords: ["support", "", " "] },
},
validNodes,
);
expect(
issues.some(
(i) =>
i.scope === "trigger" &&
i.severity === "warning" &&
i.message.includes("blank"),
),
).toBe(true);
});
it("first_inbound_message trigger needs no config", () => {
const issues = validateFlowForActivation(
{
...validFlow,
trigger_type: "first_inbound_message",
trigger_config: {},
},
validNodes,
);
expect(issues.filter((i) => i.scope === "trigger")).toEqual([]);
});
});
describe("validateFlowForActivation — nodes", () => {
it("flags send_buttons without text", () => {
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: {
buttons: [{ reply_id: "x", title: "X", next_node_key: "h" }],
},
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some((i) => i.node_key === "b" && i.field === "text"),
).toBe(true);
});
it("flags send_buttons with zero buttons", () => {
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: { text: "Hi", buttons: [] },
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some(
(i) =>
i.node_key === "b" &&
i.field === "buttons" &&
i.message.includes("at least one"),
),
).toBe(true);
});
it("flags send_buttons with more than 3 buttons (Meta limit)", () => {
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: {
text: "Hi",
buttons: [
{ reply_id: "1", title: "1", next_node_key: "h" },
{ reply_id: "2", title: "2", next_node_key: "h" },
{ reply_id: "3", title: "3", next_node_key: "h" },
{ reply_id: "4", title: "4", next_node_key: "h" },
],
},
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some(
(i) =>
i.node_key === "b" &&
i.field === "buttons" &&
i.message.includes("at most 3"),
),
).toBe(true);
});
it("flags button title over 20 chars", () => {
const longTitle = "x".repeat(21);
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: {
text: "Hi",
buttons: [
{ reply_id: "1", title: longTitle, next_node_key: "h" },
],
},
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some(
(i) =>
i.node_key === "b" &&
i.field === "buttons.0.title" &&
i.message.includes("over 20"),
),
).toBe(true);
});
it("flags button pointing at non-existent next node", () => {
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: {
text: "Hi",
buttons: [
{ reply_id: "1", title: "Go", next_node_key: "ghost" },
],
},
},
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some(
(i) =>
i.field === "buttons.0.next_node_key" &&
i.message.includes("ghost"),
),
).toBe(true);
});
it("flags duplicate button reply_ids", () => {
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: {
text: "Hi",
buttons: [
{ reply_id: "x", title: "X1", next_node_key: "h" },
{ reply_id: "x", title: "X2", next_node_key: "h" },
],
},
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some((i) => i.message.includes("Duplicate button reply id")),
).toBe(true);
});
it("flags send_list with more than 10 rows total", () => {
const eleven = Array.from({ length: 11 }, (_, i) => ({
reply_id: `r${i}`,
title: `Row ${i}`,
next_node_key: "h",
}));
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "l" } },
{
node_key: "l",
node_type: "send_list",
config: {
text: "Pick",
button_label: "Pick",
sections: [{ rows: eleven }],
},
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some(
(i) =>
i.node_key === "l" &&
i.field === "sections" &&
i.message.includes("at most 10"),
),
).toBe(true);
});
it("flags list row title over 24 chars", () => {
const longTitle = "x".repeat(25);
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "l" } },
{
node_key: "l",
node_type: "send_list",
config: {
text: "Pick",
button_label: "Pick",
sections: [
{
rows: [
{
reply_id: "x",
title: longTitle,
next_node_key: "h",
},
],
},
],
},
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some((i) => i.message.includes("exceeds 24 chars")),
).toBe(true);
});
it("warns about unreachable nodes", () => {
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "h" } },
{ node_key: "h", node_type: "handoff", config: {} },
// Orphaned — nothing points at it.
{ node_key: "orphan", node_type: "end", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some(
(i) =>
i.node_key === "orphan" &&
i.severity === "warning" &&
i.message.includes("unreachable"),
),
).toBe(true);
});
it("doesn't crash on unknown node_type — flags it", () => {
const nodes = [
{ node_key: "s", node_type: "wibble", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some((i) => i.message.includes("Unknown node type")),
).toBe(true);
});
});
describe("reachableFromEntry", () => {
it("walks the graph from the entry", () => {
const set = reachableFromEntry("start", validNodes);
expect(set.has("start")).toBe(true);
expect(set.has("menu")).toBe(true);
expect(set.has("ho")).toBe(true);
});
it("returns the entry alone when no edges lead out", () => {
const set = reachableFromEntry("only", [
{ node_key: "only", node_type: "handoff", config: {} },
]);
expect(set).toEqual(new Set(["only"]));
});
it("survives a cycle (visited guard)", () => {
const nodes = [
{ node_key: "a", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: {
text: "Loop",
buttons: [{ reply_id: "x", title: "Back", next_node_key: "a" }],
},
},
];
const set = reachableFromEntry("a", nodes);
expect(set).toEqual(new Set(["a", "b"]));
});
});

552
src/lib/flows/validate.ts Normal file
View File

@ -0,0 +1,552 @@
/**
* Save-time validation for flows.
*
* Run before activation (not on every draft save) — drafts are
* intentionally allowed to be incomplete so users can save progress
* mid-build. The builder calls these from BOTH client (so the user
* sees issues live) and server (so a broken POST/PUT can't slip in
* via direct API call).
*
* Three rule categories:
* 1. Trigger sanity — keyword flows need keywords, etc.
* 2. Graph integrity — entry node exists, all next_node_key
* references resolve, no unreachable nodes, non-terminal nodes
* have an outgoing edge.
* 3. Meta API limits — button title ≤20 chars, ≤3 buttons per
* send_buttons, ≤10 list rows total, ≤24 chars per list row
* title. Mirrors the runtime checks inside
* `src/lib/whatsapp/meta-api.ts` so save-time and send-time
* can never disagree.
*
* Issues carry enough field info that the builder can highlight the
* exact input that triggered them. Node-scoped issues include
* `node_key`; trigger-scoped use `scope: 'trigger'`.
*/
import { INTERACTIVE_LIMITS } from "@/lib/whatsapp/meta-api";
export interface ValidationIssue {
severity: "error" | "warning";
scope: "flow" | "trigger" | "node";
/** Stable node_key the issue is attached to, when scope === 'node'. */
node_key?: string;
/** Dotted path to the bad field, e.g. 'buttons.0.title'. */
field?: string;
message: string;
}
interface FlowInput {
name: string;
trigger_type: "keyword" | "first_inbound_message" | "manual";
trigger_config: Record<string, unknown>;
entry_node_id: string | null;
}
interface NodeInput {
node_key: string;
node_type: string;
config: Record<string, unknown>;
}
export function validateFlowForActivation(
flow: FlowInput,
nodes: NodeInput[],
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
// ---- name ----
if (!flow.name || !flow.name.trim()) {
issues.push({
severity: "error",
scope: "flow",
field: "name",
message: "Flow name is required.",
});
}
// ---- trigger ----
issues.push(...validateTrigger(flow.trigger_type, flow.trigger_config));
// ---- graph integrity ----
if (!flow.entry_node_id) {
issues.push({
severity: "error",
scope: "flow",
field: "entry_node_id",
message: "Pick an entry node before activating.",
});
}
const keys = new Set(nodes.map((n) => n.node_key));
if (nodes.length === 0) {
issues.push({
severity: "error",
scope: "flow",
message: "A flow needs at least one node before activation.",
});
}
if (flow.entry_node_id && !keys.has(flow.entry_node_id)) {
issues.push({
severity: "error",
scope: "flow",
field: "entry_node_id",
message: `Entry node "${flow.entry_node_id}" doesn't exist.`,
});
}
// Duplicate node_key (the DB UNIQUE constraint catches this on save
// too, but surfacing it client-side gives a friendlier error path).
const seen = new Set<string>();
for (const n of nodes) {
if (seen.has(n.node_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: n.node_key,
message: `Duplicate node_key "${n.node_key}".`,
});
}
seen.add(n.node_key);
}
// Per-node rules (Meta limits + dead-end + edge resolution).
for (const n of nodes) {
issues.push(...validateNode(n, keys));
}
// Reachability — every non-orphan node must be reachable from the
// entry. Done after per-node validation so we don't double-report
// when a node has bad config AND is unreachable.
if (flow.entry_node_id && keys.has(flow.entry_node_id)) {
const reached = reachableFromEntry(flow.entry_node_id, nodes);
for (const n of nodes) {
if (!reached.has(n.node_key)) {
issues.push({
severity: "warning",
scope: "node",
node_key: n.node_key,
message: `Node "${n.node_key}" is unreachable from the entry node.`,
});
}
}
}
return issues;
}
// ============================================================
// Trigger
// ============================================================
function validateTrigger(
trigger_type: FlowInput["trigger_type"],
trigger_config: Record<string, unknown>,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
if (trigger_type === "keyword") {
const keywords = Array.isArray(trigger_config.keywords)
? (trigger_config.keywords as unknown[])
: null;
if (!keywords || keywords.length === 0) {
issues.push({
severity: "error",
scope: "trigger",
field: "trigger_config.keywords",
message: "Keyword triggers need at least one keyword.",
});
} else {
// Empty / whitespace-only keywords are silent no-ops at match
// time — call them out so the user doesn't think they configured
// a keyword that never fires.
const blanks = keywords.filter(
(k) => typeof k !== "string" || !k.trim(),
).length;
if (blanks > 0) {
issues.push({
severity: "warning",
scope: "trigger",
field: "trigger_config.keywords",
message: `${blanks} keyword${blanks === 1 ? " is" : "s are"} blank — they won't match anything.`,
});
}
}
}
// first_inbound_message / manual have no config; nothing to validate.
return issues;
}
// ============================================================
// Per-node
// ============================================================
function validateNode(
node: NodeInput,
knownKeys: Set<string>,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
switch (node.node_type) {
case "start": {
const cfg = node.config as { next_node_key?: string };
if (!cfg.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: "Start node 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: `Start points to non-existent node "${cfg.next_node_key}".`,
});
}
break;
}
case "send_message": {
const cfg = node.config as { text?: string; next_node_key?: string };
if (!cfg.text?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "text",
message: "Send-message node needs a text body.",
});
}
if (!cfg.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: "Send-message node 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: `Send-message points to non-existent node "${cfg.next_node_key}".`,
});
}
break;
}
case "send_buttons": {
const cfg = node.config as {
text?: string;
buttons?: Array<{
reply_id?: string;
title?: string;
next_node_key?: string;
}>;
};
if (!cfg.text?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "text",
message: "Send-buttons node needs a text body.",
});
}
const btns = cfg.buttons ?? [];
if (btns.length < 1) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "buttons",
message: "Send-buttons needs at least one button.",
});
}
if (btns.length > INTERACTIVE_LIMITS.maxButtons) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "buttons",
message: `WhatsApp allows at most ${INTERACTIVE_LIMITS.maxButtons} buttons per message.`,
});
}
const seenIds = new Set<string>();
btns.forEach((b, i) => {
const field = `buttons.${i}`;
if (!b.reply_id?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.reply_id`,
message: `Button ${i + 1} needs a reply id.`,
});
} else if (seenIds.has(b.reply_id)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.reply_id`,
message: `Duplicate button reply id "${b.reply_id}".`,
});
}
if (b.reply_id) seenIds.add(b.reply_id);
if (!b.title?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.title`,
message: `Button ${i + 1} needs a title.`,
});
} else if (b.title.length > INTERACTIVE_LIMITS.buttonTitleMaxLength) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.title`,
message: `Button ${i + 1} title is over ${INTERACTIVE_LIMITS.buttonTitleMaxLength} chars (WhatsApp limit).`,
});
}
if (!b.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.next_node_key`,
message: `Button ${i + 1} needs a next node.`,
});
} else if (!knownKeys.has(b.next_node_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.next_node_key`,
message: `Button ${i + 1} points to non-existent node "${b.next_node_key}".`,
});
}
});
break;
}
case "send_list": {
const cfg = node.config as {
text?: string;
button_label?: string;
sections?: Array<{
title?: string;
rows?: Array<{
reply_id?: string;
title?: string;
description?: string;
next_node_key?: string;
}>;
}>;
};
if (!cfg.text?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "text",
message: "Send-list node needs a text body.",
});
}
if (!cfg.button_label?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "button_label",
message: "Send-list needs a button label (the tap-to-expand text).",
});
}
const sections = cfg.sections ?? [];
const totalRows = sections.reduce(
(sum, s) => sum + (s.rows?.length ?? 0),
0,
);
if (totalRows < 1) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "sections",
message: "Send-list needs at least one row.",
});
}
if (totalRows > INTERACTIVE_LIMITS.maxListRowsTotal) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "sections",
message: `Send-list allows at most ${INTERACTIVE_LIMITS.maxListRowsTotal} rows total across sections.`,
});
}
const seenIds = new Set<string>();
sections.forEach((section, si) => {
const rows = section.rows ?? [];
rows.forEach((row, ri) => {
const field = `sections.${si}.rows.${ri}`;
if (!row.reply_id?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.reply_id`,
message: `Row ${ri + 1} in section ${si + 1} needs a reply id.`,
});
} else if (seenIds.has(row.reply_id)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.reply_id`,
message: `Duplicate list row id "${row.reply_id}".`,
});
}
if (row.reply_id) seenIds.add(row.reply_id);
if (!row.title?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.title`,
message: `Row ${ri + 1} needs a title.`,
});
} else if (
row.title.length > INTERACTIVE_LIMITS.listRowTitleMaxLength
) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.title`,
message: `Row ${ri + 1} title exceeds ${INTERACTIVE_LIMITS.listRowTitleMaxLength} chars.`,
});
}
if (
row.description &&
row.description.length >
INTERACTIVE_LIMITS.listRowDescriptionMaxLength
) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.description`,
message: `Row ${ri + 1} description exceeds ${INTERACTIVE_LIMITS.listRowDescriptionMaxLength} chars.`,
});
}
if (!row.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.next_node_key`,
message: `Row ${ri + 1} needs a next node.`,
});
} else if (!knownKeys.has(row.next_node_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.next_node_key`,
message: `Row ${ri + 1} points to non-existent node "${row.next_node_key}".`,
});
}
});
});
break;
}
case "handoff":
case "end":
// Terminal nodes have no outgoing edges; nothing to validate
// beyond their existence.
break;
default:
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
message: `Unknown node type "${node.node_type}".`,
});
}
return issues;
}
// ============================================================
// Reachability — BFS from the entry, follow outgoing edges per node
// ============================================================
export function reachableFromEntry(
entryKey: string,
nodes: NodeInput[],
): Set<string> {
const byKey = new Map<string, NodeInput>();
for (const n of nodes) byKey.set(n.node_key, n);
const visited = new Set<string>();
const queue: string[] = [entryKey];
while (queue.length > 0) {
const key = queue.shift() as string;
if (visited.has(key)) continue;
visited.add(key);
const node = byKey.get(key);
if (!node) continue;
for (const next of outgoingEdges(node)) {
if (!visited.has(next)) queue.push(next);
}
}
return visited;
}
function outgoingEdges(node: NodeInput): string[] {
switch (node.node_type) {
case "start":
case "send_message": {
const cfg = node.config as { next_node_key?: string };
return cfg.next_node_key ? [cfg.next_node_key] : [];
}
case "send_buttons": {
const cfg = node.config as {
buttons?: Array<{ next_node_key?: string }>;
};
return (cfg.buttons ?? [])
.map((b) => b.next_node_key)
.filter((k): k is string => !!k);
}
case "send_list": {
const cfg = node.config as {
sections?: Array<{ rows?: Array<{ next_node_key?: string }> }>;
};
const out: string[] = [];
for (const s of cfg.sections ?? []) {
for (const r of s.rows ?? []) {
if (r.next_node_key) out.push(r.next_node_key);
}
}
return out;
}
case "handoff":
case "end":
default:
return [];
}
}