diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx index 89d287d3..b2d13a15 100644 --- a/docs/content/docs/api/error-codes.mdx +++ b/docs/content/docs/api/error-codes.mdx @@ -75,6 +75,9 @@ Returned when the request cannot be processed due to invalid syntax. | `code` | Meaning | |--------|---------| +| `invalid_lead_status` | `POST /contacts/search` or `POST /contacts/export` was given a `lead_status` that is not one of the documented values | +| `invalid_engagement` | `POST /contacts/search` or `POST /contacts/export` was given an `engagement` that is not one of the documented values | +| `lead_filter_requires_campaign` | `lead_status` or `engagement` was set without exactly one `campaign_ids` entry; both filters describe a contact inside one campaign | | `no_organization` | The request needs a workspace and the caller has none selected. Every entitlement, limit and suppression rule is scoped to a workspace, so a write that would run unscoped is refused rather than run without those checks. API keys always carry their workspace; a dashboard session picks one at sign-in, so this normally means the session predates the workspace being chosen. Select a workspace and retry | ### 401 Unauthorized diff --git a/docs/content/docs/api/reference/contacts.mdx b/docs/content/docs/api/reference/contacts.mdx index 49936944..7e38acf2 100644 --- a/docs/content/docs/api/reference/contacts.mdx +++ b/docs/content/docs/api/reference/contacts.mdx @@ -28,7 +28,8 @@ Every field is optional; an empty body matches all contacts in the organization. | `query` | string | No | Text search across core fields (name, email, company). | | `custom_field_filters` | array | No | Per custom-field filters: `{ "name", "value", "type" }` where `type` is one of `equal`, `starts_with`, `ends_with`, `contains`. | | `campaign_ids` | string[] | No | Contact must be in ALL of these campaigns. | -| `lead_status` | string | No | Filter to one derived lead status: `pending`, `active`, `completed`, `replied`, `bounced`, `failed`, `undeliverable`, or `unsubscribed`. Requires exactly one `campaign_ids` entry, otherwise the request is rejected. | +| `lead_status` | string | No | Filter to one derived lead status: `pending`, `active`, `completed`, `replied`, `bounced`, `failed`, `undeliverable`, or `unsubscribed`. Requires exactly one `campaign_ids` entry, otherwise the request is rejected with `lead_filter_requires_campaign`; an unknown value is rejected with `invalid_lead_status`. | +| `engagement` | string | No | Filter by engagement inside that campaign: `opened`, `not_opened`, `clicked`, `not_clicked`, `replied`, `not_replied`, or `bounced`. `opened` means a human open (machine opens never count); the `not_*` values match only leads sent at least one step. Combines with `lead_status` as AND. Requires exactly one `campaign_ids` entry (`lead_filter_requires_campaign`); an unknown value is rejected with `invalid_engagement`. | | `category_ids` | string[] | No | Contact must have ALL of these categories. | | `min_campaigns` | integer | No | Minimum number of associated campaigns. | | `max_campaigns` | integer | No | Maximum number of associated campaigns. | @@ -85,9 +86,9 @@ Returns a `data` array of contacts plus a `pagination` envelope. } ``` -When the search filters by exactly one campaign, each contact additionally carries a `campaign_lead` object with its processing state inside that campaign (`status`, `sent`, `opened`, `clicked`, `replied`, `bounced`, `current_step`, `last_activity_at`, and `failure_reason` when failed). The `status` derivation, highest priority first, is `unsubscribed` (not subscribed), then `bounced`, `replied`, `failed` (a step could not be sent after every retry; `failure_reason` carries the sending worker's reason), `completed` (every email step sent, no reply), `active` (some steps sent, more to send), `undeliverable` (pre-send verification refused the address, so the campaign skips the lead and never sends to it), and `pending` (queued, nothing sent). A step counts as sent only once the sending worker has delivered it to the mailbox provider; a send the worker could not complete is retried on the campaign's next pass and never shows as sent. The `lead_status` filter narrows to one of these buckets. +When the search filters by exactly one campaign, each contact additionally carries a `campaign_lead` object with its processing state inside that campaign (`status`, `sent`, `opened`, `machine_opened`, `clicked`, `replied`, `bounced`, `current_step`, `last_activity_at`, and `failure_reason` when failed). `opened` counts steps opened by a person; steps fetched automatically by a mail client (Apple Mail Privacy Protection and similar) are in `machine_opened` instead, matching the machine opens the analytics summary reports. The `status` derivation, highest priority first, is `unsubscribed` (not subscribed), then `bounced`, `replied`, `failed` (a step could not be sent after every retry; `failure_reason` carries the sending worker's reason), `completed` (every email step sent, no reply), `active` (some steps sent, more to send), `undeliverable` (pre-send verification refused the address, so the campaign skips the lead and never sends to it), and `pending` (queued, nothing sent). A step counts as sent only once the sending worker has delivered it to the mailbox provider; a send the worker could not complete is retried on the campaign's next pass and never shows as sent. The `lead_status` filter narrows to one of these buckets. -When the search filters by exactly one campaign, the first page (no `cursor`) also includes a `lead_counts` object: per-status lead totals for that campaign, independent of the `lead_status` filter so every scope's total is available at once. +When the search filters by exactly one campaign, the first page (no `cursor`) also includes a `lead_counts` object: per-status lead totals for that campaign, independent of the `lead_status` and `engagement` filters so every scope's total is available at once. Alongside the status buckets it carries engagement totals that match the `engagement` filter: `contacted` (leads sent at least one step), `opened` (a human open on any step), `clicked`, and `replied_any` (a reply on any step, whatever the derived status). ```json { @@ -267,9 +268,11 @@ Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts` | `scope` | string | Yes | `all`, `filtered`, or `selected`. | | `contact_ids` | string[] | No | Contact IDs when `scope` is `selected`. | | `filters` | object | No | A search-contacts filter body when `scope` is `filtered`. | -| `fields` | string[] | No | Column identifiers in display order (built-ins like `email`, `first_name`, or `custom:`). Empty uses the default columns. | +| `fields` | string[] | No | Column identifiers in display order (built-ins like `email`, `first_name`, or `custom:`). Empty uses the default columns. `lead_status`, `lead_opened`, `lead_clicked` and `lead_replied` are the contact's engagement inside the one campaign named in `filters.campaign_ids`; they are blank when the filters do not name exactly one campaign. | | `filename` | string | No | Filename without extension. Sanitized server-side; empty falls back to `contacts-`. | +With `scope` set to `selected`, `filters` is optional and is applied on top of `contact_ids`; pass the campaign there to populate the `lead_*` columns for the selected rows. + ```json { "format": "csv", diff --git a/docs/content/docs/guides/campaigns.mdx b/docs/content/docs/guides/campaigns.mdx index 6a1121ce..ff6466a8 100644 --- a/docs/content/docs/guides/campaigns.mdx +++ b/docs/content/docs/guides/campaigns.mdx @@ -75,6 +75,14 @@ The **Leads** tab takes contacts four ways. **From contacts** opens a picker ove A lead is **Processing** only while steps remain, so a finished campaign reads as done rather than stuck mid-flight. +### Who opened, clicked and replied + +Next to each lead's status, the Leads list shows three engagement columns: **Opened**, **Clicked** and **Replied**, each with the number of emails in the sequence the person engaged with. A dash means the lead was emailed and has not engaged; the cell is blank for a lead not emailed yet. An open counts only when a person opened the email. Mail clients that fetch every image automatically (Apple Mail Privacy Protection, for example) show as **auto** instead, the same opens the campaign overview reports as automatic, so they never pass for engagement. + +The chips above the list are filters. Click a status chip (**Processing**, **Done**, **Replied**, **Queued**, **Bounced**, **Unsub**) or an engagement chip (**Opened**, **Not opened**, **Clicked**, **Not clicked**, **Replied**, **Not replied**) to show only those leads; click it again to clear. One status and one engagement chip can be active at once and both must match. The numbers on the chips are campaign-wide totals, and filtering happens on the server, so a scope shows every matching lead however long the list is. **Not opened**, **Not clicked** and **Not replied** only cover leads that have been sent at least one email: a queued lead has not had the chance. The same filters live in the **Filters** sheet under **Lead status** and **Engagement**. + +Engagement updates live as opens, clicks and replies arrive; no refresh is needed. **Export** from the Leads tab offers four extra columns (**Lead status**, **Opened**, **Clicked**, **Replied**), whether you export the current scope or selected rows. + A step counts as sent only once the sending worker has handed it to the mailbox provider. If the worker cannot send (the mailbox was still loading, the provider refused the message, a storage hiccup), the step goes back to the queue, the failure appears in **Needs attention** with the reason, and the next pass retries it. After five failed attempts the lead is marked **Failed** and dropped from the campaign, so a mailbox that can never send does not loop forever. A recipient the mail server refuses outright is not retried: it is recorded as a **Bounced** lead straight away and goes through the same bounce handling (suppression, guardrails, webhooks) as a bounce notice. A campaign that finished while a send was still in flight reopens to retry it. No contact is emailed the same step twice. Each step is recorded as attempted before the send is handed to a worker, so a crash, a restart, or a database hiccup in the moment between the two cannot make the step look unsent and send it again. The trade-off is a step whose outcome is genuinely unknown, when the worker stops responding mid-send: after 30 minutes with no answer the step is treated as a failed attempt, appears in **Needs attention**, and is retried like any other failure. diff --git a/internal/app/contact/export.go b/internal/app/contact/export.go index 6444283d..a5ae0089 100644 --- a/internal/app/contact/export.go +++ b/internal/app/contact/export.go @@ -65,6 +65,14 @@ func (s *contactService) Export( return "", "", 0, errx.New(errx.BadRequest, "contact_ids required when scope=selected") } contactIDs = req.ContactIDs + // Filters are optional here: a campaign Leads export passes its + // campaign so the lead_* columns are populated for the selected rows. + searchFilters = req.Filters + } + if searchFilters != nil { + if err := validateLeadFilters(*searchFilters); err != nil { + return "", "", 0, err + } } rows, xerr := s.contactRepository.ExportAll(ctx, userID, searchFilters, contactIDs, models.MaxContactExportRows) @@ -145,7 +153,11 @@ func validateFieldList(fields []string) *errx.Error { models.ContactExportFieldCategories, models.ContactExportFieldCampaigns, models.ContactExportFieldCreatedAt, - models.ContactExportFieldUpdatedAt: + models.ContactExportFieldUpdatedAt, + models.ContactExportFieldLeadStatus, + models.ContactExportFieldLeadOpened, + models.ContactExportFieldLeadClicked, + models.ContactExportFieldLeadReplied: default: return errx.New(errx.BadRequest, "unknown export field: "+f) } @@ -183,6 +195,14 @@ func fieldHeader(f string) string { return "Created At" case models.ContactExportFieldUpdatedAt: return "Updated At" + case models.ContactExportFieldLeadStatus: + return "Lead Status" + case models.ContactExportFieldLeadOpened: + return "Opened" + case models.ContactExportFieldLeadClicked: + return "Clicked" + case models.ContactExportFieldLeadReplied: + return "Replied" } return f } @@ -235,10 +255,34 @@ func fieldValue(c *models.Contact, f string) string { return "" } return c.UpdatedAt.UTC().Format(time.RFC3339) + case models.ContactExportFieldLeadStatus: + if c.CampaignLead == nil { + return "" + } + return c.CampaignLead.Status + case models.ContactExportFieldLeadOpened, models.ContactExportFieldLeadClicked, models.ContactExportFieldLeadReplied: + if c.CampaignLead == nil { + return "" + } + return strconv.FormatBool(leadEngaged(c.CampaignLead, f)) } return "" } +// leadEngaged is the yes/no engagement cell: any human open, click or reply on +// any step of the campaign. +func leadEngaged(lead *models.ContactCampaignProgress, f string) bool { + switch f { + case models.ContactExportFieldLeadOpened: + return lead.Opened > 0 + case models.ContactExportFieldLeadClicked: + return lead.Clicked > 0 + case models.ContactExportFieldLeadReplied: + return lead.Replied > 0 + } + return false +} + // writeCSV uses encoding/csv from the stdlib. The leading UTF-8 BOM is // what Excel-on-Windows looks for to render non-ASCII characters // correctly when opening a CSV directly — without it, names like "Söre" @@ -285,6 +329,8 @@ func writeJSON(w io.Writer, rows []models.Contact, fields []string) error { obj[f] = c.Categories } else if f == models.ContactExportFieldCampaigns { obj[f] = c.Campaigns + } else if c.CampaignLead != nil && (f == models.ContactExportFieldLeadOpened || f == models.ContactExportFieldLeadClicked || f == models.ContactExportFieldLeadReplied) { + obj[f] = leadEngaged(c.CampaignLead, f) } else { obj[f] = fieldValue(c, f) } diff --git a/internal/app/contact/handler.go b/internal/app/contact/handler.go index 185bf019..cf0081da 100644 --- a/internal/app/contact/handler.go +++ b/internal/app/contact/handler.go @@ -85,21 +85,29 @@ func (s *contactService) Search(ctx context.Context, orgID, cursor, category, li return nil, err } - // The lead_status filter is a single-campaign Leads-view feature: an invalid - // value or the wrong campaign cardinality is a client contract error (400), - // not a silently-ignored no-op. - if filters.LeadStatus != "" { - if !models.ValidLeadStatus(filters.LeadStatus) { - return nil, errx.New(errx.BadRequest, "invalid lead_status") - } - if len(filters.CampaignIDs) != 1 { - return nil, errx.New(errx.BadRequest, "lead_status requires exactly one campaign_id") - } + if err := validateLeadFilters(filters); err != nil { + return nil, err } return s.contactRepository.Search(ctx, orgID, categoryId, cursorId, filters, limitN) } +// validateLeadFilters gates the single-campaign Leads-view filters: an unknown +// value or the wrong campaign cardinality is a client contract error (400 with +// a stable code), not a silently-ignored no-op. +func validateLeadFilters(filters models.SearchContacts) *errx.Error { + if filters.LeadStatus != "" && !models.ValidLeadStatus(filters.LeadStatus) { + return errx.NewWithIdentifier(errx.BadRequest, "invalid_lead_status", "invalid lead_status") + } + if filters.Engagement != "" && !models.ValidLeadEngagement(filters.Engagement) { + return errx.NewWithIdentifier(errx.BadRequest, "invalid_engagement", "invalid engagement") + } + if (filters.LeadStatus != "" || filters.Engagement != "") && len(filters.CampaignIDs) != 1 { + return errx.NewWithIdentifier(errx.BadRequest, "lead_filter_requires_campaign", "lead_status and engagement require exactly one campaign_id") + } + return nil +} + func (s *contactService) SearchCounts(ctx context.Context, orgID string) (*models.ContactsCounts, *errx.Error) { return s.contactRepository.SearchCounts(ctx, orgID) } diff --git a/internal/models/contact.go b/internal/models/contact.go index 59c3baa4..0aafe254 100644 --- a/internal/models/contact.go +++ b/internal/models/contact.go @@ -67,9 +67,11 @@ type ContactCampaignProgress struct { // bounced — a send hard-bounced (terminal/negative) // failed — the mailbox could not send a step after every retry (terminal/negative) // unsubscribed — the contact is unsubscribed/suppressed (terminal) - Status string `json:"status"` - Sent int `json:"sent"` + Status string `json:"status"` + Sent int `json:"sent"` + // Opened counts human opens only; automated fetches are in MachineOpened. Opened int `json:"opened"` + MachineOpened int `json:"machine_opened"` Clicked int `json:"clicked"` Replied int `json:"replied"` Bounced int `json:"bounced"` @@ -110,6 +112,30 @@ func ValidLeadStatus(s string) bool { } } +// Lead engagement filter values for SearchContacts.Engagement. Each is a +// predicate over the contact's progress rows in one campaign; the negative +// forms only match leads that were sent at least one step, so a lead never +// emailed is neither "opened" nor "not opened". +const ( + LeadEngagementOpened = "opened" + LeadEngagementNotOpened = "not_opened" + LeadEngagementClicked = "clicked" + LeadEngagementNotClicked = "not_clicked" + LeadEngagementReplied = "replied" + LeadEngagementNotReplied = "not_replied" + LeadEngagementBounced = "bounced" +) + +// ValidLeadEngagement reports whether s is one of the engagement filter values. +func ValidLeadEngagement(s string) bool { + switch s { + case LeadEngagementOpened, LeadEngagementNotOpened, LeadEngagementClicked, LeadEngagementNotClicked, LeadEngagementReplied, LeadEngagementNotReplied, LeadEngagementBounced: + return true + default: + return false + } +} + type ContactsResult struct { Data []Contact `json:"data"` Pagination Pagination `json:"pagination"` @@ -141,6 +167,13 @@ type CampaignLeadCounts struct { Unsubscribed int `json:"unsubscribed"` // Undeliverable: address verification refused it, so routing skips it. Undeliverable int `json:"undeliverable"` + // Engagement totals, matching the `engagement` search filter: leads sent + // at least one step, and of those the ones with a human open, a click, or + // a reply on any step. + Contacted int `json:"contacted"` + Opened int `json:"opened"` + Clicked int `json:"clicked"` + RepliedAny int `json:"replied_any"` } // ContactsCounts are org-wide contact facet totals for the browse sidebar. @@ -349,6 +382,7 @@ type SearchContacts struct { CustomFieldFilters []SearchContactsFilter `json:"custom_field_filters"` // Custom Field Filters CampaignIDs []string `json:"campaign_ids"` // Contacts must be in ALL these campaigns LeadStatus string `json:"lead_status"` // Filter by derived lead status; requires exactly one campaign_id + Engagement string `json:"engagement"` // Filter by lead engagement (opened, not_opened, ...); ANDed with lead_status; requires exactly one campaign_id CategoryIDs []string `json:"category_ids"` // Contacts must have ALL these categories MinCampaigns *int `json:"min_campaigns"` // Minimum number of associated campaigns MaxCampaigns *int `json:"max_campaigns"` // Maximum number of associated campaigns diff --git a/internal/models/contact_export.go b/internal/models/contact_export.go index 4d2b8f66..d36236b8 100644 --- a/internal/models/contact_export.go +++ b/internal/models/contact_export.go @@ -65,6 +65,12 @@ const ( ContactExportFieldCampaigns = "campaigns" ContactExportFieldCreatedAt = "created_at" ContactExportFieldUpdatedAt = "updated_at" + // Per-lead campaign engagement. Populated only when the export filters + // target exactly one campaign; blank otherwise. + ContactExportFieldLeadStatus = "lead_status" + ContactExportFieldLeadOpened = "lead_opened" + ContactExportFieldLeadClicked = "lead_clicked" + ContactExportFieldLeadReplied = "lead_replied" ) // DefaultExportFields is what the UI lands on when the user just clicks diff --git a/internal/repository/contact_engagement_live_test.go b/internal/repository/contact_engagement_live_test.go new file mode 100644 index 00000000..cca202db --- /dev/null +++ b/internal/repository/contact_engagement_live_test.go @@ -0,0 +1,142 @@ +package repository + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/warmbly/warmbly/internal/models" +) + +// Issue #250: the campaign Leads view filters by who opened, clicked and +// replied on the server, so counts and pagination hold across pages. These +// prove each `engagement` value, that a machine open is not an open, and that +// "not opened" means sent-but-unopened rather than never-sent. +// +// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \ +// go test ./internal/repository/ -run LiveContactEngagement -v + +type engagementLeads struct { + opened, machineOpened, clicked, replied, bounced, sentOnly, neverSent uuid.UUID +} + +// seedEngagementLeads adds one email step and one lead per engagement shape. +func seedEngagementLeads(t *testing.T, f *sharedOrgFixture, pool *pgxpool.Pool) engagementLeads { + t.Helper() + ctx := context.Background() + step := uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO sequences (id, campaign_id, organization_id, name, subject, + body_plain, body_html, wait_after, position, kind) + VALUES ($1, $2, $3, 'Step 1', 'Hi', 'Hello', '

