mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-24 08:00:37 +00:00
feat: add automation graph client models
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user