diff --git a/src/app/(dashboard)/flows/[id]/runs/page.tsx b/src/app/(dashboard)/flows/[id]/runs/page.tsx new file mode 100644 index 0000000..d3a6106 --- /dev/null +++ b/src/app/(dashboard)/flows/[id]/runs/page.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { + ArrowLeft, + Loader2, + CircleCheck, + CircleAlert, + Clock, + UserPlus, + PlayCircle, + PauseCircle, + ChevronDown, + ChevronRight, +} from "lucide-react"; +import { toast } from "sonner"; +import { format, formatDistanceToNow } from "date-fns"; + +import { useAuth } from "@/hooks/use-auth"; +import { isFlowsEnabled } from "@/lib/flows/feature-flag"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +/** + * Run history viewer. + * + * Lists the 50 most recent runs for a flow, newest first. Each row + * collapses to a one-liner (contact + status + time); expanding shows + * the full `flow_run_events` timeline for that run — useful for + * debugging "why didn't my flow advance?" by surfacing the engine's + * own log. + */ + +interface RunRow { + id: string; + status: + | "active" + | "completed" + | "handed_off" + | "timed_out" + | "paused_by_agent" + | "failed"; + current_node_key: string | null; + started_at: string; + last_advanced_at: string; + ended_at: string | null; + end_reason: string | null; + vars: Record; + reprompt_count: number; + contact: { id: string; name: string | null; phone: string } | null; +} + +interface EventRow { + flow_run_id: string; + event_type: string; + node_key: string | null; + payload: Record; + created_at: string; +} + +const STATUS_META: Record< + RunRow["status"], + { label: string; classes: string; icon: typeof Clock } +> = { + active: { + label: "Active", + classes: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300", + icon: PlayCircle, + }, + completed: { + label: "Completed", + classes: "border-slate-700 bg-slate-800 text-slate-300", + icon: CircleCheck, + }, + handed_off: { + label: "Handed off", + classes: "border-amber-600/40 bg-amber-500/10 text-amber-300", + icon: UserPlus, + }, + timed_out: { + label: "Timed out", + classes: "border-slate-700 bg-slate-800/60 text-slate-400", + icon: Clock, + }, + paused_by_agent: { + label: "Paused by agent", + classes: "border-slate-700 bg-slate-800 text-slate-300", + icon: PauseCircle, + }, + failed: { + label: "Failed", + classes: "border-red-600/40 bg-red-500/10 text-red-300", + icon: CircleAlert, + }, +}; + +export default function FlowRunsPage() { + const router = useRouter(); + const params = useParams<{ id: string }>(); + const { profile, loading: authLoading } = useAuth(); + + const [flow, setFlow] = useState<{ id: string; name: string } | null>(null); + const [runs, setRuns] = useState([]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState>(new Set()); + const [notFound, setNotFound] = useState(false); + + const allowed = isFlowsEnabled(profile); + + useEffect(() => { + if (authLoading) return; + if (!allowed) { + router.replace("/dashboard"); + return; + } + if (!params.id) return; + let cancelled = false; + (async () => { + try { + const res = await fetch(`/api/flows/${params.id}/runs`); + if (res.status === 404) { + if (!cancelled) setNotFound(true); + return; + } + if (!res.ok) throw new Error(`Failed: ${res.status}`); + const json = (await res.json()) as { + flow: { id: string; name: string }; + runs: RunRow[]; + events: EventRow[]; + }; + if (!cancelled) { + setFlow(json.flow); + setRuns(json.runs ?? []); + setEvents(json.events ?? []); + } + } catch (err) { + if (!cancelled) { + console.error(err); + toast.error("Couldn't load runs."); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [allowed, authLoading, params.id, router]); + + function toggle(runId: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(runId)) next.delete(runId); + else next.add(runId); + return next; + }); + } + + if (authLoading || (allowed && loading)) { + return ( +
+ +
+ ); + } + if (notFound || !flow) { + return ( +
+

Flow not found.

+ +
+ ); + } + + return ( +
+ +

Runs

+

+ The 50 most recent times this flow ran. Expand a row to see the engine's + per-step log. +

+ + {runs.length === 0 ? ( +
+ No runs yet. Trigger the flow from a personal WhatsApp number to see + it appear here. +
+ ) : ( +
+ {runs.map((run) => ( + e.flow_run_id === run.id)} + expanded={expanded.has(run.id)} + onToggle={() => toggle(run.id)} + /> + ))} +
+ )} +
+ ); +} + +function RunCard({ + run, + events, + expanded, + onToggle, +}: { + run: RunRow; + events: EventRow[]; + expanded: boolean; + onToggle: () => void; +}) { + const meta = STATUS_META[run.status]; + const StatusIcon = meta.icon; + const contactLabel = + run.contact?.name?.trim() || run.contact?.phone || "Unknown contact"; + const duration = run.ended_at + ? formatDistanceToNow(new Date(run.ended_at), { + addSuffix: false, + }) + : null; + return ( +
+ + {expanded && ( +
+ {Object.keys(run.vars).length > 0 && ( +
+ + Captured vars ({Object.keys(run.vars).length}) + +
+                {JSON.stringify(run.vars, null, 2)}
+              
+
+ )} +
+ {events.length === 0 ? ( +

+ No events recorded for this run. +

+ ) : ( + events.map((ev, ix) => ) + )} +
+
+ )} +
+ ); +} + +const EVENT_COLOR: Record = { + started: "text-emerald-300", + node_entered: "text-slate-300", + message_sent: "text-sky-300", + reply_received: "text-violet-300", + fallback_fired: "text-amber-300", + handoff: "text-amber-300", + timeout: "text-slate-500", + error: "text-red-300", + completed: "text-emerald-300", +}; + +function EventLine({ ev }: { ev: EventRow }) { + const cls = EVENT_COLOR[ev.event_type] ?? "text-slate-400"; + return ( +
+ + {format(new Date(ev.created_at), "HH:mm:ss")} + + + {ev.event_type} + + {ev.node_key && ( + + {ev.node_key} + + )} + {Object.keys(ev.payload).length > 0 && ( + + {summarizePayload(ev.payload)} + + )} +
+ ); +} + +function summarizePayload(payload: Record): string { + // Show the keys that matter most to a human debugger; full JSON is + // available via the "Captured vars" details panel for the run. + const keys = ["reply_id", "captured_key", "reason", "advancing_to"]; + for (const k of keys) { + if (k in payload && payload[k] !== null && payload[k] !== undefined) { + return `${k}=${String(payload[k]).slice(0, 80)}`; + } + } + return ""; +} diff --git a/src/app/(dashboard)/flows/page.tsx b/src/app/(dashboard)/flows/page.tsx index a6e19b3..70d467a 100644 --- a/src/app/(dashboard)/flows/page.tsx +++ b/src/app/(dashboard)/flows/page.tsx @@ -13,6 +13,9 @@ import { PlayCircle, PauseCircle, Archive, + HelpCircle, + UserPlus, + FileText, } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; @@ -64,6 +67,21 @@ const STATUS_COLORS: Record = { archived: "border-slate-700 bg-slate-800/50 text-slate-500", }; +interface TemplateSummary { + slug: string; + name: string; + description: string; + icon: "MessageSquare" | "HelpCircle" | "UserPlus"; + trigger_type: string; + node_count: number; +} + +const TEMPLATE_ICONS = { + MessageSquare, + HelpCircle, + UserPlus, +} as const; + export default function FlowsPage() { const router = useRouter(); const { profile, loading: authLoading } = useAuth(); @@ -72,6 +90,7 @@ export default function FlowsPage() { const [createOpen, setCreateOpen] = useState(false); const [newName, setNewName] = useState(""); const [creating, setCreating] = useState(false); + const [templates, setTemplates] = useState([]); const flowsAccessAllowed = isFlowsEnabled(profile); @@ -85,10 +104,23 @@ export default function FlowsPage() { let cancelled = false; (async () => { try { - const res = await fetch("/api/flows"); - if (!res.ok) throw new Error(`Failed to load flows: ${res.status}`); - const json = (await res.json()) as { flows: FlowRow[] }; - if (!cancelled) setFlows(json.flows ?? []); + const [flowsRes, tmplRes] = await Promise.all([ + fetch("/api/flows"), + fetch("/api/flows/templates"), + ]); + if (!flowsRes.ok) { + throw new Error(`Failed to load flows: ${flowsRes.status}`); + } + const flowsJson = (await flowsRes.json()) as { flows: FlowRow[] }; + if (!cancelled) setFlows(flowsJson.flows ?? []); + // Templates endpoint is forward-looking — if it 404s on an + // older deployment, gracefully fall through. + if (tmplRes.ok) { + const tmplJson = (await tmplRes.json()) as { + templates: TemplateSummary[]; + }; + if (!cancelled) setTemplates(tmplJson.templates ?? []); + } } catch (err) { if (!cancelled) { console.error(err); @@ -129,6 +161,29 @@ export default function FlowsPage() { } } + async function handleUseTemplate(slug: string) { + setCreating(true); + try { + const res = await fetch("/api/flows", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ template_slug: slug }), + }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(json.error ?? `Clone failed: ${res.status}`); + } + const json = (await res.json()) as { flow: FlowRow }; + setCreateOpen(false); + router.push(`/flows/${json.flow.id}`); + } catch (err) { + const msg = err instanceof Error ? err.message : "Clone failed"; + toast.error(msg); + } finally { + setCreating(false); + } + } + async function handleDelete(flow: FlowRow) { const yes = window.confirm( `Delete "${flow.name}"? Any active runs will end immediately.`, @@ -185,23 +240,62 @@ export default function FlowsPage() { )} - + Create a new flow - Give it a name. You can configure the trigger and nodes on the - next screen. + Start from a template or build from scratch. - setNewName(e.target.value)} - placeholder="e.g. Welcome menu" - className="bg-slate-800" - onKeyDown={(e) => { - if (e.key === "Enter") handleCreate(); - }} - /> + + {templates.length > 0 && ( +
+

+ Start from a template +

+
+ {templates.map((t) => { + const Icon = TEMPLATE_ICONS[t.icon] ?? FileText; + return ( + + ); + })} +
+
+ )} + +
+

