From 4871d7aa06463fca5f53bc0c2926dfd8ffc068fa Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 22 May 2026 13:57:38 +0300 Subject: [PATCH] feat(flows): no-code builder UI + flows API + validator (PR 3/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/(dashboard)/flows/[id]/page.tsx | 96 ++ src/app/(dashboard)/flows/page.tsx | 325 +++++ src/app/api/flows/[id]/activate/route.ts | 117 ++ src/app/api/flows/[id]/route.ts | 201 +++ src/app/api/flows/route.ts | 101 ++ src/components/flows/flow-builder.tsx | 1410 ++++++++++++++++++++++ src/components/layout/sidebar.tsx | 27 +- src/lib/flows/validate.test.ts | 453 +++++++ src/lib/flows/validate.ts | 552 +++++++++ 9 files changed, 3281 insertions(+), 1 deletion(-) create mode 100644 src/app/(dashboard)/flows/[id]/page.tsx create mode 100644 src/app/(dashboard)/flows/page.tsx create mode 100644 src/app/api/flows/[id]/activate/route.ts create mode 100644 src/app/api/flows/[id]/route.ts create mode 100644 src/app/api/flows/route.ts create mode 100644 src/components/flows/flow-builder.tsx create mode 100644 src/lib/flows/validate.test.ts create mode 100644 src/lib/flows/validate.ts diff --git a/src/app/(dashboard)/flows/[id]/page.tsx b/src/app/(dashboard)/flows/[id]/page.tsx new file mode 100644 index 0000000..efcea18 --- /dev/null +++ b/src/app/(dashboard)/flows/[id]/page.tsx @@ -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 + * ``. 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(null); + const [nodes, setNodes] = useState([]); + 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 ( +
+ +
+ ); + } + if (notFound || !flow) { + return ( +
+

Flow not found.

+ +
+ ); + } + + return ; +} diff --git a/src/app/(dashboard)/flows/page.tsx b/src/app/(dashboard)/flows/page.tsx new file mode 100644 index 0000000..a6e19b3 --- /dev/null +++ b/src/app/(dashboard)/flows/page.tsx @@ -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; + execution_count: number; + last_executed_at: string | null; + created_at: string; + updated_at: string; +} + +const STATUS_LABELS: Record = { + draft: "Draft", + active: "Active", + archived: "Archived", +}; + +const STATUS_COLORS: Record = { + 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([]); + 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 ( +
+ +
+ ); + } + + return ( +
+
+
+

Flows

+

+ Build branching, button-driven WhatsApp conversations. Useful for + menus, FAQs, and triage before a human steps in. +

+
+ +
+ + {flows.length === 0 ? ( + setCreateOpen(true)} /> + ) : ( +
+ {flows.map((flow) => ( + router.push(`/flows/${flow.id}`)} + onDelete={() => handleDelete(flow)} + /> + ))} +
+ )} + + + + + Create a new flow + + Give it a name. You can configure the trigger and nodes on the + next screen. + + + setNewName(e.target.value)} + placeholder="e.g. Welcome menu" + className="bg-slate-800" + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + /> + + + + + + +
+ ); +} + +function EmptyState({ onCreate }: { onCreate: () => void }) { + return ( +
+
+ +
+

+ No flows yet +

+

+ 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). +

+ +
+ ); +} + +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 ( +
+
+
+ +

+ {flow.name} +

+
+ + + {STATUS_LABELS[flow.status]} + +
+ +

+ {flow.description || triggerSummary} +

+ +
+ + + {flow.execution_count} {flow.execution_count === 1 ? "run" : "runs"} + +
+ +
+ + +
+
+ ); +} + +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"; +} diff --git a/src/app/api/flows/[id]/activate/route.ts b/src/app/api/flows/[id]/activate/route.ts new file mode 100644 index 0000000..43a7ed0 --- /dev/null +++ b/src/app/api/flows/[id]/activate/route.ts @@ -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 + entry_node_id: string | null + }, + (nodes ?? []) as Array<{ + node_key: string + node_type: string + config: Record + }>, + ) + 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 }) +} diff --git a/src/app/api/flows/[id]/route.ts b/src/app/api/flows/[id]/route.ts new file mode 100644 index 0000000..2b9d05b --- /dev/null +++ b/src/app/api/flows/[id]/route.ts @@ -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> + } + | { 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 + entry_node_id?: string | null + fallback_policy?: Record + nodes?: Array<{ + node_key: string + node_type: string + config: Record + 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 = { + 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 }) +} + diff --git a/src/app/api/flows/route.ts b/src/app/api/flows/route.ts new file mode 100644 index 0000000..33d0028 --- /dev/null +++ b/src/app/api/flows/route.ts @@ -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> } + | { 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 + } + | 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 }) +} diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx new file mode 100644 index 0000000..bac07fc --- /dev/null +++ b/src/components/flows/flow-builder.tsx @@ -0,0 +1,1410 @@ +"use client"; + +/** + * Linear-list flow editor. + * + * The whole flow (header, trigger config, node list, validation panel) + * is owned by this single component. State lives client-side as a + * single `BuilderState` object; `Save` PUTs the whole structure to + * `/api/flows/[id]`; `Activate` hits `/api/flows/[id]/activate`. + * + * Why one big file: keeps the diff between fields + the form code + * obvious, matches the existing `automation-builder.tsx` shape, and + * sidesteps over-componentization for a UI that will be replaced by a + * react-flow canvas in v2 anyway. The node-config sub-forms live in + * the same file as small components rather than separate modules. + */ + +import { useCallback, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + ArrowLeft, + CircleCheck, + CircleAlert, + Loader2, + Plus, + Save, + Trash2, + Workflow, + ChevronDown, + ChevronUp, + MessageCircle, + ListChecks, + ListPlus, + CornerDownRight, + UserPlus, + Flag, + PlayCircle, + PauseCircle, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { + validateFlowForActivation, + type ValidationIssue, +} from "@/lib/flows/validate"; +import type { FlowNodeRow, FlowRow } from "@/lib/flows/types"; + +interface FlowBuilderProps { + initialFlow: FlowRow; + initialNodes: FlowNodeRow[]; +} + +// ============================================================ +// Local state shape — mirrors the DB but the configs are typed +// loosely (Record) since each node_type carries a +// different shape. The sub-form components narrow as needed. +// ============================================================ + +type NodeType = + | "start" + | "send_message" + | "send_buttons" + | "send_list" + | "handoff" + | "end"; + +interface BuilderNode { + node_key: string; + node_type: NodeType; + config: Record; +} + +interface BuilderState { + name: string; + description: string; + trigger_type: "keyword" | "first_inbound_message" | "manual"; + trigger_config: Record; + entry_node_id: string | null; + status: FlowRow["status"]; + nodes: BuilderNode[]; +} + +// ============================================================ +// Per-node-type metadata used to render icons + labels everywhere +// the user sees a node summary. +// ============================================================ + +const NODE_META: Record< + NodeType, + { label: string; icon: typeof Workflow; color: string } +> = { + start: { label: "Start", icon: PlayCircle, color: "text-emerald-400" }, + send_message: { + label: "Send message", + icon: MessageCircle, + color: "text-sky-400", + }, + send_buttons: { + label: "Send buttons", + icon: ListChecks, + color: "text-violet-400", + }, + send_list: { + label: "Send list", + icon: ListPlus, + color: "text-indigo-400", + }, + handoff: { + label: "Handoff to agent", + icon: UserPlus, + color: "text-amber-400", + }, + end: { label: "End", icon: Flag, color: "text-slate-400" }, +}; + +// ============================================================ +// Helpers +// ============================================================ + +function slugify(s: string, fallback: string): string { + const cleaned = s + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + return cleaned || fallback; +} + +function uniqueNodeKey(base: string, existing: BuilderNode[]): string { + if (!existing.some((n) => n.node_key === base)) return base; + let i = 2; + while (existing.some((n) => n.node_key === `${base}_${i}`)) i += 1; + return `${base}_${i}`; +} + +function defaultConfigFor(type: NodeType): Record { + switch (type) { + case "start": + return { next_node_key: "" }; + case "send_message": + return { text: "", next_node_key: "" }; + case "send_buttons": + return { + text: "", + buttons: [{ reply_id: "yes", title: "Yes", next_node_key: "" }], + }; + case "send_list": + return { + text: "", + button_label: "View options", + sections: [ + { + title: "", + rows: [ + { reply_id: "row_1", title: "Option 1", next_node_key: "" }, + ], + }, + ], + }; + case "handoff": + return { note: "" }; + case "end": + return {}; + } +} + +// ============================================================ +// Root component +// ============================================================ + +export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { + const router = useRouter(); + + const [state, setState] = useState(() => ({ + name: initialFlow.name, + description: initialFlow.description ?? "", + trigger_type: initialFlow.trigger_type, + trigger_config: initialFlow.trigger_config as Record, + entry_node_id: initialFlow.entry_node_id, + status: initialFlow.status, + nodes: initialNodes.map((n) => ({ + node_key: n.node_key, + node_type: n.node_type as NodeType, + config: n.config as Record, + })), + })); + + const [saving, setSaving] = useState(false); + const [activating, setActivating] = useState(false); + const [expanded, setExpanded] = useState>( + () => new Set(initialNodes.map((n) => n.node_key)), + ); + + // ---- Validation ---- + const issues = useMemo( + () => + validateFlowForActivation( + { + name: state.name, + trigger_type: state.trigger_type, + trigger_config: state.trigger_config, + entry_node_id: state.entry_node_id, + }, + state.nodes, + ), + [state], + ); + const blockers = issues.filter((i) => i.severity === "error"); + const canActivate = blockers.length === 0; + + // ---- Save (PUT) ---- + const handleSave = useCallback(async () => { + setSaving(true); + try { + const res = await fetch(`/api/flows/${initialFlow.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: state.name, + description: state.description || null, + trigger_type: state.trigger_type, + trigger_config: state.trigger_config, + entry_node_id: state.entry_node_id, + nodes: state.nodes, + }), + }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(json.error ?? `Save failed: ${res.status}`); + } + toast.success("Saved."); + } catch (err) { + const msg = err instanceof Error ? err.message : "Save failed"; + toast.error(msg); + } finally { + setSaving(false); + } + }, [initialFlow.id, state]); + + // ---- Activate / Pause / Archive ---- + const handleStatus = useCallback( + async (next: BuilderState["status"]) => { + if (next === "active" && !canActivate) { + toast.error("Fix the issues below before activating."); + return; + } + setActivating(true); + try { + // Always save first so the activation validator sees the + // latest state — the user shouldn't have to remember "save + // then activate". + if (next === "active") { + await handleSave(); + } + const res = await fetch(`/api/flows/${initialFlow.id}/activate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: next }), + }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(json.error ?? `Status update failed: ${res.status}`); + } + setState((s) => ({ ...s, status: next })); + toast.success( + next === "active" + ? "Flow activated." + : next === "archived" + ? "Archived." + : "Saved as draft.", + ); + } catch (err) { + const msg = err instanceof Error ? err.message : "Status update failed"; + toast.error(msg); + } finally { + setActivating(false); + } + }, + [canActivate, handleSave, initialFlow.id], + ); + + // ---- Delete ---- + const handleDelete = useCallback(async () => { + const yes = window.confirm( + `Delete "${state.name}"? Any active runs end immediately. This can't be undone.`, + ); + if (!yes) return; + try { + const res = await fetch(`/api/flows/${initialFlow.id}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error(`Delete failed: ${res.status}`); + router.push("/flows"); + } catch (err) { + const msg = err instanceof Error ? err.message : "Delete failed"; + toast.error(msg); + } + }, [initialFlow.id, router, state.name]); + + // ---- Node helpers ---- + const updateNode = useCallback( + (key: string, patch: Partial) => { + setState((s) => ({ + ...s, + nodes: s.nodes.map((n) => (n.node_key === key ? { ...n, ...patch } : n)), + })); + }, + [], + ); + + const updateNodeConfig = useCallback( + (key: string, configPatch: Record) => { + setState((s) => ({ + ...s, + nodes: s.nodes.map((n) => + n.node_key === key ? { ...n, config: { ...n.config, ...configPatch } } : n, + ), + })); + }, + [], + ); + + const addNode = useCallback( + (type: NodeType) => { + const meta = NODE_META[type]; + const base = slugify(meta.label, type); + setState((s) => { + const node_key = uniqueNodeKey(base, s.nodes); + const next: BuilderNode = { + node_key, + node_type: type, + config: defaultConfigFor(type), + }; + setExpanded((prev) => new Set([...prev, node_key])); + return { + ...s, + nodes: [...s.nodes, next], + // If this is the first node and it's a start, pick it as + // the entry automatically. Saves a click. + entry_node_id: + s.entry_node_id ?? + (type === "start" ? node_key : s.entry_node_id ?? null), + }; + }); + }, + [], + ); + + const removeNode = useCallback((key: string) => { + setState((s) => ({ + ...s, + nodes: s.nodes.filter((n) => n.node_key !== key), + entry_node_id: s.entry_node_id === key ? null : s.entry_node_id, + })); + setExpanded((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + }, []); + + const toggleExpanded = useCallback((key: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + // ---- Render ---- + return ( +
+
router.push("/flows")} + /> + + i.scope === "trigger")} + /> + + + +
+
+

