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 { 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));
|
||||||
|
|||||||
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