+ Or start blank +

+ setNewName(e.target.value)} + placeholder="e.g. Welcome menu" + className="bg-slate-800" + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + /> +
+
diff --git a/src/app/api/flows/[id]/runs/route.ts b/src/app/api/flows/[id]/runs/route.ts new file mode 100644 index 0000000..e8bfb4d --- /dev/null +++ b/src/app/api/flows/[id]/runs/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { isFlowsEnabled } from '@/lib/flows/feature-flag' + +/** + * GET /api/flows/[id]/runs + * + * Newest-first list of flow runs for a single flow, with the latest + * event timeline embedded for each. Used by the run-history viewer + * page (`/flows/[id]/runs`) to give the owner end-to-end visibility + * into what the bot did with each customer. + * + * RLS does the ownership check (flow_runs has a `user_id` policy); + * we also gate on the per-account beta flag so the route 404s for + * non-beta accounts matching the rest of /api/flows. + * + * Limited to the 50 most recent runs. Pagination can come later; + * the dashboard surface here is for debugging, not heavy querying. + */ +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> }, +) { + const { id } = await context.params + + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const { data: profile } = await supabase + .from('profiles') + .select('beta_features') + .eq('user_id', user.id) + .maybeSingle() + if (!isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + // Confirm flow exists + caller owns it (RLS does this) before doing + // the run query — gives us a clean 404 instead of empty array. + const { data: flow } = await supabase + .from('flows') + .select('id, name') + .eq('id', id) + .maybeSingle() + if (!flow) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + // Pull runs + each run's contact name + each run's events. Two + // joined selects keep the round-trip count to the runs query + one + // per-run events query. + const { data: runs, error: runsErr } = await supabase + .from('flow_runs') + .select( + 'id, status, current_node_key, started_at, last_advanced_at, ended_at, end_reason, vars, reprompt_count, contact:contacts(id, name, phone)', + ) + .eq('flow_id', id) + .order('started_at', { ascending: false }) + .limit(50) + if (runsErr) { + return NextResponse.json({ error: runsErr.message }, { status: 500 }) + } + + const runIds = (runs ?? []).map((r) => (r as { id: string }).id) + let events: Array<{ + flow_run_id: string + event_type: string + node_key: string | null + payload: Record + created_at: string + }> = [] + if (runIds.length > 0) { + const { data: evs, error: evsErr } = await supabase + .from('flow_run_events') + .select('flow_run_id, event_type, node_key, payload, created_at') + .in('flow_run_id', runIds) + .order('created_at', { ascending: true }) + if (evsErr) { + // Non-fatal — the page can still show runs without timelines. + console.error('[flows-runs] events fetch failed:', evsErr.message) + } else if (evs) { + events = evs as typeof events + } + } + + return NextResponse.json({ + flow, + runs: runs ?? [], + events, + }) +} diff --git a/src/app/api/flows/route.ts b/src/app/api/flows/route.ts index 33d0028..4e05f13 100644 --- a/src/app/api/flows/route.ts +++ b/src/app/api/flows/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { supabaseAdmin } from '@/lib/flows/admin-client' import { isFlowsEnabled } from '@/lib/flows/feature-flag' +import { getFlowTemplate } from '@/lib/flows/templates' /** * GET /api/flows — list the caller's flows. @@ -68,17 +69,78 @@ export async function POST(request: Request) { description?: string | null trigger_type?: 'keyword' | 'first_inbound_message' | 'manual' trigger_config?: Record + /** + * If set, clone the matching template's name + trigger + + * entry_node_id + nodes[] into a fresh draft for this user. + * `name` from the body overrides the template default if + * provided. + */ + template_slug?: string } | null if (!body) { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) } + + const admin = supabaseAdmin() + + // -------- Template clone path -------- + if (body.template_slug) { + const template = getFlowTemplate(body.template_slug) + if (!template) { + return NextResponse.json( + { error: `Unknown template_slug "${body.template_slug}"` }, + { status: 400 }, + ) + } + const { data: flow, error: flowErr } = await admin + .from('flows') + .insert({ + user_id: userId, + name: body.name?.trim() || template.name, + description: template.description, + status: 'draft', + trigger_type: template.trigger_type, + trigger_config: template.trigger_config, + entry_node_id: template.entry_node_id, + }) + .select() + .single() + if (flowErr || !flow) { + return NextResponse.json( + { error: flowErr?.message ?? 'flow insert failed' }, + { status: 500 }, + ) + } + if (template.nodes.length > 0) { + const { error: nodesErr } = await admin.from('flow_nodes').insert( + template.nodes.map((n) => ({ + flow_id: flow.id, + node_key: n.node_key, + node_type: n.node_type, + config: n.config, + })), + ) + if (nodesErr) { + // Roll back the parent flow so a half-cloned template doesn't + // sit as an empty draft. CASCADE on flow_id removes the + // (probably zero) nodes too. + await admin.from('flows').delete().eq('id', flow.id) + return NextResponse.json( + { error: nodesErr.message }, + { status: 500 }, + ) + } + } + return NextResponse.json({ flow }, { status: 201 }) + } + + // -------- Plain (empty) create path -------- if (!body.name?.trim()) { return NextResponse.json({ error: 'name is required' }, { status: 400 }) } const trigger_type = body.trigger_type ?? 'keyword' - const admin = supabaseAdmin() const { data, error } = await admin .from('flows') .insert({ diff --git a/src/app/api/flows/templates/route.ts b/src/app/api/flows/templates/route.ts new file mode 100644 index 0000000..ca1c1d8 --- /dev/null +++ b/src/app/api/flows/templates/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { isFlowsEnabled } from '@/lib/flows/feature-flag' +import { listFlowTemplates } from '@/lib/flows/templates' + +/** + * GET /api/flows/templates + * + * Returns the static template gallery (slug + name + description + + * icon hint + node_count) so the New-flow dialog can render cards + * without bundling the full template payloads client-side. Bodies + * are fetched only on actual clone via POST /api/flows. + * + * 404 to non-beta accounts (matches the rest of the /api/flows + * surface). + */ +export async function GET() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const { data: profile } = await supabase + .from('profiles') + .select('beta_features') + .eq('user_id', user.id) + .maybeSingle() + if (!isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + // Shallow shape so the client gallery doesn't have to know about + // the full node tree. + const templates = listFlowTemplates().map((t) => ({ + slug: t.slug, + name: t.name, + description: t.description, + icon: t.icon, + trigger_type: t.trigger_type, + node_count: t.nodes.length, + })) + return NextResponse.json({ templates }) +} diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index bac07fc..095d097 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -15,12 +15,13 @@ * the same file as small components rather than separate modules. */ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { ArrowLeft, CircleCheck, CircleAlert, + History, Loader2, Plus, Save, @@ -36,6 +37,9 @@ import { Flag, PlayCircle, PauseCircle, + Inbox, + GitFork, + Tag, } from "lucide-react"; import { toast } from "sonner"; @@ -79,6 +83,9 @@ type NodeType = | "send_message" | "send_buttons" | "send_list" + | "collect_input" + | "condition" + | "set_tag" | "handoff" | "end"; @@ -123,6 +130,21 @@ const NODE_META: Record< icon: ListPlus, color: "text-indigo-400", }, + collect_input: { + label: "Collect input", + icon: Inbox, + color: "text-teal-400", + }, + condition: { + label: "If / else", + icon: GitFork, + color: "text-fuchsia-400", + }, + set_tag: { + label: "Tag contact", + icon: Tag, + color: "text-pink-400", + }, handoff: { label: "Handoff to agent", icon: UserPlus, @@ -175,6 +197,23 @@ function defaultConfigFor(type: NodeType): Record { }, ], }; + case "collect_input": + return { + prompt_text: "", + var_key: "answer", + next_node_key: "", + }; + case "condition": + return { + subject: "var", + subject_key: "", + operator: "equals", + value: "", + true_next: "", + false_next: "", + }; + case "set_tag": + return { mode: "add", tag_id: "", next_node_key: "" }; case "handoff": return { note: "" }; case "end": @@ -399,6 +438,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { onDelete={handleDelete} canActivate={canActivate} onBack={() => router.push("/flows")} + onViewRuns={() => router.push(`/flows/${initialFlow.id}/runs`)} /> >; @@ -475,6 +516,7 @@ function Header({ onDelete: () => void; canActivate: boolean; onBack: () => void; + onViewRuns: () => void; }) { return (
@@ -502,6 +544,14 @@ function Header({
+