+ Nodes ({state.nodes.length}) +

+ +
+ + {state.nodes.length === 0 ? ( +
+ Add a Start node, then a Send buttons + {" "}node, then a Handoff — that's the welcome-menu + shape from the brief. +
+ ) : ( + state.nodes.map((node) => ( + i.scope === "node" && i.node_key === node.node_key, + )} + onToggle={() => toggleExpanded(node.node_key)} + onUpdate={(patch) => updateNode(node.node_key, patch)} + onUpdateConfig={(patch) => updateNodeConfig(node.node_key, patch)} + onRemove={() => removeNode(node.node_key)} + onSetEntry={() => + setState((s) => ({ ...s, entry_node_id: node.node_key })) + } + /> + )) + )} +
+ + +
+ ); +} + +// ============================================================ +// Header +// ============================================================ + +function Header({ + state, + setState, + saving, + activating, + onSave, + onStatus, + onDelete, + canActivate, + onBack, +}: { + state: BuilderState; + setState: React.Dispatch>; + saving: boolean; + activating: boolean; + onSave: () => void; + onStatus: (s: BuilderState["status"]) => void; + onDelete: () => void; + canActivate: boolean; + onBack: () => void; +}) { + return ( +
+
+ +
+
+
+ + + setState((s) => ({ ...s, name: e.target.value })) + } + placeholder="Flow name" + className="max-w-md bg-slate-900 text-lg font-semibold" + /> + +
+
+ + {state.status === "active" ? ( + + ) : ( + + )} + +
+
+ + setState((s) => ({ ...s, description: e.target.value })) + } + placeholder="Optional description (internal — customers don't see this)" + className="bg-slate-900 text-sm" + /> +
+ ); +} + +function StatusBadge({ status }: { status: BuilderState["status"] }) { + const cls = { + 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", + }[status]; + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); +} + +// ============================================================ +// Trigger panel +// ============================================================ + +function TriggerPanel({ + state, + setState, + triggerIssues, +}: { + state: BuilderState; + setState: React.Dispatch>; + triggerIssues: ValidationIssue[]; +}) { + return ( +
+

Trigger

+
+
+ + +
+ {state.trigger_type === "keyword" && ( +
+ + + setState((s) => ({ + ...s, + trigger_config: { + ...s.trigger_config, + keywords: e.target.value + .split(",") + .map((k) => k.trim()) + .filter(Boolean), + }, + })) + } + placeholder="support, help, hi" + className="bg-slate-800" + /> +
+ )} +
+ {triggerIssues.length > 0 && ( +
+ {triggerIssues.map((i, ix) => ( + + ))} +
+ )} +
+ ); +} + +// ============================================================ +// Entry-node picker +// ============================================================ + +function EntryPicker({ + state, + setState, +}: { + state: BuilderState; + setState: React.Dispatch>; +}) { + if (state.nodes.length === 0) return null; + return ( +
+ + Entry node: + + setState((s) => ({ ...s, entry_node_id: key })) + } + placeholder="Pick the first node…" + className="flex-1 max-w-xs" + /> +
+ ); +} + +// ============================================================ +// Node card — collapsed summary + expanded config form +// ============================================================ + +function NodeCard({ + node, + allNodes, + expanded, + isEntry, + issues, + onToggle, + onUpdate, + onUpdateConfig, + onRemove, + onSetEntry, +}: { + node: BuilderNode; + allNodes: BuilderNode[]; + expanded: boolean; + isEntry: boolean; + issues: ValidationIssue[]; + onToggle: () => void; + onUpdate: (patch: Partial) => void; + onUpdateConfig: (patch: Record) => void; + onRemove: () => void; + onSetEntry: () => void; +}) { + const meta = NODE_META[node.node_type]; + const hasError = issues.some((i) => i.severity === "error"); + return ( +
+ + {expanded && ( +
+ +
+
+ {!isEntry && ( + + )} +
+ +
+ {issues.length > 0 && ( +
+ {issues.map((i, ix) => ( + + ))} +
+ )} +
+ )} +
+ ); +} + +// ============================================================ +// Per-node-type config form +// ============================================================ + +function NodeConfigForm({ + node, + allNodes, + onUpdate, + onUpdateConfig, +}: { + node: BuilderNode; + allNodes: BuilderNode[]; + onUpdate: (patch: Partial) => void; + onUpdateConfig: (patch: Record) => void; +}) { + const cfg = node.config; + return ( +
+
+ + + onUpdate({ node_key: slugify(e.target.value, node.node_key) }) + } + className="bg-slate-800" + /> +
+ + {node.node_type === "start" && ( + onUpdateConfig({ next_node_key: v })} + label="Advances to" + /> + )} + + {node.node_type === "send_message" && ( + <> + onUpdateConfig({ text: v })} + /> + onUpdateConfig({ next_node_key: v })} + label="Advances to" + /> + + )} + + {node.node_type === "send_buttons" && ( + + )} + + {node.node_type === "send_list" && ( + + )} + + {node.node_type === "handoff" && ( + onUpdateConfig({ note: v })} + rows={2} + /> + )} + + {node.node_type === "end" && ( +

+ Terminal node. When the runner reaches this node the run is marked + complete. No config needed. +

+ )} +
+ ); +} + +// ---- send_buttons form ---- + +interface SendButtonsCfg { + text?: string; + footer_text?: string; + buttons?: Array<{ reply_id: string; title: string; next_node_key: string }>; +} + +function SendButtonsForm({ + cfg, + allNodes, + currentKey, + onUpdateConfig, +}: { + cfg: SendButtonsCfg; + allNodes: BuilderNode[]; + currentKey: string; + onUpdateConfig: (patch: Record) => void; +}) { + const buttons = cfg.buttons ?? []; + const updateButton = ( + idx: number, + patch: Partial[number]>, + ) => { + onUpdateConfig({ + buttons: buttons.map((b, i) => (i === idx ? { ...b, ...patch } : b)), + }); + }; + const addButton = () => + onUpdateConfig({ + buttons: [ + ...buttons, + { + reply_id: `btn_${buttons.length + 1}`, + title: "Option", + next_node_key: "", + }, + ], + }); + const removeButton = (idx: number) => + onUpdateConfig({ buttons: buttons.filter((_, i) => i !== idx) }); + + return ( + <> + onUpdateConfig({ text: v })} + rows={3} + /> + onUpdateConfig({ footer_text: v })} + /> +
+
+ +
+
+ {buttons.map((b, i) => ( +
+ + updateButton(i, { + reply_id: slugify(e.target.value, `btn_${i + 1}`), + }) + } + placeholder="reply_id" + className="bg-slate-800 font-mono text-xs" + /> + updateButton(i, { title: e.target.value })} + placeholder="Visible title (≤20 chars)" + className="bg-slate-800" + maxLength={20} + /> + updateButton(i, { next_node_key: v ?? "" })} + placeholder="Next node…" + /> + +
+ ))} +
+ {buttons.length < 3 && ( + + )} +
+ + ); +} + +// ---- send_list form ---- + +interface SendListCfg { + text?: string; + button_label?: string; + footer_text?: string; + sections?: Array<{ + title?: string; + rows: Array<{ + reply_id: string; + title: string; + description?: string; + next_node_key: string; + }>; + }>; +} + +function SendListForm({ + cfg, + allNodes, + currentKey, + onUpdateConfig, +}: { + cfg: SendListCfg; + allNodes: BuilderNode[]; + currentKey: string; + onUpdateConfig: (patch: Record) => void; +}) { + const sections = cfg.sections ?? []; + const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0); + + const updateSection = ( + sIdx: number, + patch: Partial[number]>, + ) => { + onUpdateConfig({ + sections: sections.map((s, i) => + i === sIdx ? { ...s, ...patch } : s, + ), + }); + }; + const updateRow = ( + sIdx: number, + rIdx: number, + patch: Partial< + NonNullable[number]["rows"][number] + >, + ) => { + onUpdateConfig({ + sections: sections.map((s, i) => + i === sIdx + ? { + ...s, + rows: s.rows.map((r, j) => (j === rIdx ? { ...r, ...patch } : r)), + } + : s, + ), + }); + }; + const addRow = (sIdx: number) => + onUpdateConfig({ + sections: sections.map((s, i) => + i === sIdx + ? { + ...s, + rows: [ + ...s.rows, + { + reply_id: `row_${totalRows + 1}`, + title: `Option ${totalRows + 1}`, + next_node_key: "", + }, + ], + } + : s, + ), + }); + const removeRow = (sIdx: number, rIdx: number) => + onUpdateConfig({ + sections: sections.map((s, i) => + i === sIdx ? { ...s, rows: s.rows.filter((_, j) => j !== rIdx) } : s, + ), + }); + + return ( + <> + onUpdateConfig({ text: v })} + rows={3} + /> +
+ onUpdateConfig({ button_label: v })} + /> + onUpdateConfig({ footer_text: v })} + /> +
+ +
+ + {sections.map((section, sIdx) => ( +
+ + updateSection(sIdx, { title: e.target.value }) + } + placeholder="Section title (optional)" + className="mb-2 bg-slate-800 text-xs" + /> + {section.rows.map((row, rIdx) => ( +
+ + updateRow(sIdx, rIdx, { + reply_id: slugify( + e.target.value, + `row_${rIdx + 1}`, + ), + }) + } + placeholder="reply_id" + className="bg-slate-800 font-mono text-xs" + /> + + updateRow(sIdx, rIdx, { title: e.target.value }) + } + placeholder="Row title (≤24)" + className="bg-slate-800" + maxLength={24} + /> + + updateRow(sIdx, rIdx, { next_node_key: v ?? "" }) + } + placeholder="Next node…" + /> + +
+ ))} + {totalRows < 10 && ( + + )} +
+ ))} +
+ + ); +} + +// ---- Smaller field components ---- + +function TextRow({ + label, + value, + onChange, + rows = 1, +}: { + label: string; + value: string; + onChange: (v: string) => void; + rows?: number; +}) { + return ( +
+ + {rows > 1 ? ( +