Hello

', 0, 1, 'email')`, step, f.campaign, f.org); err != nil { + t.Fatalf("sequence: %v", err) + } + l := engagementLeads{ + opened: addLead(t, f, "opened-"+uuid.New().String()[:6]+"@test.local", "valid", true), + machineOpened: addLead(t, f, "machine-"+uuid.New().String()[:6]+"@test.local", "valid", true), + clicked: addLead(t, f, "clicked-"+uuid.New().String()[:6]+"@test.local", "valid", true), + replied: addLead(t, f, "replied-"+uuid.New().String()[:6]+"@test.local", "valid", true), + bounced: addLead(t, f, "bounced-"+uuid.New().String()[:6]+"@test.local", "valid", true), + sentOnly: addLead(t, f, "sent-"+uuid.New().String()[:6]+"@test.local", "valid", true), + neverSent: addLead(t, f, "queued-"+uuid.New().String()[:6]+"@test.local", "valid", true), + } + progress := func(contact uuid.UUID, cols string) { + t.Helper() + if _, err := pool.Exec(ctx, `INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at`+cols+`)`, + f.campaign, contact, step); err != nil { + t.Fatalf("progress %q: %v", cols, err) + } + } + progress(l.opened, `, opened_at) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour', NOW()`) + progress(l.machineOpened, `, opened_at, opened_machine) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour', NOW(), true`) + // A click implies an open on the same step, as the tracking consumer records it. + progress(l.clicked, `, opened_at, clicked_at) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour', NOW(), NOW()`) + progress(l.replied, `, replied_at) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour', NOW()`) + progress(l.bounced, `, bounced_at) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour', NOW()`) + progress(l.sentOnly, `) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour'`) + return l +} + +func searchEngagement(t *testing.T, repo *contactRepository, f *sharedOrgFixture, filters models.SearchContacts) map[uuid.UUID]models.Contact { + t.Helper() + filters.CampaignIDs = []string{f.campaign.String()} + res, err := repo.Search(context.Background(), f.org.String(), nil, nil, filters, 100) + if err != nil { + t.Fatalf("Search(%+v): %v", filters, err) + } + out := make(map[uuid.UUID]models.Contact, len(res.Data)) + for _, c := range res.Data { + out[c.ID] = c + } + return out +} + +func TestLiveContactEngagementFilters(t *testing.T) { + handle, pool := liveContactDB(t) + f := newSharedOrgFixture(t, pool) + l := seedEngagementLeads(t, f, pool) + repo := &contactRepository{DB: handle} + + cases := []struct { + engagement string + want []uuid.UUID + }{ + // A machine open is not an open, but the click lead's open is human. + {models.LeadEngagementOpened, []uuid.UUID{l.opened, l.clicked}}, + // Sent but no human open; the never-sent lead is excluded. + {models.LeadEngagementNotOpened, []uuid.UUID{l.machineOpened, l.replied, l.bounced, l.sentOnly}}, + {models.LeadEngagementClicked, []uuid.UUID{l.clicked}}, + {models.LeadEngagementNotClicked, []uuid.UUID{l.opened, l.machineOpened, l.replied, l.bounced, l.sentOnly}}, + {models.LeadEngagementReplied, []uuid.UUID{l.replied}}, + {models.LeadEngagementNotReplied, []uuid.UUID{l.opened, l.machineOpened, l.clicked, l.bounced, l.sentOnly}}, + {models.LeadEngagementBounced, []uuid.UUID{l.bounced}}, + } + // Every seeded lead, plus the fixture's own contact (a lead in no campaign + // here, so never returned by a campaign-scoped search). + all := []uuid.UUID{l.opened, l.machineOpened, l.clicked, l.replied, l.bounced, l.sentOnly, l.neverSent} + for _, tc := range cases { + got := searchEngagement(t, repo, f, models.SearchContacts{Engagement: tc.engagement}) + want := map[uuid.UUID]bool{} + for _, id := range tc.want { + want[id] = true + } + for _, id := range all { + if _, ok := got[id]; ok != want[id] { + t.Errorf("engagement=%s: lead %s returned=%v, want %v", tc.engagement, id, ok, want[id]) + } + } + if len(got) != len(tc.want) { + t.Errorf("engagement=%s: %d rows, want %d", tc.engagement, len(got), len(tc.want)) + } + } + + // The per-lead aggregate follows the same definition of an open. + got := searchEngagement(t, repo, f, models.SearchContacts{}) + if lead := got[l.machineOpened].CampaignLead; lead == nil || lead.Opened != 0 || lead.MachineOpened != 1 { + t.Errorf("machine-opened lead aggregate = %+v, want opened=0 machine_opened=1", lead) + } + if lead := got[l.opened].CampaignLead; lead == nil || lead.Opened != 1 || lead.MachineOpened != 0 { + t.Errorf("opened lead aggregate = %+v, want opened=1 machine_opened=0", lead) + } + + // Engagement composes with lead_status as AND. + got = searchEngagement(t, repo, f, models.SearchContacts{LeadStatus: models.LeadStatusReplied, Engagement: models.LeadEngagementNotOpened}) + if len(got) != 1 || got[l.replied].ID != l.replied { + t.Errorf("replied AND not_opened returned %d rows, want only the replied lead", len(got)) + } + got = searchEngagement(t, repo, f, models.SearchContacts{LeadStatus: models.LeadStatusReplied, Engagement: models.LeadEngagementOpened}) + if len(got) != 0 { + t.Errorf("replied AND opened returned %d rows, want 0", len(got)) + } + + // The campaign-wide totals the Leads chips show match the filters. + counts, err := repo.CampaignLeadCounts(context.Background(), f.org.String(), f.campaign.String()) + if err != nil { + t.Fatalf("CampaignLeadCounts: %v", err) + } + if counts.Contacted != 6 || counts.Opened != 2 || counts.Clicked != 1 || counts.RepliedAny != 1 { + t.Errorf("lead counts = contacted %d opened %d clicked %d replied_any %d, want 6/2/1/1", + counts.Contacted, counts.Opened, counts.Clicked, counts.RepliedAny) + } +} diff --git a/internal/repository/pg_contact.go b/internal/repository/pg_contact.go index 89cc5834..fca5b587 100644 --- a/internal/repository/pg_contact.go +++ b/internal/repository/pg_contact.go @@ -751,6 +751,13 @@ func (r *contactRepository) Search( whereClauses = append(whereClauses, clause) } } + // Engagement composes with lead_status as AND, so "replied AND not_opened" + // is a valid (if odd) combination rather than one overriding the other. + if filters.Engagement != "" && singleCampaignPlaceholder != "" { + if clause := leadEngagementClause(filters.Engagement, singleCampaignPlaceholder); clause != "" { + whereClauses = append(whereClauses, clause) + } + } // ----------------------------- // Category IDs filter (must have ALL specified categories) @@ -862,7 +869,10 @@ func (r *contactRepository) Search( leadProgressSelect = fmt.Sprintf(`( SELECT json_build_object( 'sent', COUNT(*) FILTER (WHERE p.sent_at IS NOT NULL), - 'opened', COUNT(*) FILTER (WHERE p.opened_at IS NOT NULL), + -- Human opens only; automated fetches (Apple MPP prefetch, UA-less + -- clients) are counted apart so they never read as engagement. + 'opened', COUNT(*) FILTER (WHERE p.opened_at IS NOT NULL AND NOT p.opened_machine), + 'machine_opened', COUNT(*) FILTER (WHERE p.opened_at IS NOT NULL AND p.opened_machine), 'clicked', COUNT(*) FILTER (WHERE p.clicked_at IS NOT NULL), 'replied', COUNT(*) FILTER (WHERE p.replied_at IS NOT NULL), 'bounced', COUNT(*) FILTER (WHERE p.bounced_at IS NOT NULL), @@ -1015,6 +1025,7 @@ func (r *contactRepository) Search( var lp struct { Sent int `json:"sent"` Opened int `json:"opened"` + MachineOpn int `json:"machine_opened"` Clicked int `json:"clicked"` Replied int `json:"replied"` Bounced int `json:"bounced"` @@ -1063,6 +1074,7 @@ func (r *contactRepository) Search( Status: status, Sent: lp.Sent, Opened: lp.Opened, + MachineOpened: lp.MachineOpn, Clicked: lp.Clicked, Replied: lp.Replied, Bounced: lp.Bounced, @@ -1272,6 +1284,44 @@ func leadStatusClause(status, cp string) string { // completed > processing > undeliverable > queued priority as the row-level // derived status. // Scoped to the org through the contacts join. +// leadEngagementClause builds the WHERE predicate for one engagement filter +// value inside ONE campaign (`cp` is that campaign's bound placeholder). An +// open counts only when it is human: opened_machine marks automated fetches, +// the same split the analytics summary reports as machine opens. The negative +// forms require at least one sent step, so a lead never emailed matches +// neither side. Returns "" for an unknown value. +func leadEngagementClause(engagement, cp string) string { + has := func(cond string) string { + return fmt.Sprintf( + "EXISTS (SELECT 1 FROM campaign_contact_progress p WHERE p.campaign_id = %s AND p.contact_id = c.id AND %s)", + cp, cond, + ) + } + sent := has("p.sent_at IS NOT NULL") + opened := has("p.opened_at IS NOT NULL AND NOT p.opened_machine") + clicked := has("p.clicked_at IS NOT NULL") + replied := has("p.replied_at IS NOT NULL") + bounced := has("p.bounced_at IS NOT NULL") + switch engagement { + case models.LeadEngagementOpened: + return opened + case models.LeadEngagementNotOpened: + return fmt.Sprintf("(%s AND NOT %s)", sent, opened) + case models.LeadEngagementClicked: + return clicked + case models.LeadEngagementNotClicked: + return fmt.Sprintf("(%s AND NOT %s)", sent, clicked) + case models.LeadEngagementReplied: + return replied + case models.LeadEngagementNotReplied: + return fmt.Sprintf("(%s AND NOT %s)", sent, replied) + case models.LeadEngagementBounced: + return bounced + default: + return "" + } +} + func (r *contactRepository) CampaignLeadCounts(ctx context.Context, orgID, campaignID string) (*models.CampaignLeadCounts, *errx.Error) { // A lead is "done" (completed) when every email step has been sent and it // hasn't replied or bounced; "processing" when some but not all steps sent. @@ -1287,7 +1337,11 @@ func (r *contactRepository) CampaignLeadCounts(ctx context.Context, orgID, campa COUNT(*) FILTER (WHERE %[2]s AND COALESCE(pr.has_sent, false) AND (%[1]s)) AS completed, COUNT(*) FILTER (WHERE %[2]s AND COALESCE(pr.has_sent, false) AND NOT (%[1]s)) AS processing, COUNT(*) FILTER (WHERE %[2]s AND NOT COALESCE(pr.has_sent, false) AND %[3]s) AS undeliverable, - COUNT(*) FILTER (WHERE %[2]s AND NOT COALESCE(pr.has_sent, false) AND NOT %[3]s) AS queued + COUNT(*) FILTER (WHERE %[2]s AND NOT COALESCE(pr.has_sent, false) AND NOT %[3]s) AS queued, + COUNT(*) FILTER (WHERE COALESCE(pr.has_sent, false)) AS contacted, + COUNT(*) FILTER (WHERE COALESCE(pr.has_opened, false)) AS opened, + COUNT(*) FILTER (WHERE COALESCE(pr.has_clicked, false)) AS clicked, + COUNT(*) FILTER (WHERE COALESCE(pr.has_replied, false)) AS replied_any FROM campaign_leads cl JOIN contacts c ON c.id = cl.contact_id AND c.organization_id = $2 CROSS JOIN (SELECT COUNT(*) AS total_steps FROM sequences st WHERE st.campaign_id = $1 AND st.kind = 'email') ts @@ -1296,6 +1350,8 @@ func (r *contactRepository) CampaignLeadCounts(ctx context.Context, orgID, campa bool_or(p.sent_at IS NOT NULL) AS has_sent, bool_or(p.replied_at IS NOT NULL) AS has_replied, bool_or(p.bounced_at IS NOT NULL) AS has_bounced, + bool_or(p.opened_at IS NOT NULL AND NOT p.opened_machine) AS has_opened, + bool_or(p.clicked_at IS NOT NULL) AS has_clicked, bool_or(p.sent_at IS NULL AND p.failed_at IS NOT NULL AND p.send_attempts >= $3) AS has_failed, COUNT(*) FILTER (WHERE p.sent_at IS NOT NULL) AS sent_steps FROM campaign_contact_progress p @@ -1306,6 +1362,7 @@ func (r *contactRepository) CampaignLeadCounts(ctx context.Context, orgID, campa out := &models.CampaignLeadCounts{} if err := r.DB.QueryRow(ctx, query, campaignID, orgID, config.CampaignSendMaxAttempts).Scan( &out.Total, &out.Unsubscribed, &out.Bounced, &out.Replied, &out.Failed, &out.Completed, &out.Processing, &out.Undeliverable, &out.Queued, + &out.Contacted, &out.Opened, &out.Clicked, &out.RepliedAny, ); err != nil { if err == pgx.ErrNoRows { return out, nil diff --git a/web/src/components/app/contacts/ContactFilters.tsx b/web/src/components/app/contacts/ContactFilters.tsx index f32c98bb..2cd73b37 100644 --- a/web/src/components/app/contacts/ContactFilters.tsx +++ b/web/src/components/app/contacts/ContactFilters.tsx @@ -13,6 +13,7 @@ import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts"; import type SearchContactsFilter from "@/lib/api/models/app/contacts/SearchContactsFilter"; import type { SearchContactsFilterType, SearchContactsSortBy } from "@/lib/api/models/app/contacts/search-contacts.types"; import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign"; +import type { LeadEngagement, LeadStatus } from "@/lib/api/models/app/contacts/Contact"; import React from "react"; import { AnimatePresence, motion } from "framer-motion"; @@ -266,6 +267,31 @@ export default function ContactFilters({

