From efd72b131e5296e97d98a57fb2bc9df756ef6e49 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Mon, 8 Jun 2026 13:21:44 +0300 Subject: [PATCH] fix(automations): pick tags, agents & templates by name in the builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The automation builder exposed several developer-facing fields that made automations hard to configure from the UI (issue #216): - Keyword Match parsed/trimmed on every keystroke, so typing a comma-separated, multi-word list like "SEO, search engine optimization" was impossible — the comma and trailing space were stripped immediately. - Tag Added / Add Tag / Remove Tag asked for a raw tag_id. - Assign Conversation → Specific agent asked for a raw agent_id. - Send Template asked for a manually typed template name + language. Changes: - Keyword input keeps a local draft and only parses into the keywords array on blur / Enter, then re-displays the cleaned form. - New tag / agent / template pickers select existing resources by name while still storing ids / names internally, so the stored config shape (and the engine/validation) is unchanged. Resources load once at the builder root and are shared via context. - Tags & approved templates load from Supabase (RLS-scoped, matching the broadcast picker); members load from /api/account/members. - Every picker falls back to its original raw input when its list is empty (fresh account / older deployment), so automations stay authorable. Saved-but-missing ids are preserved when editing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../automations/automation-builder.tsx | 382 +++++++++++++++--- 1 file changed, 323 insertions(+), 59 deletions(-) diff --git a/src/components/automations/automation-builder.tsx b/src/components/automations/automation-builder.tsx index 5f8e74a..2a4ea01 100644 --- a/src/components/automations/automation-builder.tsx +++ b/src/components/automations/automation-builder.tsx @@ -1,6 +1,12 @@ "use client" -import { useState } from "react" +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react" import { useRouter } from "next/navigation" import { toast } from "sonner" import { @@ -37,10 +43,14 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import type { + AccountMember, AutomationStepType, AutomationTriggerType, KeywordMatchTriggerConfig, + MessageTemplate, + Tag as TagRecord, } from "@/types" +import { createClient } from "@/lib/supabase/client" import { cn } from "@/lib/utils" // ------------------------------------------------------------ @@ -155,6 +165,253 @@ function blankConfig(type: AutomationStepType): Record { } } +// ------------------------------------------------------------ +// Account resources (tags, members, approved templates) +// +// Loaded once at the builder root and shared via context so the +// tag / agent / template pickers below can offer existing resources +// by name instead of asking the user to paste raw UUIDs. Every picker +// falls back to a raw input when its list is empty (fresh account or +// an older deployment), so an automation is always authorable. +// ------------------------------------------------------------ + +interface AutomationResources { + tags: TagRecord[] + members: AccountMember[] + templates: MessageTemplate[] +} + +const ResourcesContext = createContext({ + tags: [], + members: [], + templates: [], +}) + +function useResources(): AutomationResources { + return useContext(ResourcesContext) +} + +function ResourcesProvider({ children }: { children: ReactNode }) { + const [tags, setTags] = useState([]) + const [members, setMembers] = useState([]) + const [templates, setTemplates] = useState([]) + + useEffect(() => { + let cancelled = false + const supabase = createClient() + + // Tags and templates come straight from the DB — RLS scopes both + // to the caller's account. Only APPROVED templates can actually be + // sent (anything else 400s at send time), matching the broadcast + // picker. + void (async () => { + const [tagsRes, templatesRes] = await Promise.all([ + supabase.from("tags").select("*").order("name"), + supabase + .from("message_templates") + .select("*") + .eq("status", "APPROVED") + .order("name"), + ]) + if (cancelled) return + setTags((tagsRes.data as TagRecord[] | null) ?? []) + setTemplates((templatesRes.data as MessageTemplate[] | null) ?? []) + })() + + // Members go through the API so we inherit its email-visibility + // rules (agents/viewers don't see emails). Unreachable on older + // deployments → pickers fall back to a raw agent-id input. + void (async () => { + try { + const res = await fetch("/api/account/members", { cache: "no-store" }) + if (!res.ok) return + const json = (await res.json()) as { members?: AccountMember[] } + if (!cancelled) setMembers(json.members ?? []) + } catch { + // Members endpoint absent — caller falls back to raw input. + } + })() + + return () => { + cancelled = true + } + }, []) + + return ( + + {children} + + ) +} + +const SELECT_CLASS = + "w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white focus:border-primary focus:outline-none" + +/** Tag dropdown by name + color, storing the tag's id. Falls back to a + * raw id input when no tags exist yet. */ +function TagSelect({ + value, + onChange, +}: { + value: string + onChange: (v: string) => void +}) { + const { tags } = useResources() + if (tags.length === 0) { + return ( + onChange(e.target.value)} + className="bg-slate-800 text-white" + /> + ) + } + const selected = tags.find((t) => t.id === value) + return ( +
+ + +
+ ) +} + +/** Agent dropdown by name, storing the member's user_id. Falls back to + * a raw id input when the member list is unavailable. */ +function AgentSelect({ + value, + onChange, +}: { + value: string + onChange: (v: string) => void +}) { + const { members } = useResources() + if (members.length === 0) { + return ( + onChange(e.target.value)} + className="bg-slate-800 text-white" + /> + ) + } + const selected = members.find((m) => m.user_id === value) + return ( + + ) +} + +/** Template dropdown showing approved templates by name + language, + * storing both template_name and language. Falls back to manual name + + * language inputs when no approved templates are synced yet. */ +function SendTemplateFields({ + templateName, + language, + onChange, +}: { + templateName: string + language: string + onChange: (patch: { template_name: string; language: string }) => void +}) { + const { templates } = useResources() + + if (templates.length === 0) { + return ( + <> + + + onChange({ template_name: e.target.value, language }) + } + className="bg-slate-800 text-white" + /> + + + + onChange({ template_name: templateName, language: e.target.value }) + } + className="bg-slate-800 text-white" + /> + + + ) + } + + // Encode name + language in the option value so two templates that + // share a name across languages stay distinct. + const toValue = (name: string, lang: string) => `${name}::${lang}` + const current = templateName ? toValue(templateName, language) : "" + const hasMatch = templates.some( + (t) => toValue(t.name, t.language ?? "en_US") === current, + ) + + return ( + + + + ) +} + // ------------------------------------------------------------ // Main builder component // ------------------------------------------------------------ @@ -286,22 +543,24 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
- patchTop("trigger_type", t)} - onConfigChange={(c) => patchTop("trigger_config", c)} - /> - + + patchTop("trigger_type", t)} + onConfigChange={(c) => patchTop("trigger_config", c)} + /> + +
@@ -375,14 +634,15 @@ function TriggerCard({ /> )} {type === "tag_added" && ( - - onConfigChange({ ...config, tag_id: e.target.value }) - } - className="bg-slate-800 text-white" - /> +
+ + onConfigChange({ ...config, tag_id: v })} + /> +
)} {type === "time_based" && ( ) => void }) { const keywords = config?.keywords ?? [] + // Keep a local draft string so the comma and trailing space aren't + // stripped on every keystroke (which made multi-word, comma-separated + // entry like "SEO, search engine optimization" impossible to type). + // We only parse into the keywords array on blur, then re-display the + // cleaned, rejoined form. Seeded once on mount; this component remounts + // when the trigger type changes, so the seed stays in sync. + const [draft, setDraft] = useState(keywords.join(", ")) + + function commit() { + const parsed = draft + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + setDraft(parsed.join(", ")) + onChange({ ...config, keywords: parsed }) + } + return (
@@ -416,16 +693,16 @@ function KeywordMatchConfig({ Keywords (comma-separated) - onChange({ - ...config, - keywords: e.target.value - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - }) - } + value={draft} + onChange={(e) => setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + commit() + } + }} + placeholder="e.g. pricing, demo request, talk to sales" className="bg-slate-800 text-white" />
@@ -724,31 +1001,19 @@ function StepEditor({ ) case "send_template": return ( - <> - - set({ template_name: e.target.value })} - className="bg-slate-800 text-white" - /> - - - set({ language: e.target.value })} - className="bg-slate-800 text-white" - /> - - + set(patch)} + /> ) case "add_tag": case "remove_tag": return ( - - + set({ tag_id: e.target.value })} - className="bg-slate-800 text-white" + onChange={(v) => set({ tag_id: v })} /> ) @@ -766,11 +1031,10 @@ function StepEditor({ {cfg.mode === "specific" && ( - - + set({ agent_id: e.target.value })} - className="bg-slate-800 text-white" + onChange={(v) => set({ agent_id: v })} /> )}