feat: add automation graph client models

This commit is contained in:
Matthew Meszaros
2026-06-08 12:03:41 +02:00
parent ad7c87acca
commit d5032383cd
8 changed files with 453 additions and 13 deletions
@@ -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,
});
}
@@ -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<string, unknown>,
): Promise<DryRunResponse> {
return await Request<DryRunResponse>({
method: "POST",
url: `/automations/${id}/test`,
authorization: true,
data: data ? { data } : {},
});
}
@@ -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<string, unknown> }) => testAutomation(id, data),
});
}
export function useCreateAutomation() {
const qc = useQueryClient();
return useMutation({
@@ -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,
});
}
@@ -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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>; // 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<string, unknown>;
}
+256 -1
View File
@@ -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<string, string> = {
"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<string, TriggerFieldDef[]> = {
"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<string, string> = {
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<string, string[]> = {
"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;
}
}
@@ -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" },
],
},
},
];
@@ -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<string, string> = {
"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.