+ {activeCampaign && ( + <> +
+
+ setDraft((s) => ({ ...s, lead_status: v }))} + options={LEAD_STATUS_OPTIONS} + /> +
+ +
+
+ setDraft((s) => ({ ...s, engagement: v }))} + options={ENGAGEMENT_OPTIONS} + /> +

+ Opens count people, not mail clients: automatic prefetches are ignored. Not opened, not clicked and not replied only cover leads that have been sent at least one email. +

+
+ + )} +
({ + value, + onChange, + options, +}: { + value: T; + onChange: (v: T) => void; + options: { id: T; label: string }[]; +}) { + return ( +
+ {options.map((o) => { + const on = value === o.id; + return ( + + ); + })} +
+ ); +} + function Toggle3({ value, onChange, @@ -544,6 +629,8 @@ function countActiveFilters(f: SearchContacts, hasCampaignContext: boolean): num if (f.query) n++; n += f.filters.length; if (f.subscribed !== undefined) n++; + if (f.lead_status) n++; + if (f.engagement) n++; if (f.min_campaigns !== undefined) n++; if (f.max_campaigns !== undefined) n++; if (f.created_after) n++; diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index 55e79970..1f9ec637 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -22,7 +22,9 @@ import { DownloadIcon, Loader2Icon, MailIcon, + MailOpenIcon, MoreHorizontalIcon, + MousePointerClickIcon, PhoneIcon, PlusIcon, RefreshCcwIcon, @@ -54,7 +56,7 @@ import ContactFilters from "./ContactFilters"; import ContactEdit from "./ContactEdit"; import type { ContactSlideTab } from "./contact-edit/tabs"; import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign"; -import type { ContactCampaignProgress, LeadStatus } from "@/lib/api/models/app/contacts/Contact"; +import type { ContactCampaignProgress, LeadEngagement, LeadStatus } from "@/lib/api/models/app/contacts/Contact"; import type { CampaignLeadCounts } from "@/lib/api/models/app/contacts/SearchContactsResult"; import ContactsEditBulk from "./ContactsEditBulk"; import { NewContactDialog } from "./NewContactDialog"; @@ -246,6 +248,15 @@ export default function ContactsTable({ } const embedded = !!current_campaign; + // Leads-view scope chips write straight into the search request, so the + // rows, the total and pagination all come from the server for that scope. + const leadFilterActive = !!searchProps.lead_status || !!searchProps.engagement; + const setLeadStatus = (v: LeadStatus | undefined) => + setSearchProps((s) => ({ ...s, lead_status: s.lead_status === v ? undefined : v })); + const setEngagement = (v: LeadEngagement | undefined) => + setSearchProps((s) => ({ ...s, engagement: s.engagement === v ? undefined : v })); + const clearLeadFilters = () => + setSearchProps((s) => ({ ...s, lead_status: undefined, engagement: undefined })); const tableNode = ( setSubFilter("all")}> Show all + ) : leadFilterActive ? ( + + Show all leads + ) : current_campaign ? (
{tableNode} Company Phone {embedded ? "Progress" : "Status"} + {embedded && ( + <> + Opened + Clicked + Replied + + )} {embedded ? ( Current step ) : ( @@ -863,6 +893,29 @@ function ContactsTableBody({ )} + {embedded && ( + <> + 0} + Icon={MailOpenIcon} + label="opened" + auto={(lead?.machine_opened ?? 0) > 0} + /> + 0} + Icon={MousePointerClickIcon} + label="clicked" + /> + 0} + Icon={CornerUpLeftIcon} + label="replied" + /> + + )} {embedded ? ( {lead?.current_step ? ( @@ -978,6 +1031,49 @@ function StatusPill({ subscribed }: { subscribed: boolean }) { ); } +// One engagement column of the Leads view. A count of steps engaged, a dash +// for a lead that was sent but never did, and blank for a lead never emailed. +// A machine-only open (Apple MPP prefetch) reads "auto" so it is not mistaken +// for a person. +function EngagementCell({ + n, + sent, + Icon, + label, + auto = false, +}: { + n: number; + sent: boolean; + Icon: typeof MailOpenIcon; + label: string; + auto?: boolean; +}) { + return ( + + {n > 0 ? ( + + + {n} + + ) : auto ? ( + + auto + + ) : sent ? ( + + — + + ) : null} + + ); +} + // Per-lead processing state inside a campaign (campaign Leads view only). // `active` renders the animated dot-grid loader (the same "processing" motif // used across the app); every other state is a distinct lucide icon. @@ -1035,11 +1131,19 @@ function LeadProgressStrip({ total, hasMore, serverCounts, + leadStatus, + engagement, + onLeadStatus, + onEngagement, }: { contacts: { campaign_lead?: ContactCampaignProgress | null }[]; total: number; hasMore: boolean; serverCounts?: CampaignLeadCounts; + leadStatus?: LeadStatus; + engagement?: LeadEngagement; + onLeadStatus: (s: LeadStatus) => void; + onEngagement: (e: LeadEngagement) => void; }) { const counts = React.useMemo(() => { if (serverCounts) { @@ -1069,9 +1173,31 @@ function LeadProgressStrip({ }, [contacts, serverCounts]); const loaded = contacts.length; - if (loaded === 0) return null; + // Stay mounted while a chip filter is active, or an empty scope would + // take the only control that clears it off the screen. + if (loaded === 0 && !leadStatus && !engagement && !(serverCounts && serverCounts.total > 0)) return null; // The bar's segments are shares of whatever the counts cover. const barTotal = serverCounts ? Math.max(serverCounts.total, 1) : loaded; + const status = (key: LeadStatus) => ({ + active: leadStatus === key, + onClick: () => onLeadStatus(key), + }); + const engaged = (key: LeadEngagement) => ({ + active: engagement === key, + onClick: () => onEngagement(key), + }); + // Engagement totals only exist server-side; the fallback row count would + // lie past one page, so the chips carry no number until they arrive. + const eng = serverCounts + ? { + opened: serverCounts.opened, + notOpened: Math.max(serverCounts.contacted - serverCounts.opened, 0), + clicked: serverCounts.clicked, + notClicked: Math.max(serverCounts.contacted - serverCounts.clicked, 0), + replied: serverCounts.replied_any, + notReplied: Math.max(serverCounts.contacted - serverCounts.replied_any, 0), + } + : undefined; const segs: { key: LeadStatus; color: string }[] = [ { key: "active", color: "bg-sky-500" }, @@ -1099,17 +1225,26 @@ function LeadProgressStrip({ )}
-
- 0} /> - - - - - {counts.failed > 0 && } - {counts.undeliverable > 0 && ( - +
+ 0} {...status("active")} /> + + + + + {(counts.failed > 0 || leadStatus === "failed") && ( + )} - + {(counts.undeliverable > 0 || leadStatus === "undeliverable") && ( + + )} + + + + + + + +
{counts.active > 0 && ( @@ -1129,27 +1264,49 @@ function LeadProgressStrip({ ); } +// A scope chip: click toggles that scope as the server filter. Status chips +// carry a colour dot, engagement chips a lucide icon; `n` is omitted while the +// server total is not known yet. function StripChip({ dot, + Icon, label, n, loader = false, + active = false, + onClick, }: { - dot: string; + dot?: string; + Icon?: typeof MailOpenIcon; label: string; - n: number; + n?: number; loader?: boolean; + active?: boolean; + onClick: () => void; }) { return ( - + ); } diff --git a/web/src/components/app/contacts/ExportDialog.tsx b/web/src/components/app/contacts/ExportDialog.tsx index 3634744b..af540e64 100644 --- a/web/src/components/app/contacts/ExportDialog.tsx +++ b/web/src/components/app/contacts/ExportDialog.tsx @@ -58,6 +58,15 @@ const STANDARD_FIELDS: { id: string; label: string; preset: "basic" | "full" | " { id: "id", label: "Contact ID", preset: "full" }, ]; +// Per-lead engagement inside the campaign the export is scoped to. Only +// offered when the filters name exactly one campaign; blank otherwise. +const CAMPAIGN_FIELDS: { id: string; label: string }[] = [ + { id: "lead_status", label: "Lead status" }, + { id: "lead_opened", label: "Opened" }, + { id: "lead_clicked", label: "Clicked" }, + { id: "lead_replied", label: "Replied" }, +]; + const PRESETS: { id: "basic" | "full" | "campaign-ready" | "custom"; label: string; hint: string }[] = [ { id: "basic", label: "Basic", hint: "Core contact details — what most CRMs expect." }, { id: "full", label: "Full", hint: "Every standard column including categories + campaigns." }, @@ -91,6 +100,8 @@ function hasActiveFilters(f: SearchContacts): boolean { (f.campaign_ids?.length ?? 0) > 0 || (f.category_ids?.length ?? 0) > 0 || f.subscribed !== undefined || + !!f.lead_status || + !!f.engagement || f.min_campaigns !== undefined || f.max_campaigns !== undefined || !!f.created_after || @@ -107,6 +118,7 @@ export default function ExportDialog({ selectedIds, totalKnown, }: Props) { + const inCampaign = (filters.campaign_ids?.length ?? 0) === 1; const [format, setFormat] = React.useState("csv"); const [scope, setScope] = React.useState(() => selectedIds.length > 0 ? "selected" : hasActiveFilters(filters) ? "filtered" : "all", @@ -163,11 +175,13 @@ export default function ExportDialog({ } setLoading(true); try { + // A selected-rows export from a campaign still sends the filters so + // the server can fill the lead_* columns for that campaign. const result = await exportContacts({ format, scope, contact_ids: scope === "selected" ? selectedIds : undefined, - filters: scope === "filtered" ? filters : undefined, + filters: scope === "filtered" || (scope === "selected" && inCampaign) ? filters : undefined, fields: effective, filename: filename.trim() || undefined, }); @@ -299,7 +313,7 @@ export default function ExportDialog({
- {STANDARD_FIELDS.map((f) => { + {[...STANDARD_FIELDS, ...(inCampaign ? CAMPAIGN_FIELDS : [])].map((f) => { const checked = fields.includes(f.id); return (