mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(flows): no-code builder UI + flows API + validator (PR 3/4)
Ships the surface that lets opted-in beta accounts build, save, and
activate WhatsApp conversation flows entirely from the web UI — no
SQL fixtures needed. Sidebar gets a "Flows" entry (hidden for
non-beta accounts), the /flows list page surfaces existing flows,
/flows/[id] is the linear-list editor.
Wraps the runner from PR 2 with everything a real user needs to
build the welcome-menu example end-to-end.
What landed:
1. `src/lib/flows/validate.ts` — pure validator. Checks entry
node exists, all next_node_key refs resolve, no unreachable
nodes, Meta-limit violations (button title ≤20, ≤3 buttons,
≤10 list rows, ≤24 chars per list row). 24 unit tests.
2. `src/app/api/flows/route.ts` — GET (list user's flows),
POST (create draft).
3. `src/app/api/flows/[id]/route.ts` — GET (load with nodes),
PUT (replace name + trigger + nodes; delete-then-insert),
DELETE (cascade clean-up).
4. `src/app/api/flows/[id]/activate/route.ts` — status toggle.
Activation runs the validator; returns 422 + the full issue
list when blocked.
5. `src/app/(dashboard)/flows/page.tsx` — list of cards, "New
flow" dialog, delete with confirm.
6. `src/app/(dashboard)/flows/[id]/page.tsx` — editor shell
that loads data and hands it to FlowBuilder.
7. `src/components/flows/flow-builder.tsx` — the editor itself.
Header with name + save/activate/pause/delete; trigger
panel; entry-node picker; node list with expandable per-node
config forms (start, send_buttons, send_list, send_message,
handoff, end); bottom validation panel that mirrors what the
server's activate endpoint would reject.
8. `src/components/layout/sidebar.tsx` — Flows entry between
Automations and Settings, rendered only when isFlowsEnabled.
Every API route + page is gated server-side by isFlowsEnabled —
non-beta accounts get 404 (not 403) so the routes don't even hint
at existing. UI gate is for snappy redirect; security is at the
API layer.
Validation: 166/166 tests pass (added 24 validator tests);
typecheck + lint + build all clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2c029cb687
commit
4871d7aa06
96
src/app/(dashboard)/flows/[id]/page.tsx
Normal file
96
src/app/(dashboard)/flows/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
325
src/app/(dashboard)/flows/page.tsx
Normal file
325
src/app/(dashboard)/flows/page.tsx
Normal 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";
|
||||
}
|
||||
117
src/app/api/flows/[id]/activate/route.ts
Normal file
117
src/app/api/flows/[id]/activate/route.ts
Normal 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 })
|
||||
}
|
||||
201
src/app/api/flows/[id]/route.ts
Normal file
201
src/app/api/flows/[id]/route.ts
Normal 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
101
src/app/api/flows/route.ts
Normal 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 })
|
||||
}
|
||||
1410
src/components/flows/flow-builder.tsx
Normal file
1410
src/components/flows/flow-builder.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@ import { useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTotalUnread } from "@/hooks/use-total-unread";
|
||||
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
@ -13,6 +14,7 @@ import {
|
||||
GitBranch,
|
||||
Radio,
|
||||
Zap,
|
||||
Workflow,
|
||||
Settings,
|
||||
LogOut,
|
||||
User,
|
||||
@ -31,13 +33,29 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} 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: "/inbox", label: "Inbox", icon: MessageSquare },
|
||||
{ href: "/contacts", label: "Contacts", icon: Users },
|
||||
{ href: "/pipelines", label: "Pipelines", icon: GitBranch },
|
||||
{ href: "/broadcasts", label: "Broadcasts", icon: Radio },
|
||||
{ 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 = [
|
||||
@ -132,6 +150,13 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
<ul className="flex flex-col gap-1">
|
||||
{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 =
|
||||
pathname === item.href ||
|
||||
(item.href !== "/dashboard" && pathname.startsWith(item.href));
|
||||
|
||||
453
src/lib/flows/validate.test.ts
Normal file
453
src/lib/flows/validate.test.ts
Normal 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
552
src/lib/flows/validate.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user