From d5032383cd5b58ca013975728339a56d9c19e19f Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 8 Jun 2026 12:03:41 +0200 Subject: [PATCH] feat: add automation graph client models --- .../app/automations/listAutomationRuns.ts | 10 + .../client/app/automations/testAutomation.ts | 16 ++ .../app/automations/useAutomationMutations.ts | 7 + .../app/automations/useAutomationRuns.ts | 11 + .../api/models/app/automations/Automation.ts | 78 +++++- .../lib/api/models/app/automations/meta.ts | 257 +++++++++++++++++- .../api/models/app/automations/templates.ts | 77 ++++++ .../models/app/integrations/Integration.ts | 10 +- 8 files changed, 453 insertions(+), 13 deletions(-) create mode 100644 web/src/lib/api/client/app/automations/listAutomationRuns.ts create mode 100644 web/src/lib/api/client/app/automations/testAutomation.ts create mode 100644 web/src/lib/api/hooks/app/automations/useAutomationRuns.ts create mode 100644 web/src/lib/api/models/app/automations/templates.ts diff --git a/web/src/lib/api/client/app/automations/listAutomationRuns.ts b/web/src/lib/api/client/app/automations/listAutomationRuns.ts new file mode 100644 index 00000000..77750999 --- /dev/null +++ b/web/src/lib/api/client/app/automations/listAutomationRuns.ts @@ -0,0 +1,10 @@ +import type { AutomationRun } from "@/lib/api/models/app/automations/Automation"; +import Request from "../../Request"; + +export default async function listAutomationRuns(id: string): Promise<{ runs: AutomationRun[] }> { + return await Request<{ runs: AutomationRun[] }>({ + method: "GET", + url: `/automations/${id}/runs`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/automations/testAutomation.ts b/web/src/lib/api/client/app/automations/testAutomation.ts new file mode 100644 index 00000000..06c6c256 --- /dev/null +++ b/web/src/lib/api/client/app/automations/testAutomation.ts @@ -0,0 +1,16 @@ +import type { DryRunResponse } from "@/lib/api/models/app/automations/Automation"; +import Request from "../../Request"; + +// testAutomation runs an automation against sample (or provided) data without +// side effects, returning the path taken + per-action previews. +export default async function testAutomation( + id: string, + data?: Record, +): Promise { + return await Request({ + method: "POST", + url: `/automations/${id}/test`, + authorization: true, + data: data ? { data } : {}, + }); +} diff --git a/web/src/lib/api/hooks/app/automations/useAutomationMutations.ts b/web/src/lib/api/hooks/app/automations/useAutomationMutations.ts index c1144866..fddbd03e 100644 --- a/web/src/lib/api/hooks/app/automations/useAutomationMutations.ts +++ b/web/src/lib/api/hooks/app/automations/useAutomationMutations.ts @@ -2,8 +2,15 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import createAutomation from "@/lib/api/client/app/automations/createAutomation"; import updateAutomation from "@/lib/api/client/app/automations/updateAutomation"; import deleteAutomation from "@/lib/api/client/app/automations/deleteAutomation"; +import testAutomation from "@/lib/api/client/app/automations/testAutomation"; import type { AutomationWrite } from "@/lib/api/models/app/automations/Automation"; +export function useTestAutomation() { + return useMutation({ + mutationFn: ({ id, data }: { id: string; data?: Record }) => testAutomation(id, data), + }); +} + export function useCreateAutomation() { const qc = useQueryClient(); return useMutation({ diff --git a/web/src/lib/api/hooks/app/automations/useAutomationRuns.ts b/web/src/lib/api/hooks/app/automations/useAutomationRuns.ts new file mode 100644 index 00000000..ab7ea130 --- /dev/null +++ b/web/src/lib/api/hooks/app/automations/useAutomationRuns.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import listAutomationRuns from "@/lib/api/client/app/automations/listAutomationRuns"; + +export function useAutomationRuns(id: string, enabled = true) { + return useQuery({ + queryKey: ["automations", id, "runs"], + queryFn: () => listAutomationRuns(id), + enabled: enabled && !!id, + staleTime: 10_000, + }); +} diff --git a/web/src/lib/api/models/app/automations/Automation.ts b/web/src/lib/api/models/app/automations/Automation.ts index 7f976943..eb505a2c 100644 --- a/web/src/lib/api/models/app/automations/Automation.ts +++ b/web/src/lib/api/models/app/automations/Automation.ts @@ -1,15 +1,42 @@ import type { IntegrationAction } from "@/lib/api/models/app/integrations/Integration"; -// One action node of an automation: run `action` on `connection_id` with config. -export interface AutomationStep { - id?: string; - connection_id: string; - action: IntegrationAction; - config?: Record; +// A condition (IF) tested against the trigger event's data. For the generic +// "field" type, `key` names the event-data key to test. +export interface AutomationCondition { + field: string; + key?: string; + operator: string; + value?: unknown; +} + +// One node on the flow canvas. "trigger" is the single entry (id "trigger"); +// "condition" is an IF with true/false outgoing edges; "action" runs a provider +// action on a connection. +export interface AutomationNode { + id: string; + type: "trigger" | "condition" | "action"; + action?: IntegrationAction; + connection_id?: string; + config?: Record; + condition?: AutomationCondition; + x: number; + y: number; +} + +// An edge. `when` is "" for plain edges and "true"/"false" for the two outgoing +// edges of a condition node. +export interface AutomationEdge { + id: string; + source: string; + target: string; + when?: "" | "true" | "false"; +} + +export interface AutomationGraph { + nodes: AutomationNode[]; + edges: AutomationEdge[]; } -// An automation = one trigger event -> a set of action steps. Execution reuses -// the integration event-subscription dispatcher (each step is a subscription). export interface Automation { id: string; organization_id: string; @@ -17,16 +44,45 @@ export interface Automation { enabled: boolean; trigger_event: string; filter?: Record; + graph: AutomationGraph; created_at: string; updated_at: string; - steps: AutomationStep[]; } -// Create/update payload from the flow builder. export interface AutomationWrite { name: string; enabled: boolean; trigger_event: string; filter?: Record; - steps: AutomationStep[]; + graph: AutomationGraph; +} + +// One node's outcome in a run (or a dry-run trace). +export interface AutomationNodeResult { + node_id: string; + type: string; // trigger | condition | action + action?: string; + label?: string; + status: string; // success | error | skipped | branch_true | branch_false + error?: string; + preview?: Record; // dry-run only +} + +// A persisted execution of an automation graph. +export interface AutomationRun { + id: string; + automation_id: string; + organization_id: string; + trigger_event: string; + status: string; // running | success | error + node_results: AutomationNodeResult[]; + error_detail?: string; + started_at: string; + finished_at?: string | null; +} + +// Dry-run (test) response: the trace of the walk + the sample data used. +export interface DryRunResponse { + trace: AutomationNodeResult[]; + data: Record; } diff --git a/web/src/lib/api/models/app/automations/meta.ts b/web/src/lib/api/models/app/automations/meta.ts index 7930cfd3..44280a57 100644 --- a/web/src/lib/api/models/app/automations/meta.ts +++ b/web/src/lib/api/models/app/automations/meta.ts @@ -2,9 +2,12 @@ // sync with the backend's subscribable events (catalog crmEvents/notifyEvents) // and the per-provider action handlers. -import { EVENT_LABELS } from "@/lib/api/models/app/integrations/Integration"; +import { EVENT_LABELS, REPLY_INTENT_OPTIONS } from "@/lib/api/models/app/integrations/Integration"; +import type { AutomationCondition } from "@/lib/api/models/app/automations/Automation"; // Warmbly events that can trigger an automation. Order = how they're listed. +// "campaign.action" is the manual / campaign-launched trigger: it never fires on +// a real event, only via a campaign "Run automation" step (RunAutomationByID). export const TRIGGER_EVENTS: string[] = [ "campaign.reply_received", "meeting.booked", @@ -14,6 +17,7 @@ export const TRIGGER_EVENTS: string[] = [ "campaign.unsubscribed", "warmup.health_changed", "deliverability.complaint", + "campaign.action", ]; export function triggerLabel(ev: string): string { @@ -29,12 +33,53 @@ export const ACTION_LABELS: Record = { "salesforce.upsert_contact": "Create / update Salesforce contact", "close.upsert_lead": "Create / update Close lead", "webhook.ping": "Send a webhook", + // Native (Warmbly built-in) actions — no external connection needed. + "warmbly.add_tag": "Add a tag", + "warmbly.remove_tag": "Remove a tag", + "warmbly.create_task": "Create a task", + "warmbly.create_deal": "Create a deal", + "warmbly.move_deal_stage": "Move the deal stage", + "warmbly.unsubscribe": "Unsubscribe the contact", }; export function actionLabel(a: string): string { return ACTION_LABELS[a] ?? a; } +// Native (Warmbly-internal) actions operate on the event's contact directly, +// with no external connection. The "__native__" sentinel is the connection-select +// value that switches the action editor into native mode. +export const NATIVE_CONNECTION = "__native__"; + +export const NATIVE_ACTIONS: string[] = [ + "warmbly.add_tag", + "warmbly.remove_tag", + "warmbly.create_task", + "warmbly.create_deal", + "warmbly.move_deal_stage", + "warmbly.unsubscribe", +]; + +export function isNativeAction(a: string): boolean { + return a.startsWith("warmbly."); +} + +// What config a native action needs, so the editor shows the right picker. +export function nativeActionNeeds(action: string): "tag" | "deal" | "task" | "none" { + switch (action) { + case "warmbly.add_tag": + case "warmbly.remove_tag": + return "tag"; + case "warmbly.create_deal": + case "warmbly.move_deal_stage": + return "deal"; + case "warmbly.create_task": + return "task"; + default: + return "none"; + } +} + // Per-action config field needs, so the node editor shows the right inputs. export function actionNeedsChannel(action: string): boolean { return action === "slack.notify"; @@ -51,3 +96,213 @@ export function actionSupportsTemplate(action: string): boolean { export function triggerSupportsIntentFilter(ev: string): boolean { return ev === "campaign.reply_received"; } + +// --- Condition (IF) vocabulary — data-driven, per trigger ------------------- +// An IF tests a key from the trigger's event payload with an operator. Each +// trigger exposes the fields its payload actually carries (so conditions are +// always meaningful), plus a universal "Random split". Adding an event here + +// to TRIGGER_VARIABLES is all it takes for new triggers to get full conditions. + +export type ConditionValueType = "string" | "number" | "bool" | "enum"; + +export interface TriggerFieldDef { + key: string; + label: string; + type: ConditionValueType; + options?: { value: string; label: string }[]; + defaultOperator: string; +} + +// Sentinel field key for the random-split pseudo-condition. +export const RANDOM_FIELD_KEY = "__random__"; + +export const WARMUP_STATES = [ + { value: "healthy", label: "Healthy" }, + { value: "watch", label: "Watch" }, + { value: "throttled", label: "Throttled" }, + { value: "quarantined", label: "Quarantined" }, + { value: "blocked", label: "Blocked" }, +]; + +const MEETING_FIELDS: TriggerFieldDef[] = [ + { key: "source", label: "Source", type: "string", defaultOperator: "equals" }, + { key: "invitee_email", label: "Invitee email", type: "string", defaultOperator: "contains" }, + { key: "event_name", label: "Meeting name", type: "string", defaultOperator: "contains" }, + { key: "contact_id", label: "Matched contact", type: "string", defaultOperator: "exists" }, +]; + +const DELIVERABILITY_FIELDS: TriggerFieldDef[] = [ + { key: "event_type", label: "Event type", type: "string", defaultOperator: "equals" }, + { key: "provider", label: "Provider", type: "string", defaultOperator: "equals" }, + { key: "reason", label: "Reason", type: "string", defaultOperator: "contains" }, + { key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" }, + { key: "campaign_id", label: "From a campaign", type: "string", defaultOperator: "exists" }, +]; + +export const TRIGGER_FIELDS: Record = { + "campaign.reply_received": [ + { key: "intent", label: "Reply intent", type: "enum", options: REPLY_INTENT_OPTIONS, defaultOperator: "equals" }, + { key: "confidence", label: "Classifier confidence", type: "number", defaultOperator: "gte" }, + { key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" }, + { key: "subject", label: "Subject", type: "string", defaultOperator: "contains" }, + { key: "contact_id", label: "Matched contact", type: "string", defaultOperator: "exists" }, + ], + "meeting.booked": MEETING_FIELDS, + "meeting.rescheduled": MEETING_FIELDS, + "meeting.canceled": MEETING_FIELDS, + "campaign.email_bounced": DELIVERABILITY_FIELDS, + "deliverability.bounce": DELIVERABILITY_FIELDS, + "deliverability.complaint": DELIVERABILITY_FIELDS, + "campaign.unsubscribed": [ + { key: "source", label: "Unsubscribe source", type: "string", defaultOperator: "equals" }, + { key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" }, + { key: "campaign_id", label: "From a campaign", type: "string", defaultOperator: "exists" }, + ], + "warmup.health_changed": [ + { key: "new_state", label: "New state", type: "enum", options: WARMUP_STATES, defaultOperator: "equals" }, + { key: "previous_state", label: "Previous state", type: "enum", options: WARMUP_STATES, defaultOperator: "equals" }, + { key: "email", label: "Mailbox", type: "string", defaultOperator: "contains" }, + ], + "campaign.action": [ + { key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" }, + { key: "company", label: "Company", type: "string", defaultOperator: "contains" }, + { key: "campaign_id", label: "From a campaign", type: "string", defaultOperator: "exists" }, + { key: "contact_id", label: "Matched contact", type: "string", defaultOperator: "exists" }, + ], +}; + +const GENERIC_FIELDS: TriggerFieldDef[] = [ + { key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" }, + { key: "contact_id", label: "Matched contact", type: "string", defaultOperator: "exists" }, +]; + +const RANDOM_FIELD: TriggerFieldDef = { + key: RANDOM_FIELD_KEY, + label: "Random split", + type: "number", + defaultOperator: "chance", +}; + +// The condition fields offered for a trigger: its payload fields + random split. +export function triggerConditionFields(triggerEvent: string): TriggerFieldDef[] { + return [...(TRIGGER_FIELDS[triggerEvent] ?? GENERIC_FIELDS), RANDOM_FIELD]; +} + +export function triggerFieldDef(triggerEvent: string, key: string): TriggerFieldDef | undefined { + return triggerConditionFields(triggerEvent).find((f) => f.key === key); +} + +// Build a fresh condition from a picked field key (random -> chance split; +// everything else -> a generic field test with the field's default operator). +export function conditionFromFieldKey(triggerEvent: string, key: string): AutomationCondition { + if (key === RANDOM_FIELD_KEY) return { field: "random", operator: "chance", value: 50 }; + const def = triggerFieldDef(triggerEvent, key); + return { field: "field", key, operator: def?.defaultOperator ?? "equals" }; +} + +// The default condition a new IF node starts with for a given trigger. +export function defaultConditionForTrigger(triggerEvent: string): AutomationCondition { + const first = triggerConditionFields(triggerEvent)[0]; + return conditionFromFieldKey(triggerEvent, first?.key ?? RANDOM_FIELD_KEY); +} + +// The select value representing a condition's chosen field. +export function conditionFieldKey(c: AutomationCondition): string { + if (c.field === "random") return RANDOM_FIELD_KEY; + if (c.field === "field") return c.key ?? ""; + return c.field; // legacy +} + +export const OPERATOR_LABELS: Record = { + equals: "is", + not_equals: "is not", + contains: "contains", + gte: "≥", + lte: "≤", + exists: "is present", + is_true: "is true", + chance: "% of the time", +}; + +// Which operators make sense for each value type. +export function operatorsForType(type: ConditionValueType): { value: string; label: string }[] { + const ops = + type === "number" + ? ["gte", "lte", "equals", "exists"] + : type === "enum" + ? ["equals", "not_equals", "exists"] + : type === "bool" + ? ["is_true", "exists"] + : ["equals", "not_equals", "contains", "exists"]; + return ops.map((o) => ({ value: o, label: OPERATOR_LABELS[o] ?? o })); +} + +export function defaultOperatorFor(field: string): string { + // Legacy helper kept for any old callers; new code reads field def operators. + switch (field) { + case "confidence": + return "gte"; + case "has_contact": + return "is_true"; + case "random": + return "chance"; + default: + return "equals"; + } +} + +// --- Template variables per trigger (for the {{insert}} menus) -------------- +// The keys present in each trigger's event payload, offered as {{key}} inserts +// in action message/URL/value fields. +export const TRIGGER_VARIABLES: Record = { + "campaign.reply_received": ["contact_email", "contact_id", "campaign_id", "intent", "confidence", "subject", "snippet"], + "meeting.booked": ["invitee_name", "invitee_email", "event_name", "scheduled_for", "join_url", "source", "contact_id"], + "meeting.rescheduled": ["invitee_name", "invitee_email", "event_name", "scheduled_for", "join_url", "source", "contact_id"], + "meeting.canceled": ["invitee_name", "invitee_email", "event_name", "scheduled_for", "source", "contact_id"], + "campaign.email_bounced": ["contact_email", "campaign_id", "contact_id", "event_type", "provider", "reason"], + "deliverability.bounce": ["contact_email", "campaign_id", "contact_id", "event_type", "provider", "reason"], + "deliverability.complaint": ["contact_email", "campaign_id", "contact_id", "event_type", "provider", "reason"], + "campaign.unsubscribed": ["contact_email", "contact_id", "campaign_id", "source"], + "warmup.health_changed": ["email", "new_state", "previous_state", "reason"], + "campaign.action": ["contact_email", "contact_id", "campaign_id", "campaign_name", "first_name", "last_name", "company", "phone"], +}; + +export function triggerVariables(triggerEvent: string): string[] { + return TRIGGER_VARIABLES[triggerEvent] ?? ["contact_email", "contact_id"]; +} + +const prettyKey = (k: string) => k.replace(/_/g, " "); + +// A short human summary of a condition, used as the IF node label. +export function conditionLabel(c?: AutomationCondition): string { + if (!c || !c.field) return "Set a condition"; + // Random split (its own field type). + if (c.field === "random") return `${Number(c.value ?? 50)}% random`; + // Generic field condition. + if (c.field === "field") { + const key = c.key ?? ""; + const op = c.operator; + if (op === "exists") return `${prettyKey(key)} is present`; + if (op === "is_true") return `${prettyKey(key)} is true`; + if (key === "confidence") return `confidence ≥ ${Math.round(Number(c.value ?? 0) * 100)}%`; + const opLbl = OPERATOR_LABELS[op] ?? op; + const valLbl = + REPLY_INTENT_OPTIONS.find((o) => o.value === c.value)?.label ?? + WARMUP_STATES.find((o) => o.value === c.value)?.label ?? + String(c.value ?? "…"); + return `${prettyKey(key)} ${opLbl} ${valLbl}`; + } + // Legacy semantic fields (older saved automations). + switch (c.field) { + case "confidence": + return `confidence ≥ ${Math.round(Number(c.value ?? 0) * 100)}%`; + case "has_contact": + return "has a contact"; + case "intent": + return `intent is ${REPLY_INTENT_OPTIONS.find((o) => o.value === c.value)?.label ?? String(c.value ?? "…")}`; + case "source": + return `source is ${String(c.value ?? "…")}`; + default: + return c.field; + } +} diff --git a/web/src/lib/api/models/app/automations/templates.ts b/web/src/lib/api/models/app/automations/templates.ts new file mode 100644 index 00000000..40462fbc --- /dev/null +++ b/web/src/lib/api/models/app/automations/templates.ts @@ -0,0 +1,77 @@ +// Prebuilt automation starting points. Each is a full graph the "New automation" +// menu / empty state can instantiate. They lean on native (Warmbly built-in) +// actions so they work without any external connection — the user just fills in +// the specifics (which tag, which pipeline) the template can't know. + +import type { AutomationGraph } from "@/lib/api/models/app/automations/Automation"; + +export interface AutomationTemplate { + id: string; + name: string; + description: string; + trigger_event: string; + graph: AutomationGraph; +} + +export const AUTOMATION_TEMPLATES: AutomationTemplate[] = [ + { + id: "tag-hot-replies", + name: "Tag hot replies", + description: "When a prospect replies positively, add a tag to the contact.", + trigger_event: "campaign.reply_received", + graph: { + nodes: [ + { id: "trigger", type: "trigger", x: 0, y: 0 }, + { id: "c1", type: "condition", x: 0, y: 150, condition: { field: "field", key: "intent", operator: "equals", value: "positive" } }, + { id: "a1", type: "action", x: 0, y: 300, action: "warmbly.add_tag", config: {} }, + ], + edges: [ + { id: "e1", source: "trigger", target: "c1", when: "" }, + { id: "e2", source: "c1", target: "a1", when: "true" }, + ], + }, + }, + { + id: "deal-on-meeting", + name: "Deal on meeting booked", + description: "When a meeting is booked, open a CRM deal for the contact.", + trigger_event: "meeting.booked", + graph: { + nodes: [ + { id: "trigger", type: "trigger", x: 0, y: 0 }, + { id: "a1", type: "action", x: 0, y: 150, action: "warmbly.create_deal", config: { deal_name: "{{invitee_name}} — {{event_name}}" } }, + ], + edges: [{ id: "e1", source: "trigger", target: "a1", when: "" }], + }, + }, + { + id: "unsubscribe-on-bounce", + name: "Unsubscribe on bounce", + description: "When an email hard-bounces, unsubscribe the contact to protect deliverability.", + trigger_event: "campaign.email_bounced", + graph: { + nodes: [ + { id: "trigger", type: "trigger", x: 0, y: 0 }, + { id: "a1", type: "action", x: 0, y: 150, action: "warmbly.unsubscribe", config: {} }, + ], + edges: [{ id: "e1", source: "trigger", target: "a1", when: "" }], + }, + }, + { + id: "slack-positive-reply", + name: "Slack on positive reply", + description: "Ping a Slack channel when a prospect replies positively. (Pick your Slack connection.)", + trigger_event: "campaign.reply_received", + graph: { + nodes: [ + { id: "trigger", type: "trigger", x: 0, y: 0 }, + { id: "c1", type: "condition", x: 0, y: 150, condition: { field: "field", key: "intent", operator: "equals", value: "positive" } }, + { id: "a1", type: "action", x: 0, y: 300, action: "slack.notify", config: { message_template: "🔥 Positive reply from {{contact_email}}" } }, + ], + edges: [ + { id: "e1", source: "trigger", target: "c1", when: "" }, + { id: "e2", source: "c1", target: "a1", when: "true" }, + ], + }, + }, +]; diff --git a/web/src/lib/api/models/app/integrations/Integration.ts b/web/src/lib/api/models/app/integrations/Integration.ts index 0267f5cc..4cf899d1 100644 --- a/web/src/lib/api/models/app/integrations/Integration.ts +++ b/web/src/lib/api/models/app/integrations/Integration.ts @@ -159,7 +159,14 @@ export type IntegrationAction = | "pipedrive.upsert_person" | "salesforce.upsert_contact" | "close.upsert_lead" - | "webhook.ping"; + | "webhook.ping" + // Native (Warmbly built-in) automation actions — no external connection. + | "warmbly.add_tag" + | "warmbly.remove_tag" + | "warmbly.create_task" + | "warmbly.create_deal" + | "warmbly.move_deal_stage" + | "warmbly.unsubscribe"; export interface IntegrationEventSubscription { id: string; @@ -297,6 +304,7 @@ export const EVENT_LABELS: Record = { "meeting.booked": "Meeting booked", "meeting.rescheduled": "Meeting rescheduled", "meeting.canceled": "Meeting canceled", + "campaign.action": "Launched by a campaign step", }; // Which action a provider performs for an event subscription.