mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
Merge pull request #335 from warmbly/feat/lead-intake-automations
feat: lead intake through automations (contact.created and form.submitted triggers, create-or-update contact and add-to-campaign actions) for lead-form sources like Facebook Lead Ads
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
/updater
|
||||
/warmblyctl
|
||||
/warmbly
|
||||
/cli
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
+9
-3
@@ -1265,6 +1265,11 @@ func main() {
|
||||
if aware, ok := contactService.(contact.SegmentAware); ok {
|
||||
aware.WireSegments(segmentRepository, segmentService)
|
||||
}
|
||||
// A new contact is an event: customer webhooks and "contact created"
|
||||
// automations hear about it from the one write path every creator uses.
|
||||
if aware, ok := contactService.(contact.WebhookAware); ok {
|
||||
aware.WireWebhooks(webhookServiceForHandler)
|
||||
}
|
||||
formRepository := repository.NewFormRepository(primaryDB)
|
||||
formEventRepository := repository.NewFormEventRepository(primaryDB)
|
||||
formService = form.NewService(formRepository)
|
||||
@@ -1506,9 +1511,10 @@ func main() {
|
||||
// the advanced/contact/org services exist (the integration service was
|
||||
// constructed earlier).
|
||||
integrationServiceForHandler.SetNativeActions(nativeactions.Adapter{
|
||||
Adv: advancedService,
|
||||
Contacts: contactRepostory,
|
||||
Orgs: organizationRepository,
|
||||
Adv: advancedService,
|
||||
Contacts: contactRepostory,
|
||||
Orgs: organizationRepository,
|
||||
ContactSvc: contactService,
|
||||
})
|
||||
integrationServiceForHandler.SetPublisher(streamingPublisher)
|
||||
// AI automation nodes (ai_step / ai_switch) run over the same provider +
|
||||
|
||||
+12
-3
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/app/advanced"
|
||||
"github.com/warmbly/warmbly/internal/app/cipher"
|
||||
jobs "github.com/warmbly/warmbly/internal/app/consumer"
|
||||
"github.com/warmbly/warmbly/internal/app/contact"
|
||||
"github.com/warmbly/warmbly/internal/app/credits"
|
||||
"github.com/warmbly/warmbly/internal/app/creditwatch"
|
||||
"github.com/warmbly/warmbly/internal/app/feature"
|
||||
@@ -316,10 +317,18 @@ func main() {
|
||||
// wiring native actions here a reply-triggered automation's add_tag /
|
||||
// create_deal / label_email node would fail with "native actions are not
|
||||
// available". Mirrors the backend wiring.
|
||||
// The lead-intake actions (create or update contact, add to campaign) write
|
||||
// through a contact service so a reply-triggered flow in this process gets
|
||||
// the same plan check, campaign wake and contact.created as the backend.
|
||||
contactServiceC := contact.NewService(contactRepo, subscriptionRepoConsumer, planRepoConsumer, streamingPublisher)
|
||||
if aware, ok := contactServiceC.(contact.WebhookAware); ok {
|
||||
aware.WireWebhooks(webhookService)
|
||||
}
|
||||
integrationServiceC.SetNativeActions(nativeactions.Adapter{
|
||||
Adv: advancedService,
|
||||
Contacts: contactRepo,
|
||||
Orgs: orgRepoConsumer,
|
||||
Adv: advancedService,
|
||||
Contacts: contactRepo,
|
||||
Orgs: orgRepoConsumer,
|
||||
ContactSvc: contactServiceC,
|
||||
})
|
||||
// In-app notifications: the reply/bounce/complaint gate fires in THIS
|
||||
// process (inbox ingest + deliverability ingest run in the consumer), so the
|
||||
|
||||
@@ -746,7 +746,7 @@ Returns a `data` array and the standard `pagination` envelope. Paginate by passi
|
||||
|
||||
A `form_submitted` event carries `form_id` and `form_name`. A `page_hit` event is a page view on your own site from a browser tied to the contact through an email-link ticket (see [Website tracking](/guides/website-tracking/)); `subject` is the page title, or its path when the page has none, and `page_hit` carries the full view: `url`, `path`, `title`, `referrer`, `referrer_domain`, `landing` (the first view of a session), the `utm_*` parameters, `device_type`, `os`, `browser`, `browser_version`, `device_brand`, `language`, `timezone`, `screen_width`, `screen_height`, and `country_code`, `region`, `city` when known.
|
||||
|
||||
Lifecycle events carry the name of what changed as it was at the time (`campaign_name`, or `category_id` plus `category_title`), so a later rename or deletion does not rewrite history. A `contact_created` event carries `source` (`manual`, `campaign`, `import`, `sheet_sync`, `api`, `ai_assistant`, or `unknown` for contacts that predate attribution) and `source_detail` (the file, campaign, sheet or API key name). The same values are on the contact itself as `source`, `source_detail` and `first_seen_at`, and never change after creation.
|
||||
Lifecycle events carry the name of what changed as it was at the time (`campaign_name`, or `category_id` plus `category_title`), so a later rename or deletion does not rewrite history. A `contact_created` event carries `source` (`manual`, `campaign`, `import`, `sheet_sync`, `api`, `form`, `automation`, `ai_assistant`, or `unknown` for contacts that predate attribution) and `source_detail` (the file, campaign, sheet, form, automation or API key name). The same values are on the contact itself as `source`, `source_detail` and `first_seen_at`, and never change after creation.
|
||||
|
||||
## Get a contact's campaign state
|
||||
|
||||
|
||||
@@ -507,10 +507,10 @@ The full catalog is available at `GET /webhooks/event-types`. Below is the compl
|
||||
|
||||
| Event | Description | Firehose |
|
||||
| --- | --- | --- |
|
||||
| `contact.created` | A contact is created. | |
|
||||
| `contact.created` | A contact is created. Carries `contact_id`, `contact_email`, `first_name`, `last_name`, `company`, `phone`, `subscribed`, `custom_fields`, `source`, `source_detail`, `campaign_ids`, `category_ids` and `created_at`. Fires for contacts added by hand, through the API, by a form or by an automation; file imports, sheet syncs and a single API request adding more than 100 contacts are bulk arrivals and do not raise it. | |
|
||||
| `contact.updated` | A contact's fields change. | |
|
||||
| `contact.deleted` | A contact is deleted. | |
|
||||
| `form.submitted` | A hosted form received a submission. Carries the form id and name, the submission id, the answers, and the contact id when one was created or matched. | |
|
||||
| `form.submitted` | A hosted form received a submission. Carries the form id and name, the submission id, the answers under `data`, the mapped contact columns (`contact_email`, `first_name`, `last_name`, `company`, `phone`), `source_url`, and the contact id when one was created or matched. | |
|
||||
|
||||
### CRM
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ Triggers, conditions, and actions only take effect after **Save** (card position
|
||||
| Trigger | Fires when |
|
||||
| --- | --- |
|
||||
| Reply received | A contact replies to a campaign email |
|
||||
| Contact created | A new contact is added by hand, through the API, by a form or by an automation (bulk file imports and sheet syncs stay quiet, see below) |
|
||||
| Form submitted | A hosted [form](/guides/forms/) receives a submission |
|
||||
| Meeting booked / rescheduled / canceled | A Calendly or Cal.com meeting changes |
|
||||
| Email bounced | A campaign email bounces |
|
||||
| Unsubscribed | A contact unsubscribes |
|
||||
@@ -46,6 +48,8 @@ Each trigger carries its own event data (a reply carries contact email, reply in
|
||||
|
||||
**Campaign action** never fires by itself; it runs only from a campaign step, still evaluating your conditions.
|
||||
|
||||
**Contact created** fires once per new contact, with the contact's fields and its first-touch source, so a condition can branch on `source` (a form, the API, an automation) or on any field. A contact that already existed and was only updated does not fire it. Bulk arrivals are deliberately silent: a file import or a Google Sheets sync of ten thousand rows would otherwise run ten thousand flows and flood every webhook, so those paths never raise it, and neither does a single API request adding more than 100 contacts.
|
||||
|
||||
### Inbound webhook
|
||||
|
||||
Pick the trigger and **save**, and the editor shows a unique URL. Anything that can send HTTP `POST`s JSON to it.
|
||||
@@ -93,6 +97,8 @@ Slack, Discord, and webhook actions take an optional message template. Slack and
|
||||
|
||||
| Built-in action | What it does |
|
||||
| --- | --- |
|
||||
| Create or update contact | Makes a contact from the event's fields, or enriches the one with that email, then tags it and enrols it in a campaign. See [Lead intake](#lead-intake) |
|
||||
| Add to campaign | Enrols the event's contact in a campaign |
|
||||
| Add / remove a tag | Adds or removes a contact category |
|
||||
| Label the email | Applies inbox labels to the replied-on conversation |
|
||||
| Create a task | Assigned to the workspace owner |
|
||||
@@ -105,6 +111,26 @@ Slack, Discord, and webhook actions take an optional message template. Slack and
|
||||
|
||||
Three constraints worth knowing: **Move the deal stage** does nothing if the contact has no open deal in that pipeline, **Unsubscribe** needs an event carrying a campaign, and **Label the email** works only on a Reply received automation, since it needs a thread to label.
|
||||
|
||||
**Add to campaign** and every other contact action need a contact to act on: the event must carry `contact_id` or `contact_email`, which every Warmbly trigger does. On an inbound webhook, put **Create or update contact** first and the contact it writes becomes the event's contact for the steps after it.
|
||||
|
||||
### Lead intake
|
||||
|
||||
Any system that can send an HTTP request can create contacts in Warmbly without an API client: an **Inbound webhook** trigger followed by a **Create or update contact** action.
|
||||
|
||||
1. Pick the Inbound webhook trigger, save, and copy the URL.
|
||||
2. Add **Create or update contact**. Each field is a template over the JSON the caller sends: an email of `{{.email}}`, a first name of `{{.first_name}}`, a custom field `team_size` set to `{{.answers.team_size}}`. Pick the tags and the campaign the lead lands in.
|
||||
3. Point the sender at the URL. Zapier's *Webhooks by Zapier*, Make's *HTTP* module, n8n's *HTTP Request* node, a Typeform or Tally webhook, or your own code.
|
||||
|
||||
The action matches an existing contact by email, so re-sending the same lead updates it instead of duplicating it. A blank rendered value never erases a field the contact already has. **If the contact already exists** decides whether an existing contact is enriched and enrolled (the default) or left alone. New contacts carry `automation` as their first-touch source, with the automation's name as the detail.
|
||||
|
||||
<Callout type="info" title="Facebook, Instagram, LinkedIn and TikTok lead forms">
|
||||
Meta, LinkedIn and TikTok deliver lead-form submissions to Zapier, Make and n8n in real time. Either route them through the Warmbly app there with its **Create or Update Contact** and **Add to Campaign** actions, or send them to an inbound webhook automation as above. Both end the same way: a contact with the ad's answers as custom fields, tagged and enrolled in the campaign that follows up from your mailbox. See [Zapier](/guides/zapier/#lead-forms-and-other-lead-sources), [Make](/guides/make/#lead-forms-and-other-lead-sources) and [n8n](/guides/n8n/).
|
||||
</Callout>
|
||||
|
||||
Lead-form campaigns produce leads in bursts, and the follow-up still goes out from your mailboxes under their daily caps and spacing. A day of two hundred leads on one mailbox capped at fifty is a four-day send; the campaign's recipients-versus-capacity estimate shows it. Add mailboxes rather than raising caps.
|
||||
|
||||
A flow that creates a contact can be the reason a **Contact created** flow runs. That is allowed, and bounded: an event raised by an automation's own action carries how many hops led to it, and after five hops no further automation runs, so two flows cannot feed each other forever.
|
||||
|
||||
**Fire event** is the inverse of the inbound webhook: your app subscribes over the websocket with an API key holding `REALTIME_SUBSCRIBE` and receives `{ name, payload }`, so you get events without hosting a public URL. See [Realtime events](/api/realtime/).
|
||||
|
||||
### The on-error branch
|
||||
|
||||
@@ -100,11 +100,15 @@ Every contact records its first-touch source: how it entered the workspace, with
|
||||
| Import | Created by the file import wizard | The file name |
|
||||
| Sheet sync | Created by a Google Sheets sync source | The sheet |
|
||||
| API | Created through the API with an API key | The key's name |
|
||||
| Form | Submitted a hosted [form](/guides/forms/) | The form |
|
||||
| Automation | Written by an automation's **Create or update contact** action | The automation |
|
||||
| AI assistant | Created by the assistant on your behalf | |
|
||||
| Unknown | Created before Warmbly recorded sources | |
|
||||
|
||||
Only new contacts get a source. An import or API call that matches an existing contact updates its fields and leaves the original source in place, so an address that arrived by hand and later turned up in a file still reads as manual.
|
||||
|
||||
A new contact is also an event. `contact.created` goes to your [webhooks](/guides/webhooks/) and can start an [automation](/guides/automations/#triggers) with the contact's fields and source, except for bulk arrivals, which stay silent: file imports, sheet syncs, and a single API request adding more than 100 contacts.
|
||||
|
||||
## Activity timeline
|
||||
|
||||
The **Activity** tab of a contact is one feed, newest first, of everything Warmbly knows about them: every campaign email sent, opened, clicked, replied to or bounced (with the campaign, step, subject and sending mailbox), replies with their classified intent, deliverability and suppression events, notes, meetings, and the contact's lifecycle: when it was created and how, and each time it joined or left a campaign or a category.
|
||||
|
||||
@@ -42,6 +42,8 @@ New reply from {{.contact_email}} on campaign {{.campaign_id}} ({{.intent}})
|
||||
| Trigger | Variables |
|
||||
| --- | --- |
|
||||
| Reply received | `contact_email`, `contact_id`, `campaign_id`, `intent`, `confidence`, `subject`, `snippet` |
|
||||
| Contact created | `contact_email`, `contact_id`, `first_name`, `last_name`, `company`, `phone`, `subscribed`, `source`, `source_detail`, `custom_fields` (a map: `{{.custom_fields.industry}}`), `campaign_ids`, `category_ids` |
|
||||
| Form submitted | `contact_email`, `contact_id`, `first_name`, `last_name`, `company`, `phone`, `form_id`, `form_name`, `submission_id`, `source_url`, `campaign_id`, `data` (every answer by its key: `{{.data.team_size}}`) |
|
||||
| Meeting booked / rescheduled | `invitee_name`, `invitee_email`, `event_name`, `scheduled_for`, `join_url`, `source`, `contact_id` |
|
||||
| Meeting canceled | Same, without `join_url` |
|
||||
| Email bounced, Deliverability complaint | `contact_email`, `campaign_id`, `contact_id`, `event_type`, `provider`, `reason` |
|
||||
|
||||
@@ -109,6 +109,8 @@ The **Submissions** tab is a table of everyone who engaged, newest activity firs
|
||||
|
||||
Each submitted contact carries `form` as its first-touch source, gets a "Submitted a form" entry on its activity timeline, and can be filtered in segments by source. The form's page views, submission count and conversion rate show on the Forms list.
|
||||
|
||||
A submission is also the **Form submitted** [automation trigger](/guides/automations/#triggers) and the `form.submitted` webhook event, carrying the form, the contact and every answer by its key, so a flow can tag the contact, open a task or post to Slack the moment it lands.
|
||||
|
||||
Submissions also fire the `form.submitted` [webhook](/guides/webhooks/), carrying the answers and the contact id, so you can forward leads to any external system.
|
||||
|
||||
## Analytics
|
||||
|
||||
@@ -72,7 +72,9 @@ URLs are validated before storage under the same policy as customer webhooks: th
|
||||
|
||||
Integrations are most useful as the actions at the end of an [automation](/guides/automations/): post to Slack or Discord, upsert into a CRM, or fan out to Zapier, Make, n8n, or any signed webhook.
|
||||
|
||||
The most actionable events are available as triggers: reply received (with intent), email bounced, unsubscribed, meeting booked, rescheduled, or canceled, warmup health changed, and deliverability complaint.
|
||||
The most actionable events are available as triggers: reply received (with intent), contact created, form submitted, email bounced, unsubscribed, meeting booked, rescheduled, or canceled, warmup health changed, and deliverability complaint.
|
||||
|
||||
Data flows in as well as out. An automation's inbound webhook URL plus its **Create or update contact** action turns any push (a lead-form connector in Zapier, Make or n8n, a form tool's webhook, your own code) into a tagged, campaign-enrolled contact. See [Lead intake](/guides/automations/#lead-intake).
|
||||
|
||||
Actions run in the background, so a slow third party never blocks the event that triggered it. Failures are recorded against the connection's health and visible in its recent activity rather than retried forever or silently lost.
|
||||
|
||||
|
||||
@@ -51,6 +51,17 @@ Find Contact (by exact email), Find Deal, Find CRM Task, Find Campaign, Find Mai
|
||||
|
||||
Pair a search with an action for idempotent flows: Find Contact, then Create or Update Contact.
|
||||
|
||||
## Lead forms and other lead sources
|
||||
|
||||
Facebook and Instagram Lead Ads, LinkedIn Lead Gen Forms and TikTok Lead Generation all deliver new form submissions to Make in real time, which makes Make the shortest route from an ad to a follow-up sent from your own mailbox.
|
||||
|
||||
1. **Trigger:** *Facebook Lead Ads: Watch Leads* (or the LinkedIn or TikTok equivalent), picking the Page and form.
|
||||
2. **Module:** Warmbly *Create or Update Contact*. Map the form's email and name fields, put every other question into a custom field, and pick the categories. Then *Add to Campaign* with the campaign that follows up. Re-running the same lead updates the contact rather than duplicating it.
|
||||
|
||||
Prefer to keep the mapping inside Warmbly? Use the *HTTP: Make a request* module to POST the lead as JSON to an [inbound webhook automation](/guides/automations/#lead-intake) instead. The automation's **Create or update contact** action maps the JSON keys onto contact fields with templates, and the same flow can tag, notify Slack and open a task.
|
||||
|
||||
The follow-up still runs through the campaign's mailboxes, so a burst of leads queues under each mailbox's daily cap and spacing rather than going out at once. Cold email tools do not ship a native Meta connector; this route is what their customers use too.
|
||||
|
||||
## Good to know
|
||||
|
||||
- **Sending respects your limits.** Send Email and Reply in Inbox honor each mailbox's daily cap and spacing, and support smart and scheduled send modes.
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"integrations",
|
||||
"zapier",
|
||||
"make",
|
||||
"n8n",
|
||||
"---Account and team---",
|
||||
"billing",
|
||||
"analytics",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: n8n
|
||||
description: Connect Warmbly to n8n workflows, self-hosted or cloud, with the REST API and webhooks in both directions.
|
||||
---
|
||||
|
||||
n8n has no Warmbly node yet, and it does not need one: its HTTP Request and Webhook nodes cover both directions, and every Warmbly endpoint is documented in the [API reference](/api/endpoints/).
|
||||
|
||||
## Connecting
|
||||
|
||||
Create a scoped [API key](/api/authentication/) in Settings > API keys with only the scopes the workflow needs (contacts and campaigns for lead intake; add inbox for replies). In n8n, add a **Header Auth** credential with the name `Authorization` and the value `Bearer <your key>`, and attach it to every HTTP Request node that calls Warmbly.
|
||||
|
||||
## Warmbly to n8n
|
||||
|
||||
To start a workflow from a Warmbly event, add a **Webhook** node, copy its production URL, and either:
|
||||
|
||||
- register it as a [webhook endpoint](/guides/webhooks/) subscribed to the events you want (signed with HMAC so the workflow can verify the sender), or
|
||||
- add an n8n connection on the Integrations page and use it as a **Send a webhook** action inside an [automation](/guides/automations/), which lets you filter and branch before the request leaves Warmbly.
|
||||
|
||||
Both deliver the same JSON body: a delivery id, the event type and the full event data.
|
||||
|
||||
## n8n to Warmbly
|
||||
|
||||
Two routes, depending on where you want the field mapping to live.
|
||||
|
||||
**Mapping in n8n.** An HTTP Request node calling `POST /api/v1/contacts` with the contact's fields, `categories` and `campaigns`. The write is an upsert by email, so re-running a workflow updates the contact instead of duplicating it.
|
||||
|
||||
**Mapping in Warmbly.** Create an automation with the **Inbound webhook** trigger, copy its URL, and POST the raw payload to it from an HTTP Request node. The automation's **Create or update contact** action maps the JSON keys onto contact fields with templates (`{{.email}}`, `{{.answers.company}}`), tags the contact and enrols it in a campaign, and the same flow can notify Slack or open a task. See [Lead intake](/guides/automations/#lead-intake). This route needs no API key: the URL is the credential.
|
||||
|
||||
## Lead forms and other lead sources
|
||||
|
||||
n8n's *Facebook Lead Ads Trigger* node fires on a new Facebook or Instagram submission. LinkedIn Lead Gen Forms and TikTok Lead Generation have no dedicated node; point their lead notification webhooks at an n8n *Webhook* node instead. Connect either to one of the routes above and the ad's answers become a tagged, campaign-enrolled contact that your mailbox follows up with. The follow-up still runs under each mailbox's daily cap and spacing, so a burst of leads queues rather than going out at once.
|
||||
|
||||
## Good to know
|
||||
|
||||
- **Sending respects your limits.** Sends and replies made through the API honor each mailbox's daily cap and spacing.
|
||||
- **Rate limits** apply per API key; a large backfill should go through the [import endpoints](/api/reference/contacts/#commit-an-import) rather than one request per row.
|
||||
- For the lowest latency without hosting a URL, subscribe to the [developer websocket](/api/realtime/) instead of a webhook.
|
||||
@@ -51,6 +51,17 @@ Find Contact (by exact email), Find Deal, Find CRM Task, Find Campaign, Find Mai
|
||||
|
||||
Pair a search with an action for idempotent flows: Find Contact, then Create or Update Contact.
|
||||
|
||||
## Lead forms and other lead sources
|
||||
|
||||
Facebook and Instagram Lead Ads, LinkedIn Lead Gen Forms and TikTok Lead Generation all deliver new form submissions to Zapier in real time, which makes Zapier the shortest route from an ad to a follow-up sent from your own mailbox.
|
||||
|
||||
1. **Trigger:** *Facebook Lead Ads: New Lead* (or the LinkedIn or TikTok equivalent), picking the Page and form.
|
||||
2. **Action:** Warmbly *Create or Update Contact*. Map the form's email and name fields, put every other question into a custom field, and pick the categories. Then *Add to Campaign* with the campaign that follows up. Re-running the same lead updates the contact rather than duplicating it.
|
||||
|
||||
Prefer to keep the mapping inside Warmbly? Use *Webhooks by Zapier: POST* to an [inbound webhook automation](/guides/automations/#lead-intake) instead, sending the lead as JSON. The automation's **Create or update contact** action maps the JSON keys onto contact fields with templates, and the same flow can tag, notify Slack and open a task.
|
||||
|
||||
The follow-up still runs through the campaign's mailboxes, so a burst of leads queues under each mailbox's daily cap and spacing rather than going out at once. Cold email tools do not ship a native Meta connector; this route is what their customers use too.
|
||||
|
||||
## Good to know
|
||||
|
||||
- **Sending respects your limits.** Send Email and Reply in Inbox honor each mailbox's daily cap and spacing, and support smart and scheduled send modes.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
type skipCreatedEventsKey struct{}
|
||||
|
||||
// contactCreatedEventMaxBatch is the largest single write that still raises
|
||||
// contact.created per row. A request adding more contacts at once is a bulk
|
||||
// arrival like a file import, and stays silent for the same reason.
|
||||
const contactCreatedEventMaxBatch = 100
|
||||
|
||||
// WithoutCreatedEvents marks a context whose contact writes must not fire
|
||||
// contact.created. Bulk arrivals (file import, sheet sync) use it so one
|
||||
// upload cannot flood an organization's automations and webhooks.
|
||||
func WithoutCreatedEvents(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, skipCreatedEventsKey{}, true)
|
||||
}
|
||||
|
||||
func createdEventsSuppressed(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(skipCreatedEventsKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// emitCreated fires contact.created for every row the upsert inserted. Rows
|
||||
// that matched an existing contact stay silent: their first touch already
|
||||
// happened, and so does a batch past contactCreatedEventMaxBatch. in and out
|
||||
// are index-aligned, as the repository returns them.
|
||||
func (s *contactService) emitCreated(ctx context.Context, orgID uuid.UUID, in []models.AddContact, out []models.Contact) {
|
||||
if s.webhooks == nil || createdEventsSuppressed(ctx) || len(in) > contactCreatedEventMaxBatch {
|
||||
return
|
||||
}
|
||||
for i := range out {
|
||||
if !out[i].IsNew {
|
||||
continue
|
||||
}
|
||||
var src models.AddContact
|
||||
if i < len(in) {
|
||||
src = in[i]
|
||||
}
|
||||
_, _ = s.webhooks.Dispatch(ctx, orgID, models.WebhookEventContactCreated, ContactCreatedPayload(out[i], src))
|
||||
}
|
||||
}
|
||||
|
||||
// ContactCreatedPayload is the contact.created event body: the contact's
|
||||
// fields plus its first-touch source, flat so automation conditions and
|
||||
// templates read them as {{.contact_email}}, {{.first_name}}, {{.source}}.
|
||||
func ContactCreatedPayload(c models.Contact, src models.AddContact) map[string]any {
|
||||
source := src.Source
|
||||
if source == "" {
|
||||
source = models.ContactSourceUnknown
|
||||
}
|
||||
custom := c.CustomFields
|
||||
if custom == nil {
|
||||
custom = map[string]string{}
|
||||
}
|
||||
campaignIDs := make([]string, 0, len(c.Campaigns))
|
||||
for _, cm := range c.Campaigns {
|
||||
campaignIDs = append(campaignIDs, cm.ID)
|
||||
}
|
||||
categoryIDs := make([]string, 0, len(c.Categories))
|
||||
for _, cat := range c.Categories {
|
||||
categoryIDs = append(categoryIDs, cat.ID.String())
|
||||
}
|
||||
return map[string]any{
|
||||
"contact_id": c.ID.String(),
|
||||
"contact_email": c.Email,
|
||||
"first_name": c.FirstName,
|
||||
"last_name": c.LastName,
|
||||
"company": c.Company,
|
||||
"phone": c.Phone,
|
||||
"subscribed": c.Subscribed,
|
||||
"custom_fields": custom,
|
||||
"source": string(source),
|
||||
"source_detail": src.SourceDetail,
|
||||
"campaign_ids": campaignIDs,
|
||||
"category_ids": categoryIDs,
|
||||
"created_at": c.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
type recordingDispatcher struct {
|
||||
events []models.WebhookEventType
|
||||
data []map[string]any
|
||||
}
|
||||
|
||||
func (r *recordingDispatcher) Dispatch(_ context.Context, _ uuid.UUID, t models.WebhookEventType, data any) (uuid.UUID, error) {
|
||||
r.events = append(r.events, t)
|
||||
m, _ := data.(map[string]any)
|
||||
r.data = append(r.data, m)
|
||||
return uuid.New(), nil
|
||||
}
|
||||
|
||||
func TestEmitCreated_OnlyNewRowsAndNotWhenSuppressed(t *testing.T) {
|
||||
rec := &recordingDispatcher{}
|
||||
svc := &contactService{webhooks: rec}
|
||||
in := []models.AddContact{
|
||||
{Email: "new@example.com", Source: models.ContactSourceForm, SourceDetail: "Demo request"},
|
||||
{Email: "old@example.com", Source: models.ContactSourceForm},
|
||||
}
|
||||
out := []models.Contact{
|
||||
{ID: uuid.New(), Email: "new@example.com", IsNew: true, Campaigns: []models.MiniCampaign{{ID: "c1", Name: "Q3"}}},
|
||||
{ID: uuid.New(), Email: "old@example.com", IsNew: false},
|
||||
}
|
||||
svc.emitCreated(context.Background(), uuid.New(), in, out)
|
||||
if len(rec.events) != 1 || rec.events[0] != models.WebhookEventContactCreated {
|
||||
t.Fatalf("events = %v", rec.events)
|
||||
}
|
||||
got := rec.data[0]
|
||||
if got["contact_email"] != "new@example.com" || got["source"] != "form" || got["source_detail"] != "Demo request" {
|
||||
t.Fatalf("payload = %v", got)
|
||||
}
|
||||
if ids, _ := got["campaign_ids"].([]string); len(ids) != 1 || ids[0] != "c1" {
|
||||
t.Fatalf("campaign_ids = %v", got["campaign_ids"])
|
||||
}
|
||||
|
||||
rec.events = nil
|
||||
svc.emitCreated(WithoutCreatedEvents(context.Background()), uuid.New(), in, out)
|
||||
if len(rec.events) != 0 {
|
||||
t.Fatalf("suppressed context still emitted %v", rec.events)
|
||||
}
|
||||
|
||||
quiet := &contactService{}
|
||||
quiet.emitCreated(context.Background(), uuid.New(), in, out) // nil dispatcher is a no-op
|
||||
|
||||
bigIn := make([]models.AddContact, contactCreatedEventMaxBatch+1)
|
||||
bigOut := make([]models.Contact, len(bigIn))
|
||||
for i := range bigIn {
|
||||
bigIn[i] = models.AddContact{Email: "x@example.com"}
|
||||
bigOut[i] = models.Contact{ID: uuid.New(), IsNew: true}
|
||||
}
|
||||
rec.events = nil
|
||||
svc.emitCreated(context.Background(), uuid.New(), bigIn, bigOut)
|
||||
if len(rec.events) != 0 {
|
||||
t.Fatalf("a batch past the cap must stay silent, emitted %d", len(rec.events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactCreatedPayload_DefaultsUnknownSourceAndEmptyMaps(t *testing.T) {
|
||||
p := ContactCreatedPayload(models.Contact{ID: uuid.New(), Email: "x@y.z"}, models.AddContact{})
|
||||
if p["source"] != "unknown" {
|
||||
t.Fatalf("source = %v", p["source"])
|
||||
}
|
||||
if m, ok := p["custom_fields"].(map[string]string); !ok || m == nil {
|
||||
t.Fatalf("custom_fields = %v", p["custom_fields"])
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ func (s *contactService) Add(ctx context.Context, userID string, orgID uuid.UUID
|
||||
}
|
||||
s.wakeCampaigns(ctx, orgID, attached)
|
||||
s.syncSegmentCampaigns(ctx, orgID)
|
||||
s.emitCreated(ctx, orgID, contacts, created)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -445,6 +445,11 @@ func (s *contactService) ImportCommit(
|
||||
return nil, xerr
|
||||
}
|
||||
|
||||
// A file or sheet is a bulk arrival, not fifty thousand "new contact"
|
||||
// moments: the per-contact event stays quiet so automations and webhooks
|
||||
// are not flooded by a single import.
|
||||
ctx = WithoutCreatedEvents(ctx)
|
||||
|
||||
// Insert in chunks so a 50k row import doesn't blow up a single
|
||||
// pgx batch. 500 lines up with the Search page size.
|
||||
for start := 0; start < len(toInsert); start += 500 {
|
||||
|
||||
@@ -109,6 +109,17 @@ type SegmentAware interface {
|
||||
WireSegments(linker SegmentLinker, syncer SegmentCampaignSyncer)
|
||||
}
|
||||
|
||||
// WebhookDispatcher delivers contact.created to customer webhooks and
|
||||
// automations. Satisfied structurally by webhook.Service.
|
||||
type WebhookDispatcher interface {
|
||||
Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) (uuid.UUID, error)
|
||||
}
|
||||
|
||||
// WebhookAware is the optional capability the caller uses to attach it.
|
||||
type WebhookAware interface {
|
||||
WireWebhooks(w WebhookDispatcher)
|
||||
}
|
||||
|
||||
type contactService struct {
|
||||
contactRepository repository.ContactRepository
|
||||
subRepo repository.SubscriptionRepository
|
||||
@@ -123,6 +134,8 @@ type contactService struct {
|
||||
orgRisk orgrisk.Service
|
||||
// explainer builds the verification "why" for the contact drawer.
|
||||
explainer VerificationExplainer
|
||||
// webhooks fans contact.created out; nil-safe (no events).
|
||||
webhooks WebhookDispatcher
|
||||
}
|
||||
|
||||
// VerificationAware is implemented by the contact service so main can hand
|
||||
@@ -142,6 +155,9 @@ func (s *contactService) WireVerification(e VerificationExplainer) { s.explainer
|
||||
// WireOrgRisk attaches the organization risk posture.
|
||||
func (s *contactService) WireOrgRisk(r orgrisk.Service) { s.orgRisk = r }
|
||||
|
||||
// WireWebhooks attaches the event dispatcher behind contact.created.
|
||||
func (s *contactService) WireWebhooks(w WebhookDispatcher) { s.webhooks = w }
|
||||
|
||||
// OrgRiskAware is the optional capability the caller uses to attach it.
|
||||
type OrgRiskAware interface {
|
||||
WireOrgRisk(r orgrisk.Service)
|
||||
|
||||
@@ -443,6 +443,15 @@ func (s *service) Submit(ctx context.Context, publicID string, answers map[strin
|
||||
if contactID != "" {
|
||||
payload["contact_id"] = contactID
|
||||
}
|
||||
// The mapped contact columns ride along flat so an automation can read
|
||||
// {{.contact_email}} or {{.first_name}} without digging into data.
|
||||
if lead != nil {
|
||||
payload["contact_email"] = lead.Email
|
||||
payload["first_name"] = lead.FirstName
|
||||
payload["last_name"] = lead.LastName
|
||||
payload["company"] = lead.Company
|
||||
payload["phone"] = lead.Phone
|
||||
}
|
||||
if sub.CampaignID != nil {
|
||||
payload["campaign_id"] = sub.CampaignID.String()
|
||||
}
|
||||
|
||||
@@ -39,6 +39,14 @@ func (s *service) Dispatch(ctx context.Context, orgID uuid.UUID, eventType model
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("event", string(eventType)).Msg("integration dispatch: failed to load automations")
|
||||
}
|
||||
// An event raised by an automation's own action (a contact it created, a
|
||||
// deal it opened) carries its depth. Past the chain cap it still reaches
|
||||
// customer webhooks and legacy subscriptions, but runs no more flows, so
|
||||
// "on new contact, create a contact" cannot loop.
|
||||
if depth := int(toFloat(data[automationDepthKey])); depth >= maxAutomationChainDepth && len(autos) > 0 {
|
||||
log.Warn().Str("event", string(eventType)).Int("depth", depth).Msg("integration dispatch: automation chain depth limit reached; not running automations")
|
||||
autos = nil
|
||||
}
|
||||
if len(targets) == 0 && len(autos) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -290,6 +298,11 @@ func renderEventMessage(sub models.IntegrationEventSubscription, data map[string
|
||||
m.Title = "✅ Campaign completed"
|
||||
case models.WebhookEventContactCreated:
|
||||
m.Title = "🧑 Contact created"
|
||||
case models.WebhookEventFormSubmitted:
|
||||
m.Title = "📝 Form submitted"
|
||||
if n := stringFromMap(data, "form_name"); n != "" {
|
||||
m.Title = "📝 Form submitted: " + n
|
||||
}
|
||||
case models.WebhookEventContactUpdated:
|
||||
m.Title = "✏️ Contact updated"
|
||||
case models.WebhookEventCRMDealCreated:
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/app/webhook"
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
@@ -43,6 +44,11 @@ func (s *service) executeAutomationGraph(ctx context.Context, a models.Automatio
|
||||
}
|
||||
}
|
||||
|
||||
// Anything an action does under this context (a contact it creates, a deal
|
||||
// it opens) is one hop deeper, so the events those writes fire carry the
|
||||
// depth and Dispatch can stop a flow that keeps re-triggering itself.
|
||||
ctx = webhook.WithAutomationDepth(ctx, int(toFloat(data[automationDepthKey]))+1)
|
||||
|
||||
// Best-effort run record — observability, never blocks the walk.
|
||||
run := &models.AutomationRun{AutomationID: a.ID, OrganizationID: a.OrganizationID, TriggerEvent: eventType, Status: "running"}
|
||||
_ = s.repo.CreateAutomationRun(ctx, run)
|
||||
@@ -178,7 +184,7 @@ func conditionSummary(c *models.AutomationCondition) string {
|
||||
// automation, that launcher MUST forward this key so a misconfigured loop
|
||||
// (campaign -> automation -> campaign -> ...) is bounded instead of infinite.
|
||||
const (
|
||||
automationDepthKey = "_automation_depth"
|
||||
automationDepthKey = webhook.AutomationDepthKey
|
||||
maxAutomationChainDepth = 5
|
||||
)
|
||||
|
||||
@@ -386,6 +392,27 @@ func actionPreview(n models.AutomationNode, data map[string]any) map[string]any
|
||||
if cfg.TaskTitle != "" {
|
||||
p["task_title"] = renderTemplate(cfg.TaskTitle, data)
|
||||
}
|
||||
if n.Action == models.IntegrationActionUpsertContact {
|
||||
for k, v := range map[string]string{
|
||||
"email": cfg.Email, "first_name": cfg.FirstName, "last_name": cfg.LastName,
|
||||
"company": cfg.Company, "phone": cfg.Phone,
|
||||
} {
|
||||
if v != "" {
|
||||
p[k] = renderTemplate(v, data)
|
||||
}
|
||||
}
|
||||
for _, f := range cfg.CustomFields {
|
||||
if k := strings.TrimSpace(f.Key); k != "" {
|
||||
p["custom:"+k] = renderTemplate(f.Value, data)
|
||||
}
|
||||
}
|
||||
if cfg.CampaignID != "" {
|
||||
p["campaign_id"] = cfg.CampaignID
|
||||
}
|
||||
}
|
||||
if n.Action == models.IntegrationActionAddToCampaign && cfg.CampaignID != "" {
|
||||
p["campaign_id"] = cfg.CampaignID
|
||||
}
|
||||
if n.Action == models.IntegrationActionFireEvent {
|
||||
p["event"] = renderTemplate(cfg.EventName, data)
|
||||
for _, f := range cfg.EventFields {
|
||||
@@ -406,6 +433,9 @@ func actionPreview(n models.AutomationNode, data map[string]any) map[string]any
|
||||
func actionRunOutput(n models.AutomationNode, data map[string]any) map[string]any {
|
||||
out := actionPreview(n, data)
|
||||
switch {
|
||||
case n.Action == models.IntegrationActionUpsertContact:
|
||||
out["contact_id"] = valueString(data["contact_id"])
|
||||
out["contact_created"] = valueString(data["contact_created"])
|
||||
case n.Action == models.IntegrationActionSetVariables:
|
||||
cfg := parseNativeConfig(n.Config)
|
||||
for _, v := range cfg.SetVars {
|
||||
@@ -459,6 +489,27 @@ func sampleEventData(triggerEvent string) map[string]any {
|
||||
base["new_state"] = "watch"
|
||||
base["previous_state"] = "healthy"
|
||||
base["reason"] = "spam placement rising"
|
||||
case "contact.created":
|
||||
delete(base, "campaign_id")
|
||||
delete(base, "campaign_name")
|
||||
base["phone"] = "+1 555 0100"
|
||||
base["subscribed"] = true
|
||||
base["custom_fields"] = map[string]any{"industry": "SaaS"}
|
||||
base["source"] = "form"
|
||||
base["source_detail"] = "Demo request"
|
||||
base["campaign_ids"] = []any{}
|
||||
base["category_ids"] = []any{}
|
||||
case "form.submitted":
|
||||
delete(base, "campaign_name")
|
||||
base["form_id"] = "00000000-0000-0000-0000-000000000003"
|
||||
base["form_name"] = "Demo request"
|
||||
base["submission_id"] = "00000000-0000-0000-0000-000000000004"
|
||||
base["phone"] = "+1 555 0100"
|
||||
base["source_url"] = "https://example.com/pricing"
|
||||
base["data"] = map[string]any{
|
||||
"email": "jane@example.com", "first_name": "Jane", "last_name": "Doe",
|
||||
"company": "Example Inc", "team_size": "10-50",
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -48,6 +48,12 @@ func validateNativeActionConfig(action models.IntegrationAction, raw json.RawMes
|
||||
if strings.TrimSpace(cfg.EventName) == "" {
|
||||
return fmt.Errorf("a fire-event action needs an event name")
|
||||
}
|
||||
case models.IntegrationActionUpsertContact:
|
||||
return validateUpsertContactConfig(cfg)
|
||||
case models.IntegrationActionAddToCampaign:
|
||||
if _, err := uuid.Parse(strings.TrimSpace(cfg.CampaignID)); err != nil {
|
||||
return fmt.Errorf("an add-to-campaign action needs a campaign")
|
||||
}
|
||||
case models.IntegrationActionAIStep:
|
||||
return validateAIStepConfig(raw)
|
||||
case models.IntegrationActionAISwitch:
|
||||
@@ -169,6 +175,12 @@ type NativeActions interface {
|
||||
// "label_email" action; userID + threadID come from the reply event data.
|
||||
LabelThread(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error
|
||||
|
||||
// UpsertContact creates the contact or enriches the one already holding
|
||||
// its email (the same write the contacts API does), owned by actorID.
|
||||
UpsertContact(ctx context.Context, orgID, actorID uuid.UUID, in models.AddContact) (*models.Contact, error)
|
||||
// AddToCampaign enrols an existing contact in a campaign and wakes it.
|
||||
AddToCampaign(ctx context.Context, orgID, actorID, contactID, campaignID uuid.UUID) error
|
||||
|
||||
// ListCategories / CreateCategory / ListPipelines back the AI agent step's
|
||||
// argument-based tools: the model picks a tag/label/pipeline by name and the
|
||||
// executor resolves it live (empty pool = any of the owner's tags). Keyed by
|
||||
@@ -205,6 +217,99 @@ type nativeActionConfig struct {
|
||||
// gateway. EventName + each field value are Go-templated against the event data.
|
||||
EventName string `json:"event_name"`
|
||||
EventFields []setVar `json:"event_fields"`
|
||||
// upsert_contact: templated contact fields, the tags and campaign the
|
||||
// contact lands in, and what to do when the email already exists.
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Company string `json:"company"`
|
||||
Phone string `json:"phone"`
|
||||
CustomFields []setVar `json:"custom_fields"`
|
||||
CategoryIDs []string `json:"category_ids"`
|
||||
SegmentIDs []string `json:"segment_ids"`
|
||||
IfExists string `json:"if_exists"`
|
||||
// campaign_id is shared by upsert_contact (optional) and add_to_campaign
|
||||
// (required).
|
||||
CampaignID string `json:"campaign_id"`
|
||||
}
|
||||
|
||||
// Values of nativeActionConfig.IfExists for upsert_contact.
|
||||
const (
|
||||
upsertIfExistsUpdate = "update"
|
||||
upsertIfExistsSkip = "skip"
|
||||
)
|
||||
|
||||
// validateUpsertContactConfig checks the lead-intake action at write time: an
|
||||
// email template is the one thing it cannot do without, and every id it
|
||||
// references must at least parse.
|
||||
func validateUpsertContactConfig(cfg nativeActionConfig) error {
|
||||
if strings.TrimSpace(cfg.Email) == "" {
|
||||
return fmt.Errorf("a create-or-update-contact action needs an email")
|
||||
}
|
||||
switch strings.TrimSpace(cfg.IfExists) {
|
||||
case "", upsertIfExistsUpdate, upsertIfExistsSkip:
|
||||
default:
|
||||
return fmt.Errorf("unknown if_exists value %q", cfg.IfExists)
|
||||
}
|
||||
if id := strings.TrimSpace(cfg.CampaignID); id != "" {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return fmt.Errorf("the campaign id is not valid")
|
||||
}
|
||||
}
|
||||
for _, raw := range append(append([]string{}, cfg.CategoryIDs...), cfg.SegmentIDs...) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := uuid.Parse(strings.TrimSpace(raw)); err != nil {
|
||||
return fmt.Errorf("a tag or segment id is not valid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildUpsertContact renders the action's templates against the event data
|
||||
// into the contact to write. A blank rendered field is left empty so the
|
||||
// upsert's enrich-never-erase rule keeps whatever the contact already has.
|
||||
func buildUpsertContact(a models.Automation, cfg nativeActionConfig, data map[string]any) (models.AddContact, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(renderTemplate(cfg.Email, data)))
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
return models.AddContact{}, fmt.Errorf("the email rendered empty or invalid (%q)", truncate(email, 80))
|
||||
}
|
||||
custom := map[string]string{}
|
||||
for _, f := range cfg.CustomFields {
|
||||
key := strings.TrimSpace(f.Key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if v := strings.TrimSpace(renderTemplate(f.Value, data)); v != "" {
|
||||
custom[key] = v
|
||||
}
|
||||
}
|
||||
in := models.AddContact{
|
||||
Email: email,
|
||||
FirstName: strings.TrimSpace(renderTemplate(cfg.FirstName, data)),
|
||||
LastName: strings.TrimSpace(renderTemplate(cfg.LastName, data)),
|
||||
Company: strings.TrimSpace(renderTemplate(cfg.Company, data)),
|
||||
Phone: strings.TrimSpace(renderTemplate(cfg.Phone, data)),
|
||||
CustomFields: custom,
|
||||
Categories: uuidStrings(cfg.CategoryIDs),
|
||||
Segments: uuidStrings(cfg.SegmentIDs),
|
||||
Source: models.ContactSourceAutomation,
|
||||
SourceDetail: a.Name,
|
||||
}
|
||||
if id := strings.TrimSpace(cfg.CampaignID); id != "" {
|
||||
in.Campaigns = []string{id}
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// uuidStrings keeps the entries of a saved id list that parse, trimmed.
|
||||
func uuidStrings(ids []string) []string {
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range parseUUIDList(ids) {
|
||||
out = append(out, id.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type setVar struct {
|
||||
@@ -295,12 +400,63 @@ func (s *service) execNativeAction(ctx context.Context, a models.Automation, n m
|
||||
contactID := stringFromMap(data, "contact_id")
|
||||
email := stringFromMap(data, "contact_email", "invitee_email", "email")
|
||||
|
||||
// upsert_contact is the one action that may run with no contact yet: it
|
||||
// makes one from the event. The written contact becomes the event's
|
||||
// contact so the nodes after it (tag, task, deal) find it.
|
||||
if n.Action == models.IntegrationActionUpsertContact {
|
||||
in, berr := buildUpsertContact(a, cfg, data)
|
||||
if berr != nil {
|
||||
return berr
|
||||
}
|
||||
if strings.TrimSpace(cfg.IfExists) == upsertIfExistsSkip {
|
||||
existing, rerr := s.native.ResolveContact(ctx, a.OrganizationID, "", in.Email)
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("look up the existing contact: %w", rerr)
|
||||
}
|
||||
if existing != nil {
|
||||
data["contact_id"] = existing.ID.String()
|
||||
data["contact_email"] = existing.Email
|
||||
data["contact_created"] = false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
owner, oerr := s.native.OrgOwner(ctx, a.OrganizationID)
|
||||
if oerr != nil {
|
||||
return oerr
|
||||
}
|
||||
written, werr := s.native.UpsertContact(ctx, a.OrganizationID, owner, in)
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
if written == nil {
|
||||
return fmt.Errorf("the contact write returned nothing")
|
||||
}
|
||||
data["contact_id"] = written.ID.String()
|
||||
data["contact_email"] = written.Email
|
||||
data["contact_created"] = written.IsNew
|
||||
return nil
|
||||
}
|
||||
|
||||
c, err := s.native.ResolveContact(ctx, a.OrganizationID, contactID, email)
|
||||
if err != nil || c == nil {
|
||||
if err != nil {
|
||||
return fmt.Errorf("look up the event's contact: %w", err)
|
||||
}
|
||||
if c == nil {
|
||||
return fmt.Errorf("no contact matched the event (need contact_id or contact_email)")
|
||||
}
|
||||
|
||||
switch n.Action {
|
||||
case models.IntegrationActionAddToCampaign:
|
||||
campID, perr := uuid.Parse(strings.TrimSpace(cfg.CampaignID))
|
||||
if perr != nil {
|
||||
return fmt.Errorf("an add-to-campaign action needs a campaign")
|
||||
}
|
||||
owner, oerr := s.native.OrgOwner(ctx, a.OrganizationID)
|
||||
if oerr != nil {
|
||||
return oerr
|
||||
}
|
||||
return s.native.AddToCampaign(ctx, a.OrganizationID, owner, c.ID, campID)
|
||||
|
||||
case models.IntegrationActionUnsubscribe:
|
||||
campID, perr := uuid.Parse(stringFromMap(data, "campaign_id"))
|
||||
if perr != nil {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
func TestValidateNativeActionConfig_UpsertContact(t *testing.T) {
|
||||
campaign := uuid.New().String()
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg string
|
||||
wantErr bool
|
||||
}{
|
||||
{"needs an email", `{}`, true},
|
||||
{"email alone is enough", `{"email":"{{.email}}"}`, false},
|
||||
{"unknown if_exists", `{"email":"{{.email}}","if_exists":"merge"}`, true},
|
||||
{"skip is allowed", `{"email":"{{.email}}","if_exists":"skip"}`, false},
|
||||
{"bad campaign id", `{"email":"{{.email}}","campaign_id":"nope"}`, true},
|
||||
{"good campaign id", `{"email":"{{.email}}","campaign_id":"` + campaign + `"}`, false},
|
||||
{"bad tag id", `{"email":"{{.email}}","category_ids":["x"]}`, true},
|
||||
{"blank tag id is ignored", `{"email":"{{.email}}","category_ids":[""]}`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateNativeActionConfig(models.IntegrationActionUpsertContact, json.RawMessage(tc.cfg))
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNativeActionConfig_AddToCampaign(t *testing.T) {
|
||||
if err := validateNativeActionConfig(models.IntegrationActionAddToCampaign, json.RawMessage(`{}`)); err == nil {
|
||||
t.Fatal("expected an error without a campaign")
|
||||
}
|
||||
if err := validateNativeActionConfig(models.IntegrationActionAddToCampaign, json.RawMessage(`{"campaign_id":"`+uuid.New().String()+`"}`)); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpsertContact_RendersTemplatesAgainstEvent(t *testing.T) {
|
||||
campaign := uuid.New().String()
|
||||
tag := uuid.New().String()
|
||||
cfg := parseNativeConfig(json.RawMessage(`{
|
||||
"email": "{{.data.email}}",
|
||||
"first_name": "{{.data.first_name}}",
|
||||
"company": "{{.data.company}}",
|
||||
"custom_fields": [{"key":"team_size","value":"{{.data.team_size}}"},{"key":"empty","value":"{{.data.missing}}"},{"key":"","value":"x"}],
|
||||
"category_ids": ["` + tag + `", "junk"],
|
||||
"campaign_id": "` + campaign + `"
|
||||
}`))
|
||||
data := map[string]any{
|
||||
"data": map[string]any{"email": " Jane@Example.com ", "first_name": "Jane", "company": "Example Inc", "team_size": "10-50"},
|
||||
}
|
||||
in, err := buildUpsertContact(models.Automation{Name: "Lead ads intake"}, cfg, data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if in.Email != "jane@example.com" {
|
||||
t.Fatalf("email = %q", in.Email)
|
||||
}
|
||||
if in.FirstName != "Jane" || in.Company != "Example Inc" || in.LastName != "" {
|
||||
t.Fatalf("fields = %+v", in)
|
||||
}
|
||||
if in.CustomFields["team_size"] != "10-50" {
|
||||
t.Fatalf("custom fields = %v", in.CustomFields)
|
||||
}
|
||||
if _, ok := in.CustomFields["empty"]; ok {
|
||||
t.Fatal("a blank rendered custom field must be dropped, not written as empty")
|
||||
}
|
||||
if len(in.Categories) != 1 || in.Categories[0] != tag {
|
||||
t.Fatalf("categories = %v", in.Categories)
|
||||
}
|
||||
if len(in.Campaigns) != 1 || in.Campaigns[0] != campaign {
|
||||
t.Fatalf("campaigns = %v", in.Campaigns)
|
||||
}
|
||||
if in.Source != models.ContactSourceAutomation || in.SourceDetail != "Lead ads intake" {
|
||||
t.Fatalf("source = %s / %s", in.Source, in.SourceDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpsertContact_RejectsEmptyOrInvalidEmail(t *testing.T) {
|
||||
cfg := parseNativeConfig(json.RawMessage(`{"email":"{{.email}}"}`))
|
||||
if _, err := buildUpsertContact(models.Automation{}, cfg, map[string]any{}); err == nil {
|
||||
t.Fatal("expected an error when the email renders empty")
|
||||
}
|
||||
if _, err := buildUpsertContact(models.Automation{}, cfg, map[string]any{"email": "not-an-email"}); err == nil {
|
||||
t.Fatal("expected an error for an address without @")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleEventData_NewTriggersCarryTheirFields(t *testing.T) {
|
||||
created := sampleEventData("contact.created")
|
||||
if created["source"] != "form" || created["contact_email"] == "" {
|
||||
t.Fatalf("contact.created sample = %v", created)
|
||||
}
|
||||
if _, ok := created["campaign_id"]; ok {
|
||||
t.Fatal("a new contact carries no single campaign_id")
|
||||
}
|
||||
form := sampleEventData("form.submitted")
|
||||
if form["form_name"] == "" {
|
||||
t.Fatalf("form.submitted sample = %v", form)
|
||||
}
|
||||
answers, ok := form["data"].(map[string]any)
|
||||
if !ok || answers["email"] == "" {
|
||||
t.Fatalf("form answers = %v", form["data"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPreview_UpsertContactRendersFields(t *testing.T) {
|
||||
n := models.AutomationNode{
|
||||
Type: models.AutomationNodeAction,
|
||||
Action: models.IntegrationActionUpsertContact,
|
||||
Config: json.RawMessage(`{"email":"{{.email}}","company":"{{.company}}","custom_fields":[{"key":"plan","value":"{{.plan}}"}]}`),
|
||||
}
|
||||
p := actionPreview(n, map[string]any{"email": "a@b.co", "company": "Acme", "plan": "pro"})
|
||||
if p["email"] != "a@b.co" || p["company"] != "Acme" || p["custom:plan"] != "pro" {
|
||||
t.Fatalf("preview = %v", p)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/app/advanced"
|
||||
"github.com/warmbly/warmbly/internal/app/contact"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
@@ -21,20 +22,34 @@ type Adapter struct {
|
||||
Adv advanced.Service
|
||||
Contacts repository.ContactRepository
|
||||
Orgs repository.OrganizationRepository
|
||||
// ContactSvc is the contact service behind the lead-intake actions: its
|
||||
// upsert runs the plan check, wakes campaigns and fires contact.created,
|
||||
// which a bare repository write would not.
|
||||
ContactSvc contact.ContactService
|
||||
}
|
||||
|
||||
func (a Adapter) ResolveContact(ctx context.Context, orgID uuid.UUID, contactID, email string) (*models.Contact, error) {
|
||||
// Both lookups are ORG-SCOPED — never resolve a contact id from another org,
|
||||
// even if a stale/crafted id reaches the event data.
|
||||
// even if a stale/crafted id reaches the event data. A failed lookup is an
|
||||
// error, not a miss: "skip if it exists" must not write over a contact it
|
||||
// could not see.
|
||||
if contactID != "" {
|
||||
if id, perr := uuid.Parse(contactID); perr == nil {
|
||||
if cs, e := a.Contacts.GetByIDsAndOrganization(ctx, orgID, []uuid.UUID{id}); e == nil && len(cs) > 0 {
|
||||
cs, e := a.Contacts.GetByIDsAndOrganization(ctx, orgID, []uuid.UUID{id})
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if len(cs) > 0 {
|
||||
return &cs[0], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if email != "" {
|
||||
if c, e := a.Contacts.GetByEmailAndOrganization(ctx, orgID, email); e == nil && c != nil {
|
||||
c, e := a.Contacts.GetByEmailAndOrganization(ctx, orgID, email)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if c != nil {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
@@ -98,6 +113,37 @@ func (a Adapter) Unsubscribe(ctx context.Context, campaignID, contactID uuid.UUI
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertContact writes one contact through the contact service (upsert by
|
||||
// email, tags, campaign and segment links, contact.created for a new row).
|
||||
func (a Adapter) UpsertContact(ctx context.Context, orgID, actorID uuid.UUID, in models.AddContact) (*models.Contact, error) {
|
||||
if a.ContactSvc == nil {
|
||||
return nil, fmt.Errorf("contact writes are not available")
|
||||
}
|
||||
created, xerr := a.ContactSvc.Add(ctx, actorID.String(), orgID, []models.AddContact{in})
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if len(created) == 0 {
|
||||
return nil, fmt.Errorf("the contact write returned nothing")
|
||||
}
|
||||
return &created[0], nil
|
||||
}
|
||||
|
||||
// AddToCampaign enrols an existing contact in a campaign through the bulk
|
||||
// edit path, which also wakes the campaign's parked send chain.
|
||||
func (a Adapter) AddToCampaign(ctx context.Context, orgID, actorID, contactID, campaignID uuid.UUID) error {
|
||||
if a.ContactSvc == nil {
|
||||
return fmt.Errorf("contact writes are not available")
|
||||
}
|
||||
if _, xerr := a.ContactSvc.BulkUpdate(ctx, actorID.String(), orgID, &models.BulkEditContactsData{
|
||||
Contacts: []string{contactID.String()},
|
||||
AddCampaigns: []string{campaignID.String()},
|
||||
}); xerr != nil {
|
||||
return xerr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LabelThread applies unibox conversation labels to a thread on behalf of the
|
||||
// mailbox owner (the advanced service guards category ownership). The error is
|
||||
// already a plain error, so it passes straight through.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package webhook
|
||||
|
||||
import "context"
|
||||
|
||||
// AutomationDepthKey is the internal event-data key carrying how many automation
|
||||
// hops led to an event. Underscore-prefixed so it is stripped from customer
|
||||
// deliveries and cannot be set by an inbound webhook body.
|
||||
const AutomationDepthKey = "_automation_depth"
|
||||
|
||||
type automationDepthCtxKey struct{}
|
||||
|
||||
// WithAutomationDepth marks a context as running inside an automation action,
|
||||
// depth hops down from the original event. Events dispatched under it carry the
|
||||
// depth so a flow that creates a contact cannot re-trigger itself forever.
|
||||
func WithAutomationDepth(ctx context.Context, depth int) context.Context {
|
||||
if depth <= 0 {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, automationDepthCtxKey{}, depth)
|
||||
}
|
||||
|
||||
// AutomationDepth reports the automation depth carried by ctx, 0 outside one.
|
||||
func AutomationDepth(ctx context.Context) int {
|
||||
if d, ok := ctx.Value(automationDepthCtxKey{}).(int); ok && d > 0 {
|
||||
return d
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// stampAutomationDepth returns a copy of a map payload for the sink, carrying
|
||||
// the context's automation depth when there is one. Always a copy: the sink
|
||||
// runs automations on a goroutine that writes into the map while this call
|
||||
// still marshals the same payload for endpoint delivery.
|
||||
func stampAutomationDepth(ctx context.Context, data any) any {
|
||||
m, ok := data.(map[string]any)
|
||||
if !ok {
|
||||
return data
|
||||
}
|
||||
out := make(map[string]any, len(m)+1)
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
if depth := AutomationDepth(ctx); depth > 0 {
|
||||
out[AutomationDepthKey] = float64(depth)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAutomationDepth_RoundTrip(t *testing.T) {
|
||||
if got := AutomationDepth(context.Background()); got != 0 {
|
||||
t.Fatalf("depth outside an automation = %d", got)
|
||||
}
|
||||
ctx := WithAutomationDepth(context.Background(), 2)
|
||||
if got := AutomationDepth(ctx); got != 2 {
|
||||
t.Fatalf("depth = %d, want 2", got)
|
||||
}
|
||||
if WithAutomationDepth(context.Background(), 0) != context.Background() {
|
||||
t.Fatal("a zero depth must not wrap the context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStampAutomationDepth_CopiesMapPayloadsOnly(t *testing.T) {
|
||||
data := map[string]any{"contact_email": "a@b.co"}
|
||||
plain := stampAutomationDepth(context.Background(), data).(map[string]any)
|
||||
if plain[AutomationDepthKey] != nil {
|
||||
t.Fatal("no depth outside an automation")
|
||||
}
|
||||
plain["written_by_sink"] = true
|
||||
if _, shared := data["written_by_sink"]; shared {
|
||||
t.Fatal("the sink must get its own copy even without a depth")
|
||||
}
|
||||
ctx := WithAutomationDepth(context.Background(), 3)
|
||||
out, ok := stampAutomationDepth(ctx, data).(map[string]any)
|
||||
if !ok || out[AutomationDepthKey] != float64(3) {
|
||||
t.Fatalf("stamped = %v", out)
|
||||
}
|
||||
if _, leaked := data[AutomationDepthKey]; leaked {
|
||||
t.Fatal("the caller's map must stay untouched")
|
||||
}
|
||||
type typed struct{ A string }
|
||||
if got := stampAutomationDepth(ctx, typed{A: "x"}); got != (typed{A: "x"}) {
|
||||
t.Fatalf("struct payloads pass through unchanged, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,7 @@ func (s *service) Dispatch(ctx context.Context, orgID uuid.UUID, eventType model
|
||||
// Fan the event to non-webhook subscribers (integration actions) first,
|
||||
// independently of whether any webhook endpoint is configured.
|
||||
if s.sink != nil {
|
||||
s.sink(ctx, orgID, eventType, data)
|
||||
s.sink(ctx, orgID, eventType, stampAutomationDepth(ctx, data))
|
||||
}
|
||||
|
||||
endpoints, err := s.repo.MatchingEndpoints(ctx, orgID, eventType)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Rows stamped 'automation' fall back to 'unknown' so the narrower CHECK holds.
|
||||
UPDATE public.contacts SET source = 'unknown' WHERE source = 'automation';
|
||||
-- NOT VALID skips the table scan under the migration's lock; the rows already
|
||||
-- satisfy the set, so nothing needs validating.
|
||||
ALTER TABLE public.contacts DROP CONSTRAINT contacts_source_check;
|
||||
ALTER TABLE public.contacts
|
||||
ADD CONSTRAINT contacts_source_check
|
||||
CHECK (source IN ('unknown', 'manual', 'campaign', 'import', 'sheet_sync', 'api', 'ai_assistant', 'form')) NOT VALID;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- A contact created by an automation's "create or update contact" action is a
|
||||
-- first-touch origin of its own, so an inbound lead (a Zapier, Make or n8n
|
||||
-- push, a lead-ads form) reads as what it is instead of "unknown".
|
||||
-- NOT VALID skips the table scan under the migration's lock; the rows already
|
||||
-- satisfy the set, so nothing needs validating.
|
||||
ALTER TABLE public.contacts DROP CONSTRAINT contacts_source_check;
|
||||
ALTER TABLE public.contacts
|
||||
ADD CONSTRAINT contacts_source_check
|
||||
CHECK (source IN ('unknown', 'manual', 'campaign', 'import', 'sheet_sync', 'api', 'ai_assistant', 'form', 'automation')) NOT VALID;
|
||||
@@ -63,6 +63,11 @@ type Contact struct {
|
||||
// leads are queued, in progress, replied, bounced, or unsubscribed.
|
||||
CampaignLead *ContactCampaignProgress `json:"campaign_lead,omitempty"`
|
||||
|
||||
// IsNew is set by the upsert write when this call inserted the row rather
|
||||
// than matching an existing contact. Server-side only: it decides whether
|
||||
// a contact.created event fires.
|
||||
IsNew bool `json:"-"`
|
||||
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -661,13 +666,17 @@ const (
|
||||
ContactSourceAPI ContactSource = "api"
|
||||
ContactSourceAIAssistant ContactSource = "ai_assistant"
|
||||
ContactSourceForm ContactSource = "form"
|
||||
// ContactSourceAutomation is a contact an automation's "create or update
|
||||
// contact" action wrote; the detail is the automation's name.
|
||||
ContactSourceAutomation ContactSource = "automation"
|
||||
)
|
||||
|
||||
// Valid reports whether the value is one the database accepts.
|
||||
func (s ContactSource) Valid() bool {
|
||||
switch s {
|
||||
case ContactSourceUnknown, ContactSourceManual, ContactSourceCampaign, ContactSourceImport,
|
||||
ContactSourceSheetSync, ContactSourceAPI, ContactSourceAIAssistant, ContactSourceForm:
|
||||
ContactSourceSheetSync, ContactSourceAPI, ContactSourceAIAssistant, ContactSourceForm,
|
||||
ContactSourceAutomation:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -289,6 +289,13 @@ const (
|
||||
// with REALTIME_SUBSCRIBE on the org websocket) receive it with no public URL,
|
||||
// so it replaces an outbound webhook for "tell my system this happened".
|
||||
IntegrationActionFireEvent IntegrationAction = "warmbly.fire_event"
|
||||
// IntegrationActionUpsertContact creates a contact from templated event
|
||||
// fields, or enriches the one already holding that email, then tags it and
|
||||
// enrols it in a campaign. The lead-intake action: an inbound webhook or a
|
||||
// form submission becomes a contact without leaving Warmbly.
|
||||
IntegrationActionUpsertContact IntegrationAction = "warmbly.upsert_contact"
|
||||
// IntegrationActionAddToCampaign enrols the event's contact in a campaign.
|
||||
IntegrationActionAddToCampaign IntegrationAction = "warmbly.add_to_campaign"
|
||||
|
||||
// AI nodes mirror the campaign step types: one unified AI step plus an AI
|
||||
// switch router.
|
||||
@@ -328,6 +335,7 @@ func IsNativeAction(a IntegrationAction) bool {
|
||||
IntegrationActionCreateDeal, IntegrationActionMoveDealStage, IntegrationActionUnsubscribe,
|
||||
IntegrationActionRunAutomation, IntegrationActionLabelEmail,
|
||||
IntegrationActionSetVariables, IntegrationActionFireEvent,
|
||||
IntegrationActionUpsertContact, IntegrationActionAddToCampaign,
|
||||
IntegrationActionAIStep, IntegrationActionAISwitch:
|
||||
return true
|
||||
default:
|
||||
|
||||
@@ -439,9 +439,9 @@ func WebhookEventForAudit(entityType AuditEntityType, action AuditAction) (Webho
|
||||
return WebhookEventCampaignPaused, true
|
||||
}
|
||||
case AuditEntityContact:
|
||||
// contact.created has a dedicated, richer emit in the contact service
|
||||
// (the contact's fields and source), so the audit row is not bridged.
|
||||
switch action {
|
||||
case AuditActionCreate:
|
||||
return WebhookEventContactCreated, true
|
||||
case AuditActionUpdate:
|
||||
return WebhookEventContactUpdated, true
|
||||
case AuditActionDelete:
|
||||
|
||||
@@ -372,6 +372,7 @@ func (r *contactRepository) Add(ctx context.Context, userID string, orgID uuid.U
|
||||
if ncon.CustomFields == nil {
|
||||
ncon.CustomFields = map[string]string{}
|
||||
}
|
||||
ncon.IsNew = inserted
|
||||
ncontacts = append(ncontacts, ncon)
|
||||
created = append(created, inserted)
|
||||
if inserted {
|
||||
|
||||
@@ -19,6 +19,8 @@ import AutomationsDemo from '../components/AutomationsDemo.astro';
|
||||
// --- Trigger events ----------------------------------------------------------
|
||||
const triggers = [
|
||||
{ icon: 'reply', label: 'Prospect replies', key: 'campaign.reply_received', desc: 'The classifier labels intent and confidence before the trigger fires, so you can branch on both.' },
|
||||
{ icon: 'userPlus', label: 'Contact created', key: 'contact.created', desc: 'A new contact, with its fields and where it came from: a form, the API, an automation.' },
|
||||
{ icon: 'fileText', label: 'Form submitted', key: 'form.submitted', desc: 'A hosted form lands with every answer by its key, and the contact it created.' },
|
||||
{ icon: 'calendarCheck', label: 'Meeting booked', key: 'meeting.booked', desc: 'Calendly and Cal.com invitees, matched back to the contact that booked.' },
|
||||
{ icon: 'calendarClock', label: 'Meeting rescheduled', key: 'meeting.rescheduled', desc: 'Same payload, new time, so downstream reminders stay honest.' },
|
||||
{ icon: 'calendarX', label: 'Meeting canceled', key: 'meeting.canceled', desc: 'Catch the cancellation and put the contact back to work.' },
|
||||
@@ -26,11 +28,14 @@ const triggers = [
|
||||
{ icon: 'userMinus', label: 'Contact unsubscribes', key: 'campaign.unsubscribed', desc: 'Suppression already happened. This is your cue to sync it everywhere else.' },
|
||||
{ icon: 'activity', label: 'Warmup health changes', key: 'warmup.health_changed', desc: 'A mailbox moves bands: healthy, watch, quarantined. React before placement slips.' },
|
||||
{ icon: 'shieldAlert', label: 'Spam complaint', key: 'deliverability.complaint', desc: 'The signal worth reacting to fastest. Page the channel, pause the risk.' },
|
||||
{ icon: 'webhook', label: 'Inbound webhook', key: 'inbound.webhook', desc: 'Any system POSTs JSON to the flow\'s own URL. Zapier, Make, n8n, a form tool, your code.' },
|
||||
{ icon: 'workflow', label: 'Launched by a campaign step', key: 'campaign.action', desc: 'A sequence action node can fire any automation, per contact, mid-flow.' },
|
||||
];
|
||||
|
||||
const tIcons: Record<string, string> = {
|
||||
reply: '<polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/>',
|
||||
userPlus: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
|
||||
fileText: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
|
||||
calendarCheck: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m9 16 2 2 4-4"/>',
|
||||
calendarClock: '<path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><path d="M17.5 17.5 16 16.3V14"/><circle cx="16" cy="16" r="6"/>',
|
||||
calendarX: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m14 14-4 4"/><path d="m10 14 4 4"/>',
|
||||
@@ -39,9 +44,12 @@ const tIcons: Record<string, string> = {
|
||||
activity: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
|
||||
shieldAlert: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M12 8v4"/><path d="M12 16h.01"/>',
|
||||
workflow: '<rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M6 9v4a2 2 0 0 0 2 2h7"/>',
|
||||
webhook: '<path d="M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2"/><path d="m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06"/><path d="m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8"/>',
|
||||
};
|
||||
|
||||
const aIcons: Record<string, string> = {
|
||||
userPlus: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
|
||||
megaphone: '<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>',
|
||||
tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
|
||||
checkSquare: '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||
briefcase: '<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/><rect width="20" height="14" x="2" y="6" rx="2"/>',
|
||||
@@ -55,6 +63,8 @@ const aIcons: Record<string, string> = {
|
||||
|
||||
// --- Actions: built in -------------------------------------------------------
|
||||
const nativeActions = [
|
||||
{ icon: 'userPlus', tint: 'bg-emerald-50 text-emerald-600 ring-emerald-200', name: 'Create or update contact', key: 'warmbly.upsert_contact', desc: 'Turn an inbound webhook or form into a tagged, campaign-enrolled contact.' },
|
||||
{ icon: 'megaphone', tint: 'bg-sky-50 text-sky-600 ring-sky-200', name: 'Add to campaign', key: 'warmbly.add_to_campaign', desc: 'Enrol the contact; sending still follows the mailbox caps and spacing.' },
|
||||
{ icon: 'tag', tint: 'bg-emerald-50 text-emerald-600 ring-emerald-200', name: 'Add a tag', key: 'warmbly.add_tag', desc: 'Label the contact for the next segment or suppression list.' },
|
||||
{ icon: 'tag', tint: 'bg-amber-50 text-amber-600 ring-amber-200', name: 'Remove a tag', key: 'warmbly.remove_tag', desc: 'Pull them out of a segment the moment the reason expires.' },
|
||||
{ icon: 'checkSquare', tint: 'bg-violet-50 text-violet-600 ring-violet-200', name: 'Create a task', key: 'warmbly.create_task', desc: 'Typed, owned, with a due date: call back, review, follow up.' },
|
||||
@@ -109,7 +119,7 @@ const trust = [
|
||||
// --- FAQ -----------------------------------------------------------------------
|
||||
const faq = [
|
||||
['What can start an automation?',
|
||||
'Nine triggers: a classified reply, a booked, rescheduled or canceled meeting, a bounce, an unsubscribe, a warmup health change, a spam complaint, and a campaign step that launches the automation per contact mid-sequence.'],
|
||||
'Twelve triggers: a classified reply, a new contact, a form submission, a booked, rescheduled or canceled meeting, a bounce, an unsubscribe, a warmup health change, a spam complaint, an inbound webhook from any system, and a campaign step that launches the automation per contact mid-sequence.'],
|
||||
['Do I still need Zapier?',
|
||||
'For the common paths, no: Slack, Discord, HubSpot, Salesforce, Pipedrive, Close and the built-in CRM actions are direct. Zapier, Make and n8n still connect with a scoped key when you want them, and the signed webhook action reaches anything else.'],
|
||||
['What happens when a step fails?',
|
||||
@@ -241,14 +251,14 @@ const faq = [
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
TRIGGERS · nine moments it can fire
|
||||
TRIGGERS · twelve moments it can fire
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] py-20 md:py-28">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Triggers</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
Nine moments it can fire.
|
||||
Twelve moments it can fire.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
||||
Every automation starts from one event. Replies arrive already classified, meetings arrive matched to the contact, and a campaign step can launch an automation for each contact that reaches it.
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
HistoryIcon,
|
||||
Link2Icon,
|
||||
Loader2Icon,
|
||||
MegaphoneIcon,
|
||||
MessageSquareIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
TriangleAlertIcon,
|
||||
Trash2Icon,
|
||||
UserMinusIcon,
|
||||
UserPlusIcon,
|
||||
WandSparklesIcon,
|
||||
XCircleIcon,
|
||||
XIcon,
|
||||
@@ -115,6 +117,7 @@ import {
|
||||
} from "@/lib/api/models/app/automations/meta";
|
||||
import { API_URL } from "@/lib/information";
|
||||
import CategoryPicker from "@/components/app/contacts/CategoryPicker";
|
||||
import CampaignPicker from "@/components/app/campaigns/CampaignPicker";
|
||||
import { ExpressionReference } from "@/components/app/automations/ExpressionReference";
|
||||
import DealStagePicker from "@/components/app/crm/DealStagePicker";
|
||||
import TaskTypePicker from "@/components/app/crm/TaskTypePicker";
|
||||
@@ -289,8 +292,18 @@ const switchCaseHandle = (name: string) => "label:" + name.trim();
|
||||
|
||||
// Fresh config for a newly-created action node: agent mode for the AI step, two
|
||||
// starter cases for the AI switch (so its case dots show at once), else empty.
|
||||
function defaultConfigForAction(action: string): Record<string, unknown> {
|
||||
// The event key that carries the person's address differs per trigger, so a
|
||||
// fresh create-or-update-contact node starts from the one this trigger has.
|
||||
function defaultEmailTemplate(trigger: string): string {
|
||||
const vars = triggerVariables(trigger);
|
||||
if (vars.includes("contact_email")) return "{{.contact_email}}";
|
||||
if (vars.includes("invitee_email")) return "{{.invitee_email}}";
|
||||
return "{{.email}}";
|
||||
}
|
||||
|
||||
function defaultConfigForAction(action: string, trigger: string): Record<string, unknown> {
|
||||
if (action === "warmbly.ai_step") return { mode: "agent" };
|
||||
if (action === "warmbly.upsert_contact") return { email: defaultEmailTemplate(trigger), if_exists: "update" };
|
||||
if (action === "warmbly.ai_switch") return { switch_on: "ai", cases: ["interested", "not interested"] };
|
||||
return {};
|
||||
}
|
||||
@@ -1005,7 +1018,7 @@ export default function AutomationFlow({
|
||||
connection_id: undefined,
|
||||
// A fresh AI step defaults to agent mode; a fresh AI switch
|
||||
// seeds two cases so its case dots show immediately.
|
||||
config: defaultConfigForAction(presetAction),
|
||||
config: defaultConfigForAction(presetAction, trigger),
|
||||
title: actionLabel(presetAction),
|
||||
sub: presetAction === "warmbly.ai_switch" ? "Routes to one case" : native ? "Built-in action" : "Pick an integration…",
|
||||
provider: "",
|
||||
@@ -1103,6 +1116,16 @@ export default function AutomationFlow({
|
||||
setSelectedId(n.id);
|
||||
return false;
|
||||
}
|
||||
if (need === "contact" && !String(d.config?.email ?? "").trim()) {
|
||||
toast.error("A create-or-update-contact action needs an email");
|
||||
setSelectedId(n.id);
|
||||
return false;
|
||||
}
|
||||
if (need === "campaign" && !String(d.config?.campaign_id ?? "").trim()) {
|
||||
toast.error("An add-to-campaign action needs a campaign");
|
||||
setSelectedId(n.id);
|
||||
return false;
|
||||
}
|
||||
const aiInstruction = String(d.config?.instruction ?? "").trim();
|
||||
const aiStepMode = String(d.config?.mode ?? "agent");
|
||||
const switchValueMode = need === "ai_switch" && String(d.config?.switch_on ?? "ai") === "value";
|
||||
@@ -2273,6 +2296,8 @@ const ACTION_VISUAL: Record<string, { Icon: typeof TagIcon; tint: string; bg: st
|
||||
"warmbly.label_email": { Icon: TagsIcon, tint: "text-fuchsia-600", bg: "bg-fuchsia-50", desc: "Label the conversation the contact replied on." },
|
||||
"warmbly.set_variables": { Icon: WandSparklesIcon, tint: "text-amber-600", bg: "bg-amber-50", desc: "Compute named values from templates for later steps to reuse." },
|
||||
"warmbly.fire_event": { Icon: SendIcon, tint: "text-sky-600", bg: "bg-sky-50", desc: "Publish a custom event to the realtime gateway — your app receives it over the API websocket, no public URL." },
|
||||
"warmbly.upsert_contact": { Icon: UserPlusIcon, tint: "text-emerald-600", bg: "bg-emerald-50", desc: "Create a contact from the event's fields, or enrich the one with that email, then tag it and enrol it in a campaign." },
|
||||
"warmbly.add_to_campaign": { Icon: MegaphoneIcon, tint: "text-sky-600", bg: "bg-sky-50", desc: "Enrol the event's contact in a campaign. Sending still follows the campaign's mailboxes, caps and spacing." },
|
||||
"warmbly.ai_step": { Icon: SparklesIcon, tint: "text-purple-600", bg: "bg-purple-50", desc: "One AI step: an agent that takes reversible actions (tag, task, deal, label…), or a single-shot classify, extract, or generate over the event. Billed in credits." },
|
||||
"warmbly.ai_switch": { Icon: GitBranchIcon, tint: "text-purple-600", bg: "bg-purple-50", desc: "Route the event: AI picks one of your cases, or match a value template. AI mode costs 1 credit; value mode is free." },
|
||||
"slack.notify": { Icon: MessageSquareIcon, tint: "text-violet-600", bg: "bg-violet-50" },
|
||||
@@ -2494,7 +2519,7 @@ function ActionEditor({
|
||||
const pickAction = (action: string) =>
|
||||
onAction({
|
||||
action,
|
||||
config: defaultConfigForAction(action),
|
||||
config: defaultConfigForAction(action, trigger),
|
||||
title: actionLabel(action),
|
||||
native: isNativeAction(action),
|
||||
...(isNativeAction(action) ? { connection_id: undefined } : {}),
|
||||
@@ -2749,6 +2774,25 @@ function NativeActionConfig({
|
||||
|
||||
{need === "automation" && <RunAnotherAutomationFields config={config} patchConfig={patchConfig} selfId={selfId} />}
|
||||
|
||||
{need === "contact" && <UpsertContactFields trigger={trigger} config={config} patchConfig={patchConfig} />}
|
||||
|
||||
{need === "campaign" && (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label>Campaign</Label>
|
||||
<CampaignPicker
|
||||
campaignId={config.campaign_id ? String(config.campaign_id) : null}
|
||||
campaignName={String(config.campaign_name ?? "")}
|
||||
onChange={(id, name) => patchConfig({ campaign_id: id ?? "", campaign_name: name })}
|
||||
noneLabel="Pick a campaign…"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||
Enrols the contact this event is about (by <code>contact_id</code> or <code>contact_email</code>). Sending still runs through the campaign's mailboxes, daily caps and spacing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{need === "event" && <FireEventFields config={config} patchConfig={patchConfig} />}
|
||||
|
||||
{need === "vars" && <SetVariablesFields config={config} patchConfig={patchConfig} />}
|
||||
@@ -2768,6 +2812,142 @@ function NativeActionConfig({
|
||||
|
||||
type SetVarRow = { key: string; value: string };
|
||||
|
||||
const IF_EXISTS_OPTIONS: SelectOption[] = [
|
||||
{ value: "update", label: "Update it (fill blanks, add tags and campaign)" },
|
||||
{ value: "skip", label: "Leave it alone" },
|
||||
];
|
||||
|
||||
// The contact columns a lead-intake action fills, each a template rendered
|
||||
// against the event data. Email is the identity; the rest enrich.
|
||||
const UPSERT_FIELDS: { key: string; label: string; placeholder: string }[] = [
|
||||
{ key: "first_name", label: "First name", placeholder: "{{.first_name}}" },
|
||||
{ key: "last_name", label: "Last name", placeholder: "{{.last_name}}" },
|
||||
{ key: "company", label: "Company", placeholder: "{{.company}}" },
|
||||
{ key: "phone", label: "Phone", placeholder: "{{.phone}}" },
|
||||
];
|
||||
|
||||
// UpsertContactFields edits the lead-intake action: which event fields become
|
||||
// the contact, where it lands (tags, campaign), and what to do when the email
|
||||
// already exists. Every value is a Go template against the trigger data, so an
|
||||
// inbound webhook's own JSON keys map straight onto contact columns.
|
||||
function UpsertContactFields({
|
||||
trigger,
|
||||
config,
|
||||
patchConfig,
|
||||
}: {
|
||||
trigger: string;
|
||||
config: Record<string, unknown>;
|
||||
patchConfig: (p: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const vars = triggerVariables(trigger);
|
||||
const insertInto = (k: string, token: string) => patchConfig({ [k]: `${String(config[k] ?? "")}{{.${token}}}` });
|
||||
const rows: SetVarRow[] = Array.isArray(config.custom_fields)
|
||||
? (config.custom_fields as SetVarRow[]).map((v) => ({ key: String(v?.key ?? ""), value: String(v?.value ?? "") }))
|
||||
: [];
|
||||
const update = (next: SetVarRow[]) => patchConfig({ custom_fields: next });
|
||||
const setRow = (i: number, patch: Partial<SetVarRow>) => update(rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||
const addRow = () => update([...rows, { key: "", value: "" }]);
|
||||
const removeRow = (i: number) => update(rows.filter((_, idx) => idx !== i));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Email</Label>
|
||||
<TextInput
|
||||
value={String(config.email ?? "")}
|
||||
onChange={(v) => patchConfig({ email: v })}
|
||||
placeholder="{{.email}}"
|
||||
className="w-full font-mono"
|
||||
/>
|
||||
<VarChips vars={vars} onPick={(t) => insertInto("email", t)} />
|
||||
<p className="mt-1 text-[11px] text-slate-400 leading-relaxed">
|
||||
The contact's identity. An existing contact with this address is matched instead of duplicated.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{UPSERT_FIELDS.map((f) => (
|
||||
<div key={f.key}>
|
||||
<Label>{f.label}</Label>
|
||||
<TextInput
|
||||
value={String(config[f.key] ?? "")}
|
||||
onChange={(v) => patchConfig({ [f.key]: v })}
|
||||
placeholder={f.placeholder}
|
||||
className="w-full font-mono"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Custom fields</Label>
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<TextInput
|
||||
value={row.key}
|
||||
onChange={(v) => setRow(i, { key: v })}
|
||||
placeholder="field"
|
||||
className="w-28 shrink-0 font-mono"
|
||||
/>
|
||||
<span className="text-[12.5px] text-slate-400">=</span>
|
||||
<TextInput
|
||||
value={row.value}
|
||||
onChange={(v) => setRow(i, { value: v })}
|
||||
placeholder="{{.team_size}}"
|
||||
className="flex-1 min-w-0 font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(i)}
|
||||
className="shrink-0 text-slate-400 hover:text-rose-500"
|
||||
aria-label="Remove custom field"
|
||||
>
|
||||
<XIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-600 hover:text-sky-700"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" /> Add custom field
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Tags</Label>
|
||||
<CategoryPicker
|
||||
value={Array.isArray(config.category_ids) ? (config.category_ids as string[]) : []}
|
||||
onChange={(ids) => patchConfig({ category_ids: ids })}
|
||||
placeholder="Pick tags…"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Add to campaign</Label>
|
||||
<CampaignPicker
|
||||
campaignId={config.campaign_id ? String(config.campaign_id) : null}
|
||||
campaignName={String(config.campaign_name ?? "")}
|
||||
onChange={(id, name) => patchConfig({ campaign_id: id ?? "", campaign_name: name })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>If the contact already exists</Label>
|
||||
<SelectMenu
|
||||
value={String(config.if_exists ?? "update")}
|
||||
onChange={(v) => patchConfig({ if_exists: v })}
|
||||
options={IF_EXISTS_OPTIONS}
|
||||
className="w-full"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||
A blank value never erases what the contact already has. The written contact becomes this event's contact, so the
|
||||
steps after it (tag, task, deal) act on it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SetVariablesFields edits a list of named template values written back into the
|
||||
// event data for later steps to reuse (the safe "transform" node).
|
||||
function SetVariablesFields({
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import {
|
||||
PopoverMenu,
|
||||
PopoverMenuContent,
|
||||
PopoverMenuItem,
|
||||
PopoverMenuTrigger,
|
||||
SelectButton,
|
||||
} from "@/components/ui/popover-menu";
|
||||
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
|
||||
|
||||
// CampaignPicker — house-theme PopoverMenu campaign selector backed by the
|
||||
// existing campaigns list query. Single-select with an explicit "None". Shared
|
||||
// by the sheet sync wizard and the automation builder's campaign actions.
|
||||
export default function CampaignPicker({
|
||||
campaignId,
|
||||
campaignName,
|
||||
onChange,
|
||||
noneLabel = "No campaign",
|
||||
className = "w-full",
|
||||
}: {
|
||||
campaignId: string | null;
|
||||
campaignName: string;
|
||||
onChange: (id: string | null, name: string) => void;
|
||||
noneLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const campaigns = useCampaigns({ query, folder: "" });
|
||||
// A saved id whose name is not cached yet (an automation reopened later)
|
||||
// resolves from the list once it loads.
|
||||
const resolved = campaignId ? campaigns.campaigns.find((c) => c.id === campaignId)?.name : undefined;
|
||||
const label = campaignId ? campaignName || resolved || "Selected campaign" : noneLabel;
|
||||
|
||||
return (
|
||||
<PopoverMenu align="start">
|
||||
<PopoverMenuTrigger asChild>
|
||||
<SelectButton label={label} className={className} />
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={280}>
|
||||
<div className="px-2 py-1.5 border-b border-slate-200">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search campaigns…"
|
||||
className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<PopoverMenuItem selected={!campaignId} onSelect={() => onChange(null, "")}>
|
||||
{noneLabel}
|
||||
</PopoverMenuItem>
|
||||
{campaigns.campaigns.map((c) => (
|
||||
<PopoverMenuItem
|
||||
key={c.id}
|
||||
selected={c.id === campaignId}
|
||||
onSelect={() => onChange(c.id, c.name)}
|
||||
>
|
||||
{c.name}
|
||||
</PopoverMenuItem>
|
||||
))}
|
||||
{campaigns.campaigns.length === 0 && (
|
||||
<div className="px-3 py-2 text-[11.5px] text-slate-400 text-center">
|
||||
{campaigns.isPending ? "Loading…" : "No campaigns found."}
|
||||
</div>
|
||||
)}
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
useStartIntegrationOAuth,
|
||||
} from "@/lib/api/hooks/app/integrations/useIntegrationOAuth";
|
||||
import { openOAuthPopup } from "@/lib/integrations/oauthPopup";
|
||||
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
|
||||
import CampaignPicker from "@/components/app/campaigns/CampaignPicker";
|
||||
import useGoogleConnection from "@/lib/api/hooks/app/leadsync/useGoogleConnection";
|
||||
import {
|
||||
useGetSpreadsheet,
|
||||
@@ -676,53 +676,3 @@ function OptionsStep({
|
||||
);
|
||||
}
|
||||
|
||||
// CampaignPicker — house-theme PopoverMenu campaign selector backed by the
|
||||
// existing campaigns list query. Single-select with an explicit "None".
|
||||
function CampaignPicker({
|
||||
campaignId,
|
||||
campaignName,
|
||||
onChange,
|
||||
}: {
|
||||
campaignId: string | null;
|
||||
campaignName: string;
|
||||
onChange: (id: string | null, name: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const campaigns = useCampaigns({ query, folder: "" });
|
||||
const label = campaignId ? campaignName || "Selected campaign" : "No campaign";
|
||||
|
||||
return (
|
||||
<PopoverMenu align="start">
|
||||
<PopoverMenuTrigger asChild>
|
||||
<SelectButton label={label} className="w-full" />
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={280}>
|
||||
<div className="px-2 py-1.5 border-b border-slate-200">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search campaigns…"
|
||||
className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<PopoverMenuItem selected={!campaignId} onSelect={() => onChange(null, "")}>
|
||||
No campaign
|
||||
</PopoverMenuItem>
|
||||
{campaigns.campaigns.map((c) => (
|
||||
<PopoverMenuItem
|
||||
key={c.id}
|
||||
selected={c.id === campaignId}
|
||||
onSelect={() => onChange(c.id, c.name)}
|
||||
>
|
||||
{c.name}
|
||||
</PopoverMenuItem>
|
||||
))}
|
||||
{campaigns.campaigns.length === 0 && (
|
||||
<div className="px-3 py-2 text-[11.5px] text-slate-400 text-center">
|
||||
{campaigns.isPending ? "Loading…" : "No campaigns found."}
|
||||
</div>
|
||||
)}
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1435,6 +1435,8 @@ export function sourceLabel(source?: string | null): string {
|
||||
return "Created by the AI assistant";
|
||||
case "form":
|
||||
return "Submitted a form";
|
||||
case "automation":
|
||||
return "Created by an automation";
|
||||
case "unknown":
|
||||
case undefined:
|
||||
case null:
|
||||
@@ -1506,6 +1508,8 @@ function createdLabel(source?: string | null): string {
|
||||
return "Created by AI assistant";
|
||||
case "form":
|
||||
return "Submitted a form";
|
||||
case "automation":
|
||||
return "Created by automation";
|
||||
case "manual":
|
||||
return "Created manually";
|
||||
default:
|
||||
|
||||
@@ -197,6 +197,7 @@ const ENUM_LABELS: Record<string, string> = {
|
||||
api: "API",
|
||||
ai_assistant: "AI assistant",
|
||||
form: "Form submission",
|
||||
automation: "Automation",
|
||||
valid: "Valid",
|
||||
risky: "Risky",
|
||||
invalid: "Invalid",
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { AutomationCondition } from "@/lib/api/models/app/automations/Autom
|
||||
// a real event, only via a campaign "Run automation" step (RunAutomationByID).
|
||||
export const TRIGGER_EVENTS: string[] = [
|
||||
"campaign.reply_received",
|
||||
"contact.created",
|
||||
"form.submitted",
|
||||
"meeting.booked",
|
||||
"meeting.rescheduled",
|
||||
"meeting.canceled",
|
||||
@@ -52,6 +54,8 @@ export const ACTION_LABELS: Record<string, string> = {
|
||||
"warmbly.label_email": "Label the email",
|
||||
"warmbly.set_variables": "Set variables",
|
||||
"warmbly.fire_event": "Fire event",
|
||||
"warmbly.upsert_contact": "Create or update contact",
|
||||
"warmbly.add_to_campaign": "Add to campaign",
|
||||
"warmbly.ai_step": "AI step",
|
||||
"warmbly.ai_switch": "AI switch",
|
||||
};
|
||||
@@ -66,6 +70,8 @@ export function actionLabel(a: string): string {
|
||||
export const NATIVE_CONNECTION = "__native__";
|
||||
|
||||
export const NATIVE_ACTIONS: string[] = [
|
||||
"warmbly.upsert_contact",
|
||||
"warmbly.add_to_campaign",
|
||||
"warmbly.add_tag",
|
||||
"warmbly.remove_tag",
|
||||
"warmbly.create_task",
|
||||
@@ -117,10 +123,16 @@ export function nativeActionNeeds(
|
||||
| "automation"
|
||||
| "vars"
|
||||
| "event"
|
||||
| "contact"
|
||||
| "campaign"
|
||||
| "ai_step"
|
||||
| "ai_switch"
|
||||
| "none" {
|
||||
switch (action) {
|
||||
case "warmbly.upsert_contact":
|
||||
return "contact";
|
||||
case "warmbly.add_to_campaign":
|
||||
return "campaign";
|
||||
case "warmbly.ai_step":
|
||||
return "ai_step";
|
||||
case "warmbly.ai_switch":
|
||||
@@ -217,7 +229,32 @@ const DELIVERABILITY_FIELDS: TriggerFieldDef[] = [
|
||||
{ key: "campaign_id", label: "From a campaign", type: "string", defaultOperator: "exists" },
|
||||
];
|
||||
|
||||
export const CONTACT_SOURCES = [
|
||||
{ value: "manual", label: "Added manually" },
|
||||
{ value: "campaign", label: "Added from a campaign" },
|
||||
{ value: "api", label: "API" },
|
||||
{ value: "form", label: "Form submission" },
|
||||
{ value: "automation", label: "Automation" },
|
||||
{ value: "ai_assistant", label: "AI assistant" },
|
||||
];
|
||||
|
||||
export const TRIGGER_FIELDS: Record<string, TriggerFieldDef[]> = {
|
||||
"contact.created": [
|
||||
{ key: "source", label: "Source", type: "enum", options: CONTACT_SOURCES, defaultOperator: "equals" },
|
||||
{ key: "source_detail", label: "Source detail", type: "string", defaultOperator: "contains" },
|
||||
{ key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" },
|
||||
{ key: "company", label: "Company", type: "string", defaultOperator: "contains" },
|
||||
{ key: "phone", label: "Phone", type: "string", defaultOperator: "exists" },
|
||||
{ key: "subscribed", label: "Subscribed", type: "bool", defaultOperator: "is_true" },
|
||||
],
|
||||
"form.submitted": [
|
||||
{ key: "form_name", label: "Form", type: "string", defaultOperator: "equals" },
|
||||
{ key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" },
|
||||
{ key: "company", label: "Company", type: "string", defaultOperator: "contains" },
|
||||
{ key: "source_url", label: "Page URL", 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" },
|
||||
],
|
||||
"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" },
|
||||
@@ -354,6 +391,8 @@ export function defaultOperatorFor(field: string): string {
|
||||
// The keys present in each trigger's event payload, offered as {{.key}} inserts
|
||||
// (standard Go-template dotted field access) in action message/URL/value fields.
|
||||
export const TRIGGER_VARIABLES: Record<string, string[]> = {
|
||||
"contact.created": ["contact_email", "contact_id", "first_name", "last_name", "company", "phone", "source", "source_detail", "subscribed"],
|
||||
"form.submitted": ["contact_email", "contact_id", "first_name", "last_name", "company", "phone", "form_name", "form_id", "submission_id", "source_url", "campaign_id"],
|
||||
"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"],
|
||||
@@ -417,6 +456,26 @@ export function sampleEventData(triggerEvent: string): Record<string, unknown> {
|
||||
base.previous_state = "healthy";
|
||||
base.reason = "spam placement rising";
|
||||
break;
|
||||
case "contact.created":
|
||||
delete base.campaign_id;
|
||||
delete base.campaign_name;
|
||||
base.phone = "+1 555 0100";
|
||||
base.subscribed = true;
|
||||
base.custom_fields = { industry: "SaaS" };
|
||||
base.source = "form";
|
||||
base.source_detail = "Demo request";
|
||||
base.campaign_ids = [];
|
||||
base.category_ids = [];
|
||||
break;
|
||||
case "form.submitted":
|
||||
delete base.campaign_name;
|
||||
base.form_id = "00000000-0000-0000-0000-000000000003";
|
||||
base.form_name = "Demo request";
|
||||
base.submission_id = "00000000-0000-0000-0000-000000000004";
|
||||
base.phone = "+1 555 0100";
|
||||
base.source_url = "https://example.com/pricing";
|
||||
base.data = { email: "jane@example.com", first_name: "Jane", last_name: "Doe", company: "Example Inc", team_size: "10-50" };
|
||||
break;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
@@ -451,6 +510,7 @@ export function conditionLabel(c?: AutomationCondition): string {
|
||||
const valLbl =
|
||||
REPLY_INTENT_OPTIONS.find((o) => o.value === c.value)?.label ??
|
||||
WARMUP_STATES.find((o) => o.value === c.value)?.label ??
|
||||
(key === "source" ? CONTACT_SOURCES.find((o) => o.value === c.value)?.label : undefined) ??
|
||||
String(c.value ?? "…");
|
||||
return `${prettyKey(key)} ${opLbl} ${valLbl}`;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ export type ContactSource =
|
||||
| "sheet_sync"
|
||||
| "api"
|
||||
| "ai_assistant"
|
||||
| "form";
|
||||
| "form"
|
||||
| "automation";
|
||||
|
||||
// One observed fact about the mailbox. Silence is never recorded: a contact
|
||||
// who does not open or reply has said nothing about their address.
|
||||
|
||||
@@ -309,6 +309,8 @@ export const REPLY_INTENT_OPTIONS: { value: string; label: string }[] = [
|
||||
// Human labels for the Warmbly event vocabulary (subset surfaced as triggers).
|
||||
export const EVENT_LABELS: Record<string, string> = {
|
||||
"campaign.reply_received": "Prospect replies",
|
||||
"contact.created": "Contact created",
|
||||
"form.submitted": "Form submitted",
|
||||
"campaign.email_bounced": "Email bounces",
|
||||
"campaign.unsubscribed": "Contact unsubscribes",
|
||||
"warmup.health_changed": "Warmup health changes",
|
||||
|
||||
Reference in New Issue
Block a user