mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 00:04:27 +00:00
feat: add recipient-level engagement to the campaign Leads view by adding an engagement search filter (opened, not_opened, clicked, not_clicked, replied, not_replied, bounced) that composes with lead_status as AND and rejects unknown values with stable 400 codes, counting only human opens so machine opens never read as engagement, adding contacted/opened/clicked/replied_any totals to lead_counts, rendering Opened/Clicked/Replied columns and clickable server-backed status and engagement chips in the Leads table with matching Filters sheet sections, offering lead_status/lead_opened/lead_clicked/lead_replied export columns from a campaign, covering every filter value with a live Postgres test, and documenting the columns, filters, error codes and export fields (issue #250)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:<key>`). Empty uses the default columns. |
|
||||
| `fields` | string[] | No | Column identifiers in display order (built-ins like `email`, `first_name`, or `custom:<key>`). 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-<YYYY-MM-DD>`. |
|
||||
|
||||
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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', '<p>Hello</p>', 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activeCampaign && (
|
||||
<>
|
||||
<Section label="Lead status" />
|
||||
<div className="px-4 py-3">
|
||||
<ChoiceRow
|
||||
value={draft.lead_status}
|
||||
onChange={(v) => setDraft((s) => ({ ...s, lead_status: v }))}
|
||||
options={LEAD_STATUS_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Section label="Engagement" />
|
||||
<div className="px-4 py-3">
|
||||
<ChoiceRow
|
||||
value={draft.engagement}
|
||||
onChange={(v) => setDraft((s) => ({ ...s, engagement: v }))}
|
||||
options={ENGAGEMENT_OPTIONS}
|
||||
/>
|
||||
<p className="text-[10.5px] text-slate-400 mt-1.5 leading-tight">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Section label="Subscription" />
|
||||
<div className="px-4 py-3">
|
||||
<Toggle3
|
||||
@@ -419,6 +445,65 @@ function FilterRow({
|
||||
);
|
||||
}
|
||||
|
||||
// Lead status and engagement choices for the campaign Leads view. "Any" is
|
||||
// `undefined` so the field is dropped from the request.
|
||||
const LEAD_STATUS_OPTIONS: { id: LeadStatus | undefined; label: string }[] = [
|
||||
{ id: undefined, label: "Any" },
|
||||
{ id: "pending", label: "Queued" },
|
||||
{ id: "active", label: "Processing" },
|
||||
{ id: "completed", label: "Done" },
|
||||
{ id: "replied", label: "Replied" },
|
||||
{ id: "bounced", label: "Bounced" },
|
||||
{ id: "failed", label: "Failed" },
|
||||
{ id: "undeliverable", label: "Undeliverable" },
|
||||
{ id: "unsubscribed", label: "Unsubscribed" },
|
||||
];
|
||||
|
||||
const ENGAGEMENT_OPTIONS: { id: LeadEngagement | undefined; label: string }[] = [
|
||||
{ id: undefined, label: "Any" },
|
||||
{ id: "opened", label: "Opened" },
|
||||
{ id: "not_opened", label: "Not opened" },
|
||||
{ id: "clicked", label: "Clicked" },
|
||||
{ id: "not_clicked", label: "Not clicked" },
|
||||
{ id: "replied", label: "Replied" },
|
||||
{ id: "not_replied", label: "Not replied" },
|
||||
{ id: "bounced", label: "Bounced" },
|
||||
];
|
||||
|
||||
// Wrapping single-choice chips for option sets too long for Toggle3's pill.
|
||||
function ChoiceRow<T extends string | undefined>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: { id: T; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{options.map((o) => {
|
||||
const on = value === o.id;
|
||||
return (
|
||||
<button
|
||||
key={String(o.id)}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => onChange(o.id)}
|
||||
className={`h-6 px-2 rounded-md border text-[11.5px] font-medium transition-colors ${
|
||||
on
|
||||
? "border-sky-200 bg-sky-50 text-sky-700"
|
||||
: "border-slate-200 text-slate-500 hover:text-slate-900 hover:border-slate-300"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle3<T extends boolean | undefined>({
|
||||
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++;
|
||||
|
||||
@@ -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 = (
|
||||
<ContactsTableBody
|
||||
embedded={embedded}
|
||||
@@ -271,22 +282,30 @@ export default function ContactsTable({
|
||||
emptyTitle={
|
||||
subFilter !== "all"
|
||||
? `No ${subFilter} contacts`
|
||||
: current_campaign
|
||||
? "No contacts in this campaign"
|
||||
: "No contacts yet"
|
||||
: leadFilterActive
|
||||
? "No leads match"
|
||||
: current_campaign
|
||||
? "No contacts in this campaign"
|
||||
: "No contacts yet"
|
||||
}
|
||||
emptyBody={
|
||||
subFilter !== "all"
|
||||
? "Switch to All to see the full list."
|
||||
: current_campaign
|
||||
? "Pick people from your contacts, import a file, or add one by hand."
|
||||
: "Add or upload contacts to get started."
|
||||
: leadFilterActive
|
||||
? "Clear the status or engagement filter to see every lead."
|
||||
: current_campaign
|
||||
? "Pick people from your contacts, import a file, or add one by hand."
|
||||
: "Add or upload contacts to get started."
|
||||
}
|
||||
emptyCta={
|
||||
subFilter !== "all" ? (
|
||||
<TopbarAction variant="ghost" onClick={() => setSubFilter("all")}>
|
||||
Show all
|
||||
</TopbarAction>
|
||||
) : leadFilterActive ? (
|
||||
<TopbarAction variant="ghost" onClick={clearLeadFilters}>
|
||||
Show all leads
|
||||
</TopbarAction>
|
||||
) : current_campaign ? (
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<TopbarAction
|
||||
@@ -368,6 +387,10 @@ export default function ContactsTable({
|
||||
total={total}
|
||||
hasMore={!!contactsData.hasNextPage}
|
||||
serverCounts={contactsData.data?.pages[0]?.lead_counts}
|
||||
leadStatus={searchProps.lead_status}
|
||||
engagement={searchProps.engagement}
|
||||
onLeadStatus={setLeadStatus}
|
||||
onEngagement={setEngagement}
|
||||
/>
|
||||
{tableNode}
|
||||
<SelectionBar
|
||||
@@ -751,6 +774,13 @@ function ContactsTableBody({
|
||||
<Th className="hidden md:table-cell">Company</Th>
|
||||
<Th className="hidden lg:table-cell">Phone</Th>
|
||||
<Th className="w-auto md:w-32">{embedded ? "Progress" : "Status"}</Th>
|
||||
{embedded && (
|
||||
<>
|
||||
<Th className="w-16 hidden md:table-cell">Opened</Th>
|
||||
<Th className="w-16 hidden md:table-cell">Clicked</Th>
|
||||
<Th className="w-16 hidden md:table-cell">Replied</Th>
|
||||
</>
|
||||
)}
|
||||
{embedded ? (
|
||||
<Th className="w-28 hidden md:table-cell">Current step</Th>
|
||||
) : (
|
||||
@@ -863,6 +893,29 @@ function ContactsTableBody({
|
||||
<StatusPill subscribed={c.subscribed} />
|
||||
)}
|
||||
</td>
|
||||
{embedded && (
|
||||
<>
|
||||
<EngagementCell
|
||||
n={lead?.opened ?? 0}
|
||||
sent={(lead?.sent ?? 0) > 0}
|
||||
Icon={MailOpenIcon}
|
||||
label="opened"
|
||||
auto={(lead?.machine_opened ?? 0) > 0}
|
||||
/>
|
||||
<EngagementCell
|
||||
n={lead?.clicked ?? 0}
|
||||
sent={(lead?.sent ?? 0) > 0}
|
||||
Icon={MousePointerClickIcon}
|
||||
label="clicked"
|
||||
/>
|
||||
<EngagementCell
|
||||
n={lead?.replied ?? 0}
|
||||
sent={(lead?.sent ?? 0) > 0}
|
||||
Icon={CornerUpLeftIcon}
|
||||
label="replied"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{embedded ? (
|
||||
<td className="px-3 hidden md:table-cell">
|
||||
{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 (
|
||||
<td className="px-3 hidden md:table-cell">
|
||||
{n > 0 ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-700 tabular-nums"
|
||||
title={`${label} ${n} ${n === 1 ? "email" : "emails"}`}
|
||||
>
|
||||
<Icon className="w-3 h-3 shrink-0" />
|
||||
{n}
|
||||
</span>
|
||||
) : auto ? (
|
||||
<span
|
||||
className="text-[10.5px] text-slate-400"
|
||||
title="Opened by a mail client automatically, not by a person"
|
||||
>
|
||||
auto
|
||||
</span>
|
||||
) : sent ? (
|
||||
<span className="text-slate-300 text-[11px]" aria-label={`not ${label}`}>
|
||||
—
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// 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({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px] flex-wrap">
|
||||
<StripChip dot="bg-sky-500" label="Processing" n={counts.active} loader={counts.active > 0} />
|
||||
<StripChip dot="bg-indigo-500" label="Done" n={counts.completed} />
|
||||
<StripChip dot="bg-emerald-500" label="Replied" n={counts.replied} />
|
||||
<StripChip dot="bg-slate-300" label="Queued" n={counts.pending} />
|
||||
<StripChip dot="bg-rose-400" label="Bounced" n={counts.bounced} />
|
||||
{counts.failed > 0 && <StripChip dot="bg-rose-500" label="Failed" n={counts.failed} />}
|
||||
{counts.undeliverable > 0 && (
|
||||
<StripChip dot="bg-amber-500" label="Undeliverable" n={counts.undeliverable} />
|
||||
<div className="flex items-center gap-2 text-[11px] flex-wrap">
|
||||
<StripChip dot="bg-sky-500" label="Processing" n={counts.active} loader={counts.active > 0} {...status("active")} />
|
||||
<StripChip dot="bg-indigo-500" label="Done" n={counts.completed} {...status("completed")} />
|
||||
<StripChip dot="bg-emerald-500" label="Replied" n={counts.replied} {...status("replied")} />
|
||||
<StripChip dot="bg-slate-300" label="Queued" n={counts.pending} {...status("pending")} />
|
||||
<StripChip dot="bg-rose-400" label="Bounced" n={counts.bounced} {...status("bounced")} />
|
||||
{(counts.failed > 0 || leadStatus === "failed") && (
|
||||
<StripChip dot="bg-rose-500" label="Failed" n={counts.failed} {...status("failed")} />
|
||||
)}
|
||||
<StripChip dot="bg-slate-300" label="Unsub" n={counts.unsubscribed} />
|
||||
{(counts.undeliverable > 0 || leadStatus === "undeliverable") && (
|
||||
<StripChip dot="bg-amber-500" label="Undeliverable" n={counts.undeliverable} {...status("undeliverable")} />
|
||||
)}
|
||||
<StripChip dot="bg-slate-300" label="Unsub" n={counts.unsubscribed} {...status("unsubscribed")} />
|
||||
<span className="h-4 w-px bg-slate-200 mx-0.5" aria-hidden />
|
||||
<StripChip Icon={MailOpenIcon} label="Opened" n={eng?.opened} {...engaged("opened")} />
|
||||
<StripChip Icon={MailOpenIcon} label="Not opened" n={eng?.notOpened} {...engaged("not_opened")} />
|
||||
<StripChip Icon={MousePointerClickIcon} label="Clicked" n={eng?.clicked} {...engaged("clicked")} />
|
||||
<StripChip Icon={MousePointerClickIcon} label="Not clicked" n={eng?.notClicked} {...engaged("not_clicked")} />
|
||||
<StripChip Icon={CornerUpLeftIcon} label="Replied" n={eng?.replied} {...engaged("replied")} />
|
||||
<StripChip Icon={CornerUpLeftIcon} label="Not replied" n={eng?.notReplied} {...engaged("not_replied")} />
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 text-[10.5px] text-slate-400 tabular-nums">
|
||||
{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 (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={`inline-flex items-center gap-1.5 h-6 px-1.5 -mx-0.5 rounded-md border transition-colors ${
|
||||
active
|
||||
? "border-sky-200 bg-sky-50 text-sky-700"
|
||||
: "border-transparent hover:bg-slate-100 text-slate-500"
|
||||
}`}
|
||||
>
|
||||
{loader ? (
|
||||
<span className="campaign-grid text-sky-600" aria-hidden />
|
||||
) : Icon ? (
|
||||
<Icon className={`w-3 h-3 ${active ? "text-sky-600" : "text-slate-400"}`} />
|
||||
) : (
|
||||
<span className={`size-1.5 rounded-full ${dot}`} />
|
||||
)}
|
||||
<span className="text-slate-500">{label}</span>
|
||||
<span className="font-mono tabular-nums text-slate-900">{n}</span>
|
||||
</span>
|
||||
<span className={active ? "text-sky-700" : "text-slate-500"}>{label}</span>
|
||||
{n !== undefined && (
|
||||
<span className={`font-mono tabular-nums ${active ? "text-sky-800" : "text-slate-900"}`}>{n}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ExportFormat>("csv");
|
||||
const [scope, setScope] = React.useState<ExportScope>(() =>
|
||||
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({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
|
||||
{STANDARD_FIELDS.map((f) => {
|
||||
{[...STANDARD_FIELDS, ...(inCampaign ? CAMPAIGN_FIELDS : [])].map((f) => {
|
||||
const checked = fields.includes(f.id);
|
||||
return (
|
||||
<label
|
||||
|
||||
@@ -16,12 +16,27 @@ export type LeadStatus =
|
||||
| "unsubscribed"
|
||||
| "undeliverable";
|
||||
|
||||
// LeadEngagement mirrors models.SearchContacts.Engagement. "opened" is a human
|
||||
// open (machine opens never count); the not_* forms match leads that were sent
|
||||
// at least one step, so a queued lead is neither opened nor not opened.
|
||||
export type LeadEngagement =
|
||||
| "opened"
|
||||
| "not_opened"
|
||||
| "clicked"
|
||||
| "not_clicked"
|
||||
| "replied"
|
||||
| "not_replied"
|
||||
| "bounced";
|
||||
|
||||
// ContactCampaignProgress is set only on contacts returned by a single-campaign
|
||||
// (Leads view) search; it summarises how far the lead is through the campaign.
|
||||
export interface ContactCampaignProgress {
|
||||
status: LeadStatus;
|
||||
sent: number;
|
||||
// Human opens; automated fetches (Apple MPP prefetch, UA-less clients)
|
||||
// are counted apart in machine_opened.
|
||||
opened: number;
|
||||
machine_opened: number;
|
||||
clicked: number;
|
||||
replied: number;
|
||||
bounced: number;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { SearchContactsSortBy } from "./search-contacts.types";
|
||||
import type SearchContactsFilter from "./SearchContactsFilter";
|
||||
import type { LeadEngagement, LeadStatus } from "./Contact";
|
||||
|
||||
export default interface SearchContacts {
|
||||
query: string;
|
||||
filters: SearchContactsFilter[];
|
||||
campaign_ids: string[];
|
||||
// Campaign Leads view only (exactly one campaign_id): narrow to one derived
|
||||
// lead status and/or one engagement bucket. The server ANDs the two.
|
||||
lead_status?: LeadStatus;
|
||||
engagement?: LeadEngagement;
|
||||
category_ids?: string[];
|
||||
min_campaigns?: number;
|
||||
max_campaigns?: number;
|
||||
|
||||
@@ -33,6 +33,12 @@ export interface CampaignLeadCounts {
|
||||
unsubscribed: number;
|
||||
// Leads the campaign will never send to: address verification refused them.
|
||||
undeliverable: number;
|
||||
// Engagement totals matching the `engagement` filter: leads sent at least
|
||||
// one step, and of those the ones with a human open, click, or reply.
|
||||
contacted: number;
|
||||
opened: number;
|
||||
clicked: number;
|
||||
replied_any: number;
|
||||
}
|
||||
|
||||
export default interface SearchContactsResult {
|
||||
|
||||
Reference in New Issue
Block a user