Merge pull request #270 from warmbly/feature/contact-segments

feat: contact segments (saved audiences) with live SQL evaluation, manual overrides and one-step campaign enrolment
This commit is contained in:
Matthew Meszaros
2026-08-30 02:36:29 -07:00
committed by GitHub
61 changed files with 5982 additions and 741 deletions
+16
View File
@@ -78,6 +78,7 @@ import (
"github.com/warmbly/warmbly/internal/app/releases"
"github.com/warmbly/warmbly/internal/app/replyclassify"
"github.com/warmbly/warmbly/internal/app/research"
"github.com/warmbly/warmbly/internal/app/segment"
"github.com/warmbly/warmbly/internal/app/sequence"
"github.com/warmbly/warmbly/internal/app/settings"
"github.com/warmbly/warmbly/internal/app/skills"
@@ -162,6 +163,7 @@ func main() {
var rateLimitService ratelimit.RateLimitService
var sequenceService sequence.SequenceService
var contactService contact.ContactService
var segmentService segment.Service
var websiteTrackingService websitetracking.Service
var socketService socket.SocketService
var uniboxService unibox.UniboxService
@@ -1191,6 +1193,8 @@ func main() {
rateLimitService = ratelimit.NewService(cache, rateLimitRepository)
sequenceService = sequence.NewService(sequenceRepostory)
contactService = contact.NewService(contactRepostory, subscriptionRepository, planRepository, streamingPublisher)
segmentRepository := repository.NewSegmentRepository(primaryDB)
segmentService = segment.NewService(segmentRepository, contactRepostory)
// A visibly bad import is filed on the workspace's posture. On its own
// it can only reach `watch`, which changes nothing.
if aware, ok := contactService.(contact.OrgRiskAware); ok && orgRiskService != nil {
@@ -1282,6 +1286,9 @@ func main() {
// parked send chain, or the lead sits queued until the chain's next
// tick. Wired here because contactService is built before the scheduler
// and Cloud Tasks client exist.
if segmentService != nil {
segmentService.SetCampaignWaker(campaignService)
}
if contactService != nil {
contactService.SetCampaignWaker(campaignService)
// The contact drawer's "next action" is a read-only pass through
@@ -1484,6 +1491,14 @@ func main() {
trackedLinkRepository,
integrationServiceForHandler, // AutomationRunner for campaign run_automation steps
)
// Sequence action nodes that pin a contact into or out of a segment,
// both on the scheduled path (tasks) and the instant reply path (advanced).
if aware, ok := tasksService.(tasks.SegmentAware); ok {
aware.WireSegments(segmentRepository)
}
if aware, ok := advancedService.(advanced.SegmentAware); ok {
aware.WireSegments(segmentRepository)
}
// A restricted organization warms in the free pool, whatever it pays.
if aware, ok := tasksService.(tasks.OrgRiskAware); ok {
aware.WireOrgRisk(orgRiskRepository)
@@ -1814,6 +1829,7 @@ func main() {
AnalyticsService: analyticsService,
RateLimitService: rateLimitService,
ContactService: contactService,
SegmentService: segmentService,
SequenceService: sequenceService,
UniboxService: uniboxService,
+4
View File
@@ -292,6 +292,10 @@ func main() {
nil, // tasksClient: the consumer does not schedule Cloud Tasks
warmupService,
)
// Instant reply actions can pin a contact into or out of a segment.
if aware, ok := advancedService.(advanced.SegmentAware); ok {
aware.WireSegments(repository.NewSegmentRepository(primaryDB))
}
advancedService.WireDispatcher(webhookService)
// Replies, bounces, opens and clicks teach verification what real mail
// showed about each address.
+19
View File
@@ -84,6 +84,7 @@ All paths below are relative to the versioned base URL `https://api.warmbly.com/
| GET | `/contacts/:id/emails` | `READ_CONTACTS` |
| GET | `/contacts/:id/timeline` | `READ_CONTACTS` |
| GET | `/contacts/:id/campaigns` | `READ_CONTACTS` |
| GET | `/contacts/:id/segments` | `READ_CONTACTS` |
| POST | `/contacts/export` | `READ_CONTACTS` |
| POST | `/contacts/import/preview` | `WRITE_CONTACTS` |
| POST | `/contacts/import/commit` | `BULK_CONTACTS` |
@@ -103,6 +104,24 @@ The import pair is two steps over the same file: preview parses it and suggests
AI contact research charges credits (2 per run, billable even when it finds nothing) and only saves cited findings. See the [AI contact research](/guides/ai-contact-research/) guide. The batch endpoint accepts up to 500 contact ids and drains in the background.
### Segments
| Method | Path | API Permission |
|--------|------|----------------|
| GET | `/segments` | `READ_CONTACTS` |
| GET | `/segments/fields` | `READ_CONTACTS` |
| POST | `/segments/preview` | `READ_CONTACTS` |
| POST | `/segments` | `WRITE_CONTACTS` |
| GET | `/segments/:id` | `READ_CONTACTS` |
| PATCH | `/segments/:id` | `WRITE_CONTACTS` |
| DELETE | `/segments/:id` | `WRITE_CONTACTS` |
| POST | `/segments/:id/members` | `WRITE_CONTACTS` |
| POST | `/segments/:id/members/lookup` | `READ_CONTACTS` |
| GET | `/segments/:id/overrides` | `READ_CONTACTS` |
| POST | `/segments/:id/add-to-campaign` | `WRITE_CAMPAIGNS` |
Segments are saved contact audiences: a condition list plus per-contact manual overrides, evaluated live. Contact scopes cover them because a segment is a view over contacts; enrolling one into a campaign writes leads, so that call takes the campaign write scope. `POST /contacts/search` and `POST /contacts/export` accept `segment_ids` to scope any contact query to a segment. See [contacts](/api/reference/contacts/#segments) for the condition format.
### Lead sync
Saved Google Sheets sources that upsert contacts on demand. Gated under the contact write scope because a sync ultimately writes contacts. Nothing syncs on a timer: a source only runs when `/lead-sync/sources/:id/sync` is called.
@@ -877,7 +877,7 @@ All fields optional.
| `wait_after` | integer | no | Days to wait before this step, counted from the contact's previous step (`0` to `60`). Spacing belongs to the target step, so there is no standalone wait node for email steps. |
| `conditions` | object | no | The connections out of this step (`{branches: [...]}`), evaluated in order; a branch with no `conditions` is a plain "go there next" link. Routing follows connections only: a step with `{}` or no branches has no outgoing path and ends the flow for the contact. |
| `kind` | string | no | `email` (default), `action`, or `wait`. |
| `action` | object | no | Typed config for non-email nodes. `type` is the switch (`wait`, `add_tag`, `remove_tag`, `unsubscribe`, `notify`, `create_task`, `create_deal`, `move_deal_stage`, `run_automation`, `end`), the remaining fields are type-scoped. |
| `action` | object | no | Typed config for non-email nodes. `type` is the switch (`wait`, `add_tag`, `remove_tag`, `add_to_segment`, `remove_from_segment` (each with a `segment_id`), `unsubscribe`, `notify`, `create_task`, `create_deal`, `move_deal_stage`, `run_automation`, `end`), the remaining fields are type-scoped. |
```json
{
@@ -31,6 +31,7 @@ Every field is optional; an empty body matches all contacts in the organization.
| `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. |
| `segment_ids` | string[] | No | Contact must be a member of ALL of these segments (conditions plus manual overrides). An id that is not a valid UUID is rejected with `400`; an unknown segment matches nothing. |
| `verification_status` | string | No | Filter by verification verdict: `valid`, `risky`, `invalid`, or `unknown`. |
| `min_campaigns` | integer | No | Minimum number of associated campaigns. |
| `max_campaigns` | integer | No | Maximum number of associated campaigns. |
@@ -978,3 +979,95 @@ Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts`
```
`status` is `open`, `won`, or `lost`. `value`, `expected_close_date`, `won_at`, `lost_at`, `lost_reason`, `assigned_to`, `campaign_id`, and `source_mailbox_id` are nullable and omitted when unset.
## Segments
Segments are saved contact audiences: a list of conditions plus per-contact manual overrides. Membership is evaluated live on every read, so a segment never needs rebuilding. Every segment endpoint takes the contact scopes, except enrolling into a campaign, which writes leads and takes `WRITE_CAMPAIGNS`.
A segment object:
```json
{
"id": "0b6f9c3e-2f7a-4c0e-9d8e-1a2b3c4d5e6f",
"organization_id": "…",
"name": "Warm fintech leads",
"description": "Opened in the last 30 days, not yet replied",
"color": "#0284c7",
"match": "all",
"conditions": [
{ "field": "custom.industry", "operator": "equals", "value": "fintech" },
{ "field": "last_opened_at", "operator": "within_days", "value": "30" },
{ "field": "emails_replied", "operator": "equals", "value": "0" }
],
"contact_count": 412,
"included_count": 3,
"excluded_count": 1,
"created_at": "2026-08-01T09:12:00Z",
"updated_at": "2026-08-20T14:03:00Z"
}
```
`match` is `all` or `any`. A contact is a member when it matches the conditions or is manually included, and is not manually excluded. A segment with no conditions holds only its manual includes.
### Conditions
Each condition names a `field`, an `operator`, and either a `value` (scalar operators) or `values` (list operators). Fields and their kinds are returned by `GET /segments/fields`, including the workspace's custom fields as `custom.<key>` (written with the key in place of the angle-bracket placeholder, for example `custom.industry`).
| Kind | Fields | Operators | Value |
| --- | --- | --- | --- |
| text | `first_name`, `last_name`, `email`, `email_domain`, `phone`, `company`, `custom.*` | `equals`, `not_equals`, `contains`, `not_contains`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty` | `value` string; comparisons ignore case |
| enum | `source`, `verification_status`, `esp_provider` | `in`, `not_in` | `values`, drawn from the field's `options` |
| bool | `subscribed`, `suppressed`, `is_catch_all` | `is_true`, `is_false` | none |
| date | `created_at`, `updated_at`, `last_sent_at`, `last_opened_at`, `last_clicked_at`, `last_replied_at` | `within_days`, `not_within_days` (`value` is a day count, 1 to 3650); `before`, `after` (`value` is `YYYY-MM-DD` or RFC 3339); `is_empty`, `is_not_empty` | see operators |
| number | `campaign_count`, `emails_sent`, `emails_opened`, `emails_clicked`, `emails_replied`, `emails_bounced` | `equals`, `not_equals`, `gt`, `gte`, `lt`, `lte` | `value`, a whole number |
| category | `category` | `in`, `not_in`, `is_empty`, `is_not_empty` | `values`, category ids |
| campaign | `campaign` | `in`, `not_in`, `is_empty`, `is_not_empty` | `values`, campaign ids |
| segment | `segment` | `in`, `not_in` | `values`, segment ids; at most five levels deep, no loops |
Engagement counters add up every campaign the contact has been in, and opens count human opens only. Limits: 50 conditions per segment, 200 values per list condition, 200 segments per workspace. A condition that fails validation is rejected with `400` and a message naming the condition.
### List, create, read, update, delete
`GET /segments` returns every segment with live counts under `data`. `POST /segments` creates one from `name` (required), `description`, `color` (`#rrggbb`), `match` and `conditions`; a duplicate name is a `409`. `GET /segments/:id` returns one segment. `PATCH /segments/:id` accepts the same fields, all optional. `DELETE /segments/:id` returns `204`, or `409` when another segment's conditions reference it.
Auth: **Scope** `READ_CONTACTS` for reads, `WRITE_CONTACTS` for writes · **Org permission** `view_contacts` / `manage_contacts`
### Preview a definition
`POST /segments/preview`
Counts the contacts an unsaved definition would match. Send `match` and `conditions`; include `id` to keep that segment's manual overrides in the count while editing it.
```json
{ "contact_count": 412 }
```
### Manual overrides
`POST /segments/:id/members`
```json
{ "contacts": ["…", "…"], "mode": "include" }
```
`mode` is `include` (pin in), `exclude` (pin out) or `auto` (clear the override). Up to 1,000 contact ids per call; ids outside the organization are ignored. Returns `{ "updated": n }`.
`POST /segments/:id/members/lookup` takes `{ "contacts": [...] }` and returns `{ "data": { "<contact id>": "include" | "exclude" } }` for the contacts that carry an override.
`GET /segments/:id/overrides` lists every pinned contact (`contact_id`, `first_name`, `last_name`, `email`, `company`, `mode`, `created_at`) under `data`, includes first, newest first, capped at 500.
`GET /contacts/:id/segments` is the contact-side view: every segment in the organization with `member` (whether the contact is in it right now) and `mode` (its override, when any) under `data`.
Sequence action steps `add_to_segment` and `remove_from_segment` take a `segment_id` and apply the include or exclude override to the contact when the step runs; see [campaigns](/api/reference/campaigns/).
### Add to a campaign
`POST /segments/:id/add-to-campaign`
```json
{ "campaign_id": "…" }
```
Enrols every current member as a lead. Contacts already in the campaign are skipped, each new lead gets a `campaign_added` activity, and a running campaign is woken so the leads are scheduled. Returns `{ "campaign_id", "added", "members" }`. This is a snapshot: later members are not added until the call is repeated. Safe to retry.
Auth: **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`
+10 -1
View File
@@ -65,13 +65,21 @@ Both forms work everywhere, including inside `{{if}}` conditions and helper func
See [Personalization & expressions](/guides/expressions/) for the full templating language.
## Filtering the list
A filter bar sits above the contact list. **Category**, **Segment**, **Status** and **Campaign** are always there; open one, tick values, and the list updates immediately with the matching count next to the bar. **Add filter** adds a custom-field condition (field, contains/is/starts with/ends with, value), a date-added or last-updated range, a number-of-campaigns range, the address verification verdict and, on a campaign's Leads tab, lead status and engagement. Each active filter is a pill you can reopen to change or remove with its cross; **Clear** drops them all, and **Save as segment** turns the current set into a [segment](/guides/segments/). Free-text search and sort stay in the toolbar.
## Categories
Colored labels that group and filter contacts (`Warm lead`, `Conference 2026`, `Enterprise`), behaving like tags. The same picker appears in bulk edit, a contact's Details tab, the new-contact dialog, the filters, and the import and sync wizards.
It supports type-ahead search, and typing an unmatched name offers **Create** to add and select it in one step. Each category keeps its color everywhere its chip appears.
Categories also drive automation: a sequence can run **Add tag** or **Remove tag** as a contact moves through a flow, so a label can be applied automatically on a positive reply.
**Contacts > Categories** lists every category with a live contact count. From there you can create one, rename it, change its color, delete it (contacts are kept; the label is removed from them and from inbox threads), or click a row to open the contact list filtered to it.
Categories also drive automation: a sequence can run **Add tag** or **Remove tag** as a contact moves through a flow, so a label can be applied automatically on a positive reply. **Add to segment** and **Remove from segment** do the same for [segments](/guides/segments/).
To turn labels and activity into a reusable audience, build a [segment](/guides/segments/): a saved set of conditions over contacts (categories, fields, campaign activity, engagement) that you can browse and add to a campaign in one step.
## Where a contact came from
@@ -121,6 +129,7 @@ This is the safe default response to a bad signal: stop sending rather than keep
## Where to go next
<Cards>
<Card title="Segments" href="/guides/segments/" />
<Card title="Personalization & expressions" href="/guides/expressions/" />
<Card title="Deliverability" href="/guides/deliverability/" />
<Card title="Automations" href="/guides/automations/" />
+1
View File
@@ -15,6 +15,7 @@
"advisor",
"---Contacts and inbox---",
"contacts-crm",
"segments",
"website-tracking",
"unibox",
"meetings",
+71
View File
@@ -0,0 +1,71 @@
---
title: Segments
description: Save reusable audiences built from contact fields, categories, campaign activity and email engagement, then add them to campaigns in one step.
---
A segment is a saved audience: a set of conditions over your contacts plus any contacts you pin in or out by hand. A segment says which contacts belong together; a campaign says what happens to them. Membership is evaluated live, so a contact that starts matching (a new category, a reply, a bounce) is in the segment the next time anyone looks, with no rebuild step.
Segments live under **Contacts > Segments**, next to **All contacts** and **Categories**, and need the same permissions as contacts: **View contacts** to browse, **Manage contacts** to create, edit or delete.
## Building a segment
The quickest start is from the contact list: set any filters in the bar above the list, then press **Save as segment** and the editor opens with those filters as conditions (free-text search and a campaign's lead status do not carry over; add a condition for them instead). Or start from scratch with **New segment** on the Segments tab.
Give the segment a name and a color, then add conditions. Each condition is a field, an operator and a value. Pick whether a contact must match **all** conditions or **any** of them. The drawer shows a live count of matching contacts as you edit, so you can see the effect of every change before saving.
| Group | Fields | Operators |
|-------|--------|-----------|
| Contact | First name, last name, email, email domain, phone, and every custom field | is, is not, contains, does not contain, starts with, ends with, is empty, is not empty |
| Contact | Subscribed, on the suppression list, catch-all domain | is yes, is no |
| Contact | Source, verification status, email provider | is any of, is none of |
| Contact | Created, updated | in the last N days, not in the last N days, after, before |
| Contact | Category | has any of, has none of, has none, has any |
| Company | Company name | the text operators above |
| Campaign activity | In campaign | is in any of, is in none of, is in no campaign, is in a campaign |
| Campaign activity | Number of campaigns | is, is not, more than, at least, less than, at most |
| Email engagement | Emails sent, emails opened, links clicked, replies, bounces | the number operators above |
| Email engagement | Last email sent, last open, last click, last reply | in the last N days, not in the last N days, after, before, never, ever |
| Segments | In segment | is in any of, is in none of |
Engagement counts add up every campaign the contact has been in. Opens count human opens only; automated fetches are ignored, the same way the campaign analytics report them. Text comparisons ignore case.
A segment can be built on other segments (**In segment**), up to five levels deep. A segment cannot reference itself or form a loop, and a segment that others depend on cannot be deleted until those references are removed.
A segment with no conditions is a manual list: it holds only the contacts you add by hand.
## Adding and removing contacts by hand
Conditions decide membership, and two overrides sit on top of them:
- **Add contacts** on a segment page (or **Segment** in the selection bar of any contact list) pins contacts in. They stay members whatever the conditions say.
- **Remove from segment** in the selection bar of a segment's member list pins contacts out. They stay out even while the conditions still match them.
The segment header shows how many contacts are pinned in or out, and a **Pinned contacts** panel below it lists them; **Back to automatic** clears an override so the conditions decide again. A contact's own drawer has a **Segments** section that shows every segment, whether the contact is in it, and the same pin in, pin out and back-to-automatic controls.
Sequences can pin as well: the **Add to segment** and **Remove from segment** action steps apply the override to a contact as it moves through a campaign flow, so a positive reply can drop someone into a "warm" segment automatically.
## Using a segment
- **Browse**: a segment page lists its current members with the same table, filters, detail drawer and bulk actions as the contacts page.
- **Add to campaign**: enrols every current member as a lead of the campaign you pick, from the segment page or with **From segment** on a campaign's Leads tab. Contacts already in that campaign are skipped, and a running campaign wakes up to schedule the new leads. This is a snapshot: contacts who join the segment later are not added until you run it again.
- **Filter**: the contact list's filter bar has a **Segment** pill, and the same scope carries into an export.
- **Duplicate**: the segment menu copies a definition to start a variation from.
- **Search and export**: the contact search and export accept `segment_ids`, so anything that takes a contact filter can be scoped to a segment.
<Callout type="info" title="Segments and categories">
Categories are labels you put on a contact. Segments are rules that read those labels (and everything else) to decide who belongs. Use a category to mark a fact about a contact, and a segment to describe an audience.
</Callout>
## Limits
- 200 segments per workspace
- 50 conditions per segment, 200 values per list condition
- 1,000 contacts per manual add or remove request
## Where to go next
<Cards>
<Card title="Contacts and CRM" href="/guides/contacts-crm/" />
<Card title="Campaigns" href="/guides/campaigns/" />
<Card title="Analytics" href="/guides/analytics/" />
</Cards>
@@ -14,7 +14,7 @@ The data is split into groups. Every export includes **Workspace**; the rest are
| Group | Contents |
|-------|----------|
| Workspace | The organization, members, roles, teams, mailboxes, API keys, webhooks, and settings, including the website tracking site key. Always included |
| Contacts | Contacts, categories, notes, activities, and the suppression list |
| Contacts | Contacts, categories, segments with their manual overrides, notes, activities, and the suppression list |
| Campaigns | Campaigns, sequences, senders, attachments, and per-campaign settings |
| CRM | Pipelines, deals, tasks, and meeting bookings |
| Automations | Automations, connected integrations, and lead sync sources |
+2
View File
@@ -44,6 +44,7 @@ import (
"github.com/warmbly/warmbly/internal/app/referral"
"github.com/warmbly/warmbly/internal/app/releases"
"github.com/warmbly/warmbly/internal/app/research"
"github.com/warmbly/warmbly/internal/app/segment"
"github.com/warmbly/warmbly/internal/app/sequence"
"github.com/warmbly/warmbly/internal/app/skills"
"github.com/warmbly/warmbly/internal/app/socket"
@@ -99,6 +100,7 @@ type Handler struct {
EmailService email.EmailService
CampaignService campaign.CampaignService
ContactService contact.ContactService
SegmentService segment.Service
SequenceService sequence.SequenceService
UniboxService unibox.UniboxService
+243
View File
@@ -0,0 +1,243 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// segmentScope pulls the org and the segment id (when the route has one).
func segmentScope(c *gin.Context) (uuid.UUID, uuid.UUID, bool) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
return uuid.Nil, uuid.Nil, false
}
raw := c.Param("id")
if raw == "" {
return *orgID, uuid.Nil, true
}
id, err := uuid.Parse(raw)
if err != nil {
errx.Handle(c, errx.New(errx.BadRequest, "invalid segment id"))
return uuid.Nil, uuid.Nil, false
}
return *orgID, id, true
}
func (h *Handler) ListSegments(c *gin.Context) {
orgID, _, ok := segmentScope(c)
if !ok {
return
}
out, xerr := h.SegmentService.List(c.Request.Context(), orgID)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
func (h *Handler) ListSegmentFields(c *gin.Context) {
orgID, _, ok := segmentScope(c)
if !ok {
return
}
out, xerr := h.SegmentService.Fields(c.Request.Context(), orgID)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
func (h *Handler) GetSegment(c *gin.Context) {
orgID, id, ok := segmentScope(c)
if !ok {
return
}
out, xerr := h.SegmentService.Get(c.Request.Context(), orgID, id)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, out)
}
func (h *Handler) CreateSegment(c *gin.Context) {
orgID, _, ok := segmentScope(c)
if !ok {
return
}
var in models.SegmentWrite
if err := c.ShouldBindJSON(&in); err != nil {
errx.Handle(c, errx.ErrInvalid)
return
}
var createdBy *uuid.UUID
if uid, err := middleware.GetUserUUID(c); err == nil {
createdBy = &uid
}
out, xerr := h.SegmentService.Create(c.Request.Context(), orgID, createdBy, &in)
if xerr != nil {
errx.Handle(c, xerr)
return
}
h.auditOrg(c, models.AuditActionCreate, models.AuditEntitySegment, &out.ID, nil, map[string]string{"name": out.Name})
c.JSON(http.StatusCreated, out)
}
func (h *Handler) UpdateSegment(c *gin.Context) {
orgID, id, ok := segmentScope(c)
if !ok {
return
}
var in models.SegmentWrite
if err := c.ShouldBindJSON(&in); err != nil {
errx.Handle(c, errx.ErrInvalid)
return
}
out, xerr := h.SegmentService.Update(c.Request.Context(), orgID, id, &in)
if xerr != nil {
errx.Handle(c, xerr)
return
}
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntitySegment, &out.ID, nil, map[string]string{"name": out.Name})
c.JSON(http.StatusOK, out)
}
func (h *Handler) DeleteSegment(c *gin.Context) {
orgID, id, ok := segmentScope(c)
if !ok {
return
}
if xerr := h.SegmentService.Delete(c.Request.Context(), orgID, id); xerr != nil {
errx.Handle(c, xerr)
return
}
h.auditOrg(c, models.AuditActionDelete, models.AuditEntitySegment, &id, nil, nil)
c.Status(http.StatusNoContent)
}
// PreviewSegment counts the contacts an unsaved definition would match.
func (h *Handler) PreviewSegment(c *gin.Context) {
orgID, _, ok := segmentScope(c)
if !ok {
return
}
var in models.SegmentPreview
if err := c.ShouldBindJSON(&in); err != nil {
errx.Handle(c, errx.ErrInvalid)
return
}
n, xerr := h.SegmentService.Preview(c.Request.Context(), orgID, &in)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"contact_count": n})
}
// SetSegmentMembers writes a manual include/exclude override (or clears it).
func (h *Handler) SetSegmentMembers(c *gin.Context) {
orgID, id, ok := segmentScope(c)
if !ok {
return
}
var in models.SegmentMembersWrite
if err := c.ShouldBindJSON(&in); err != nil {
errx.Handle(c, errx.ErrInvalid)
return
}
n, xerr := h.SegmentService.SetMembers(c.Request.Context(), orgID, id, &in)
if xerr != nil {
errx.Handle(c, xerr)
return
}
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntitySegment, &id, nil, map[string]string{"members": string(in.Mode)})
c.JSON(http.StatusOK, gin.H{"updated": n})
}
// GetSegmentMemberModes reports the manual override of the given contacts.
func (h *Handler) GetSegmentMemberModes(c *gin.Context) {
orgID, id, ok := segmentScope(c)
if !ok {
return
}
var in struct {
Contacts []string `json:"contacts"`
}
if err := c.ShouldBindJSON(&in); err != nil {
errx.Handle(c, errx.ErrInvalid)
return
}
modes, xerr := h.SegmentService.MemberModes(c.Request.Context(), orgID, id, in.Contacts)
if xerr != nil {
errx.Handle(c, xerr)
return
}
out := map[string]models.SegmentMemberMode{}
for k, v := range modes {
out[k.String()] = v
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
// AddSegmentToCampaign enrols the segment's current members as campaign leads.
func (h *Handler) AddSegmentToCampaign(c *gin.Context) {
orgID, id, ok := segmentScope(c)
if !ok {
return
}
var in models.SegmentAddToCampaign
if err := c.ShouldBindJSON(&in); err != nil {
errx.Handle(c, errx.ErrInvalid)
return
}
res, xerr := h.SegmentService.AddToCampaign(c.Request.Context(), orgID, middleware.GetUserID(c), id, &in)
if xerr != nil {
errx.Handle(c, xerr)
return
}
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityCampaign, &res.CampaignID, nil, map[string]string{"segment_id": id.String(), "added": itoa(res.Added)})
c.JSON(http.StatusOK, res)
}
// ListSegmentOverrides lists the contacts pinned into or out of a segment.
func (h *Handler) ListSegmentOverrides(c *gin.Context) {
orgID, id, ok := segmentScope(c)
if !ok {
return
}
out, xerr := h.SegmentService.Overrides(c.Request.Context(), orgID, id)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
// ListContactSegments reports every segment of the org with whether this
// contact is a member and any manual override on it.
func (h *Handler) ListContactSegments(c *gin.Context) {
contactID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.Handle(c, errx.ErrUuid)
return
}
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
out, xerr := h.SegmentService.SegmentsForContact(c.Request.Context(), *orgID, contactID)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
+19
View File
@@ -578,6 +578,7 @@ func Run(
contacts.GET("/:id/emails", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactEmails)
contacts.GET("/:id/timeline", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactTimeline)
contacts.GET("/:id/campaigns", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactCampaignStates)
contacts.GET("/:id/segments", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactSegments)
// AI contact research (dedicated AI_RESEARCH scope; JWT callers by
// the matching contact permission). Batch queues and drains in the
@@ -596,6 +597,24 @@ func Run(
}
// Group endpoints map to the resources they organize: campaign
// Segments: saved contact audiences. Contact permissions on both
// sides, since a segment is only a view over contacts.
segments := protected.Group("/segments")
segments.Use(m.RateLimitMiddleware(models.RateLimitWrite))
{
segments.GET("", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListSegments)
segments.GET("/fields", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListSegmentFields)
segments.POST("/preview", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.PreviewSegment)
segments.POST("", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.CreateSegment)
segments.GET("/:id", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.GetSegment)
segments.PATCH("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.UpdateSegment)
segments.DELETE("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.DeleteSegment)
segments.POST("/:id/members", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.SetSegmentMembers)
segments.POST("/:id/members/lookup", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.GetSegmentMemberModes)
segments.GET("/:id/overrides", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListSegmentOverrides)
segments.POST("/:id/add-to-campaign", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.AddSegmentToCampaign)
}
// folders, email-account tags, and contact categories.
grouph.New(protected, h.FolderService, h.AuditService, "folders", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns))
grouph.New(protected, h.TagService, h.AuditService, "tags", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails))
+10
View File
@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
// EventDispatcher fans a platform event out to customer webhooks and, via the
@@ -22,6 +23,15 @@ type EventDispatcher interface {
// way (rather than via the constructor) so the dispatcher — which itself may
// depend on services constructed later — can be supplied once the graph is
// fully wired. No-op if never called: emit() guards on a nil dispatcher.
// WireSegments attaches the segment repository the instant add_to_segment /
// remove_from_segment actions write through.
func (s *service) WireSegments(r repository.SegmentRepository) { s.segmentRepo = r }
// SegmentAware is the optional capability the caller uses to attach segments.
type SegmentAware interface {
WireSegments(r repository.SegmentRepository)
}
func (s *service) WireDispatcher(d EventDispatcher) {
s.dispatcher = d
}
+11
View File
@@ -269,6 +269,17 @@ func (s *service) executeInstantActionNode(ctx context.Context, campaign *models
}); xerr != nil {
s.logActionErr(campaign, contact, cfg.Type, eventKind, xerr)
}
case "add_to_segment", "remove_from_segment":
if cfg.SegmentID == nil || campaign.OrganizationID == nil || s.segmentRepo == nil {
return
}
mode := models.SegmentMemberInclude
if cfg.Type == "remove_from_segment" {
mode = models.SegmentMemberExclude
}
if _, xerr := s.segmentRepo.SetMembers(ctx, *campaign.OrganizationID, *cfg.SegmentID, []uuid.UUID{contact.ID}, mode); xerr != nil {
s.logActionErr(campaign, contact, cfg.Type, eventKind, xerr)
}
case "label_email":
// Label the conversation the contact just replied on. The most recent
// thread for the contact in the campaign owner's unibox is that reply.
+1
View File
@@ -157,6 +157,7 @@ type service struct {
emailRepo repository.EmailRepository
taskRepo repository.TaskRepository
contactRepo repository.ContactRepository
segmentRepo repository.SegmentRepository
campaignProgressRepo repository.CampaignProgressRepository
crmRepo repository.CRMRepository
categoryRepo repository.GroupRepository
+9
View File
@@ -263,6 +263,15 @@ var Tables = []Table{
Name: "contact_notes", Group: models.OrgDataGroupContacts,
Scope: scopeOrg,
},
{
Name: "segments", Group: models.OrgDataGroupContacts,
Scope: scopeOrg,
Note: "Conditions travel as written; ones naming a campaign or category still match once that group arrives.",
},
{
Name: "segment_members", Group: models.OrgDataGroupContacts,
Scope: `segment_id IN (SELECT id FROM segments WHERE organization_id = $1)`,
},
{
Name: "contact_activities", Group: models.OrgDataGroupContacts,
Scope: scopeOrg,
+292
View File
@@ -0,0 +1,292 @@
// Package segment manages saved contact audiences; membership is computed at
// read time, so nothing here schedules work.
package segment
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
// CampaignWaker wakes a campaign's parked send chain after leads are added.
type CampaignWaker interface {
WakeCampaigns(ctx context.Context, orgID uuid.UUID, campaignIDs []string)
}
type Service interface {
List(ctx context.Context, orgID uuid.UUID) ([]models.Segment, *errx.Error)
Get(ctx context.Context, orgID, id uuid.UUID) (*models.Segment, *errx.Error)
Create(ctx context.Context, orgID uuid.UUID, createdBy *uuid.UUID, in *models.SegmentWrite) (*models.Segment, *errx.Error)
Update(ctx context.Context, orgID, id uuid.UUID, in *models.SegmentWrite) (*models.Segment, *errx.Error)
Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error
Preview(ctx context.Context, orgID uuid.UUID, in *models.SegmentPreview) (int, *errx.Error)
SetMembers(ctx context.Context, orgID, id uuid.UUID, in *models.SegmentMembersWrite) (int, *errx.Error)
MemberModes(ctx context.Context, orgID, id uuid.UUID, contactIDs []string) (map[uuid.UUID]models.SegmentMemberMode, *errx.Error)
AddToCampaign(ctx context.Context, orgID uuid.UUID, actor string, id uuid.UUID, in *models.SegmentAddToCampaign) (*models.SegmentAddToCampaignResult, *errx.Error)
// Fields describes every filterable field for the condition builder.
Fields(ctx context.Context, orgID uuid.UUID) ([]models.SegmentFieldSpec, *errx.Error)
SegmentsForContact(ctx context.Context, orgID, contactID uuid.UUID) ([]models.ContactSegment, *errx.Error)
Overrides(ctx context.Context, orgID, id uuid.UUID) ([]models.SegmentOverride, *errx.Error)
SetCampaignWaker(w CampaignWaker)
}
// CustomFieldLister is the slice of the contact repository Fields needs.
type CustomFieldLister interface {
DistinctCustomFieldKeys(ctx context.Context, orgID uuid.UUID) ([]string, error)
}
type service struct {
repo repository.SegmentRepository
fields CustomFieldLister
waker CampaignWaker
}
func NewService(repo repository.SegmentRepository, fields CustomFieldLister) Service {
return &service{repo: repo, fields: fields}
}
func (s *service) SetCampaignWaker(w CampaignWaker) { s.waker = w }
func (s *service) List(ctx context.Context, orgID uuid.UUID) ([]models.Segment, *errx.Error) {
return s.repo.List(ctx, orgID)
}
func (s *service) Get(ctx context.Context, orgID, id uuid.UUID) (*models.Segment, *errx.Error) {
return s.repo.Get(ctx, orgID, id)
}
// applyWrite folds a create/update body into seg, validating each field it
// sets. selfID guards self-reference on update.
func applyWrite(seg *models.Segment, in *models.SegmentWrite, selfID *uuid.UUID) *errx.Error {
if in.Name != nil {
name, xerr := models.ValidateSegmentName(*in.Name)
if xerr != nil {
return xerr
}
seg.Name = name
}
if in.Description != nil {
d := strings.TrimSpace(*in.Description)
if len(d) > models.SegmentMaxDescLen {
return errx.New(errx.BadRequest, fmt.Sprintf("description must be at most %d characters", models.SegmentMaxDescLen))
}
seg.Description = d
}
if in.Color != nil {
color, xerr := models.ValidateSegmentColor(*in.Color)
if xerr != nil {
return xerr
}
seg.Color = color
}
if in.Match != nil {
if xerr := models.ValidateSegmentMatch(*in.Match); xerr != nil {
return xerr
}
seg.Match = *in.Match
}
if in.Conditions != nil {
conds := *in.Conditions
if conds == nil {
conds = []models.SegmentCondition{}
}
if xerr := models.ValidateSegmentConditions(conds, selfID); xerr != nil {
return xerr
}
seg.Conditions = conds
}
return nil
}
// checkReferences makes sure every referenced segment exists in the org and
// that following references from it never leads back to selfID.
func (s *service) checkReferences(ctx context.Context, orgID uuid.UUID, conds []models.SegmentCondition, selfID *uuid.UUID) *errx.Error {
refs := models.SegmentReferences(conds)
if len(refs) == 0 {
return nil
}
seen := map[uuid.UUID]bool{}
pending := refs
for depth := 0; len(pending) > 0; depth++ {
if depth > models.SegmentMaxNestingDeep {
return errx.New(errx.BadRequest, fmt.Sprintf("segments can be nested at most %d levels deep", models.SegmentMaxNestingDeep))
}
var next []uuid.UUID
for _, id := range pending {
if selfID != nil && id == *selfID {
return errx.New(errx.BadRequest, "segments cannot reference each other in a loop")
}
if seen[id] {
continue
}
seen[id] = true
ref, xerr := s.repo.Get(ctx, orgID, id)
if xerr != nil {
if xerr.Code == errx.NotFound {
return errx.New(errx.BadRequest, "a referenced segment does not exist")
}
return xerr
}
next = append(next, models.SegmentReferences(ref.Conditions)...)
}
pending = next
}
return nil
}
func (s *service) Create(ctx context.Context, orgID uuid.UUID, createdBy *uuid.UUID, in *models.SegmentWrite) (*models.Segment, *errx.Error) {
seg := &models.Segment{Color: "#0284c7", Match: models.SegmentMatchAll, Conditions: []models.SegmentCondition{}}
if in.Name == nil {
return nil, errx.New(errx.BadRequest, "segment name is required")
}
if xerr := applyWrite(seg, in, nil); xerr != nil {
return nil, xerr
}
if xerr := s.checkReferences(ctx, orgID, seg.Conditions, nil); xerr != nil {
return nil, xerr
}
return s.repo.Create(ctx, orgID, createdBy, seg)
}
func (s *service) Update(ctx context.Context, orgID, id uuid.UUID, in *models.SegmentWrite) (*models.Segment, *errx.Error) {
seg, xerr := s.repo.Get(ctx, orgID, id)
if xerr != nil {
return nil, xerr
}
if xerr := applyWrite(seg, in, &id); xerr != nil {
return nil, xerr
}
if in.Conditions != nil {
if xerr := s.checkReferences(ctx, orgID, seg.Conditions, &id); xerr != nil {
return nil, xerr
}
}
return s.repo.Update(ctx, orgID, seg)
}
func (s *service) Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error {
names, xerr := s.repo.ReferencedBy(ctx, orgID, id)
if xerr != nil {
return xerr
}
if len(names) > 0 {
return errx.New(errx.Conflict, "this segment is used by: "+strings.Join(names, ", ")+". Remove it from those segments first")
}
return s.repo.Delete(ctx, orgID, id)
}
func (s *service) Preview(ctx context.Context, orgID uuid.UUID, in *models.SegmentPreview) (int, *errx.Error) {
if in.Match == "" {
in.Match = models.SegmentMatchAll
}
if xerr := models.ValidateSegmentMatch(in.Match); xerr != nil {
return 0, xerr
}
if in.Conditions == nil {
in.Conditions = []models.SegmentCondition{}
}
if xerr := models.ValidateSegmentConditions(in.Conditions, in.ID); xerr != nil {
return 0, xerr
}
if xerr := s.checkReferences(ctx, orgID, in.Conditions, in.ID); xerr != nil {
return 0, xerr
}
if in.ID != nil {
if _, xerr := s.repo.Get(ctx, orgID, *in.ID); xerr != nil {
return 0, xerr
}
}
return s.repo.Count(ctx, orgID, in.ID, in.Match, in.Conditions)
}
func parseContactIDs(raw []string) ([]uuid.UUID, *errx.Error) {
if len(raw) == 0 {
return nil, errx.New(errx.BadRequest, "no contacts provided")
}
if len(raw) > 1000 {
return nil, errx.New(errx.BadRequest, "at most 1000 contacts per request")
}
out := make([]uuid.UUID, 0, len(raw))
for _, r := range raw {
id, err := uuid.Parse(r)
if err != nil {
return nil, errx.New(errx.BadRequest, "invalid contact id")
}
out = append(out, id)
}
return out, nil
}
func (s *service) SetMembers(ctx context.Context, orgID, id uuid.UUID, in *models.SegmentMembersWrite) (int, *errx.Error) {
switch in.Mode {
case models.SegmentMemberInclude, models.SegmentMemberExclude, models.SegmentMemberAuto:
default:
return 0, errx.New(errx.BadRequest, "mode must be include, exclude or auto")
}
ids, xerr := parseContactIDs(in.Contacts)
if xerr != nil {
return 0, xerr
}
if _, xerr := s.repo.Get(ctx, orgID, id); xerr != nil {
return 0, xerr
}
return s.repo.SetMembers(ctx, orgID, id, ids, in.Mode)
}
func (s *service) MemberModes(ctx context.Context, orgID, id uuid.UUID, contactIDs []string) (map[uuid.UUID]models.SegmentMemberMode, *errx.Error) {
ids, xerr := parseContactIDs(contactIDs)
if xerr != nil {
return nil, xerr
}
if _, xerr := s.repo.Get(ctx, orgID, id); xerr != nil {
return nil, xerr
}
return s.repo.MemberModes(ctx, id, ids)
}
func (s *service) AddToCampaign(ctx context.Context, orgID uuid.UUID, actor string, id uuid.UUID, in *models.SegmentAddToCampaign) (*models.SegmentAddToCampaignResult, *errx.Error) {
campaignID, err := uuid.Parse(in.CampaignID)
if err != nil {
return nil, errx.New(errx.BadRequest, "invalid campaign id")
}
res, xerr := s.repo.AddToCampaign(ctx, orgID, actor, id, campaignID)
if xerr != nil {
return nil, xerr
}
if s.waker != nil && res.Added > 0 {
s.waker.WakeCampaigns(ctx, orgID, []string{campaignID.String()})
}
return res, nil
}
func (s *service) Fields(ctx context.Context, orgID uuid.UUID) ([]models.SegmentFieldSpec, *errx.Error) {
out := make([]models.SegmentFieldSpec, 0, len(models.SegmentFieldCatalog)+16)
out = append(out, models.SegmentFieldCatalog...)
if s.fields == nil {
return out, nil
}
keys, err := s.fields.DistinctCustomFieldKeys(ctx, orgID)
if err != nil {
return nil, errx.InternalError()
}
for _, k := range keys {
out = append(out, models.SegmentFieldSpec{Field: models.SegmentCustomFieldPrefix + k, Label: k, Group: "Custom field", Kind: models.SegmentFieldText})
}
return out, nil
}
func (s *service) SegmentsForContact(ctx context.Context, orgID, contactID uuid.UUID) ([]models.ContactSegment, *errx.Error) {
return s.repo.SegmentsForContact(ctx, orgID, contactID)
}
func (s *service) Overrides(ctx context.Context, orgID, id uuid.UUID) ([]models.SegmentOverride, *errx.Error) {
if _, xerr := s.repo.Get(ctx, orgID, id); xerr != nil {
return nil, xerr
}
return s.repo.ListOverrides(ctx, orgID, id)
}
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS public.segment_members;
DROP TABLE IF EXISTS public.segments;
@@ -0,0 +1,35 @@
-- Contact segments (issue #266). Membership is evaluated at read time; only
-- the definition and the manual overrides are stored.
CREATE TABLE public.segments (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
created_by uuid REFERENCES public.users(id) ON DELETE SET NULL,
name text NOT NULL,
description text NOT NULL DEFAULT '',
color character varying(7) NOT NULL DEFAULT '#0284c7',
match text NOT NULL DEFAULT 'all',
conditions jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT segments_color_check CHECK (color ~* '^#[a-f0-9]{6}$'),
CONSTRAINT segments_match_check CHECK (match IN ('all', 'any')),
CONSTRAINT segments_conditions_check CHECK (jsonb_typeof(conditions) = 'array')
);
CREATE UNIQUE INDEX segments_org_name_unique ON public.segments (organization_id, lower(name));
COMMENT ON COLUMN public.segments.conditions IS
'Filter list, validated at the app boundary (models.SegmentCondition); match says whether all or any must hold.';
-- Manual overrides: an included contact is a member whether or not it matches
-- the conditions, an excluded one is never a member even when it does.
CREATE TABLE public.segment_members (
segment_id uuid NOT NULL REFERENCES public.segments(id) ON DELETE CASCADE,
contact_id uuid NOT NULL REFERENCES public.contacts(id) ON DELETE CASCADE,
mode text NOT NULL,
created_at timestamp with time zone NOT NULL DEFAULT now(),
PRIMARY KEY (segment_id, contact_id),
CONSTRAINT segment_members_mode_check CHECK (mode IN ('include', 'exclude'))
);
CREATE INDEX idx_segment_members_contact ON public.segment_members (contact_id);
+1
View File
@@ -76,6 +76,7 @@ const (
AuditEntityFolder AuditEntityType = "folder"
AuditEntityTag AuditEntityType = "tag"
AuditEntityCategory AuditEntityType = "category"
AuditEntitySegment AuditEntityType = "segment"
AuditEntitySubscription AuditEntityType = "subscription"
AuditEntitySettings AuditEntityType = "settings"
+1
View File
@@ -569,6 +569,7 @@ type SearchContacts struct {
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
SegmentIDs []string `json:"segment_ids"` // Contacts must be members of ALL these segments
MinCampaigns *int `json:"min_campaigns"` // Minimum number of associated campaigns
MaxCampaigns *int `json:"max_campaigns"` // Maximum number of associated campaigns
Subscribed *bool `json:"subscribed"` // Filter by subscription status
+407
View File
@@ -0,0 +1,407 @@
package models
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/utils"
)
// Segment is a saved, reusable audience: a list of conditions over contacts
// plus per-contact manual overrides. Membership is evaluated at read time.
type Segment struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
Match SegmentMatch `json:"match"`
Conditions []SegmentCondition `json:"conditions"`
// ContactCount is the live membership size; IncludedCount and
// ExcludedCount are the manual overrides. Populated by reads.
ContactCount int `json:"contact_count"`
IncludedCount int `json:"included_count"`
ExcludedCount int `json:"excluded_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SegmentMatch says whether every condition or any condition must hold.
type SegmentMatch string
const (
SegmentMatchAll SegmentMatch = "all"
SegmentMatchAny SegmentMatch = "any"
)
// SegmentMemberMode is a manual override on one contact.
type SegmentMemberMode string
const (
SegmentMemberInclude SegmentMemberMode = "include"
SegmentMemberExclude SegmentMemberMode = "exclude"
// SegmentMemberAuto clears the override so the conditions decide again.
SegmentMemberAuto SegmentMemberMode = "auto"
)
// SegmentCondition is one predicate. Field picks the column or derived value,
// Operator the comparison; scalar operators read Value, list operators read
// Values. Custom fields are addressed as "custom.<key>".
type SegmentCondition struct {
Field string `json:"field"`
Operator string `json:"operator"`
Value string `json:"value,omitempty"`
Values []string `json:"values,omitempty"`
}
// SegmentFieldKind groups fields by the operators they accept.
type SegmentFieldKind string
const (
SegmentFieldText SegmentFieldKind = "text"
SegmentFieldEnum SegmentFieldKind = "enum"
SegmentFieldBool SegmentFieldKind = "bool"
SegmentFieldDate SegmentFieldKind = "date"
SegmentFieldNumber SegmentFieldKind = "number"
SegmentFieldCategory SegmentFieldKind = "category"
SegmentFieldCampaign SegmentFieldKind = "campaign"
SegmentFieldSegment SegmentFieldKind = "segment"
)
// Segment condition operators.
const (
SegOpEquals = "equals"
SegOpNotEquals = "not_equals"
SegOpContains = "contains"
SegOpNotContains = "not_contains"
SegOpStartsWith = "starts_with"
SegOpEndsWith = "ends_with"
SegOpIsEmpty = "is_empty"
SegOpIsNotEmpty = "is_not_empty"
SegOpIn = "in"
SegOpNotIn = "not_in"
SegOpIsTrue = "is_true"
SegOpIsFalse = "is_false"
SegOpBefore = "before"
SegOpAfter = "after"
SegOpWithinDays = "within_days"
SegOpNotWithinDays = "not_within_days"
SegOpGT = "gt"
SegOpGTE = "gte"
SegOpLT = "lt"
SegOpLTE = "lte"
)
// SegmentFieldSpec describes one filterable field for validation and for the
// dashboard's condition builder (GET /segments/fields).
type SegmentFieldSpec struct {
Field string `json:"field"`
Label string `json:"label"`
Group string `json:"group"`
Kind SegmentFieldKind `json:"kind"`
// Options lists the accepted values of an enum field.
Options []string `json:"options,omitempty"`
}
// SegmentFieldCatalog is every non-custom field a condition may name.
var SegmentFieldCatalog = []SegmentFieldSpec{
{Field: "first_name", Label: "First name", Group: "Contact", Kind: SegmentFieldText},
{Field: "last_name", Label: "Last name", Group: "Contact", Kind: SegmentFieldText},
{Field: "email", Label: "Email", Group: "Contact", Kind: SegmentFieldText},
{Field: "email_domain", Label: "Email domain", Group: "Contact", Kind: SegmentFieldText},
{Field: "phone", Label: "Phone", Group: "Contact", Kind: SegmentFieldText},
{Field: "subscribed", Label: "Subscribed", Group: "Contact", Kind: SegmentFieldBool},
{Field: "suppressed", Label: "On the suppression list", Group: "Contact", Kind: SegmentFieldBool},
{Field: "source", Label: "Source", Group: "Contact", Kind: SegmentFieldEnum, Options: []string{"unknown", "manual", "campaign", "import", "sheet_sync", "api", "ai_assistant"}},
{Field: "verification_status", Label: "Verification status", Group: "Contact", Kind: SegmentFieldEnum, Options: []string{"valid", "risky", "invalid", "unknown"}},
{Field: "is_catch_all", Label: "Catch-all domain", Group: "Contact", Kind: SegmentFieldBool},
{Field: "esp_provider", Label: "Email provider", Group: "Contact", Kind: SegmentFieldEnum, Options: []string{"gmail", "outlook", "other"}},
{Field: "created_at", Label: "Created", Group: "Contact", Kind: SegmentFieldDate},
{Field: "updated_at", Label: "Updated", Group: "Contact", Kind: SegmentFieldDate},
{Field: "category", Label: "Category", Group: "Contact", Kind: SegmentFieldCategory},
{Field: "company", Label: "Company name", Group: "Company", Kind: SegmentFieldText},
{Field: "campaign", Label: "In campaign", Group: "Campaign activity", Kind: SegmentFieldCampaign},
{Field: "campaign_count", Label: "Number of campaigns", Group: "Campaign activity", Kind: SegmentFieldNumber},
{Field: "emails_sent", Label: "Emails sent", Group: "Email engagement", Kind: SegmentFieldNumber},
{Field: "emails_opened", Label: "Emails opened", Group: "Email engagement", Kind: SegmentFieldNumber},
{Field: "emails_clicked", Label: "Links clicked", Group: "Email engagement", Kind: SegmentFieldNumber},
{Field: "emails_replied", Label: "Replies", Group: "Email engagement", Kind: SegmentFieldNumber},
{Field: "emails_bounced", Label: "Bounces", Group: "Email engagement", Kind: SegmentFieldNumber},
{Field: "last_sent_at", Label: "Last email sent", Group: "Email engagement", Kind: SegmentFieldDate},
{Field: "last_opened_at", Label: "Last open", Group: "Email engagement", Kind: SegmentFieldDate},
{Field: "last_clicked_at", Label: "Last click", Group: "Email engagement", Kind: SegmentFieldDate},
{Field: "last_replied_at", Label: "Last reply", Group: "Email engagement", Kind: SegmentFieldDate},
{Field: "segment", Label: "In segment", Group: "Segments", Kind: SegmentFieldSegment},
}
// SegmentCustomFieldPrefix addresses a contact custom field: "custom.industry".
const SegmentCustomFieldPrefix = "custom."
// Segment validation limits.
const (
SegmentMaxConditions = 50
SegmentMaxListValues = 200
SegmentMaxNameLen = 120
SegmentMaxDescLen = 1000
SegmentMaxValueLen = 500
SegmentMaxNestingDeep = 5
SegmentsPerOrgMax = 200
)
var segmentColorRe = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
// SegmentFieldSpecFor resolves a condition's field to its spec. Custom fields
// resolve to a synthetic text spec.
func SegmentFieldSpecFor(field string) (SegmentFieldSpec, bool) {
if strings.HasPrefix(field, SegmentCustomFieldPrefix) {
key := utils.NormalizeJSONKey(strings.TrimPrefix(field, SegmentCustomFieldPrefix))
if key == "" || !utils.IsValidJSONKey(key) {
return SegmentFieldSpec{}, false
}
return SegmentFieldSpec{Field: SegmentCustomFieldPrefix + key, Label: key, Group: "Custom field", Kind: SegmentFieldText}, true
}
for _, s := range SegmentFieldCatalog {
if s.Field == field {
return s, true
}
}
return SegmentFieldSpec{}, false
}
// OperatorsForKind lists the operators a field kind accepts.
func OperatorsForKind(kind SegmentFieldKind) []string {
switch kind {
case SegmentFieldText:
return []string{SegOpEquals, SegOpNotEquals, SegOpContains, SegOpNotContains, SegOpStartsWith, SegOpEndsWith, SegOpIsEmpty, SegOpIsNotEmpty}
case SegmentFieldEnum:
return []string{SegOpIn, SegOpNotIn}
case SegmentFieldBool:
return []string{SegOpIsTrue, SegOpIsFalse}
case SegmentFieldDate:
return []string{SegOpBefore, SegOpAfter, SegOpWithinDays, SegOpNotWithinDays, SegOpIsEmpty, SegOpIsNotEmpty}
case SegmentFieldNumber:
return []string{SegOpEquals, SegOpNotEquals, SegOpGT, SegOpGTE, SegOpLT, SegOpLTE}
case SegmentFieldCategory, SegmentFieldCampaign:
return []string{SegOpIn, SegOpNotIn, SegOpIsEmpty, SegOpIsNotEmpty}
case SegmentFieldSegment:
return []string{SegOpIn, SegOpNotIn}
}
return nil
}
// SegmentWrite is the create/update body.
type SegmentWrite struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Color *string `json:"color,omitempty"`
Match *SegmentMatch `json:"match,omitempty"`
Conditions *[]SegmentCondition `json:"conditions,omitempty"`
}
// SegmentPreview is the body of POST /segments/preview: an unsaved definition
// to count. ID, when set, keeps that segment's manual overrides in the count.
type SegmentPreview struct {
ID *uuid.UUID `json:"id,omitempty"`
Match SegmentMatch `json:"match"`
Conditions []SegmentCondition `json:"conditions"`
}
// SegmentMembersWrite sets a manual override on a batch of contacts.
type SegmentMembersWrite struct {
Contacts []string `json:"contacts"`
Mode SegmentMemberMode `json:"mode"`
}
// SegmentAddToCampaign enrols the segment's current members as leads.
type SegmentAddToCampaign struct {
CampaignID string `json:"campaign_id"`
}
// SegmentAddToCampaignResult reports how many leads were actually new.
type SegmentAddToCampaignResult struct {
CampaignID uuid.UUID `json:"campaign_id"`
Added int `json:"added"`
Members int `json:"members"`
}
// ContactSegment is one segment a contact belongs to, with its override.
type ContactSegment struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
// Mode is "include" or "exclude" when the contact carries a manual
// override, empty when the conditions alone decide.
Mode SegmentMemberMode `json:"mode,omitempty"`
// Member is whether the contact is currently in the segment.
Member bool `json:"member"`
}
// SegmentOverride is one manually included or excluded contact.
type SegmentOverride struct {
ContactID uuid.UUID `json:"contact_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Company string `json:"company"`
Mode SegmentMemberMode `json:"mode"`
CreatedAt time.Time `json:"created_at"`
}
// SegmentOverridesMax bounds one overrides listing.
const SegmentOverridesMax = 500
// ValidateSegmentName trims and bounds the name.
func ValidateSegmentName(name string) (string, *errx.Error) {
name = strings.TrimSpace(name)
if name == "" {
return "", errx.New(errx.BadRequest, "segment name is required")
}
if len(name) > SegmentMaxNameLen {
return "", errx.New(errx.BadRequest, fmt.Sprintf("segment name must be at most %d characters", SegmentMaxNameLen))
}
return name, nil
}
// ValidateSegmentColor accepts a #rrggbb color.
func ValidateSegmentColor(color string) (string, *errx.Error) {
color = strings.ToLower(strings.TrimSpace(color))
if !segmentColorRe.MatchString(color) {
return "", errx.New(errx.BadRequest, "color must be a #rrggbb value")
}
return color, nil
}
// ValidateSegmentMatch accepts all|any.
func ValidateSegmentMatch(m SegmentMatch) *errx.Error {
if m != SegmentMatchAll && m != SegmentMatchAny {
return errx.New(errx.BadRequest, "match must be all or any")
}
return nil
}
// ValidateSegmentConditions normalizes every condition in place and rejects
// anything the SQL builder would not know how to compile. selfID, when set,
// refuses a segment that references itself.
func ValidateSegmentConditions(conds []SegmentCondition, selfID *uuid.UUID) *errx.Error {
if len(conds) > SegmentMaxConditions {
return errx.New(errx.BadRequest, fmt.Sprintf("at most %d conditions per segment", SegmentMaxConditions))
}
for i := range conds {
c := &conds[i]
c.Field = strings.TrimSpace(c.Field)
c.Operator = strings.TrimSpace(c.Operator)
spec, ok := SegmentFieldSpecFor(c.Field)
if !ok {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: unknown field %q", i+1, c.Field))
}
c.Field = spec.Field
if !containsString(OperatorsForKind(spec.Kind), c.Operator) {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: operator %q is not valid for %s", i+1, c.Operator, spec.Label))
}
if len(c.Value) > SegmentMaxValueLen {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: value too long", i+1))
}
if len(c.Values) > SegmentMaxListValues {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: at most %d values", i+1, SegmentMaxListValues))
}
switch c.Operator {
case SegOpIsEmpty, SegOpIsNotEmpty, SegOpIsTrue, SegOpIsFalse:
c.Value, c.Values = "", nil
continue
case SegOpIn, SegOpNotIn:
c.Value = ""
if len(c.Values) == 0 {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: pick at least one value", i+1))
}
default:
c.Values = nil
if strings.TrimSpace(c.Value) == "" {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: a value is required", i+1))
}
}
switch spec.Kind {
case SegmentFieldEnum:
for _, v := range c.Values {
if !containsString(spec.Options, v) {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: %q is not a valid %s", i+1, v, spec.Label))
}
}
case SegmentFieldCategory, SegmentFieldCampaign, SegmentFieldSegment:
for _, v := range c.Values {
id, err := uuid.Parse(v)
if err != nil {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: %q is not a valid id", i+1, v))
}
if spec.Kind == SegmentFieldSegment && selfID != nil && id == *selfID {
return errx.New(errx.BadRequest, "a segment cannot reference itself")
}
}
case SegmentFieldNumber:
n, err := strconv.Atoi(strings.TrimSpace(c.Value))
if err != nil || n < 0 {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: value must be a whole number", i+1))
}
c.Value = strconv.Itoa(n)
case SegmentFieldDate:
switch c.Operator {
case SegOpWithinDays, SegOpNotWithinDays:
n, err := strconv.Atoi(strings.TrimSpace(c.Value))
if err != nil || n < 1 || n > 3650 {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: days must be between 1 and 3650", i+1))
}
c.Value = strconv.Itoa(n)
default:
t, err := parseSegmentDate(c.Value)
if err != nil {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: value must be a date (YYYY-MM-DD)", i+1))
}
c.Value = t.UTC().Format(time.RFC3339)
}
}
}
return nil
}
// SegmentReferences lists the other segments the conditions depend on.
func SegmentReferences(conds []SegmentCondition) []uuid.UUID {
var out []uuid.UUID
for _, c := range conds {
if c.Field != "segment" {
continue
}
for _, v := range c.Values {
if id, err := uuid.Parse(v); err == nil {
out = append(out, id)
}
}
}
return out
}
func parseSegmentDate(v string) (time.Time, error) {
v = strings.TrimSpace(v)
if t, err := time.Parse(time.RFC3339, v); err == nil {
return t, nil
}
return time.Parse("2006-01-02", v)
}
func containsString(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}
+79
View File
@@ -0,0 +1,79 @@
package models
import (
"testing"
"github.com/google/uuid"
)
func TestValidateSegmentConditionsNormalizes(t *testing.T) {
self := uuid.New()
conds := []SegmentCondition{
{Field: " company ", Operator: "contains", Value: "acme"},
{Field: "custom.Job Title", Operator: "is_empty", Value: "junk", Values: []string{"x"}},
{Field: "emails_opened", Operator: "gte", Value: " 3 "},
{Field: "created_at", Operator: "after", Value: "2026-01-15"},
{Field: "last_replied_at", Operator: "within_days", Value: "30"},
{Field: "source", Operator: "in", Values: []string{"import", "api"}},
{Field: "category", Operator: "in", Values: []string{uuid.New().String()}},
}
if err := ValidateSegmentConditions(conds, &self); err != nil {
t.Fatalf("valid conditions rejected: %v", err)
}
if conds[0].Field != "company" {
t.Errorf("field not trimmed: %q", conds[0].Field)
}
if conds[1].Value != "" || conds[1].Values != nil {
t.Errorf("valueless operator kept values: %+v", conds[1])
}
if conds[2].Value != "3" {
t.Errorf("number not normalized: %q", conds[2].Value)
}
if conds[3].Value != "2026-01-15T00:00:00Z" {
t.Errorf("date not normalized: %q", conds[3].Value)
}
}
func TestValidateSegmentConditionsRejects(t *testing.T) {
self := uuid.New()
bad := []struct {
name string
cond SegmentCondition
}{
{"unknown field", SegmentCondition{Field: "nope", Operator: "equals", Value: "x"}},
{"wrong operator", SegmentCondition{Field: "subscribed", Operator: "contains", Value: "x"}},
{"missing value", SegmentCondition{Field: "email", Operator: "equals"}},
{"empty list", SegmentCondition{Field: "category", Operator: "in"}},
{"bad enum", SegmentCondition{Field: "source", Operator: "in", Values: []string{"martian"}}},
{"bad uuid", SegmentCondition{Field: "campaign", Operator: "in", Values: []string{"abc"}}},
{"negative number", SegmentCondition{Field: "emails_sent", Operator: "gt", Value: "-1"}},
{"days out of range", SegmentCondition{Field: "created_at", Operator: "within_days", Value: "0"}},
{"bad date", SegmentCondition{Field: "updated_at", Operator: "before", Value: "yesterday"}},
{"self reference", SegmentCondition{Field: "segment", Operator: "in", Values: []string{self.String()}}},
{"bad custom key", SegmentCondition{Field: "custom.", Operator: "equals", Value: "x"}},
}
for _, tc := range bad {
if err := ValidateSegmentConditions([]SegmentCondition{tc.cond}, &self); err == nil {
t.Errorf("%s: accepted", tc.name)
}
}
many := make([]SegmentCondition, SegmentMaxConditions+1)
for i := range many {
many[i] = SegmentCondition{Field: "email", Operator: "is_not_empty"}
}
if err := ValidateSegmentConditions(many, nil); err == nil {
t.Errorf("over the condition cap accepted")
}
}
func TestSegmentReferences(t *testing.T) {
a, b := uuid.New(), uuid.New()
refs := SegmentReferences([]SegmentCondition{
{Field: "segment", Operator: "in", Values: []string{a.String(), "junk"}},
{Field: "category", Operator: "in", Values: []string{b.String()}},
{Field: "segment", Operator: "not_in", Values: []string{b.String()}},
})
if len(refs) != 2 || refs[0] != a || refs[1] != b {
t.Fatalf("refs = %v", refs)
}
}
+5 -1
View File
@@ -51,7 +51,7 @@ type Sequence struct {
// ActionConfig is the persisted config for a non-email (action/wait) node. Type
// is the switch the task executes on; the remaining fields are type-scoped.
type ActionConfig struct {
Type string `json:"type"` // wait | add_tag | remove_tag | label_email | unsubscribe | notify | create_task | create_deal | move_deal_stage | run_automation | fire_event | switch | ai_step | end
Type string `json:"type"` // wait | add_tag | remove_tag | add_to_segment | remove_from_segment | label_email | unsubscribe | notify | create_task | create_deal | move_deal_stage | run_automation | fire_event | switch | ai_step | end
// wait
WaitMinutes *int `json:"wait_minutes,omitempty"`
@@ -59,6 +59,10 @@ type ActionConfig struct {
// add_tag / remove_tag — a contact category id (product "tags" == categories)
CategoryID *uuid.UUID `json:"category_id,omitempty"`
// add_to_segment / remove_from_segment — pins the contact into or out of a
// segment as a manual override.
SegmentID *uuid.UUID `json:"segment_id,omitempty"`
// label_email — apply unibox conversation labels to the contact's most recent
// thread. Labels are the same registry as contact tags (categories), but in
// the inbox they're "labels", so the field is label_ids. Reply-branch only.
+26
View File
@@ -1026,6 +1026,30 @@ func (r *contactRepository) Search(
whereClauses = append(whereClauses, categoryClause)
}
// -----------------------------
// Segment membership (must be in ALL specified segments)
// -----------------------------
// Each segment compiles to its own predicate; args grow in lockstep with
// argIndex, which the builder relies on.
for _, raw := range filters.SegmentIDs {
sid, err := uuid.Parse(raw)
if err != nil {
return nil, errx.New(errx.BadRequest, "invalid segment id")
}
orgUUID, err := uuid.Parse(orgID)
if err != nil {
return nil, errx.New(errx.BadRequest, "invalid organization id")
}
clause, nextArgs, cerr := compileSavedSegment(ctx, r.DB, orgUUID, sid, args)
if cerr != nil {
db.CaptureError(cerr, "segment compile", nil, "query")
return nil, errx.InternalError()
}
args = nextArgs
argIndex = len(args) + 1
whereClauses = append(whereClauses, "("+clause+")")
}
// -----------------------------
// Sort logic
// -----------------------------
@@ -1151,6 +1175,8 @@ func (r *contactRepository) Search(
WHEN s.kind = 'action' THEN (CASE s.action->>'type'
WHEN 'add_tag' THEN 'Add tag'
WHEN 'remove_tag' THEN 'Remove tag'
WHEN 'add_to_segment' THEN 'Add to segment'
WHEN 'remove_from_segment' THEN 'Remove from segment'
WHEN 'unsubscribe' THEN 'Unsubscribe'
WHEN 'notify' THEN 'Notify'
ELSE 'Action' END)
@@ -208,6 +208,10 @@ func stepLabel(name, kind string, action []byte, emailOrdinal int) string {
return "Add tag"
case "remove_tag":
return "Remove tag"
case "add_to_segment":
return "Add to segment"
case "remove_from_segment":
return "Remove from segment"
case "unsubscribe":
return "Unsubscribe"
case "notify":
+404
View File
@@ -0,0 +1,404 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/db"
"github.com/warmbly/warmbly/internal/models"
)
type SegmentRepository interface {
List(ctx context.Context, orgID uuid.UUID) ([]models.Segment, *errx.Error)
Get(ctx context.Context, orgID, id uuid.UUID) (*models.Segment, *errx.Error)
Create(ctx context.Context, orgID uuid.UUID, createdBy *uuid.UUID, seg *models.Segment) (*models.Segment, *errx.Error)
Update(ctx context.Context, orgID uuid.UUID, seg *models.Segment) (*models.Segment, *errx.Error)
Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error
// ReferencedBy names the segments whose conditions point at id.
ReferencedBy(ctx context.Context, orgID, id uuid.UUID) ([]string, *errx.Error)
// Count evaluates a definition (saved or not) against the org's contacts.
Count(ctx context.Context, orgID uuid.UUID, id *uuid.UUID, match models.SegmentMatch, conds []models.SegmentCondition) (int, *errx.Error)
// SetMembers writes a manual override for each contact; Auto removes it.
SetMembers(ctx context.Context, orgID, segmentID uuid.UUID, contactIDs []uuid.UUID, mode models.SegmentMemberMode) (int, *errx.Error)
// MemberModes reports the manual override of each listed contact.
MemberModes(ctx context.Context, segmentID uuid.UUID, contactIDs []uuid.UUID) (map[uuid.UUID]models.SegmentMemberMode, *errx.Error)
// AddToCampaign enrols every current member of the segment as a lead.
AddToCampaign(ctx context.Context, orgID uuid.UUID, actor string, segmentID, campaignID uuid.UUID) (*models.SegmentAddToCampaignResult, *errx.Error)
// SegmentsForContact evaluates every segment of the org for one contact.
SegmentsForContact(ctx context.Context, orgID, contactID uuid.UUID) ([]models.ContactSegment, *errx.Error)
// ListOverrides lists the manually included and excluded contacts.
ListOverrides(ctx context.Context, orgID, segmentID uuid.UUID) ([]models.SegmentOverride, *errx.Error)
}
type segmentRepository struct {
DB *db.DB
}
func NewSegmentRepository(d *db.DB) SegmentRepository {
return &segmentRepository{DB: d}
}
const segmentColumns = `s.id, s.organization_id, s.created_by, s.name, s.description, s.color, s.match, s.conditions,
(SELECT COUNT(*) FROM segment_members sm WHERE sm.segment_id = s.id AND sm.mode = 'include'),
(SELECT COUNT(*) FROM segment_members sm WHERE sm.segment_id = s.id AND sm.mode = 'exclude'),
s.created_at, s.updated_at`
func scanSegment(row pgx.Row) (*models.Segment, error) {
var s models.Segment
var raw []byte
if err := row.Scan(&s.ID, &s.OrganizationID, &s.CreatedBy, &s.Name, &s.Description, &s.Color, &s.Match, &raw,
&s.IncludedCount, &s.ExcludedCount, &s.CreatedAt, &s.UpdatedAt); err != nil {
return nil, err
}
s.Conditions = []models.SegmentCondition{}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &s.Conditions); err != nil {
return nil, err
}
}
return &s, nil
}
func (r *segmentRepository) List(ctx context.Context, orgID uuid.UUID) ([]models.Segment, *errx.Error) {
rows, err := r.DB.Query(ctx, `SELECT `+segmentColumns+` FROM segments s WHERE s.organization_id = $1 ORDER BY lower(s.name) ASC`, orgID)
if err != nil {
db.CaptureError(err, "segments list", nil, "query")
return nil, errx.InternalError()
}
defer rows.Close()
out := []models.Segment{}
for rows.Next() {
s, err := scanSegment(rows)
if err != nil {
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
out = append(out, *s)
}
for i := range out {
n, xerr := r.Count(ctx, orgID, &out[i].ID, out[i].Match, out[i].Conditions)
if xerr != nil {
return nil, xerr
}
out[i].ContactCount = n
}
return out, nil
}
func (r *segmentRepository) Get(ctx context.Context, orgID, id uuid.UUID) (*models.Segment, *errx.Error) {
s, err := scanSegment(r.DB.QueryRow(ctx, `SELECT `+segmentColumns+` FROM segments s WHERE s.organization_id = $1 AND s.id = $2`, orgID, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errx.New(errx.NotFound, "segment not found")
}
db.CaptureError(err, "segments get", nil, "queryrow")
return nil, errx.InternalError()
}
n, xerr := r.Count(ctx, orgID, &s.ID, s.Match, s.Conditions)
if xerr != nil {
return nil, xerr
}
s.ContactCount = n
return s, nil
}
func (r *segmentRepository) Create(ctx context.Context, orgID uuid.UUID, createdBy *uuid.UUID, seg *models.Segment) (*models.Segment, *errx.Error) {
var total int
if err := r.DB.QueryRow(ctx, `SELECT COUNT(*) FROM segments WHERE organization_id = $1`, orgID).Scan(&total); err != nil {
db.CaptureError(err, "segments count", nil, "queryrow")
return nil, errx.InternalError()
}
if total >= models.SegmentsPerOrgMax {
return nil, errx.New(errx.BadRequest, fmt.Sprintf("a workspace can have at most %d segments", models.SegmentsPerOrgMax))
}
conds, _ := json.Marshal(seg.Conditions)
var id uuid.UUID
err := r.DB.QueryRow(ctx, `
INSERT INTO segments (organization_id, created_by, name, description, color, match, conditions)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`, orgID, createdBy, seg.Name, seg.Description, seg.Color, seg.Match, conds).Scan(&id)
if err != nil {
if isUniqueViolation(err) {
return nil, errx.New(errx.Conflict, "a segment with that name already exists")
}
db.CaptureError(err, "segments insert", nil, "queryrow")
return nil, errx.InternalError()
}
return r.Get(ctx, orgID, id)
}
func (r *segmentRepository) Update(ctx context.Context, orgID uuid.UUID, seg *models.Segment) (*models.Segment, *errx.Error) {
conds, _ := json.Marshal(seg.Conditions)
tag, err := r.DB.Exec(ctx, `
UPDATE segments SET name = $3, description = $4, color = $5, match = $6, conditions = $7, updated_at = now()
WHERE organization_id = $1 AND id = $2`, orgID, seg.ID, seg.Name, seg.Description, seg.Color, seg.Match, conds)
if err != nil {
if isUniqueViolation(err) {
return nil, errx.New(errx.Conflict, "a segment with that name already exists")
}
db.CaptureError(err, "segments update", nil, "exec")
return nil, errx.InternalError()
}
if tag.RowsAffected() == 0 {
return nil, errx.New(errx.NotFound, "segment not found")
}
return r.Get(ctx, orgID, seg.ID)
}
func (r *segmentRepository) Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error {
tag, err := r.DB.Exec(ctx, `DELETE FROM segments WHERE organization_id = $1 AND id = $2`, orgID, id)
if err != nil {
db.CaptureError(err, "segments delete", nil, "exec")
return errx.InternalError()
}
if tag.RowsAffected() == 0 {
return errx.New(errx.NotFound, "segment not found")
}
return nil
}
func (r *segmentRepository) ReferencedBy(ctx context.Context, orgID, id uuid.UUID) ([]string, *errx.Error) {
needle, _ := json.Marshal([]map[string]any{{"field": "segment", "values": []string{id.String()}}})
rows, err := r.DB.Query(ctx, `SELECT name FROM segments WHERE organization_id = $1 AND id <> $2 AND conditions @> $3::jsonb ORDER BY lower(name)`, orgID, id, needle)
if err != nil {
db.CaptureError(err, "segments referenced", nil, "query")
return nil, errx.InternalError()
}
defer rows.Close()
var names []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
names = append(names, n)
}
return names, nil
}
func (r *segmentRepository) Count(ctx context.Context, orgID uuid.UUID, id *uuid.UUID, match models.SegmentMatch, conds []models.SegmentCondition) (int, *errx.Error) {
def := &segmentDef{Match: match, Conditions: conds}
if id != nil {
def.ID = *id
}
args := []any{orgID}
clause, args, err := compileSegment(ctx, r.DB, orgID, def, args)
if err != nil {
db.CaptureError(err, "segment compile", nil, "query")
return 0, errx.InternalError()
}
query := `SELECT COUNT(*) FROM contacts c WHERE c.organization_id = $1 AND (` + clause + `)`
var n int
if err := r.DB.QueryRow(ctx, query, args...).Scan(&n); err != nil {
db.CaptureError(err, query, args, "queryrow")
return 0, errx.InternalError()
}
return n, nil
}
func (r *segmentRepository) SetMembers(ctx context.Context, orgID, segmentID uuid.UUID, contactIDs []uuid.UUID, mode models.SegmentMemberMode) (int, *errx.Error) {
var tag pgconn.CommandTag
var err error
if mode == models.SegmentMemberAuto {
tag, err = r.DB.Exec(ctx, `
DELETE FROM segment_members sm USING segments s
WHERE sm.segment_id = s.id AND s.organization_id = $1 AND s.id = $2 AND sm.contact_id = ANY($3::uuid[])`,
orgID, segmentID, contactIDs)
} else {
tag, err = r.DB.Exec(ctx, `
INSERT INTO segment_members (segment_id, contact_id, mode)
SELECT s.id, c.id, $4
FROM segments s
JOIN contacts c ON c.organization_id = s.organization_id
WHERE s.organization_id = $1 AND s.id = $2 AND c.id = ANY($3::uuid[])
ON CONFLICT (segment_id, contact_id) DO UPDATE SET mode = EXCLUDED.mode, created_at = now()`,
orgID, segmentID, contactIDs, string(mode))
}
if err != nil {
db.CaptureError(err, "segment members", nil, "exec")
return 0, errx.InternalError()
}
return int(tag.RowsAffected()), nil
}
func (r *segmentRepository) MemberModes(ctx context.Context, segmentID uuid.UUID, contactIDs []uuid.UUID) (map[uuid.UUID]models.SegmentMemberMode, *errx.Error) {
out := map[uuid.UUID]models.SegmentMemberMode{}
if len(contactIDs) == 0 {
return out, nil
}
rows, err := r.DB.Query(ctx, `SELECT contact_id, mode FROM segment_members WHERE segment_id = $1 AND contact_id = ANY($2::uuid[])`, segmentID, contactIDs)
if err != nil {
db.CaptureError(err, "segment member modes", nil, "query")
return nil, errx.InternalError()
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
var mode string
if err := rows.Scan(&id, &mode); err != nil {
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
out[id] = models.SegmentMemberMode(mode)
}
return out, nil
}
func (r *segmentRepository) AddToCampaign(ctx context.Context, orgID uuid.UUID, actor string, segmentID, campaignID uuid.UUID) (*models.SegmentAddToCampaignResult, *errx.Error) {
tx, err := r.DB.Begin(ctx)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, errx.InternalError()
}
defer tx.Rollback(ctx)
var exists bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM campaigns WHERE id = $1 AND organization_id = $2)`, campaignID, orgID).Scan(&exists); err != nil {
db.CaptureError(err, "campaign exists", nil, "queryrow")
return nil, errx.InternalError()
}
if !exists {
return nil, errx.New(errx.NotFound, "campaign not found")
}
args := []any{orgID}
clause, args, err := compileSavedSegment(ctx, tx, orgID, segmentID, args)
if err != nil {
db.CaptureError(err, "segment compile", nil, "query")
return nil, errx.InternalError()
}
if clause == "FALSE" {
return nil, errx.New(errx.NotFound, "segment not found")
}
var members int
countQ := `SELECT COUNT(*) FROM contacts c WHERE c.organization_id = $1 AND (` + clause + `)`
if err := tx.QueryRow(ctx, countQ, args...).Scan(&members); err != nil {
db.CaptureError(err, countQ, args, "queryrow")
return nil, errx.InternalError()
}
// The campaign is bound after the compiled clause so the count query
// above carries no unused parameter.
args = append(args, campaignID)
insertQ := fmt.Sprintf(`INSERT INTO campaign_leads (contact_id, campaign_id)
SELECT c.id, $%d::uuid FROM contacts c WHERE c.organization_id = $1 AND (%s)
ON CONFLICT DO NOTHING
RETURNING contact_id, campaign_id`, len(args), clause)
rows, err := tx.Query(ctx, insertQ, args...)
if err != nil {
db.CaptureError(err, insertQ, args, "query")
return nil, errx.InternalError()
}
links, err := collectLinkPairs(rows)
if err != nil {
db.CaptureError(err, insertQ, args, "returning")
return nil, errx.InternalError()
}
if err := logCampaignLinks(ctx, tx, orgID, actorID(actor), models.ActivityCampaignAdded, links); err != nil {
db.CaptureError(err, "", nil, "campaign_added activity")
return nil, errx.InternalError()
}
if err := tx.Commit(ctx); err != nil {
db.CaptureError(err, "", nil, "commit")
return nil, errx.InternalError()
}
return &models.SegmentAddToCampaignResult{CampaignID: campaignID, Added: len(links), Members: members}, nil
}
func (r *segmentRepository) SegmentsForContact(ctx context.Context, orgID, contactID uuid.UUID) ([]models.ContactSegment, *errx.Error) {
rows, err := r.DB.Query(ctx, `SELECT `+segmentColumns+` FROM segments s WHERE s.organization_id = $1 ORDER BY lower(s.name) ASC`, orgID)
if err != nil {
db.CaptureError(err, "segments for contact", nil, "query")
return nil, errx.InternalError()
}
var defs []*models.Segment
for rows.Next() {
seg, err := scanSegment(rows)
if err != nil {
rows.Close()
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
defs = append(defs, seg)
}
rows.Close()
out := make([]models.ContactSegment, 0, len(defs))
if len(defs) == 0 {
return out, nil
}
ids := make([]uuid.UUID, 0, len(defs))
for _, d := range defs {
ids = append(ids, d.ID)
}
modes := map[uuid.UUID]models.SegmentMemberMode{}
mrows, err := r.DB.Query(ctx, `SELECT segment_id, mode FROM segment_members WHERE contact_id = $1 AND segment_id = ANY($2::uuid[])`, contactID, ids)
if err != nil {
db.CaptureError(err, "contact segment modes", nil, "query")
return nil, errx.InternalError()
}
for mrows.Next() {
var id uuid.UUID
var mode string
if err := mrows.Scan(&id, &mode); err != nil {
mrows.Close()
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
modes[id] = models.SegmentMemberMode(mode)
}
mrows.Close()
// One membership probe per segment: the compiled predicate over a single
// contact row, which is what the segment page would compute for it anyway.
for _, d := range defs {
args := []any{orgID, contactID}
clause, args, cerr := compileSavedSegment(ctx, r.DB, orgID, d.ID, args)
if cerr != nil {
db.CaptureError(cerr, "segment compile", nil, "query")
return nil, errx.InternalError()
}
var member bool
q := `SELECT EXISTS (SELECT 1 FROM contacts c WHERE c.organization_id = $1 AND c.id = $2 AND (` + clause + `))`
if err := r.DB.QueryRow(ctx, q, args...).Scan(&member); err != nil {
db.CaptureError(err, q, args, "queryrow")
return nil, errx.InternalError()
}
out = append(out, models.ContactSegment{ID: d.ID, Name: d.Name, Color: d.Color, Mode: modes[d.ID], Member: member})
}
return out, nil
}
func (r *segmentRepository) ListOverrides(ctx context.Context, orgID, segmentID uuid.UUID) ([]models.SegmentOverride, *errx.Error) {
rows, err := r.DB.Query(ctx, `
SELECT c.id, c.first_name, c.last_name, c.email, c.company, sm.mode, sm.created_at
FROM segment_members sm
JOIN segments s ON s.id = sm.segment_id
JOIN contacts c ON c.id = sm.contact_id
WHERE s.organization_id = $1 AND s.id = $2
ORDER BY sm.mode ASC, sm.created_at DESC
LIMIT $3`, orgID, segmentID, models.SegmentOverridesMax)
if err != nil {
db.CaptureError(err, "segment overrides", nil, "query")
return nil, errx.InternalError()
}
defer rows.Close()
out := []models.SegmentOverride{}
for rows.Next() {
var o models.SegmentOverride
var mode string
if err := rows.Scan(&o.ContactID, &o.FirstName, &o.LastName, &o.Email, &o.Company, &mode, &o.CreatedAt); err != nil {
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
o.Mode = models.SegmentMemberMode(mode)
out = append(out, o)
}
return out, nil
}
+329
View File
@@ -0,0 +1,329 @@
package repository
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/warmbly/warmbly/internal/models"
)
// segmentQuerier is the subset of pgx both the pool and a transaction satisfy.
type segmentQuerier interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
}
// segmentDef is the part of a segment the predicate compiler needs.
type segmentDef struct {
ID uuid.UUID
Match models.SegmentMatch
Conditions []models.SegmentCondition
}
// segmentBuilder compiles segment definitions into a WHERE fragment over the
// `contacts c` alias. Values are always bound, never interpolated; the only
// strings that reach the SQL text are column names picked from a fixed map.
type segmentBuilder struct {
orgID uuid.UUID
args []any
graph map[uuid.UUID]*segmentDef
}
func (b *segmentBuilder) bind(v any) string {
b.args = append(b.args, v)
return fmt.Sprintf("$%d", len(b.args))
}
// loadSegmentGraph fetches the referenced segments transitively, stopping at
// SegmentMaxNestingDeep hops: anything deeper compiles to FALSE.
func loadSegmentGraph(ctx context.Context, q segmentQuerier, orgID uuid.UUID, roots []uuid.UUID) (map[uuid.UUID]*segmentDef, error) {
graph := map[uuid.UUID]*segmentDef{}
pending := roots
for depth := 0; depth <= models.SegmentMaxNestingDeep && len(pending) > 0; depth++ {
var want []uuid.UUID
for _, id := range pending {
if _, ok := graph[id]; !ok {
want = append(want, id)
}
}
if len(want) == 0 {
break
}
rows, err := q.Query(ctx, `SELECT id, match, conditions FROM segments WHERE organization_id = $1 AND id = ANY($2::uuid[])`, orgID, want)
if err != nil {
return nil, err
}
var next []uuid.UUID
for rows.Next() {
var d segmentDef
var raw []byte
if err := rows.Scan(&d.ID, &d.Match, &raw); err != nil {
rows.Close()
return nil, err
}
if err := json.Unmarshal(raw, &d.Conditions); err != nil {
rows.Close()
return nil, err
}
graph[d.ID] = &d
next = append(next, models.SegmentReferences(d.Conditions)...)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
pending = next
}
return graph, nil
}
// segmentClause compiles one segment (saved or preview) to a predicate.
// withOverrides folds the manual include/exclude rows in when the segment has
// an id. visited guards reference cycles: a loop compiles to FALSE.
func (b *segmentBuilder) segmentClause(def *segmentDef, withOverrides bool, visited map[uuid.UUID]bool) string {
if def.ID != uuid.Nil {
if visited[def.ID] {
return "FALSE"
}
visited[def.ID] = true
defer delete(visited, def.ID)
}
dyn := "FALSE"
if len(def.Conditions) > 0 {
parts := make([]string, 0, len(def.Conditions))
for _, c := range def.Conditions {
parts = append(parts, b.condition(c, visited))
}
joiner := " AND "
if def.Match == models.SegmentMatchAny {
joiner = " OR "
}
dyn = "(" + strings.Join(parts, joiner) + ")"
}
if !withOverrides || def.ID == uuid.Nil {
return dyn
}
id := b.bind(def.ID)
return fmt.Sprintf(
"((%s OR c.id IN (SELECT sm.contact_id FROM segment_members sm WHERE sm.segment_id = %s AND sm.mode = 'include')) "+
"AND c.id NOT IN (SELECT sm.contact_id FROM segment_members sm WHERE sm.segment_id = %s AND sm.mode = 'exclude'))",
dyn, id, id)
}
var segmentTextColumns = map[string]string{
"first_name": "c.first_name",
"last_name": "c.last_name",
"email": "c.email",
"email_domain": "split_part(c.email, '@', 2)",
"phone": "c.phone",
"company": "c.company",
}
var segmentEnumColumns = map[string]string{
"source": "c.source",
"verification_status": "c.verification_status",
"esp_provider": "c.esp_provider",
}
var segmentDateExprs = map[string]string{
"created_at": "c.created_at",
"updated_at": "c.updated_at",
"last_sent_at": "(SELECT MAX(p.sent_at) FROM campaign_contact_progress p WHERE p.contact_id = c.id)",
"last_opened_at": "(SELECT MAX(p.opened_at) FROM campaign_contact_progress p WHERE p.contact_id = c.id AND NOT p.opened_machine)",
"last_clicked_at": "(SELECT MAX(p.clicked_at) FROM campaign_contact_progress p WHERE p.contact_id = c.id)",
"last_replied_at": "(SELECT MAX(p.replied_at) FROM campaign_contact_progress p WHERE p.contact_id = c.id)",
}
var segmentNumberExprs = map[string]string{
"campaign_count": "(SELECT COUNT(*) FROM campaign_leads cl WHERE cl.contact_id = c.id)",
"emails_sent": "(SELECT COUNT(*) FROM campaign_contact_progress p WHERE p.contact_id = c.id AND p.sent_at IS NOT NULL)",
"emails_opened": "(SELECT COUNT(*) FROM campaign_contact_progress p WHERE p.contact_id = c.id AND p.opened_at IS NOT NULL AND NOT p.opened_machine)",
"emails_clicked": "(SELECT COUNT(*) FROM campaign_contact_progress p WHERE p.contact_id = c.id AND p.clicked_at IS NOT NULL)",
"emails_replied": "(SELECT COUNT(*) FROM campaign_contact_progress p WHERE p.contact_id = c.id AND p.replied_at IS NOT NULL)",
"emails_bounced": "(SELECT COUNT(*) FROM campaign_contact_progress p WHERE p.contact_id = c.id AND p.bounced_at IS NOT NULL)",
}
// segmentInt and segmentTime re-parse validated values so the bound parameter
// carries the Postgres type the cast expects.
func segmentInt(v string) int {
n, _ := strconv.Atoi(strings.TrimSpace(v))
return n
}
func segmentTime(v string) time.Time {
t, err := time.Parse(time.RFC3339, strings.TrimSpace(v))
if err != nil {
t, _ = time.Parse("2006-01-02", strings.TrimSpace(v))
}
return t
}
// escapeLike makes a user string safe inside an ILIKE pattern.
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
func (b *segmentBuilder) condition(c models.SegmentCondition, visited map[uuid.UUID]bool) string {
spec, ok := models.SegmentFieldSpecFor(c.Field)
if !ok {
return "FALSE"
}
switch spec.Kind {
case models.SegmentFieldText:
expr, ok := segmentTextColumns[spec.Field]
if !ok {
key := strings.TrimPrefix(spec.Field, models.SegmentCustomFieldPrefix)
expr = fmt.Sprintf("COALESCE(c.custom_fields ->> %s::text, '')", b.bind(key))
}
return b.textOp(expr, c)
case models.SegmentFieldEnum:
expr := segmentEnumColumns[spec.Field]
list := b.bind(c.Values)
if c.Operator == models.SegOpNotIn {
return fmt.Sprintf("NOT (%s = ANY(%s::text[]))", expr, list)
}
return fmt.Sprintf("%s = ANY(%s::text[])", expr, list)
case models.SegmentFieldBool:
var expr string
switch spec.Field {
case "subscribed":
expr = "c.subscribed"
case "is_catch_all":
expr = "c.is_catch_all"
case "suppressed":
expr = fmt.Sprintf("EXISTS (SELECT 1 FROM suppressed_recipients sr WHERE sr.organization_id = %s AND lower(sr.email) = lower(c.email) AND (sr.expires_at IS NULL OR sr.expires_at > now()))", b.bind(b.orgID))
default:
return "FALSE"
}
if c.Operator == models.SegOpIsFalse {
return "NOT " + expr
}
return expr
case models.SegmentFieldDate:
expr := segmentDateExprs[spec.Field]
switch c.Operator {
case models.SegOpBefore:
return fmt.Sprintf("%s < %s::timestamptz", expr, b.bind(segmentTime(c.Value)))
case models.SegOpAfter:
return fmt.Sprintf("%s > %s::timestamptz", expr, b.bind(segmentTime(c.Value)))
case models.SegOpWithinDays:
return fmt.Sprintf("%s >= now() - (%s::int * interval '1 day')", expr, b.bind(segmentInt(c.Value)))
case models.SegOpNotWithinDays:
return fmt.Sprintf("(%[1]s IS NULL OR %[1]s < now() - (%[2]s::int * interval '1 day'))", expr, b.bind(segmentInt(c.Value)))
case models.SegOpIsEmpty:
return fmt.Sprintf("%s IS NULL", expr)
case models.SegOpIsNotEmpty:
return fmt.Sprintf("%s IS NOT NULL", expr)
}
case models.SegmentFieldNumber:
expr := segmentNumberExprs[spec.Field]
ops := map[string]string{
models.SegOpEquals: "=", models.SegOpNotEquals: "<>",
models.SegOpGT: ">", models.SegOpGTE: ">=", models.SegOpLT: "<", models.SegOpLTE: "<=",
}
if op, ok := ops[c.Operator]; ok {
return fmt.Sprintf("%s %s %s::bigint", expr, op, b.bind(segmentInt(c.Value)))
}
case models.SegmentFieldCategory, models.SegmentFieldCampaign:
table, col := "contact_categories", "category_id"
if spec.Kind == models.SegmentFieldCampaign {
table, col = "campaign_leads", "campaign_id"
}
switch c.Operator {
case models.SegOpIn:
return fmt.Sprintf("EXISTS (SELECT 1 FROM %s x WHERE x.contact_id = c.id AND x.%s = ANY(%s::uuid[]))", table, col, b.bind(c.Values))
case models.SegOpNotIn:
return fmt.Sprintf("NOT EXISTS (SELECT 1 FROM %s x WHERE x.contact_id = c.id AND x.%s = ANY(%s::uuid[]))", table, col, b.bind(c.Values))
case models.SegOpIsEmpty:
return fmt.Sprintf("NOT EXISTS (SELECT 1 FROM %s x WHERE x.contact_id = c.id)", table)
case models.SegOpIsNotEmpty:
return fmt.Sprintf("EXISTS (SELECT 1 FROM %s x WHERE x.contact_id = c.id)", table)
}
case models.SegmentFieldSegment:
parts := make([]string, 0, len(c.Values))
for _, v := range c.Values {
id, err := uuid.Parse(v)
if err != nil {
continue
}
def, ok := b.graph[id]
if !ok {
parts = append(parts, "FALSE")
continue
}
parts = append(parts, b.segmentClause(def, true, visited))
}
if len(parts) == 0 {
return "FALSE"
}
anyOf := "(" + strings.Join(parts, " OR ") + ")"
if c.Operator == models.SegOpNotIn {
return "NOT " + anyOf
}
return anyOf
}
return "FALSE"
}
func (b *segmentBuilder) textOp(expr string, c models.SegmentCondition) string {
switch c.Operator {
case models.SegOpEquals:
return fmt.Sprintf("lower(%s) = lower(%s)", expr, b.bind(c.Value))
case models.SegOpNotEquals:
return fmt.Sprintf("lower(%s) <> lower(%s)", expr, b.bind(c.Value))
case models.SegOpContains:
return fmt.Sprintf("%s ILIKE %s", expr, b.bind("%"+escapeLike(c.Value)+"%"))
case models.SegOpNotContains:
return fmt.Sprintf("%s NOT ILIKE %s", expr, b.bind("%"+escapeLike(c.Value)+"%"))
case models.SegOpStartsWith:
return fmt.Sprintf("%s ILIKE %s", expr, b.bind(escapeLike(c.Value)+"%"))
case models.SegOpEndsWith:
return fmt.Sprintf("%s ILIKE %s", expr, b.bind("%"+escapeLike(c.Value)))
case models.SegOpIsEmpty:
return fmt.Sprintf("COALESCE(%s, '') = ''", expr)
case models.SegOpIsNotEmpty:
return fmt.Sprintf("COALESCE(%s, '') <> ''", expr)
}
return "FALSE"
}
// compileSegment returns the membership predicate for a saved segment or an
// unsaved preview, with `args` extended by whatever it bound. Callers append
// the clause to a query whose parameter list is exactly `args`.
func compileSegment(ctx context.Context, q segmentQuerier, orgID uuid.UUID, def *segmentDef, args []any) (string, []any, error) {
roots := models.SegmentReferences(def.Conditions)
if def.ID != uuid.Nil {
roots = append(roots, def.ID)
}
graph, err := loadSegmentGraph(ctx, q, orgID, roots)
if err != nil {
return "", args, err
}
b := &segmentBuilder{orgID: orgID, args: args, graph: graph}
visited := map[uuid.UUID]bool{}
clause := b.segmentClause(def, true, visited)
return clause, b.args, nil
}
// compileSavedSegment loads a segment by id and compiles it. An unknown id
// compiles to FALSE, so a stale filter matches nothing rather than erroring.
func compileSavedSegment(ctx context.Context, q segmentQuerier, orgID, segmentID uuid.UUID, args []any) (string, []any, error) {
graph, err := loadSegmentGraph(ctx, q, orgID, []uuid.UUID{segmentID})
if err != nil {
return "", args, err
}
def, ok := graph[segmentID]
if !ok {
return "FALSE", args, nil
}
b := &segmentBuilder{orgID: orgID, args: args, graph: graph}
visited := map[uuid.UUID]bool{}
clause := b.segmentClause(def, true, visited)
return clause, b.args, nil
}
+243
View File
@@ -0,0 +1,243 @@
package repository
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
// Issue #266: segments compile to SQL over the real schema. These prove each
// field family, the manual overrides and nested segments against Postgres.
// The shared fixture already holds one contact (Pied Piper), so org-wide
// counts are one higher than the three contacts seeded here.
//
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
// go test ./internal/repository/ -run LiveSegment -v
type segmentFixture struct {
*sharedOrgFixture
category uuid.UUID
alice uuid.UUID // Acme, VP, in campaign, opened, categorised
bob uuid.UUID // Globex, custom title Engineer, unsubscribed
carol uuid.UUID // Acme, no activity
}
func newSegmentFixture(t *testing.T) (*segmentFixture, SegmentRepository) {
t.Helper()
handle, pool := liveContactDB(t)
base := newSharedOrgFixture(t, pool)
f := &segmentFixture{sharedOrgFixture: base, category: uuid.New(), alice: uuid.New(), bob: uuid.New(), carol: uuid.New()}
ctx := context.Background()
exec := func(sql string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, sql, args...); err != nil {
t.Fatalf("fixture %q: %v", sql[:min(60, len(sql))], err)
}
}
tag := uuid.New().String()[:6]
contact := func(id uuid.UUID, first, company string, custom string, subscribed bool) {
exec(`INSERT INTO contacts (id, user_id, organization_id, email, first_name, last_name, company, phone, custom_fields, subscribed)
VALUES ($1, $2, $3, $4, $5, 'Seg', $6, '', $7::jsonb, $8)`,
id, f.owner, f.org, first+"-"+tag+"@"+company+".test", first, company, custom, subscribed)
}
contact(f.alice, "alice", "acme", `{"title":"VP Sales"}`, true)
contact(f.bob, "bob", "globex", `{"title":"Engineer"}`, false)
contact(f.carol, "carol", "acme", `{}`, true)
exec(`INSERT INTO categories (id, user_id, title, color, position) VALUES ($1, $2, 'Hot', '#ff0000', 0)`, f.category, f.owner)
exec(`INSERT INTO contact_categories (contact_id, category_id) VALUES ($1, $2)`, f.alice, f.category)
exec(`INSERT INTO campaign_leads (campaign_id, contact_id) VALUES ($1, $2)`, f.campaign, f.alice)
seq := uuid.New()
exec(`INSERT INTO sequences (id, campaign_id, organization_id, name, subject, body_plain, body_html) VALUES ($1, $2, $3, 'Email 1', 'Hi', 'Body', 'Body')`, seq, f.campaign, f.org)
exec(`INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at, opened_at, opened_machine)
VALUES ($1, $2, $3, NOW() - interval '2 days', NOW() - interval '1 day', false)`, f.campaign, f.alice, seq)
t.Cleanup(func() {
c := context.Background()
for _, step := range []struct {
sql string
arg any
}{
{`DELETE FROM segments WHERE organization_id = $1`, f.org},
{`DELETE FROM campaign_contact_progress WHERE campaign_id = $1`, f.campaign},
{`DELETE FROM sequences WHERE campaign_id = $1`, f.campaign},
{`DELETE FROM contact_categories WHERE category_id = $1`, f.category},
{`DELETE FROM categories WHERE id = $1`, f.category},
} {
if _, err := pool.Exec(c, step.sql, step.arg); err != nil {
t.Errorf("cleanup %q: %v", step.sql, err)
}
}
})
return f, NewSegmentRepository(handle)
}
func segCount(t *testing.T, repo SegmentRepository, org uuid.UUID, match models.SegmentMatch, conds ...models.SegmentCondition) int {
t.Helper()
if err := models.ValidateSegmentConditions(conds, nil); err != nil {
t.Fatalf("validate: %v", err)
}
n, xerr := repo.Count(context.Background(), org, nil, match, conds)
if xerr != nil {
t.Fatalf("count: %v", xerr)
}
return n
}
func TestLiveSegmentConditionsMatchEachFamily(t *testing.T) {
f, repo := newSegmentFixture(t)
cases := []struct {
name string
want int
cond models.SegmentCondition
}{
{"company equals", 2, models.SegmentCondition{Field: "company", Operator: "equals", Value: "ACME"}},
{"company contains escapes wildcards", 0, models.SegmentCondition{Field: "company", Operator: "contains", Value: "%"}},
{"email domain", 1, models.SegmentCondition{Field: "email_domain", Operator: "equals", Value: "globex.test"}},
{"custom field", 1, models.SegmentCondition{Field: "custom.title", Operator: "starts_with", Value: "vp"}},
{"custom field empty", 2, models.SegmentCondition{Field: "custom.title", Operator: "is_empty"}},
{"subscribed false", 1, models.SegmentCondition{Field: "subscribed", Operator: "is_false"}},
{"category in", 1, models.SegmentCondition{Field: "category", Operator: "in", Values: []string{f.category.String()}}},
{"category none", 3, models.SegmentCondition{Field: "category", Operator: "is_empty"}},
{"campaign in", 1, models.SegmentCondition{Field: "campaign", Operator: "in", Values: []string{f.campaign.String()}}},
{"campaign not in", 3, models.SegmentCondition{Field: "campaign", Operator: "not_in", Values: []string{f.campaign.String()}}},
{"campaign count", 3, models.SegmentCondition{Field: "campaign_count", Operator: "equals", Value: "0"}},
{"emails opened", 1, models.SegmentCondition{Field: "emails_opened", Operator: "gte", Value: "1"}},
{"last opened within", 1, models.SegmentCondition{Field: "last_opened_at", Operator: "within_days", Value: "7"}},
{"last opened not within", 3, models.SegmentCondition{Field: "last_opened_at", Operator: "not_within_days", Value: "7"}},
{"last replied empty", 4, models.SegmentCondition{Field: "last_replied_at", Operator: "is_empty"}},
{"created after yesterday", 4, models.SegmentCondition{Field: "created_at", Operator: "after", Value: "2000-01-01"}},
{"source enum", 4, models.SegmentCondition{Field: "source", Operator: "in", Values: []string{"unknown"}}},
{"suppressed", 0, models.SegmentCondition{Field: "suppressed", Operator: "is_true"}},
}
for _, tc := range cases {
if got := segCount(t, repo, f.org, models.SegmentMatchAll, tc.cond); got != tc.want {
t.Errorf("%s: got %d, want %d", tc.name, got, tc.want)
}
}
}
func TestLiveSegmentMatchAnyAndAll(t *testing.T) {
f, repo := newSegmentFixture(t)
acme := models.SegmentCondition{Field: "company", Operator: "equals", Value: "acme"}
opened := models.SegmentCondition{Field: "emails_opened", Operator: "gte", Value: "1"}
if got := segCount(t, repo, f.org, models.SegmentMatchAll, acme, opened); got != 1 {
t.Errorf("all: got %d, want 1", got)
}
unsub := models.SegmentCondition{Field: "subscribed", Operator: "is_false"}
if got := segCount(t, repo, f.org, models.SegmentMatchAny, opened, unsub); got != 2 {
t.Errorf("any: got %d, want 2", got)
}
}
func TestLiveSegmentOverridesNestingAndCampaign(t *testing.T) {
f, repo := newSegmentFixture(t)
ctx := context.Background()
acme, xerr := repo.Create(ctx, f.org, &f.owner, &models.Segment{
Name: "Acme", Color: "#0284c7", Match: models.SegmentMatchAll,
Conditions: []models.SegmentCondition{{Field: "company", Operator: "equals", Value: "acme"}},
})
if xerr != nil {
t.Fatalf("create: %v", xerr)
}
if acme.ContactCount != 2 {
t.Fatalf("acme count = %d, want 2", acme.ContactCount)
}
// Exclude carol, include bob: membership is (conditions OR include) AND NOT exclude.
if _, xerr := repo.SetMembers(ctx, f.org, acme.ID, []uuid.UUID{f.carol}, models.SegmentMemberExclude); xerr != nil {
t.Fatalf("exclude: %v", xerr)
}
if _, xerr := repo.SetMembers(ctx, f.org, acme.ID, []uuid.UUID{f.bob}, models.SegmentMemberInclude); xerr != nil {
t.Fatalf("include: %v", xerr)
}
got, xerr := repo.Get(ctx, f.org, acme.ID)
if xerr != nil {
t.Fatalf("get: %v", xerr)
}
if got.ContactCount != 2 || got.IncludedCount != 1 || got.ExcludedCount != 1 {
t.Fatalf("after overrides: count=%d included=%d excluded=%d", got.ContactCount, got.IncludedCount, got.ExcludedCount)
}
modes, xerr := repo.MemberModes(ctx, acme.ID, []uuid.UUID{f.alice, f.bob, f.carol})
if xerr != nil {
t.Fatalf("modes: %v", xerr)
}
if modes[f.bob] != models.SegmentMemberInclude || modes[f.carol] != models.SegmentMemberExclude || modes[f.alice] != "" {
t.Fatalf("modes = %v", modes)
}
// The contact-side view reports membership and the override per segment,
// and the overrides listing shows both pinned contacts.
forBob, xerr := repo.SegmentsForContact(ctx, f.org, f.bob)
if xerr != nil {
t.Fatalf("segments for contact: %v", xerr)
}
if len(forBob) != 1 || !forBob[0].Member || forBob[0].Mode != models.SegmentMemberInclude {
t.Fatalf("segments for bob = %+v", forBob)
}
forCarol, _ := repo.SegmentsForContact(ctx, f.org, f.carol)
if len(forCarol) != 1 || forCarol[0].Member || forCarol[0].Mode != models.SegmentMemberExclude {
t.Fatalf("segments for carol = %+v", forCarol)
}
overrides, xerr := repo.ListOverrides(ctx, f.org, acme.ID)
if xerr != nil || len(overrides) != 2 {
t.Fatalf("overrides = %+v, %v", overrides, xerr)
}
// A segment built on another one sees its overrides.
nested, xerr := repo.Create(ctx, f.org, &f.owner, &models.Segment{
Name: "Acme not opened", Color: "#0284c7", Match: models.SegmentMatchAll,
Conditions: []models.SegmentCondition{
{Field: "segment", Operator: "in", Values: []string{acme.ID.String()}},
{Field: "emails_opened", Operator: "equals", Value: "0"},
},
})
if xerr != nil {
t.Fatalf("create nested: %v", xerr)
}
if nested.ContactCount != 1 { // bob (included), since carol is excluded and alice opened
t.Fatalf("nested count = %d, want 1", nested.ContactCount)
}
names, xerr := repo.ReferencedBy(ctx, f.org, acme.ID)
if xerr != nil || len(names) != 1 || names[0] != "Acme not opened" {
t.Fatalf("referenced by = %v, %v", names, xerr)
}
// The contacts search honours segment_ids.
handle, _ := liveContactDB(t)
contacts := NewContactRepostory(handle)
res, xerr := contacts.Search(ctx, f.org.String(), nil, nil, models.SearchContacts{SegmentIDs: []string{acme.ID.String()}}, 50)
if xerr != nil {
t.Fatalf("search: %v", xerr)
}
if len(res.Data) != 2 {
t.Fatalf("search by segment = %d contacts, want 2", len(res.Data))
}
// Enrolling the segment adds only the members not already leads.
out, xerr := repo.AddToCampaign(ctx, f.org, f.mate.String(), acme.ID, f.other)
if xerr != nil {
t.Fatalf("add to campaign: %v", xerr)
}
if out.Added != 2 || out.Members != 2 {
t.Fatalf("add to campaign = %+v", out)
}
out, xerr = repo.AddToCampaign(ctx, f.org, f.mate.String(), acme.ID, f.other)
if xerr != nil || out.Added != 0 {
t.Fatalf("second add = %+v, %v", out, xerr)
}
// Duplicate names collide, and a referenced segment cannot be deleted
// (the service refuses; the repository reports the reference).
if _, xerr := repo.Create(ctx, f.org, nil, &models.Segment{Name: "ACME", Color: "#0284c7", Match: models.SegmentMatchAll}); xerr == nil {
t.Fatalf("duplicate name accepted")
}
if xerr := repo.Delete(ctx, f.org, nested.ID); xerr != nil {
t.Fatalf("delete nested: %v", xerr)
}
if got, _ := repo.Get(ctx, f.org, acme.ID); got == nil || got.ContactCount != 2 {
t.Fatalf("acme after nested delete: %+v", got)
}
}
+2 -1
View File
@@ -17,7 +17,8 @@ func validateActionConfig(a *models.ActionConfig) *errx.Error {
return nil
}
switch a.Type {
case "wait", "add_tag", "remove_tag", "label_email", "unsubscribe",
case "wait", "add_tag", "remove_tag", "add_to_segment", "remove_from_segment",
"label_email", "unsubscribe",
"notify", "create_task", "create_deal", "move_deal_stage",
"run_automation", "fire_event", "end":
// Type must be known. Sub-config (wait minutes, tag, event name, HTTP
+12
View File
@@ -965,6 +965,18 @@ func (s *tasksService) executeActionNode(ctx context.Context, campaign *models.C
return xerr
}
return nil
case "add_to_segment", "remove_from_segment":
if cfg.SegmentID == nil || campaign.OrganizationID == nil || s.segmentRepo == nil {
return nil
}
mode := models.SegmentMemberInclude
if cfg.Type == "remove_from_segment" {
mode = models.SegmentMemberExclude
}
if _, xerr := s.segmentRepo.SetMembers(ctx, *campaign.OrganizationID, *cfg.SegmentID, []uuid.UUID{contact.ID}, mode); xerr != nil {
return xerr
}
return nil
case "label_email":
// Apply unibox labels to the contact's most recent conversation. A no-op
// when the contact has no thread yet (returns "" thread, nil error).
+12
View File
@@ -129,6 +129,7 @@ type tasksService struct {
emailRepo repository.EmailRepository
campaignRepo repository.CampaignRepository
contactRepo repository.ContactRepository
segmentRepo repository.SegmentRepository
campaignLogRepo repository.CampaignLogRepository
// orgRiskRepo bars a restricted organization from the paid warmup pool.
// Optional/nil-safe.
@@ -270,3 +271,14 @@ func (s *tasksService) WireOrgRisk(r repository.OrgRiskRepository) {
type OrgRiskAware interface {
WireOrgRisk(r repository.OrgRiskRepository)
}
// WireSegments attaches the segment repository the add_to_segment and
// remove_from_segment action nodes write through.
func (s *tasksService) WireSegments(r repository.SegmentRepository) {
s.segmentRepo = r
}
// SegmentAware is the optional capability the caller uses to attach segments.
type SegmentAware interface {
WireSegments(r repository.SegmentRepository)
}
@@ -0,0 +1,275 @@
// Categories tab: the workspace's contact labels with a live contact count,
// inline rename, color, create and delete. Clicking a row opens the contact
// list filtered to that category.
import React from "react";
import { useNavigate } from "react-router-dom";
import { useQueries } from "@tanstack/react-query";
import { CheckIcon, MoreHorizontalIcon, PlusIcon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import { EmptyBlock, Page, PageBody, PageTopbar, SectionBar, TopbarAction } from "@/components/layout/Page";
import { SearchInput, TextInput } from "@/components/ui/field";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuSeparator,
PopoverMenuTrigger,
} from "@/components/ui/popover-menu";
import { useConfirm } from "@/hooks/context/confirm";
import { useUserProfile } from "@/hooks/context/user";
import { useWriteGuard } from "@/hooks/usePermission";
import useCreateCategory from "@/lib/api/hooks/app/categories/useCreateCategory";
import useDeleteCategory from "@/lib/api/hooks/app/categories/useDeleteCategory";
import useUpdateCategory from "@/lib/api/hooks/app/categories/useUpdateCategory";
import { previewSegment } from "@/lib/api/client/app/segments";
import type Category from "@/lib/api/models/app/Category";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { cn } from "@/lib/utils";
const COLORS = ["#0284c7", "#7c3aed", "#db2777", "#dc2626", "#ea580c", "#ca8a04", "#16a34a", "#0d9488", "#475569"];
export default function CategoriesPage() {
const { user } = useUserProfile();
const write = useWriteGuard("MANAGE_CONTACTS");
const guarded = (fn: () => void) => () => write.guard(fn)({});
const create = useCreateCategory();
const [query, setQuery] = React.useState("");
const [creating, setCreating] = React.useState(false);
const [newTitle, setNewTitle] = React.useState("");
const categories = React.useMemo(
() => [...(user.categories ?? [])].sort((a, b) => a.position - b.position),
[user.categories],
);
const list = React.useMemo(() => {
const q = query.trim().toLowerCase();
return q ? categories.filter((c) => c.title.toLowerCase().includes(q)) : categories;
}, [categories, query]);
// One live count per category through the segment preview, so the number
// agrees with what a "has any of" condition would match.
const counts = useQueries({
queries: categories.map((c) => ({
queryKey: ["segments", "preview", "category", c.id],
queryFn: () => previewSegment({ match: "all", conditions: [{ field: "category", operator: "in", values: [c.id] }] }),
staleTime: 30_000,
})),
});
const countById = new Map<string, number | undefined>();
categories.forEach((c, i) => countById.set(c.id, counts[i]?.data));
async function submitCreate() {
const title = newTitle.trim();
if (!title || create.isPending) return;
try {
await create.mutateAsync(title);
toast.success(`Created ${title}`);
setNewTitle("");
setCreating(false);
} catch (err) {
toast.error(buildError(err as AppError));
}
}
return (
<Page>
<PageTopbar eyebrow="Categories" subtitle="Labels you put on contacts by hand, on import or from a sequence">
<TopbarAction icon={<PlusIcon className="w-3 h-3" />} onClick={guarded(() => setCreating(true))}>
New category
</TopbarAction>
</PageTopbar>
<SectionBar label="All categories" count={list.length}>
<SearchInput value={query} onChange={setQuery} placeholder="Search categories…" className="w-full sm:w-64" />
</SectionBar>
<PageBody>
{creating && (
<form
onSubmit={(e) => {
e.preventDefault();
void submitCreate();
}}
className="h-11 px-5 flex items-center gap-2 border-b border-slate-200/60 bg-sky-50/40"
>
<TextInput value={newTitle} onChange={setNewTitle} placeholder="Category name" autoFocus className="w-64" />
<button
type="submit"
disabled={!newTitle.trim() || create.isPending}
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium transition-colors disabled:opacity-50"
>
Create
</button>
<button
type="button"
onClick={() => {
setCreating(false);
setNewTitle("");
}}
className="h-7 px-2.5 rounded-md text-[12px] text-slate-600 hover:text-slate-900 hover:bg-slate-100 transition-colors"
>
Cancel
</button>
</form>
)}
{list.length === 0 ? (
<EmptyBlock
title={query ? "No categories match" : "No categories yet"}
body={
query
? "Try a different search."
: "Categories are labels on a contact. Create one here, on a contact, or by mapping a column during import."
}
cta={
query ? undefined : (
<TopbarAction icon={<PlusIcon className="w-3 h-3" />} onClick={guarded(() => setCreating(true))}>
New category
</TopbarAction>
)
}
/>
) : (
<div>
{list.map((c) => (
<CategoryRow key={c.id} category={c} count={countById.get(c.id)} />
))}
</div>
)}
</PageBody>
</Page>
);
}
function CategoryRow({ category, count }: { category: Category; count?: number }) {
const navigate = useNavigate();
const confirm = useConfirm();
const write = useWriteGuard("MANAGE_CONTACTS");
const guarded = (fn: () => void) => () => write.guard(fn)({});
const update = useUpdateCategory(category.id);
const remove = useDeleteCategory(category.id);
const [renaming, setRenaming] = React.useState(false);
const [title, setTitle] = React.useState(category.title);
const open = () => navigate(`/app/contacts?category=${category.id}`);
async function submitRename() {
const next = title.trim();
if (!next || next === category.title) {
setRenaming(false);
setTitle(category.title);
return;
}
try {
await update.mutateAsync({ title: next });
toast.success("Category renamed");
setRenaming(false);
} catch (err) {
toast.error(buildError(err as AppError));
}
}
async function setColor(color: string) {
if (color === category.color) return;
try {
await update.mutateAsync({ color });
} catch (err) {
toast.error(buildError(err as AppError));
}
}
function askDelete() {
confirm.show(`Delete the category "${category.title}"? It is removed from every contact and inbox thread; the contacts themselves are kept.`, async () => {
try {
await remove.mutateAsync();
toast.success("Category deleted");
} catch (err) {
toast.error(buildError(err as AppError));
}
});
}
return (
<div
role="link"
tabIndex={0}
onClick={() => {
if (!renaming) open();
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !renaming) open();
}}
className="group h-11 px-5 flex items-center gap-3 border-b border-slate-200/60 transition-colors hover:bg-slate-50/80 cursor-pointer"
>
<span className="size-2.5 rounded-full shrink-0" style={{ backgroundColor: category.color }} />
<div className="min-w-0 flex-1" onClick={(e) => renaming && e.stopPropagation()}>
{renaming ? (
<form
onSubmit={(e) => {
e.preventDefault();
void submitRename();
}}
className="flex items-center gap-1.5"
>
<TextInput value={title} onChange={setTitle} autoFocus className="w-64" />
<button type="submit" aria-label="Save" className="size-7 rounded-md text-emerald-700 hover:bg-emerald-50 inline-flex items-center justify-center">
<CheckIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
aria-label="Cancel"
onClick={() => {
setRenaming(false);
setTitle(category.title);
}}
className="size-7 rounded-md text-slate-500 hover:bg-slate-100 inline-flex items-center justify-center"
>
<XIcon className="w-3.5 h-3.5" />
</button>
</form>
) : (
<span className="text-[12.5px] font-medium text-slate-900 truncate">{category.title}</span>
)}
</div>
<span className="font-mono text-[12px] text-slate-900 tabular-nums w-20 text-right shrink-0">
{count === undefined ? <span className="text-slate-300"></span> : count.toLocaleString()}
</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-slate-400 w-16 shrink-0 hidden sm:inline">contacts</span>
<PopoverMenu align="end">
<PopoverMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label="More"
className="size-7 rounded-md text-slate-400 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors shrink-0"
>
<MoreHorizontalIcon className="w-3.5 h-3.5" />
</button>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={200}>
<PopoverMenuItem onSelect={open}>View contacts</PopoverMenuItem>
<PopoverMenuItem onSelect={guarded(() => setRenaming(true))}>Rename</PopoverMenuItem>
<div className="px-2.5 py-1.5 flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{COLORS.map((color) => (
<button
key={color}
type="button"
aria-label={`Set color ${color}`}
onClick={guarded(() => void setColor(color))}
className={cn(
"size-4 rounded-full border-2 transition-transform hover:scale-110",
color === category.color ? "border-slate-900" : "border-transparent",
)}
style={{ backgroundColor: color }}
/>
))}
</div>
<PopoverMenuSeparator />
<PopoverMenuItem onSelect={guarded(askDelete)}>Delete</PopoverMenuItem>
</PopoverMenuContent>
</PopoverMenu>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
// Contacts area: one tab strip over the contact list, saved segments and
// categories, since all three are views of the same contact database.
import { Link, Outlet, useLocation } from "react-router-dom";
import { motion } from "framer-motion";
import { LayersIcon, TagIcon, UsersIcon } from "lucide-react";
import { NoAccess } from "@/components/layout/NoAccess";
import { usePermission } from "@/hooks/usePermission";
const TABS = [
{ label: "All contacts", path: "", Icon: UsersIcon },
{ label: "Segments", path: "/segments", Icon: LayersIcon },
{ label: "Categories", path: "/categories", Icon: TagIcon },
] as const;
export default function ContactsLayout() {
const canView = usePermission("VIEW_CONTACTS");
const { pathname } = useLocation();
if (!canView) return <NoAccess feature="contacts" permissionLabel="View contacts" />;
const current = pathname.replace(/\/$/, "");
return (
<div className="flex flex-col min-h-full">
<div className="shrink-0 px-3 flex items-center gap-1 border-b border-slate-200 bg-white overflow-x-auto no-scrollbar">
{TABS.map(({ label, path, Icon }) => {
const to = `/app/contacts${path}`;
const active = path === "" ? current === to : current.startsWith(to);
return (
<Link
key={path || "all"}
to={to}
className={`relative h-10 px-2.5 inline-flex items-center gap-1.5 text-[12.5px] transition-colors ${
active ? "text-slate-900 font-medium" : "text-slate-500 hover:text-slate-800"
}`}
>
<Icon className="w-3.5 h-3.5" />
{label}
{active && (
<motion.span
layoutId="contacts-tab-underline"
className="absolute left-1.5 right-1.5 -bottom-px h-0.5 rounded-full bg-sky-600"
transition={{ type: "spring", duration: 0.3, bounce: 0.15 }}
/>
)}
</Link>
);
})}
</div>
<div className="flex-1 min-h-0 flex flex-col">
<Outlet />
</div>
</div>
);
}
@@ -0,0 +1,250 @@
// One segment: its definition up top, its live contact list below (the
// shared ContactsTable scoped by segment_ids, with include/exclude actions).
import React from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ArrowLeftIcon, ChevronDownIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from "lucide-react";
import toast from "react-hot-toast";
import ContactsTable from "@/components/app/contacts/ContactsTable";
import SegmentEditor from "@/components/app/segments/SegmentEditor";
import AddSegmentToCampaignDialog from "@/components/app/segments/AddSegmentToCampaignDialog";
import { EmptyBlock } from "@/components/layout/Page";
import { NoAccess } from "@/components/layout/NoAccess";
import { useConfirm } from "@/hooks/context/confirm";
import { usePermission, useWriteGuard } from "@/hooks/usePermission";
import { useDeleteSegment, useSegment, useSegmentFields, useSegmentOverrides, useSetSegmentMembers } from "@/lib/api/hooks/app/segments";
import type Segment from "@/lib/api/models/app/segments/Segment";
import {
SEGMENT_OPERATORS,
VALUELESS_OPERATORS,
type SegmentCondition,
type SegmentFieldSpec,
type SegmentOverride,
} from "@/lib/api/models/app/segments/Segment";
import { cn } from "@/lib/utils";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
export default function SegmentPage() {
const canView = usePermission("VIEW_CONTACTS");
if (!canView) return <NoAccess feature="segments" permissionLabel="View contacts" />;
return <SegmentDetail />;
}
function SegmentDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const confirm = useConfirm();
const write = useWriteGuard("MANAGE_CONTACTS");
// Enrolment writes campaign leads, so it takes the campaign permission.
const campaigns = useWriteGuard("MANAGE_CAMPAIGNS");
const segment = useSegment(id);
const fields = useSegmentFields();
const remove = useDeleteSegment();
const [editorOpen, setEditorOpen] = React.useState(false);
const [campaignOpen, setCampaignOpen] = React.useState(false);
if (segment.isPending) {
return (
<div className="p-5 space-y-2">
<div className="h-6 w-56 rounded bg-slate-100 animate-pulse" />
<div className="h-4 w-80 rounded bg-slate-100 animate-pulse" />
</div>
);
}
if (segment.isError || !segment.data) {
return (
<div className="p-5">
<Link to="/app/contacts/segments" className="text-[12px] text-slate-500 hover:text-slate-900 inline-flex items-center gap-1">
<ArrowLeftIcon className="w-3 h-3" /> Segments
</Link>
<EmptyBlock title="Segment not found" body="It may have been deleted by a teammate." />
</div>
);
}
const s = segment.data;
function askDelete() {
confirm.show(`Delete the segment "${s.name}"? Contacts themselves are kept.`, async () => {
try {
await remove.mutateAsync(s.id);
toast.success("Segment deleted");
navigate("/app/contacts/segments");
} catch (err) {
toast.error(buildError(err as AppError));
}
});
}
return (
<div className="flex flex-col min-h-full">
<div className="px-5 pt-3 pb-3 border-b border-slate-200 bg-white">
<Link to="/app/contacts/segments" className="text-[11px] text-slate-500 hover:text-slate-900 inline-flex items-center gap-1">
<ArrowLeftIcon className="w-3 h-3" /> Segments
</Link>
<div className="mt-1.5 flex flex-wrap items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<span className="size-3 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
<h1 className="text-[15px] font-semibold text-slate-900 truncate">{s.name}</h1>
<span className="font-mono text-[11px] text-slate-500 tabular-nums">
{s.contact_count.toLocaleString()} contact{s.contact_count === 1 ? "" : "s"}
</span>
</div>
{s.description && <p className="text-[12px] text-slate-500 mt-0.5">{s.description}</p>}
<ConditionSummary conditions={s.conditions} match={s.match} specs={fields.data ?? []} included={s.included_count} excluded={s.excluded_count} />
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={write.guard(() => setEditorOpen(true))}
className="h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 bg-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<PencilIcon className="w-3 h-3" />
Edit
</button>
<button
type="button"
onClick={campaigns.guard(() => setCampaignOpen(true))}
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<MegaphoneIcon className="w-3 h-3" />
Add to campaign
</button>
<button
type="button"
onClick={write.guard(askDelete)}
aria-label="Delete segment"
className="size-7 rounded-md text-slate-400 hover:text-red-600 hover:bg-red-50 inline-flex items-center justify-center transition-colors"
>
<Trash2Icon className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
{(s.included_count > 0 || s.excluded_count > 0) && <OverridesPanel segment={s} />}
<ContactsTable key={s.id} segment={{ id: s.id, name: s.name }} />
<SegmentEditor open={editorOpen} onClose={() => setEditorOpen(false)} segment={s} />
<AddSegmentToCampaignDialog open={campaignOpen} onClose={() => setCampaignOpen(false)} segment={s} />
</div>
);
}
// Pinned contacts. Excluded ones never show in the member list, so this is
// the only place they can be seen and released.
function OverridesPanel({ segment }: { segment: Segment }) {
const write = useWriteGuard("MANAGE_CONTACTS");
const overrides = useSegmentOverrides(segment.id);
const set = useSetSegmentMembers();
const [open, setOpen] = React.useState(false);
const [busyId, setBusyId] = React.useState<string | null>(null);
async function clear(o: SegmentOverride) {
setBusyId(o.contact_id);
try {
await set.mutateAsync({ id: segment.id, contacts: [o.contact_id], mode: "auto" });
toast.success("Back to automatic");
} catch (err) {
toast.error(buildError(err as AppError));
} finally {
setBusyId(null);
}
}
const list = overrides.data ?? [];
return (
<div className="border-b border-slate-200 bg-slate-50/40">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="w-full h-9 px-5 flex items-center gap-2 text-left"
aria-expanded={open}
>
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Pinned contacts</span>
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums">
{segment.included_count} in · {segment.excluded_count} out
</span>
<ChevronDownIcon className={cn("w-3.5 h-3.5 text-slate-400 ml-auto transition-transform", open && "rotate-180")} />
</button>
{open && (
<ul className="divide-y divide-slate-100 border-t border-slate-200/60 max-h-72 overflow-y-auto bg-white">
{overrides.isPending && <li className="px-5 h-9 flex items-center text-[11.5px] text-slate-400">Loading</li>}
{list.map((o) => {
const name = `${o.first_name} ${o.last_name}`.trim() || o.email;
return (
<li key={o.contact_id} className="px-5 h-9 flex items-center gap-2">
<span
className={cn(
"inline-flex items-center h-4 px-1 rounded text-[10px] font-medium shrink-0",
o.mode === "include" ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700",
)}
>
{o.mode === "include" ? "in" : "out"}
</span>
<span className="text-[12px] text-slate-900 truncate">{name}</span>
<span className="text-[11px] text-slate-400 truncate hidden sm:inline">{o.email}</span>
<button
type="button"
onClick={write.guard(() => clear(o))}
disabled={busyId === o.contact_id}
className="ml-auto h-6 px-2 rounded text-[11px] text-slate-500 hover:text-slate-900 hover:bg-slate-100 transition-colors disabled:opacity-50"
>
{busyId === o.contact_id ? "…" : "Back to automatic"}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
function describe(c: SegmentCondition, specs: SegmentFieldSpec[]): string {
const spec = specs.find((f) => f.field === c.field);
const label = spec?.label ?? c.field.replace(/^custom\./, "");
const op = spec ? SEGMENT_OPERATORS[spec.kind].find((o) => o.id === c.operator)?.label : c.operator;
if (VALUELESS_OPERATORS.has(c.operator)) return `${label} ${op ?? c.operator}`;
if (c.values && c.values.length > 0) return `${label} ${op} ${c.values.length} value${c.values.length === 1 ? "" : "s"}`;
if (c.operator === "within_days" || c.operator === "not_within_days") return `${label} ${op} ${c.value} days`;
return `${label} ${op} ${c.value ?? ""}`.trim();
}
function ConditionSummary({
conditions,
match,
specs,
included,
excluded,
}: {
conditions: SegmentCondition[];
match: "all" | "any";
specs: SegmentFieldSpec[];
included: number;
excluded: number;
}) {
return (
<div className="mt-2 flex flex-wrap items-center gap-1">
{conditions.length === 0 ? (
<span className="text-[11px] text-slate-400">No conditions: only contacts added by hand.</span>
) : (
conditions.map((c, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="text-[10px] uppercase tracking-[0.1em] text-slate-400">{match === "all" ? "and" : "or"}</span>}
<span className="inline-flex items-center h-5 px-1.5 rounded bg-slate-100 text-slate-700 text-[11px]">{describe(c, specs)}</span>
</React.Fragment>
))
)}
{included > 0 && (
<span className="inline-flex items-center h-5 px-1.5 rounded bg-emerald-50 text-emerald-700 text-[11px]">+{included} added by hand</span>
)}
{excluded > 0 && (
<span className="inline-flex items-center h-5 px-1.5 rounded bg-amber-50 text-amber-700 text-[11px]">{excluded} excluded</span>
)}
</div>
);
}
+228
View File
@@ -0,0 +1,228 @@
// Segments: saved contact audiences (issue #266). Each row is one segment
// with its live member count; clicking opens the segment's contact list.
import React from "react";
import { useNavigate } from "react-router-dom";
import { MoreHorizontalIcon, PlusIcon } from "lucide-react";
import toast from "react-hot-toast";
import { EmptyBlock, Page, PageBody, PageTopbar, SectionBar, StatStrip, Stat, TopbarAction } from "@/components/layout/Page";
import { NoAccess } from "@/components/layout/NoAccess";
import { SearchInput } from "@/components/ui/field";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuSeparator,
PopoverMenuTrigger,
} from "@/components/ui/popover-menu";
import SegmentEditor from "@/components/app/segments/SegmentEditor";
import AddSegmentToCampaignDialog from "@/components/app/segments/AddSegmentToCampaignDialog";
import { useConfirm } from "@/hooks/context/confirm";
import { usePermission, useWriteGuard } from "@/hooks/usePermission";
import { useCreateSegment, useDeleteSegment, useSegments } from "@/lib/api/hooks/app/segments";
import type Segment from "@/lib/api/models/app/segments/Segment";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
export default function SegmentsPage() {
const canView = usePermission("VIEW_CONTACTS");
if (!canView) return <NoAccess feature="segments" permissionLabel="View contacts" />;
return <SegmentsList />;
}
function SegmentsList() {
const navigate = useNavigate();
const confirm = useConfirm();
const write = useWriteGuard("MANAGE_CONTACTS");
// Menu items and topbar actions take a bare () => void, so give the
// permission guard an empty event to swallow.
const guarded = (fn: () => void) => () => write.guard(fn)({});
const campaigns = useWriteGuard("MANAGE_CAMPAIGNS");
const campaignGuarded = (fn: () => void) => () => campaigns.guard(fn)({});
const segments = useSegments();
const remove = useDeleteSegment();
const create = useCreateSegment();
async function duplicate(s: Segment) {
try {
const copy = await create.mutateAsync({
name: `${s.name} (copy)`.slice(0, 120),
description: s.description,
color: s.color,
match: s.match,
conditions: s.conditions,
});
toast.success(`Duplicated as ${copy.name}`);
navigate(`/app/contacts/segments/${copy.id}`);
} catch (err) {
toast.error(buildError(err as AppError));
}
}
const [query, setQuery] = React.useState("");
const [editorOpen, setEditorOpen] = React.useState(false);
const [editing, setEditing] = React.useState<Segment | null>(null);
const [campaignFor, setCampaignFor] = React.useState<Segment | null>(null);
const list = React.useMemo(() => {
const all = segments.data ?? [];
const q = query.trim().toLowerCase();
if (!q) return all;
return all.filter((s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
}, [segments.data, query]);
const totals = React.useMemo(() => {
const all = segments.data ?? [];
return {
segments: all.length,
contacts: all.reduce((n, s) => n + s.contact_count, 0),
manual: all.reduce((n, s) => n + s.included_count + s.excluded_count, 0),
largest: all.reduce<Segment | null>((best, s) => (!best || s.contact_count > best.contact_count ? s : best), null),
};
}, [segments.data]);
function openNew() {
setEditing(null);
setEditorOpen(true);
}
function openEdit(s: Segment) {
setEditing(s);
setEditorOpen(true);
}
function askDelete(s: Segment) {
confirm.show(`Delete the segment "${s.name}"? Contacts themselves are kept.`, async () => {
try {
await remove.mutateAsync(s.id);
toast.success("Segment deleted");
} catch (err) {
toast.error(buildError(err as AppError));
}
});
}
return (
<Page>
<PageTopbar eyebrow="Segments" subtitle="Reusable audiences built from contact data">
<TopbarAction icon={<PlusIcon className="w-3 h-3" />} onClick={guarded(openNew)}>
New segment
</TopbarAction>
</PageTopbar>
<StatStrip cols={3}>
<Stat label="Segments" value={totals.segments} sub="saved audiences" />
<Stat label="Memberships" value={totals.contacts.toLocaleString()} sub="across all segments" accent={totals.contacts > 0} />
<Stat
label="Largest"
value={totals.largest ? totals.largest.contact_count.toLocaleString() : "0"}
sub={totals.largest ? totals.largest.name : "no segments yet"}
last
/>
</StatStrip>
<SectionBar label="All segments" count={list.length}>
<SearchInput value={query} onChange={setQuery} placeholder="Search segments…" className="w-full sm:w-64" />
</SectionBar>
<PageBody>
{segments.isPending ? (
<div className="p-3 space-y-1.5">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-11 rounded-md bg-slate-100 animate-pulse" />
))}
</div>
) : segments.isError ? (
<EmptyBlock title="Couldn't load segments" body="Try again in a moment." />
) : list.length === 0 ? (
<EmptyBlock
title={query ? "No segments match" : "No segments yet"}
body={
query
? "Try a different search."
: "Build an audience from contact fields, categories, campaign activity and engagement, then add it to a campaign in one step."
}
cta={
query ? undefined : (
<TopbarAction icon={<PlusIcon className="w-3 h-3" />} onClick={guarded(openNew)}>
New segment
</TopbarAction>
)
}
/>
) : (
<div>
{list.map((s) => (
<div
key={s.id}
role="link"
tabIndex={0}
onClick={() => navigate(`/app/contacts/segments/${s.id}`)}
onKeyDown={(e) => {
if (e.key === "Enter") navigate(`/app/contacts/segments/${s.id}`);
}}
className="group h-11 px-5 flex items-center gap-3 border-b border-slate-200/60 transition-colors hover:bg-slate-50/80 cursor-pointer"
>
<span className="size-2.5 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<span className="text-[12.5px] font-medium text-slate-900 truncate">{s.name}</span>
{s.conditions.length === 0 && (
<span className="inline-flex items-center h-4 px-1 rounded bg-slate-100 text-slate-500 text-[10px] font-medium">
manual
</span>
)}
</div>
{s.description && <div className="text-[11px] text-slate-500 truncate">{s.description}</div>}
</div>
<span className="hidden md:inline text-[11px] text-slate-400 tabular-nums shrink-0">
{s.conditions.length} condition{s.conditions.length === 1 ? "" : "s"}
{s.match === "any" && s.conditions.length > 1 ? " · any" : ""}
</span>
<span className="font-mono text-[12px] text-slate-900 tabular-nums w-20 text-right shrink-0">
{s.contact_count.toLocaleString()}
</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-slate-400 w-16 shrink-0 hidden sm:inline">
contacts
</span>
<PopoverMenu align="end">
<PopoverMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label="More"
className="size-7 rounded-md text-slate-400 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors shrink-0"
>
<MoreHorizontalIcon className="w-3.5 h-3.5" />
</button>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={180}>
<PopoverMenuItem onSelect={() => navigate(`/app/contacts/segments/${s.id}`)}>View contacts</PopoverMenuItem>
<PopoverMenuItem onSelect={guarded(() => openEdit(s))}>Edit conditions</PopoverMenuItem>
<PopoverMenuItem onSelect={campaignGuarded(() => setCampaignFor(s))}>Add to campaign</PopoverMenuItem>
<PopoverMenuItem onSelect={guarded(() => duplicate(s))}>Duplicate</PopoverMenuItem>
<PopoverMenuSeparator />
<PopoverMenuItem onSelect={guarded(() => askDelete(s))}>Delete</PopoverMenuItem>
</PopoverMenuContent>
</PopoverMenu>
</div>
))}
</div>
)}
</PageBody>
<SegmentEditor
open={editorOpen}
onClose={() => setEditorOpen(false)}
segment={editing}
onSaved={(saved) => {
if (!editing) navigate(`/app/contacts/segments/${saved.id}`);
}}
/>
{campaignFor && (
<AddSegmentToCampaignDialog open={!!campaignFor} onClose={() => setCampaignFor(null)} segment={campaignFor} />
)}
</Page>
);
}
@@ -37,6 +37,7 @@ import {
SparklesIcon,
SplitIcon,
TagIcon,
LayersIcon,
TagsIcon,
Trash2Icon,
UnlinkIcon,
@@ -95,6 +96,7 @@ import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import StepEmailArms from "./StepEmailArms";
import CategoryPicker from "@/components/app/contacts/CategoryPicker";
import { SegmentMultiPicker } from "@/components/app/segments/SegmentPickers";
import type { ActionKV, AITagRef, SequenceAction, SequenceActionType } from "@/lib/api/models/app/campaigns/sequences/Action";
import { useAutomations } from "@/lib/api/hooks/app/automations/useAutomations";
import { triggerLabel } from "@/lib/api/models/app/automations/meta";
@@ -441,6 +443,8 @@ function StopNode() {
const ACTION_META: Record<string, { label: string; Icon: typeof ClockIcon; tint: string }> = {
add_tag: { label: "Add tag", Icon: TagIcon, tint: "text-emerald-600" },
remove_tag: { label: "Remove tag", Icon: TagIcon, tint: "text-amber-600" },
add_to_segment: { label: "Add to segment", Icon: LayersIcon, tint: "text-emerald-600" },
remove_from_segment: { label: "Remove from segment", Icon: LayersIcon, tint: "text-amber-600" },
label_email: { label: "Label email", Icon: TagsIcon, tint: "text-fuchsia-600" },
create_task: { label: "Create task", Icon: CheckSquareIcon, tint: "text-violet-600" },
create_deal: { label: "Create deal", Icon: HandshakeIcon, tint: "text-emerald-600" },
@@ -460,6 +464,10 @@ function actionSummary(a?: SequenceAction | null): string {
return a.category_id ? "Add a tag" : "Pick a tag…";
case "remove_tag":
return a.category_id ? "Remove a tag" : "Pick a tag…";
case "add_to_segment":
return a.segment_id ? "Pin into a segment" : "Pick a segment…";
case "remove_from_segment":
return a.segment_id ? "Pin out of a segment" : "Pick a segment…";
case "label_email":
return a.label_ids && a.label_ids.length ? "Label the conversation" : "Pick a label…";
case "create_deal":
@@ -2443,6 +2451,8 @@ function ConnectionEditor({
const ADD_ACTION_OPTIONS: { type: SequenceActionType; label: string }[] = [
{ type: "add_tag", label: "Add tag" },
{ type: "remove_tag", label: "Remove tag" },
{ type: "add_to_segment", label: "Add to segment" },
{ type: "remove_from_segment", label: "Remove from segment" },
{ type: "label_email", label: "Label email" },
{ type: "create_task", label: "Create task" },
{ type: "create_deal", label: "Create deal" },
@@ -2891,6 +2901,23 @@ function ActionConfigFields({
</div>
)}
{(action.type === "add_to_segment" || action.type === "remove_from_segment") && (
<div>
<Label>{action.type === "add_to_segment" ? "Segment to add to" : "Segment to remove from"}</Label>
<SegmentMultiPicker
value={action.segment_id ? [action.segment_id] : []}
onChange={(ids) =>
setAction((a) => ({ ...a, segment_id: ids.length ? ids[ids.length - 1] : null }))
}
/>
<p className="mt-1.5 text-[11px] text-slate-400">
{action.type === "add_to_segment"
? "The contact stays in the segment whatever its conditions say."
: "The contact stays out of the segment even while its conditions match."}
</p>
</div>
)}
{action.type === "label_email" && (
<div>
<Label>Labels to apply</Label>
@@ -21,6 +21,7 @@ import { SearchInput } from "@/components/ui/field";
import CategoryPicker from "./CategoryPicker";
import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts";
import useUpdateContactsBulk from "@/lib/api/hooks/app/contacts/useUpdateContactsBulk";
import { useSetSegmentMembers } from "@/lib/api/hooks/app/segments";
import searchContacts from "@/lib/api/client/app/contacts/searchContacts";
import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts";
import type Contact from "@/lib/api/models/app/contacts/Contact";
@@ -33,10 +34,17 @@ import { cn, hexToRgba } from "@/lib/utils";
const PAGE = 100;
const MAX_SELECTION = 1000;
// The target is either a campaign (contacts become leads) or a segment
// (contacts are pinned in as manual includes).
export type AddFromContactsTarget =
| { kind: "campaign"; campaign: MiniCampaign }
| { kind: "segment"; segment: { id: string; name: string } };
interface Props {
open: boolean;
onClose: () => void;
campaign: MiniCampaign;
campaign?: MiniCampaign;
target?: AddFromContactsTarget;
}
function displayName(c: Contact): string {
@@ -44,8 +52,14 @@ function displayName(c: Contact): string {
return n || c.email;
}
export default function AddFromContactsDialog({ open, onClose, campaign }: Props) {
export default function AddFromContactsDialog({ open, onClose, campaign: campaignProp, target: targetProp }: Props) {
const bulk = useUpdateContactsBulk();
const members = useSetSegmentMembers();
const target: AddFromContactsTarget = React.useMemo(
() => targetProp ?? { kind: "campaign", campaign: campaignProp as MiniCampaign },
[targetProp, campaignProp],
);
const targetName = target.kind === "campaign" ? target.campaign.name : target.segment.name;
const [query, setQuery] = React.useState("");
const [categoryIds, setCategoryIds] = React.useState<string[]>([]);
@@ -87,8 +101,8 @@ export default function AddFromContactsDialog({ open, onClose, campaign }: Props
}, [open]);
const inCampaign = React.useCallback(
(c: Contact) => (c.campaigns ?? []).some((x) => x.id === campaign.id),
[campaign.id],
(c: Contact) => target.kind === "campaign" && (c.campaigns ?? []).some((x) => x.id === target.campaign.id),
[target],
);
const selectable = React.useMemo(() => contacts.filter((c) => !inCampaign(c)), [contacts, inCampaign]);
@@ -143,22 +157,27 @@ export default function AddFromContactsDialog({ open, onClose, campaign }: Props
}
async function submit() {
if (bulk.isPending || selected.size === 0) return;
if (busy || selected.size === 0) return;
try {
await bulk.mutateAsync({
contacts: [...selected],
add_campaigns: [campaign.id],
remove_campaigns: [],
fields: [],
});
toast.success(`Added ${selected.size} lead${selected.size === 1 ? "" : "s"} to ${campaign.name}`);
if (target.kind === "campaign") {
await bulk.mutateAsync({
contacts: [...selected],
add_campaigns: [target.campaign.id],
remove_campaigns: [],
fields: [],
});
toast.success(`Added ${selected.size} lead${selected.size === 1 ? "" : "s"} to ${target.campaign.name}`);
} else {
await members.mutateAsync({ id: target.segment.id, contacts: [...selected], mode: "include" });
toast.success(`Added ${selected.size} contact${selected.size === 1 ? "" : "s"} to ${target.segment.name}`);
}
onClose();
} catch (err) {
toast.error(buildError(err as AppError));
}
}
const busy = bulk.isPending;
const busy = bulk.isPending || members.isPending;
const requestClose = React.useCallback(() => {
if (!busy) onClose();
}, [busy, onClose]);
@@ -206,12 +225,12 @@ export default function AddFromContactsDialog({ open, onClose, campaign }: Props
<UsersIcon className="w-3 h-3" />
</div>
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Add leads
{target.kind === "campaign" ? "Add leads" : "Add contacts"}
</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12.5px] text-slate-900 font-medium">From contacts</span>
<span className="hidden sm:inline-flex items-center h-5 px-1.5 rounded bg-sky-50 text-sky-700 text-[10px] font-medium max-w-[200px] truncate">
{campaign.name}
{targetName}
</span>
<button
type="button"
@@ -385,7 +404,9 @@ export default function AddFromContactsDialog({ open, onClose, campaign }: Props
<span className="text-[11px] text-slate-400 min-w-0 truncate">
{capped
? `Capped at ${MAX_SELECTION.toLocaleString()} per batch. Add these, then repeat for the rest.`
: "Contacts already in this campaign are skipped."}
: target.kind === "campaign"
? "Contacts already in this campaign are skipped."
: "Added contacts stay in the segment whatever its conditions say."}
</span>
<button
type="button"
@@ -402,7 +423,7 @@ export default function AddFromContactsDialog({ open, onClose, campaign }: Props
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50 shrink-0"
>
{busy ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <UsersIcon className="w-3 h-3" />}
Add {selected.size > 0 ? selected.size.toLocaleString() : ""} lead{selected.size === 1 ? "" : "s"}
Add {selected.size > 0 ? selected.size.toLocaleString() : ""} {target.kind === "campaign" ? "lead" : "contact"}{selected.size === 1 ? "" : "s"}
</button>
</footer>
</motion.div>
@@ -1,665 +0,0 @@
// Contact filters — brae-density side sheet.
//
// Replaces the legacy 800px poppins-serif drawer. New panel is 400px,
// edge-to-edge with a sticky topbar and sticky footer; each filter
// group sits between hairline SectionBars so they read as a quiet
// outline of the available knobs rather than a heavy form.
//
// Local state mirrors the parent's `filters` until the user clicks
// Apply — that way fiddling with filters doesn't immediately retrigger
// the search while they're still building the query.
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, VerificationStatus } from "@/lib/api/models/app/contacts/Contact";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
ArrowDownIcon,
ArrowUpIcon,
CheckIcon,
Loader2Icon,
PlusIcon,
RotateCcwIcon,
SearchIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { NumberInput, SearchInput, TextInput } from "@/components/ui/field";
import { DatePicker } from "@/components/ui/DatePicker";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuLabel,
PopoverMenuTrigger,
SelectButton,
} from "@/components/ui/popover-menu";
import { SectionBar } from "@/components/layout/Page";
import CategoryPicker from "./CategoryPicker";
interface Props {
active: boolean;
setActive: React.Dispatch<React.SetStateAction<boolean>>;
filters: SearchContacts;
setFilters: React.Dispatch<React.SetStateAction<SearchContacts>>;
activeCampaign?: MiniCampaign;
loading?: boolean;
}
const SORT_OPTIONS: { id: SearchContactsSortBy; label: string }[] = [
{ id: "created_at", label: "Date added" },
{ id: "updated_at", label: "Last updated" },
{ id: "first_name", label: "First name" },
{ id: "last_name", label: "Last name" },
{ id: "email", label: "Email" },
{ id: "campaign_count", label: "Campaigns count" },
];
const FILTER_TYPES: { id: SearchContactsFilterType; label: string }[] = [
{ id: "contains", label: "Contains" },
{ id: "equal", label: "Equals" },
{ id: "starts_with", label: "Starts with" },
{ id: "ends_with", label: "Ends with" },
];
export default function ContactFilters({
active,
setActive,
filters,
setFilters,
activeCampaign,
loading,
}: Props) {
const [draft, setDraft] = React.useState<SearchContacts>(filters);
// When the parent passes in new committed filters, mirror them into
// the draft so re-opening the sheet doesn't show a stale state.
React.useEffect(() => {
if (active) setDraft(filters);
}, [active, filters]);
const activeCount = countActiveFilters(draft, !!activeCampaign);
const apply = () => {
setFilters(draft);
setActive(false);
};
const reset = () => {
const empty: SearchContacts = {
query: "",
filters: [],
campaign_ids: activeCampaign ? [activeCampaign.id] : [],
sort_by: "created_at",
reverse: false,
};
setDraft(empty);
};
return (
<AnimatePresence>
{active && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={() => setActive(false)}
className="fixed inset-0 z-[100] flex justify-end bg-slate-900/30 backdrop-blur-[2px]"
>
<motion.aside
key="panel"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", stiffness: 300, damping: 32 }}
onClick={(e) => e.stopPropagation()}
className="flex flex-col bg-white w-[420px] max-w-[95%] h-full border-l border-slate-200 shadow-[-8px_0_24px_-12px_rgba(15,23,42,0.12)]"
>
{/* Sticky header */}
<div className="h-12 px-4 border-b border-slate-200 flex items-center gap-3 shrink-0">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Filters
</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12.5px] text-slate-700">
{activeCount === 0
? "No filters applied"
: `${activeCount} ${activeCount === 1 ? "filter" : "filters"} active`}
</span>
<button
type="button"
onClick={() => setActive(false)}
aria-label="Close"
className="ml-auto size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
<XIcon className="w-3.5 h-3.5" />
</button>
</div>
{/* Scrollable body */}
<div className="flex-1 min-h-0 overflow-y-auto">
<Section label="Search" />
<div className="px-4 py-3">
<SearchInput
value={draft.query}
onChange={(v) => setDraft((s) => ({ ...s, query: v }))}
placeholder="Name, email, company…"
/>
</div>
<Section
label="Custom field filters"
count={draft.filters.length}
actions={
draft.filters.length < 100 ? (
<button
type="button"
onClick={() =>
setDraft((s) => ({
...s,
filters: [
...s.filters,
{ name: "", value: "", type: "contains" },
],
}))
}
className="inline-flex items-center gap-1 text-[11px] text-slate-500 hover:text-slate-900 transition-colors"
>
<PlusIcon className="w-3 h-3" />
Add
</button>
) : null
}
/>
<div className="px-4 py-2 space-y-2">
{draft.filters.length === 0 ? (
<p className="text-[11.5px] text-slate-400 py-2">
Add a filter to query custom contact properties.
</p>
) : (
draft.filters.map((f, i) => (
<FilterRow
key={i}
value={f}
onChange={(updated) =>
setDraft((s) => ({
...s,
filters: s.filters.map((it, idx) =>
idx === i ? updated : it,
),
}))
}
onRemove={() =>
setDraft((s) => ({
...s,
filters: s.filters.filter((_, idx) => idx !== i),
}))
}
/>
))
)}
</div>
<Section label="Sort" />
<div className="px-4 py-3 flex items-center gap-1.5">
<PopoverMenu align="start">
<PopoverMenuTrigger asChild>
<SelectButton
label={
SORT_OPTIONS.find((o) => o.id === draft.sort_by)?.label ?? "Date added"
}
className="flex-1"
/>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={220}>
<PopoverMenuLabel>Sort by</PopoverMenuLabel>
{SORT_OPTIONS.map((o) => (
<PopoverMenuItem
key={o.id}
selected={draft.sort_by === o.id}
onSelect={() =>
setDraft((s) => ({ ...s, sort_by: o.id }))
}
>
{o.label}
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
<button
type="button"
onClick={() => setDraft((s) => ({ ...s, reverse: !s.reverse }))}
className="h-7 px-2 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 inline-flex items-center gap-1.5 text-[12px] font-medium transition-colors"
title={draft.reverse ? "Ascending" : "Descending"}
>
{draft.reverse ? (
<ArrowUpIcon className="w-3 h-3" />
) : (
<ArrowDownIcon className="w-3 h-3" />
)}
{draft.reverse ? "Asc" : "Desc"}
</button>
</div>
<Section
label="Categories"
count={draft.category_ids?.length ?? 0}
/>
<div className="px-4 py-3">
<CategoryPicker
value={draft.category_ids ?? []}
onChange={(next) =>
setDraft((s) => ({
...s,
category_ids: next.length > 0 ? next : undefined,
}))
}
placeholder="Filter by categories…"
/>
<p className="text-[10.5px] text-slate-400 mt-1.5 leading-tight">
Contacts must have every selected category.
</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="Address verification" />
<div className="px-4 py-3">
<ChoiceRow
value={draft.verification_status}
onChange={(v) => setDraft((s) => ({ ...s, verification_status: v }))}
options={VERIFICATION_OPTIONS}
/>
<p className="text-[10.5px] text-slate-400 mt-1.5 leading-tight">
Undeliverable addresses are never sent to. Risky ones (catch-all domains, shared inboxes) send only when the campaign allows it.
</p>
</div>
<Section label="Subscription" />
<div className="px-4 py-3">
<Toggle3
value={draft.subscribed}
onChange={(v) => setDraft((s) => ({ ...s, subscribed: v }))}
options={[
{ id: undefined, label: "Any" },
{ id: true, label: "Subscribed" },
{ id: false, label: "Unsubscribed" },
]}
/>
</div>
<Section label="Campaign membership" />
<div className="px-4 py-3 space-y-2">
<RangeRow
label="At least"
value={draft.min_campaigns}
onChange={(v) => setDraft((s) => ({ ...s, min_campaigns: v }))}
suffix="campaigns"
/>
<RangeRow
label="At most"
value={draft.max_campaigns}
onChange={(v) => setDraft((s) => ({ ...s, max_campaigns: v }))}
suffix="campaigns"
/>
</div>
<Section label="Dates" />
<div className="px-4 py-3 space-y-2">
<DateRow
label="Created after"
value={draft.created_after}
onChange={(v) => setDraft((s) => ({ ...s, created_after: v }))}
/>
<DateRow
label="Created before"
value={draft.created_before}
onChange={(v) => setDraft((s) => ({ ...s, created_before: v }))}
/>
<DateRow
label="Updated after"
value={draft.updated_after}
onChange={(v) => setDraft((s) => ({ ...s, updated_after: v }))}
/>
<DateRow
label="Updated before"
value={draft.updated_before}
onChange={(v) => setDraft((s) => ({ ...s, updated_before: v }))}
/>
</div>
</div>
{/* Sticky footer */}
<div className="px-4 h-12 border-t border-slate-200 flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={reset}
className="h-7 px-2.5 rounded-md text-[12px] text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center gap-1.5 transition-colors"
>
<RotateCcwIcon className="w-3 h-3" />
Reset
</button>
<button
type="button"
onClick={() => setActive(false)}
className="ml-auto h-7 px-2.5 rounded-md text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={apply}
disabled={loading}
className="h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{loading ? (
<Loader2Icon className="w-3 h-3 animate-spin" />
) : (
<SearchIcon className="w-3 h-3" />
)}
Apply
</button>
</div>
</motion.aside>
</motion.div>
)}
</AnimatePresence>
);
}
function Section({ label, count, actions }: { label: string; count?: number; actions?: React.ReactNode }) {
return (
<SectionBar label={label} count={count}>
{actions}
</SectionBar>
);
}
function FilterRow({
value,
onChange,
onRemove,
}: {
value: SearchContactsFilter;
onChange: (v: SearchContactsFilter) => void;
onRemove: () => void;
}) {
return (
<div className="flex flex-wrap items-center gap-1.5 sm:flex-nowrap">
<TextInput
value={value.name}
onChange={(v) => onChange({ ...value, name: v })}
placeholder="field"
className="min-w-[140px] flex-1 sm:min-w-0"
/>
<PopoverMenu align="start">
<PopoverMenuTrigger asChild>
<SelectButton
label={FILTER_TYPES.find((t) => t.id === value.type)?.label ?? "Contains"}
/>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={140}>
{FILTER_TYPES.map((t) => (
<PopoverMenuItem
key={t.id}
selected={value.type === t.id}
onSelect={() => onChange({ ...value, type: t.id })}
>
{t.label}
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
<TextInput
value={value.value}
onChange={(v) => onChange({ ...value, value: v })}
placeholder="value"
className="min-w-[140px] flex-1 sm:min-w-0"
/>
<button
type="button"
onClick={onRemove}
aria-label="Remove filter"
className="size-7 rounded-md text-slate-400 hover:text-red-600 hover:bg-red-50 inline-flex items-center justify-center transition-colors shrink-0"
>
<Trash2Icon className="w-3 h-3" />
</button>
</div>
);
}
// 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 VERIFICATION_OPTIONS: { id: VerificationStatus | undefined; label: string }[] = [
{ id: undefined, label: "Any" },
{ id: "valid", label: "Deliverable" },
{ id: "risky", label: "Risky" },
{ id: "invalid", label: "Undeliverable" },
{ id: "unknown", label: "Unverified" },
];
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,
options,
}: {
value: T;
onChange: (v: T) => void;
options: { id: T; label: string }[];
}) {
return (
<div className="inline-flex items-center rounded-md border border-slate-200 bg-white p-0.5">
{options.map((o) => (
<button
key={String(o.id)}
type="button"
onClick={() => onChange(o.id)}
className={`h-6 px-2.5 rounded text-[11.5px] font-medium transition-colors ${
value === o.id
? "bg-slate-900 text-white"
: "text-slate-500 hover:text-slate-900"
}`}
>
{o.label}
</button>
))}
</div>
);
}
function RangeRow({
label,
value,
onChange,
suffix,
}: {
label: string;
value: number | undefined;
onChange: (v: number | undefined) => void;
suffix: string;
}) {
const enabled = value !== undefined;
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onChange(enabled ? undefined : 0)}
className={`size-4 rounded border flex items-center justify-center transition-colors shrink-0 ${
enabled
? "bg-slate-900 border-slate-900 text-white"
: "border-slate-300 hover:border-slate-400"
}`}
aria-pressed={enabled}
aria-label={`Toggle ${label}`}
>
{enabled && <CheckIcon className="w-2.5 h-2.5" />}
</button>
<span className="text-[12px] text-slate-700 w-20 shrink-0">{label}</span>
<NumberInput
value={value ?? Number.NaN}
onChange={(n) => onChange(n)}
min={0}
align="right"
placeholder="0"
disabled={!enabled}
className="w-20"
/>
<span className="text-[11.5px] text-slate-400">{suffix}</span>
</div>
);
}
function DateRow({
label,
value,
onChange,
}: {
label: string;
value?: Date;
onChange: (v: Date | undefined) => void;
}) {
const enabled = value !== undefined;
const dateStr = value ? toIsoDate(value) : "";
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onChange(enabled ? undefined : new Date())}
className={`size-4 rounded border flex items-center justify-center transition-colors shrink-0 ${
enabled
? "bg-slate-900 border-slate-900 text-white"
: "border-slate-300 hover:border-slate-400"
}`}
aria-pressed={enabled}
aria-label={`Toggle ${label}`}
>
{enabled && <CheckIcon className="w-2.5 h-2.5" />}
</button>
<span className="text-[12px] text-slate-700 w-28 shrink-0">{label}</span>
<DatePicker
value={dateStr}
onChange={(v) => {
if (!v) onChange(undefined);
else onChange(new Date(v));
}}
disabled={!enabled}
clearable={false}
placeholder="Pick a date"
className="flex-1"
/>
</div>
);
}
function toIsoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function countActiveFilters(f: SearchContacts, hasCampaignContext: boolean): number {
let n = 0;
if (f.query) n++;
n += f.filters.length;
if (f.subscribed !== undefined) n++;
if (f.verification_status) 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++;
if (f.created_before) n++;
if (f.updated_after) n++;
if (f.updated_before) n++;
// Don't count campaign scoping if it's coming from an outer page context.
if (!hasCampaignContext && f.campaign_ids.length > 0) n++;
if (f.category_ids && f.category_ids.length > 0) n++;
return n;
}
+145 -49
View File
@@ -11,6 +11,7 @@
// section header so it nests cleanly under the campaign view.
import React from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
AlertTriangleIcon,
BanIcon,
@@ -20,6 +21,7 @@ import {
ClockIcon,
CornerUpLeftIcon,
DownloadIcon,
LayersIcon,
Loader2Icon,
MailIcon,
MailOpenIcon,
@@ -36,9 +38,11 @@ import {
UploadIcon,
UserPlusIcon,
UsersIcon,
XIcon,
} from "lucide-react";
import { useConfirm } from "@/hooks/context/confirm";
import { useWriteGuard } from "@/hooks/usePermission";
import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts";
import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts";
import useDeleteContacts from "@/lib/api/hooks/app/contacts/useDeleteContacts";
@@ -55,7 +59,8 @@ import {
import toast from "react-hot-toast";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import ContactFilters from "./ContactFilters";
import FilterBar from "./filters/FilterBar";
import { isCompleteCustomFilter } from "./filters/helpers";
import ContactEdit from "./ContactEdit";
import type { ContactSlideTab } from "./contact-edit/tabs";
import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign";
@@ -66,6 +71,12 @@ import { NewContactDialog } from "./NewContactDialog";
import ExportDialog from "./ExportDialog";
import ImportWizard from "./ImportWizard";
import AddFromContactsDialog from "./AddFromContactsDialog";
import AddToSegmentMenu from "@/components/app/segments/AddToSegmentMenu";
import SegmentEditor from "@/components/app/segments/SegmentEditor";
import { filtersToSegment } from "@/components/app/segments/filtersToSegment";
import type { SegmentCondition } from "@/lib/api/models/app/segments/Segment";
import AddSegmentLeadsDialog from "@/components/app/segments/AddSegmentLeadsDialog";
import { useSetSegmentMembers } from "@/lib/api/hooks/app/segments";
import useAiMetered from "@/hooks/useAiMetered";
import SyncSourcesPanel from "./SyncSourcesPanel";
import { CategoryChip } from "./CategoryPicker";
@@ -95,13 +106,18 @@ type SubFilter = "all" | "subscribed" | "unsubscribed";
export default function ContactsTable({
current_campaign,
segment,
}: {
current_campaign?: MiniCampaign;
// Scope the list to one segment's members (the segment detail page).
segment?: { id: string; name: string };
}) {
const confirm = useConfirm();
const segmentMembers = useSetSegmentMembers();
// Enrolling a segment writes campaign leads, so it takes the campaign permission.
const campaignWrite = useWriteGuard("MANAGE_CAMPAIGNS");
const [selected, setSelected] = React.useState<string[]>([]);
const [del, setDelete] = React.useState<boolean>(false);
const [filtersOpen, setFiltersOpen] = React.useState<boolean>(false);
const [edit, setEdit] = React.useState<string>("");
// Which tab the drawer opens on. Row click → default (overview); the
// right-side 3-dots → "details", mirroring the mailbox 3-dots → settings.
@@ -117,15 +133,50 @@ export default function ContactsTable({
const [importOpen, setImportOpen] = React.useState<boolean>(false);
const [syncOpen, setSyncOpen] = React.useState<boolean>(false);
const [fromContactsOpen, setFromContactsOpen] = React.useState<boolean>(false);
const [fromSegmentOpen, setFromSegmentOpen] = React.useState<boolean>(false);
// A filter saved as a segment: the panel's draft becomes the editor's preset.
const [segmentPreset, setSegmentPreset] = React.useState<{ conditions: SegmentCondition[] } | null>(null);
const navigate = useNavigate();
// ?category=<id> pre-filters the list (the Categories tab links here).
const [params] = useSearchParams();
const [searchProps, setSearchProps] = React.useState<SearchContacts>({
query: "",
filters: [],
campaign_ids: current_campaign ? [current_campaign.id] : [],
sort_by: "created_at",
reverse: false,
const [searchProps, setSearchProps] = React.useState<SearchContacts>(() => {
const category = params.get("category");
return {
query: "",
filters: [],
campaign_ids: current_campaign ? [current_campaign.id] : [],
segment_ids: segment ? [segment.id] : undefined,
category_ids: category && !segment && !current_campaign ? [category] : undefined,
sort_by: "created_at",
reverse: false,
};
});
const contactsData = useSearchContacts({ options: searchProps });
function saveAsSegment(draft: SearchContacts) {
const { conditions, dropped } = filtersToSegment(draft, current_campaign?.id);
if (dropped.length > 0) toast(`Not carried over: ${dropped.join(", ")}. Add a condition for it in the editor.`);
setSegmentPreset({ conditions });
}
// Half-filled custom-field pills stay in the bar but never reach the server.
const searchOptions = React.useMemo(
() => ({ ...searchProps, filters: searchProps.filters.filter(isCompleteCustomFilter) }),
[searchProps],
);
const contactsData = useSearchContacts({ options: searchOptions });
// Inside a segment, "remove" pins the contact out as a manual exclude so
// it stays out even while the conditions still match it.
async function excludeFromSegment() {
if (!segment || selected.length === 0 || segmentMembers.isPending) return;
try {
await segmentMembers.mutateAsync({ id: segment.id, contacts: selected, mode: "exclude" });
toast.success(`Removed ${selected.length} contact${selected.length === 1 ? "" : "s"} from ${segment.name}`);
setSelected([]);
} catch (err) {
toast.error(buildError(err as AppError));
}
}
const contactsBulkDelete = useDeleteContacts();
// Connected CRM targets the "Push to CRM" bulk action can reach. Driven by
@@ -347,6 +398,13 @@ export default function ContactsTable({
>
From contacts
</TopbarAction>
<TopbarAction
variant="ghost"
icon={<LayersIcon className="w-3 h-3" />}
onClick={() => campaignWrite.guard(() => setFromSegmentOpen(true))({})}
>
From segment
</TopbarAction>
<TopbarAction
variant="ghost"
icon={<UploadIcon className="w-3 h-3" />}
@@ -380,13 +438,6 @@ export default function ContactsTable({
placeholder="Search leads…"
className="w-full sm:w-56"
/>
<TopbarAction
variant="ghost"
icon={<Settings2Icon className="w-3 h-3" />}
onClick={() => setFiltersOpen(true)}
>
Filters
</TopbarAction>
<TopbarAction
variant="ghost"
icon={<UsersIcon className="w-3 h-3" />}
@@ -415,6 +466,14 @@ export default function ContactsTable({
Add lead
</TopbarAction>
</SectionBar>
<FilterBar
filters={searchProps}
setFilters={setSearchProps}
activeCampaign={current_campaign}
total={total}
loading={contactsData.isFetching}
onSaveAsSegment={saveAsSegment}
/>
<LeadProgressStrip
contacts={contacts ?? []}
total={total}
@@ -445,14 +504,10 @@ export default function ContactsTable({
)
}
onClear={() => setSelected([])}
/>
<ContactFilters
active={filtersOpen}
setActive={setFiltersOpen}
filters={searchProps}
setFilters={setSearchProps}
activeCampaign={current_campaign}
loading={contactsData.isLoading}
selected={selected}
segment={segment}
onExclude={excludeFromSegment}
excluding={segmentMembers.isPending}
/>
<ContactEdit
contacts={contacts ?? []}
@@ -476,6 +531,11 @@ export default function ContactsTable({
onClose={() => setFromContactsOpen(false)}
campaign={current_campaign}
/>
<AddSegmentLeadsDialog
open={fromSegmentOpen}
onClose={() => setFromSegmentOpen(false)}
campaign={current_campaign}
/>
</>
);
}
@@ -483,15 +543,26 @@ export default function ContactsTable({
return (
<Page>
<PageTopbar
eyebrow="Contacts"
eyebrow={segment ? "Members" : "Contacts"}
subtitle={
contactsData.isPending
? "Loading…"
: contactsData.isError
? "Failed to load"
: `${total.toLocaleString()} total`
: segment
? `${total.toLocaleString()} in ${segment.name}`
: `${total.toLocaleString()} total`
}
>
{segment && (
<TopbarAction
variant="ghost"
icon={<UsersIcon className="w-3 h-3" />}
onClick={() => setFromContactsOpen(true)}
>
Add contacts
</TopbarAction>
)}
<div className="hidden md:contents">
<TopbarAction
variant="ghost"
@@ -544,7 +615,7 @@ export default function ContactsTable({
</TopbarAction>
</PageTopbar>
<StatStrip cols={4}>
{!segment && <StatStrip cols={4}>
<Stat
label="All"
value={counts.total}
@@ -570,10 +641,10 @@ export default function ContactsTable({
sub="active touchpoints"
last
/>
</StatStrip>
</StatStrip>}
<SectionBar
label={subFilter === "all" ? "All contacts" : `${subFilter[0].toUpperCase()}${subFilter.slice(1)}`}
label={segment ? "Segment members" : subFilter === "all" ? "All contacts" : `${subFilter[0].toUpperCase()}${subFilter.slice(1)}`}
count={filtered.length}
>
<SearchInput
@@ -621,21 +692,17 @@ export default function ContactsTable({
</PopoverMenuItem>
</PopoverMenuContent>
</PopoverMenu>
<TopbarAction
variant="ghost"
icon={<Settings2Icon className="w-3 h-3" />}
onClick={() => setFiltersOpen(true)}
>
Filters
{searchProps.filters.length > 0 && (
<span className="ml-1 font-mono text-[10px] text-sky-600 tabular-nums">
{searchProps.filters.length}
</span>
)}
</TopbarAction>
</SectionBar>
<FilterBar
filters={searchProps}
setFilters={setSearchProps}
hideSegments={!!segment}
total={total}
loading={contactsData.isFetching}
onSaveAsSegment={saveAsSegment}
/>
<PageBody>
{tableNode}
</PageBody>
@@ -659,21 +726,30 @@ export default function ContactsTable({
)
}
onClear={() => setSelected([])}
selected={selected}
segment={segment}
onExclude={excludeFromSegment}
excluding={segmentMembers.isPending}
/>
{filtered.length === 0 && !contactsData.isPending ? null : null}
<ContactFilters
active={filtersOpen}
setActive={setFiltersOpen}
filters={searchProps}
setFilters={setSearchProps}
activeCampaign={current_campaign}
loading={contactsData.isLoading}
<SegmentEditor
open={segmentPreset !== null}
onClose={() => setSegmentPreset(null)}
preset={segmentPreset}
onSaved={(saved) => navigate(`/app/contacts/segments/${saved.id}`)}
/>
<ContactEdit contacts={contacts ?? []} active={edit} setActive={setEdit} initialTab={editTab} />
<ContactsEditBulk active={bulkEdit} setActive={setBulkEdit} selected={selected} />
<NewContactDialog open={newOpen} onClose={() => setNewOpen(false)} />
{segment && (
<AddFromContactsDialog
open={fromContactsOpen}
onClose={() => setFromContactsOpen(false)}
target={{ kind: "segment", segment }}
/>
)}
<ExportDialog
open={exportOpen}
onClose={() => setExportOpen(false)}
@@ -1370,6 +1446,10 @@ function SelectionBar({
verifying,
onDelete,
onClear,
selected,
segment,
onExclude,
excluding,
}: {
count: number;
deleting: boolean;
@@ -1384,6 +1464,10 @@ function SelectionBar({
verifying: boolean;
onDelete: () => void;
onClear: () => void;
selected: string[];
segment?: { id: string; name: string };
onExclude: () => void;
excluding: boolean;
}) {
if (count === 0) return null;
return (
@@ -1434,6 +1518,18 @@ function SelectionBar({
>
Edit
</button>
<AddToSegmentMenu contacts={selected} onDone={onClear} />
{segment && (
<button
type="button"
onClick={onExclude}
disabled={excluding}
className="h-7 px-2.5 rounded text-[12px] text-amber-700 hover:text-white hover:bg-amber-600 font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{excluding ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <XIcon className="w-3 h-3" />}
<span className="hidden sm:inline">Remove from segment</span>
</button>
)}
<button
type="button"
onClick={onResearch}
@@ -99,6 +99,7 @@ function hasActiveFilters(f: SearchContacts): boolean {
f.filters.length > 0 ||
(f.campaign_ids?.length ?? 0) > 0 ||
(f.category_ids?.length ?? 0) > 0 ||
(f.segment_ids?.length ?? 0) > 0 ||
f.subscribed !== undefined ||
!!f.lead_status ||
!!f.engagement ||
@@ -0,0 +1,122 @@
// Segments panel of the contact drawer: which segments this contact is in,
// with the manual override (pin in / pin out / back to automatic) per row.
import React from "react";
import { CheckIcon, Loader2Icon, MinusIcon, RotateCcwIcon } from "lucide-react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
import { useWriteGuard } from "@/hooks/usePermission";
import { useContactSegments, useSetSegmentMembers } from "@/lib/api/hooks/app/segments";
import type { ContactSegment, SegmentMemberMode } from "@/lib/api/models/app/segments/Segment";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { cn } from "@/lib/utils";
export function ContactSegmentsSection({ contactId }: { contactId: string }) {
const segments = useContactSegments(contactId);
const set = useSetSegmentMembers();
const write = useWriteGuard("MANAGE_CONTACTS");
const [busyId, setBusyId] = React.useState<string | null>(null);
async function apply(seg: ContactSegment, mode: SegmentMemberMode) {
if (set.isPending) return;
setBusyId(seg.id);
try {
await set.mutateAsync({ id: seg.id, contacts: [contactId], mode });
toast.success(
mode === "include" ? `Pinned into ${seg.name}` : mode === "exclude" ? `Pinned out of ${seg.name}` : `${seg.name} is automatic again`,
);
} catch (err) {
toast.error(buildError(err as AppError));
} finally {
setBusyId(null);
}
}
const list = segments.data ?? [];
const members = list.filter((s) => s.member);
const others = list.filter((s) => !s.member);
return (
<section>
<h2 className="text-[10px] uppercase tracking-[0.14em] font-semibold text-slate-500 mb-2 flex items-center gap-2">
Segments
{list.length > 0 && <span className="font-mono text-[10px] text-slate-400 tabular-nums normal-case tracking-normal">{members.length} of {list.length}</span>}
</h2>
<div className="rounded-md border border-slate-200 bg-white overflow-hidden">
{segments.isPending ? (
<div className="px-3 py-2.5 text-[11.5px] text-slate-400">Loading</div>
) : list.length === 0 ? (
<div className="px-3 py-2.5 text-[11.5px] text-slate-400">
No segments yet.{" "}
<Link to="/app/contacts/segments" className="text-sky-700 hover:underline">
Create one
</Link>
.
</div>
) : (
<ul className="divide-y divide-slate-100">
{[...members, ...others].map((s) => {
const busy = busyId === s.id;
return (
<li key={s.id} className="px-3 h-9 flex items-center gap-2">
<span className="size-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
<Link
to={`/app/contacts/segments/${s.id}`}
className={cn("text-[12px] truncate hover:underline", s.member ? "text-slate-900" : "text-slate-400")}
>
{s.name}
</Link>
{s.mode === "include" && (
<span className="inline-flex items-center h-4 px-1 rounded bg-emerald-50 text-emerald-700 text-[10px] font-medium">pinned in</span>
)}
{s.mode === "exclude" && (
<span className="inline-flex items-center h-4 px-1 rounded bg-amber-50 text-amber-700 text-[10px] font-medium">pinned out</span>
)}
<div className="ml-auto flex items-center gap-0.5">
{busy ? (
<Loader2Icon className="w-3 h-3 animate-spin text-slate-400" />
) : (
<>
{s.mode !== undefined && (
<IconButton title="Back to automatic" onClick={write.guard(() => apply(s, "auto"))}>
<RotateCcwIcon className="w-3 h-3" />
</IconButton>
)}
{s.mode !== "include" && (
<IconButton title="Pin into segment" onClick={write.guard(() => apply(s, "include"))}>
<CheckIcon className="w-3 h-3" />
</IconButton>
)}
{s.mode !== "exclude" && (
<IconButton title="Pin out of segment" onClick={write.guard(() => apply(s, "exclude"))}>
<MinusIcon className="w-3 h-3" />
</IconButton>
)}
</>
)}
</div>
</li>
);
})}
</ul>
)}
</div>
</section>
);
}
function IconButton({ title, onClick, children }: { title: string; onClick: (e: React.MouseEvent<HTMLButtonElement>) => void; children: React.ReactNode }) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
className="size-6 rounded text-slate-400 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
{children}
</button>
);
}
@@ -20,6 +20,7 @@ import type ContactDetail from "@/lib/api/models/app/contacts/ContactDetail";
import type Contact from "@/lib/api/models/app/contacts/Contact";
import { fmtAbsolute, fmtRelative } from "./format";
import { sourceLabel } from "./ActivityTab";
import { ContactSegmentsSection } from "./ContactSegmentsSection";
import VerificationCard from "./VerificationCard";
export default function OverviewTab({
@@ -184,6 +185,8 @@ export default function OverviewTab({
</div>
</Section>
<ContactSegmentsSection contactId={contact.id} />
{detail && (
<Section title="Source">
<div className="rounded-md border border-slate-200 bg-white overflow-hidden">
@@ -0,0 +1,827 @@
// Interactive filter bar for the contact list: quick pills for categories,
// segments, status and campaigns, an "Add filter" menu for the rest, and every
// change applied on the spot. Pills open a popover under themselves.
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { CheckIcon, ChevronDownIcon, LayersIcon, Loader2Icon, PlusIcon, XIcon } from "lucide-react";
import { DatePicker } from "@/components/ui/DatePicker";
import { NumberInput, TextInput } from "@/components/ui/field";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuLabel,
PopoverMenuTrigger,
} from "@/components/ui/popover-menu";
import { SelectMenu } from "@/components/ui/select-menu";
import useClickOutside from "@/hooks/useClickOutside";
import useFlipPlacement from "@/hooks/useFlipPlacement";
import { useUserProfile } from "@/hooks/context/user";
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
import useCustomFieldKeys from "@/lib/api/hooks/app/contacts/useCustomFieldKeys";
import { useSegments } from "@/lib/api/hooks/app/segments";
import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign";
import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts";
import type SearchContactsFilter from "@/lib/api/models/app/contacts/SearchContactsFilter";
import type { SearchContactsFilterType } from "@/lib/api/models/app/contacts/search-contacts.types";
import type { LeadEngagement, LeadStatus, VerificationStatus } from "@/lib/api/models/app/contacts/Contact";
import { cn } from "@/lib/utils";
import { countActiveFilters, isCompleteCustomFilter } from "./helpers";
type Setter = React.Dispatch<React.SetStateAction<SearchContacts>>;
const FILTER_TYPES: { id: SearchContactsFilterType; label: string }[] = [
{ id: "contains", label: "contains" },
{ id: "equal", label: "is" },
{ id: "starts_with", label: "starts with" },
{ id: "ends_with", label: "ends with" },
];
const LEAD_STATUS: { id: LeadStatus; label: string }[] = [
{ 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" },
];
const ENGAGEMENT: { id: LeadEngagement; label: string }[] = [
{ 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" },
];
const VERIFICATION: { id: VerificationStatus; label: string }[] = [
{ id: "valid", label: "Deliverable" },
{ id: "risky", label: "Risky" },
{ id: "invalid", label: "Undeliverable" },
{ id: "unknown", label: "Unverified" },
];
// Optional pills, shown once added from the menu or when their value is set.
type ExtraKey = "created" | "updated" | "campaign_count" | "lead_status" | "engagement" | "verification";
function toIso(d?: Date): string {
return d ? new Date(d).toISOString().slice(0, 10) : "";
}
function fromIso(s: string): Date | undefined {
return s ? new Date(`${s}T00:00:00`) : undefined;
}
export default function FilterBar({
filters,
setFilters,
activeCampaign,
hideSegments,
total,
loading,
onSaveAsSegment,
}: {
filters: SearchContacts;
setFilters: Setter;
activeCampaign?: MiniCampaign;
// On a segment page the segment scope is fixed, so the pill is hidden.
hideSegments?: boolean;
total: number;
loading?: boolean;
onSaveAsSegment?: (draft: SearchContacts) => void;
}) {
const { user } = useUserProfile();
const segments = useSegments();
const customKeys = useCustomFieldKeys();
const campaignCtx = !!activeCampaign;
// Pills added from the menu stay visible while empty so the user can fill them.
const [extras, setExtras] = React.useState<ExtraKey[]>([]);
const [openKey, setOpenKey] = React.useState<string | null>(null);
const shown = (k: ExtraKey) => {
if (extras.includes(k)) return true;
switch (k) {
case "created":
return !!(filters.created_after || filters.created_before);
case "updated":
return !!(filters.updated_after || filters.updated_before);
case "campaign_count":
return filters.min_campaigns !== undefined || filters.max_campaigns !== undefined;
case "lead_status":
return !!filters.lead_status;
case "engagement":
return !!filters.engagement;
case "verification":
return !!filters.verification_status;
}
};
function addExtra(k: ExtraKey) {
setExtras((e) => (e.includes(k) ? e : [...e, k]));
setOpenKey(k);
}
function removeExtra(k: ExtraKey) {
setExtras((e) => e.filter((x) => x !== k));
setFilters((s) => {
switch (k) {
case "created":
return { ...s, created_after: undefined, created_before: undefined };
case "updated":
return { ...s, updated_after: undefined, updated_before: undefined };
case "campaign_count":
return { ...s, min_campaigns: undefined, max_campaigns: undefined };
case "lead_status":
return { ...s, lead_status: undefined };
case "engagement":
return { ...s, engagement: undefined };
case "verification":
return { ...s, verification_status: undefined };
}
});
}
function addCustom() {
setFilters((s) => ({ ...s, filters: [...s.filters, { name: "", value: "", type: "contains" }] }));
setOpenKey(`custom:${filters.filters.length}`);
}
function setCustom(i: number, next: SearchContactsFilter) {
setFilters((s) => ({ ...s, filters: s.filters.map((f, j) => (j === i ? next : f)) }));
}
function removeCustom(i: number) {
setFilters((s) => ({ ...s, filters: s.filters.filter((_, j) => j !== i) }));
setOpenKey(null);
}
const active = countActiveFilters(filters, campaignCtx);
function clearAll() {
setExtras([]);
setOpenKey(null);
setFilters((s) => ({
query: s.query,
filters: [],
campaign_ids: activeCampaign ? [activeCampaign.id] : [],
segment_ids: hideSegments ? s.segment_ids : undefined,
sort_by: s.sort_by,
reverse: s.reverse,
}));
}
const categoryOptions = React.useMemo(
() => [...(user.categories ?? [])].sort((a, b) => a.position - b.position).map((c) => ({ id: c.id, label: c.title, color: c.color })),
[user.categories],
);
const segmentOptions = React.useMemo(
() => (segments.data ?? []).map((s) => ({ id: s.id, label: s.name, color: s.color })),
[segments.data],
);
const menuItems: { key: ExtraKey | "custom"; label: string; hidden?: boolean }[] = [
{ key: "custom", label: "Custom field" },
{ key: "created", label: "Date added", hidden: shown("created") },
{ key: "updated", label: "Last updated", hidden: shown("updated") },
{ key: "campaign_count", label: "Number of campaigns", hidden: shown("campaign_count") },
{ key: "verification", label: "Address verification", hidden: shown("verification") },
{ key: "lead_status", label: "Lead status", hidden: !campaignCtx || shown("lead_status") },
{ key: "engagement", label: "Engagement", hidden: !campaignCtx || shown("engagement") },
];
return (
<div className="px-5 py-1.5 border-b border-slate-200/60 bg-white flex flex-wrap items-center gap-1.5">
<MultiPill
id="categories"
label="Category"
openKey={openKey}
setOpenKey={setOpenKey}
value={filters.category_ids ?? []}
onChange={(v) => setFilters((s) => ({ ...s, category_ids: v.length ? v : undefined }))}
options={categoryOptions}
empty="No categories yet."
hint="Contacts must have every selected category."
/>
{!hideSegments && (
<MultiPill
id="segments"
label="Segment"
openKey={openKey}
setOpenKey={setOpenKey}
value={filters.segment_ids ?? []}
onChange={(v) => setFilters((s) => ({ ...s, segment_ids: v.length ? v : undefined }))}
options={segmentOptions}
empty="No segments yet."
hint="Contacts must be in every selected segment."
/>
)}
<ChoicePill<boolean | undefined>
id="status"
label="Status"
openKey={openKey}
setOpenKey={setOpenKey}
value={filters.subscribed}
onChange={(v) => setFilters((s) => ({ ...s, subscribed: v }))}
options={[
{ id: true, label: "Subscribed" },
{ id: false, label: "Unsubscribed" },
]}
/>
{!campaignCtx && (
<CampaignPill
openKey={openKey}
setOpenKey={setOpenKey}
value={filters.campaign_ids}
onChange={(v) => setFilters((s) => ({ ...s, campaign_ids: v }))}
/>
)}
{filters.filters.map((f, i) => (
<CustomPill
key={`custom:${i}`}
id={`custom:${i}`}
openKey={openKey}
setOpenKey={setOpenKey}
value={f}
keys={customKeys.data ?? []}
onChange={(next) => setCustom(i, next)}
onRemove={() => removeCustom(i)}
/>
))}
{shown("created") && (
<DateRangePill
id="created"
label="Added"
openKey={openKey}
setOpenKey={setOpenKey}
after={filters.created_after}
before={filters.created_before}
onChange={(after, before) => setFilters((s) => ({ ...s, created_after: after, created_before: before }))}
onRemove={() => removeExtra("created")}
/>
)}
{shown("updated") && (
<DateRangePill
id="updated"
label="Updated"
openKey={openKey}
setOpenKey={setOpenKey}
after={filters.updated_after}
before={filters.updated_before}
onChange={(after, before) => setFilters((s) => ({ ...s, updated_after: after, updated_before: before }))}
onRemove={() => removeExtra("updated")}
/>
)}
{shown("campaign_count") && (
<RangePill
openKey={openKey}
setOpenKey={setOpenKey}
min={filters.min_campaigns}
max={filters.max_campaigns}
onChange={(min, max) => setFilters((s) => ({ ...s, min_campaigns: min, max_campaigns: max }))}
onRemove={() => removeExtra("campaign_count")}
/>
)}
{shown("verification") && (
<ChoicePill<VerificationStatus | undefined>
id="verification"
label="Verification"
openKey={openKey}
setOpenKey={setOpenKey}
value={filters.verification_status}
onChange={(v) => setFilters((s) => ({ ...s, verification_status: v }))}
options={VERIFICATION}
onRemove={() => removeExtra("verification")}
/>
)}
{shown("lead_status") && (
<ChoicePill<LeadStatus | undefined>
id="lead_status"
label="Lead status"
openKey={openKey}
setOpenKey={setOpenKey}
value={filters.lead_status}
onChange={(v) => setFilters((s) => ({ ...s, lead_status: v }))}
options={LEAD_STATUS}
onRemove={() => removeExtra("lead_status")}
/>
)}
{shown("engagement") && (
<ChoicePill<LeadEngagement | undefined>
id="engagement"
label="Engagement"
openKey={openKey}
setOpenKey={setOpenKey}
value={filters.engagement}
onChange={(v) => setFilters((s) => ({ ...s, engagement: v }))}
options={ENGAGEMENT}
onRemove={() => removeExtra("engagement")}
/>
)}
<PopoverMenu align="start">
<PopoverMenuTrigger asChild>
<button
type="button"
className="h-7 px-2 rounded-md border border-dashed border-slate-300 text-slate-500 hover:border-slate-400 hover:text-slate-800 text-[12px] inline-flex items-center gap-1 transition-colors"
>
<PlusIcon className="w-3 h-3" />
Add filter
</button>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={190}>
<PopoverMenuLabel>Filter by</PopoverMenuLabel>
{menuItems
.filter((m) => !m.hidden)
.map((m) => (
<PopoverMenuItem key={m.key} onSelect={() => (m.key === "custom" ? addCustom() : addExtra(m.key))}>
{m.label}
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
<div className="ml-auto flex items-center gap-1.5">
<span className="text-[11.5px] text-slate-500 tabular-nums inline-flex items-center gap-1.5">
{loading && <Loader2Icon className="w-3 h-3 animate-spin text-slate-400" />}
{total.toLocaleString()} {activeCampaign ? (total === 1 ? "lead" : "leads") : total === 1 ? "contact" : "contacts"}
</span>
{active > 0 && (
<button
type="button"
onClick={clearAll}
className="h-7 px-2 rounded-md text-[12px] text-slate-500 hover:text-slate-900 hover:bg-slate-100 transition-colors"
>
Clear
</button>
)}
{onSaveAsSegment && active > 0 && (
<button
type="button"
onClick={() => onSaveAsSegment(filters)}
className="h-7 px-2 rounded-md text-[12px] text-sky-700 hover:text-sky-800 hover:bg-sky-50 inline-flex items-center gap-1 transition-colors"
>
<LayersIcon className="w-3 h-3" />
Save as segment
</button>
)}
</div>
</div>
);
}
// Shared pill shell: trigger chip plus a floating panel beneath it. Only one
// pill is open at a time (openKey lives in the bar).
function Pill({
id,
label,
summary,
active,
openKey,
setOpenKey,
onRemove,
width = 260,
children,
}: {
id: string;
label: string;
summary?: string;
active: boolean;
openKey: string | null;
setOpenKey: (k: string | null) => void;
onRemove?: () => void;
width?: number;
children: React.ReactNode;
}) {
const open = openKey === id;
const ref = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
useClickOutside(ref, () => {
if (open) setOpenKey(null);
});
const placement = useFlipPlacement(triggerRef, open, 300);
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpenKey(null);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, setOpenKey]);
return (
<div ref={ref} className="relative">
<div
className={cn(
"h-7 rounded-md border text-[12px] inline-flex items-center transition-colors",
active ? "border-sky-200 bg-sky-50 text-sky-800" : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900",
open && "ring-2 ring-sky-100",
)}
>
<button
ref={triggerRef}
type="button"
onClick={() => setOpenKey(open ? null : id)}
aria-expanded={open}
className="h-full pl-2 pr-1.5 inline-flex items-center gap-1 max-w-[280px]"
>
<span className={cn(active ? "text-sky-600" : "text-slate-500")}>{label}</span>
{active && summary && (
<>
<span className="text-sky-400">:</span>
<span className="font-medium truncate">{summary}</span>
</>
)}
<ChevronDownIcon className={cn("w-3 h-3 shrink-0 transition-transform", open && "rotate-180", active ? "text-sky-500" : "text-slate-400")} />
</button>
{onRemove && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
aria-label={`Remove ${label} filter`}
className="h-full pr-1.5 pl-0.5 inline-flex items-center text-slate-400 hover:text-slate-900"
>
<XIcon className="w-3 h-3" />
</button>
)}
</div>
<AnimatePresence>
{open && (
<motion.div
data-floating
initial={{ opacity: 0, y: placement === "top" ? 4 : -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: placement === "top" ? 4 : -4 }}
transition={{ duration: 0.12 }}
style={{ width }}
className={cn(
"absolute left-0 z-40 rounded-md border border-slate-200 bg-white shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)] overflow-hidden max-w-[calc(100vw-2.5rem)]",
placement === "top" ? "bottom-full mb-1" : "top-full mt-1",
)}
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
interface Option {
id: string;
label: string;
color?: string;
}
function summarize(ids: string[], options: Option[]): string {
if (ids.length === 0) return "";
const first = options.find((o) => o.id === ids[0])?.label ?? "1";
return ids.length === 1 ? first : `${first} +${ids.length - 1}`;
}
function CheckList({
value,
onChange,
options,
empty,
hint,
searchable = true,
}: {
value: string[];
onChange: (next: string[]) => void;
options: Option[];
empty: string;
hint?: string;
searchable?: boolean;
}) {
const [query, setQuery] = React.useState("");
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
return q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options;
}, [options, query]);
const toggle = (id: string) => onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]);
return (
<>
{searchable && options.length > 6 && (
<div className="px-2 py-1.5 border-b border-slate-200">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
autoFocus
className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none"
/>
</div>
)}
<div className="max-h-60 overflow-y-auto py-1">
{filtered.length === 0 && <div className="px-3 py-3 text-[11.5px] text-slate-400 text-center">{options.length === 0 ? empty : "Nothing matches."}</div>}
{filtered.map((o) => {
const checked = value.includes(o.id);
return (
<button
key={o.id}
type="button"
onClick={() => toggle(o.id)}
className="w-full px-2.5 h-7 flex items-center gap-2 text-[12px] text-slate-700 hover:bg-slate-100 transition-colors"
>
<span
className={cn(
"size-3.5 rounded border flex items-center justify-center transition-colors shrink-0",
checked ? "border-slate-900 bg-slate-900" : "border-slate-300 bg-white",
)}
>
{checked && <CheckIcon className="w-2 h-2 text-white" />}
</span>
{o.color && <span className="size-2.5 rounded-full shrink-0" style={{ backgroundColor: o.color }} />}
<span className="truncate">{o.label}</span>
</button>
);
})}
</div>
{(hint || value.length > 0) && (
<div className="px-2.5 h-8 border-t border-slate-100 flex items-center gap-2">
{hint && <span className="text-[10.5px] text-slate-400 truncate">{hint}</span>}
{value.length > 0 && (
<button type="button" onClick={() => onChange([])} className="ml-auto text-[11px] text-slate-500 hover:text-slate-900 shrink-0">
Clear
</button>
)}
</div>
)}
</>
);
}
function MultiPill({
id,
label,
openKey,
setOpenKey,
value,
onChange,
options,
empty,
hint,
}: {
id: string;
label: string;
openKey: string | null;
setOpenKey: (k: string | null) => void;
value: string[];
onChange: (next: string[]) => void;
options: Option[];
empty: string;
hint?: string;
}) {
return (
<Pill id={id} label={label} summary={summarize(value, options)} active={value.length > 0} openKey={openKey} setOpenKey={setOpenKey}>
<CheckList value={value} onChange={onChange} options={options} empty={empty} hint={hint} />
</Pill>
);
}
function CampaignPill({
openKey,
setOpenKey,
value,
onChange,
}: {
openKey: string | null;
setOpenKey: (k: string | null) => void;
value: string[];
onChange: (next: string[]) => void;
}) {
const open = openKey === "campaigns";
// Only fetch once the pill is opened or already has a value.
const campaigns = useCampaigns({ query: "", folder: "", limit: 100, enabled: open || value.length > 0 });
const options = React.useMemo<Option[]>(() => campaigns.campaigns.map((c) => ({ id: c.id, label: c.name })), [campaigns.campaigns]);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = campaigns;
React.useEffect(() => {
if (open && hasNextPage && !isFetchingNextPage) void fetchNextPage();
}, [open, hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<Pill id="campaigns" label="Campaign" summary={summarize(value, options)} active={value.length > 0} openKey={openKey} setOpenKey={setOpenKey}>
<CheckList value={value} onChange={onChange} options={options} empty="No campaigns yet." hint="Contacts in any selected campaign." />
</Pill>
);
}
function ChoicePill<T extends string | boolean | undefined>({
id,
label,
openKey,
setOpenKey,
value,
onChange,
options,
onRemove,
}: {
id: string;
label: string;
openKey: string | null;
setOpenKey: (k: string | null) => void;
value: T;
onChange: (v: T) => void;
options: { id: T; label: string }[];
onRemove?: () => void;
}) {
const current = options.find((o) => o.id === value);
return (
<Pill id={id} label={label} summary={current?.label} active={value !== undefined} openKey={openKey} setOpenKey={setOpenKey} onRemove={onRemove} width={200}>
<div className="py-1">
{options.map((o) => {
const on = o.id === value;
return (
<button
key={String(o.id)}
type="button"
onClick={() => {
onChange((on ? undefined : o.id) as T);
setOpenKey(null);
}}
className={cn(
"w-full px-2.5 h-7 flex items-center gap-2 text-[12px] transition-colors hover:bg-slate-100",
on ? "text-slate-900 font-medium" : "text-slate-700",
)}
>
<span className={cn("size-3.5 rounded-full border flex items-center justify-center shrink-0", on ? "border-sky-600 bg-sky-600" : "border-slate-300 bg-white")}>
{on && <span className="size-1.5 rounded-full bg-white" />}
</span>
{o.label}
</button>
);
})}
{value !== undefined && (
<button
type="button"
onClick={() => onChange(undefined as T)}
className="w-full px-2.5 h-7 flex items-center text-[12px] text-slate-500 hover:bg-slate-100 border-t border-slate-100 mt-1"
>
Any
</button>
)}
</div>
</Pill>
);
}
function CustomPill({
id,
openKey,
setOpenKey,
value,
keys,
onChange,
onRemove,
}: {
id: string;
openKey: string | null;
setOpenKey: (k: string | null) => void;
value: SearchContactsFilter;
keys: string[];
onChange: (next: SearchContactsFilter) => void;
onRemove: () => void;
}) {
// Free text goes through a short debounce so the list does not refetch per key.
const [text, setText] = React.useState(value.value);
React.useEffect(() => setText(value.value), [value.value]);
React.useEffect(() => {
if (text === value.value) return;
const t = setTimeout(() => onChange({ ...value, value: text }), 300);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text]);
const complete = isCompleteCustomFilter(value);
const op = FILTER_TYPES.find((t) => t.id === value.type)?.label ?? value.type;
const keyOptions = React.useMemo(() => {
const set = new Set(keys);
if (value.name && !set.has(value.name)) set.add(value.name);
return [...set].map((k) => ({ value: k, label: k }));
}, [keys, value.name]);
return (
<Pill
id={id}
label={value.name.trim() ? value.name : "Custom field"}
summary={complete ? `${op} ${value.value}` : undefined}
active={complete}
openKey={openKey}
setOpenKey={setOpenKey}
onRemove={onRemove}
width={300}
>
<div className="p-2.5 space-y-2">
<div className="space-y-1">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Field</span>
{keyOptions.length > 0 ? (
<SelectMenu value={value.name} onChange={(v) => onChange({ ...value, name: v })} options={keyOptions} placeholder="Pick a field" fullWidth />
) : (
<TextInput value={value.name} onChange={(v) => onChange({ ...value, name: v })} placeholder="Field name" autoFocus />
)}
</div>
<div className="space-y-1">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Condition</span>
<SelectMenu
value={value.type}
onChange={(v) => onChange({ ...value, type: v as SearchContactsFilterType })}
options={FILTER_TYPES.map((t) => ({ value: t.id, label: t.label }))}
fullWidth
/>
</div>
<div className="space-y-1">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Value</span>
<TextInput value={text} onChange={setText} placeholder="Value" autoFocus={keyOptions.length > 0} />
</div>
</div>
</Pill>
);
}
function DateRangePill({
id,
label,
openKey,
setOpenKey,
after,
before,
onChange,
onRemove,
}: {
id: string;
label: string;
openKey: string | null;
setOpenKey: (k: string | null) => void;
after?: Date;
before?: Date;
onChange: (after?: Date, before?: Date) => void;
onRemove: () => void;
}) {
const summary = after && before ? `${toIso(after)} to ${toIso(before)}` : after ? `after ${toIso(after)}` : before ? `before ${toIso(before)}` : undefined;
return (
<Pill id={id} label={label} summary={summary} active={!!summary} openKey={openKey} setOpenKey={setOpenKey} onRemove={onRemove} width={280}>
<div className="p-2.5 space-y-2">
<div className="space-y-1">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">After</span>
<DatePicker value={toIso(after)} onChange={(v) => onChange(fromIso(v), before)} className="w-full" />
</div>
<div className="space-y-1">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Before</span>
<DatePicker value={toIso(before)} onChange={(v) => onChange(after, fromIso(v))} className="w-full" />
</div>
</div>
</Pill>
);
}
function RangePill({
openKey,
setOpenKey,
min,
max,
onChange,
onRemove,
}: {
openKey: string | null;
setOpenKey: (k: string | null) => void;
min?: number;
max?: number;
onChange: (min?: number, max?: number) => void;
onRemove: () => void;
}) {
const summary = min !== undefined && max !== undefined ? `${min} to ${max}` : min !== undefined ? `at least ${min}` : max !== undefined ? `at most ${max}` : undefined;
return (
<Pill id="campaign_count" label="Campaigns count" summary={summary} active={!!summary} openKey={openKey} setOpenKey={setOpenKey} onRemove={onRemove} width={240}>
<div className="p-2.5 space-y-2">
<Bound label="At least" value={min} onChange={(v) => onChange(v, max)} />
<Bound label="At most" value={max} onChange={(v) => onChange(min, v)} />
</div>
</Pill>
);
}
function Bound({ label, value, onChange }: { label: string; value?: number; onChange: (v?: number) => void }) {
const set = value !== undefined;
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onChange(set ? undefined : 1)}
className={cn(
"size-3.5 rounded border flex items-center justify-center transition-colors shrink-0",
set ? "border-slate-900 bg-slate-900" : "border-slate-300 bg-white",
)}
aria-pressed={set}
aria-label={label}
>
{set && <CheckIcon className="w-2 h-2 text-white" />}
</button>
<span className="text-[12px] text-slate-700 w-16">{label}</span>
<NumberInput value={value ?? 0} onChange={(v) => onChange(Math.max(0, v))} min={0} disabled={!set} suffix="campaigns" className="flex-1" />
</div>
);
}
@@ -0,0 +1,26 @@
// Pure helpers for the contact filter bar, kept out of the component file so
// fast refresh keeps working.
import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts";
import type SearchContactsFilter from "@/lib/api/models/app/contacts/SearchContactsFilter";
export function isCompleteCustomFilter(f: SearchContactsFilter): boolean {
return f.name.trim() !== "" && f.value.trim() !== "";
}
export function countActiveFilters(f: SearchContacts, campaignContext: boolean): number {
let n = 0;
n += f.filters.filter(isCompleteCustomFilter).length;
if (f.category_ids?.length) n++;
if (f.segment_ids?.length) n++;
if (!campaignContext && f.campaign_ids.length > 0) n++;
if (f.subscribed !== undefined) n++;
if (f.verification_status) n++;
if (f.min_campaigns !== undefined || f.max_campaigns !== undefined) n++;
if (f.created_after || f.created_before) n++;
if (f.updated_after || f.updated_before) n++;
if (f.lead_status) n++;
if (f.engagement) n++;
return n;
}
@@ -0,0 +1,193 @@
// From a campaign's Leads tab: pick a segment and enrol its current members
// as leads. The mirror image of AddSegmentToCampaignDialog.
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { LayersIcon, Loader2Icon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import { SearchInput } from "@/components/ui/field";
import { useAddSegmentToCampaign, useSegments } from "@/lib/api/hooks/app/segments";
import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { cn } from "@/lib/utils";
export default function AddSegmentLeadsDialog({
open,
onClose,
campaign,
}: {
open: boolean;
onClose: () => void;
campaign: MiniCampaign;
}) {
const add = useAddSegmentToCampaign();
const segments = useSegments(open);
const [query, setQuery] = React.useState("");
const [picked, setPicked] = React.useState<string | null>(null);
React.useEffect(() => {
if (!open) {
setQuery("");
setPicked(null);
}
}, [open]);
const list = React.useMemo(() => {
const all = segments.data ?? [];
const q = query.trim().toLowerCase();
return q ? all.filter((s) => s.name.toLowerCase().includes(q)) : all;
}, [segments.data, query]);
const busy = add.isPending;
const requestClose = React.useCallback(() => {
if (!busy) onClose();
}, [busy, onClose]);
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (document.querySelector("[data-floating], [role='alertdialog']")) return;
requestClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, requestClose]);
async function submit() {
if (!picked || busy) return;
const seg = list.find((s) => s.id === picked);
try {
const res = await add.mutateAsync({ id: picked, campaignId: campaign.id });
toast.success(
res.added === 0
? `Every member of ${seg?.name ?? "the segment"} is already a lead`
: `Added ${res.added.toLocaleString()} lead${res.added === 1 ? "" : "s"} from ${seg?.name ?? "the segment"}`,
);
onClose();
} catch (err) {
toast.error(buildError(err as AppError));
}
}
return (
<AnimatePresence>
{open && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onMouseDown={requestClose}
className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4"
>
<motion.div
key="card"
role="dialog"
aria-modal="true"
aria-label="Add leads from a segment"
initial={{ y: 8, opacity: 0, scale: 0.985 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: 8, opacity: 0, scale: 0.985 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
onMouseDown={(e) => e.stopPropagation()}
className="w-full max-w-[520px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[80dvh]"
>
<header className="h-12 px-4 border-b border-slate-200 flex items-center gap-2.5 shrink-0">
<div className="size-5 rounded bg-slate-100 text-slate-600 flex items-center justify-center">
<LayersIcon className="w-3 h-3" />
</div>
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Add leads</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12.5px] text-slate-900 font-medium">From a segment</span>
<span className="hidden sm:inline-flex items-center h-5 px-1.5 rounded bg-sky-50 text-sky-700 text-[10px] font-medium max-w-[200px] truncate">
{campaign.name}
</span>
<button
type="button"
onClick={requestClose}
aria-label="Close"
className="ml-auto size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
<XIcon className="w-3.5 h-3.5" />
</button>
</header>
<div className="px-4 py-3 border-b border-slate-100 shrink-0">
<SearchInput value={query} onChange={setQuery} placeholder="Search segments…" autoFocus className="w-full" />
</div>
<div className="flex-1 min-h-[200px] overflow-y-auto">
{segments.isPending ? (
<div className="p-3 space-y-1.5">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-9 rounded-md bg-slate-100 animate-pulse" />
))}
</div>
) : list.length === 0 ? (
<div className="px-5 py-10 text-center">
<p className="text-[12.5px] text-slate-900 font-medium">{query ? "No segments match" : "No segments yet"}</p>
<p className="text-[11.5px] text-slate-400 mt-0.5">Build one under Contacts &gt; Segments first.</p>
</div>
) : (
<ul className="divide-y divide-slate-100">
{list.map((s) => {
const on = picked === s.id;
return (
<li key={s.id}>
<button
type="button"
onClick={() => setPicked(s.id)}
aria-pressed={on}
className={cn(
"w-full px-4 h-10 flex items-center gap-3 text-left transition-colors",
on ? "bg-sky-50/60 hover:bg-sky-50" : "hover:bg-slate-50",
)}
>
<span
className={cn(
"size-3.5 rounded-full border flex items-center justify-center shrink-0",
on ? "border-sky-600 bg-sky-600" : "border-slate-300 bg-white",
)}
>
{on && <span className="size-1.5 rounded-full bg-white" />}
</span>
<span className="size-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
<span className="text-[12.5px] text-slate-900 font-medium truncate">{s.name}</span>
<span className="ml-auto font-mono text-[11px] text-slate-500 tabular-nums">
{s.contact_count.toLocaleString()}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
<footer className="px-3 h-12 border-t border-slate-200 flex items-center gap-2 shrink-0 bg-slate-50/30">
<span className="text-[11px] text-slate-400 min-w-0 truncate">Adds today's members. Existing leads are skipped.</span>
<button
type="button"
onClick={requestClose}
disabled={busy}
className="ml-auto h-7 px-2.5 rounded-md text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={submit}
disabled={busy || !picked}
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
>
{busy ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <LayersIcon className="w-3 h-3" />}
Add leads
</button>
</footer>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,200 @@
// Enrol a segment's current members as leads of one campaign. A snapshot:
// contacts who join the segment later are not added automatically.
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Loader2Icon, MegaphoneIcon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import { SearchInput } from "@/components/ui/field";
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
import { useAddSegmentToCampaign } from "@/lib/api/hooks/app/segments";
import type Segment from "@/lib/api/models/app/segments/Segment";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { cn } from "@/lib/utils";
export default function AddSegmentToCampaignDialog({
open,
onClose,
segment,
}: {
open: boolean;
onClose: () => void;
segment: Segment;
}) {
const add = useAddSegmentToCampaign();
const [query, setQuery] = React.useState("");
const [picked, setPicked] = React.useState<string | null>(null);
const campaigns = useCampaigns({ query: query.trim(), folder: "", enabled: open });
React.useEffect(() => {
if (!open) {
setQuery("");
setPicked(null);
}
}, [open]);
const busy = add.isPending;
const requestClose = React.useCallback(() => {
if (!busy) onClose();
}, [busy, onClose]);
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (document.querySelector("[data-floating], [role='alertdialog']")) return;
requestClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, requestClose]);
async function submit() {
if (!picked || busy) return;
const target = campaigns.campaigns.find((c) => c.id === picked);
try {
const res = await add.mutateAsync({ id: segment.id, campaignId: picked });
toast.success(
res.added === 0
? `Every member of ${segment.name} is already a lead in ${target?.name ?? "the campaign"}`
: `Added ${res.added.toLocaleString()} lead${res.added === 1 ? "" : "s"} to ${target?.name ?? "the campaign"}`,
);
onClose();
} catch (err) {
toast.error(buildError(err as AppError));
}
}
return (
<AnimatePresence>
{open && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onMouseDown={requestClose}
className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4"
>
<motion.div
key="card"
role="dialog"
aria-modal="true"
aria-label="Add segment to campaign"
initial={{ y: 8, opacity: 0, scale: 0.985 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: 8, opacity: 0, scale: 0.985 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
onMouseDown={(e) => e.stopPropagation()}
className="w-full max-w-[520px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[80dvh]"
>
<header className="h-12 px-4 border-b border-slate-200 flex items-center gap-2.5 shrink-0">
<div className="size-5 rounded bg-slate-100 text-slate-600 flex items-center justify-center">
<MegaphoneIcon className="w-3 h-3" />
</div>
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Add to campaign</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12.5px] text-slate-900 font-medium truncate">{segment.name}</span>
<span className="hidden sm:inline-flex items-center h-5 px-1.5 rounded bg-sky-50 text-sky-700 text-[10px] font-medium">
{segment.contact_count.toLocaleString()} contact{segment.contact_count === 1 ? "" : "s"}
</span>
<button
type="button"
onClick={requestClose}
aria-label="Close"
className="ml-auto size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
<XIcon className="w-3.5 h-3.5" />
</button>
</header>
<div className="px-4 py-3 border-b border-slate-100 shrink-0">
<SearchInput value={query} onChange={setQuery} placeholder="Search campaigns…" autoFocus className="w-full" />
</div>
<div className="flex-1 min-h-[200px] overflow-y-auto">
{campaigns.isPending ? (
<div className="p-3 space-y-1.5">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-9 rounded-md bg-slate-100 animate-pulse" />
))}
</div>
) : campaigns.campaigns.length === 0 ? (
<div className="px-5 py-10 text-center">
<p className="text-[12.5px] text-slate-900 font-medium">No campaigns found</p>
<p className="text-[11.5px] text-slate-400 mt-0.5">Create a campaign first, then add this segment to it.</p>
</div>
) : (
<ul className="divide-y divide-slate-100">
{campaigns.campaigns.map((c) => {
const on = picked === c.id;
return (
<li key={c.id}>
<button
type="button"
onClick={() => setPicked(c.id)}
aria-pressed={on}
className={cn(
"w-full px-4 h-10 flex items-center gap-3 text-left transition-colors",
on ? "bg-sky-50/60 hover:bg-sky-50" : "hover:bg-slate-50",
)}
>
<span
className={cn(
"size-3.5 rounded-full border flex items-center justify-center shrink-0",
on ? "border-sky-600 bg-sky-600" : "border-slate-300 bg-white",
)}
>
{on && <span className="size-1.5 rounded-full bg-white" />}
</span>
<span className="text-[12.5px] text-slate-900 font-medium truncate">{c.name}</span>
<span className="ml-auto text-[10.5px] uppercase tracking-[0.1em] text-slate-400">{c.status}</span>
</button>
</li>
);
})}
{campaigns.hasNextPage && (
<li className="p-2">
<button
type="button"
onClick={() => campaigns.fetchNextPage()}
disabled={campaigns.isFetchingNextPage}
className="w-full h-8 rounded-md text-[12px] text-slate-600 hover:text-slate-900 hover:bg-slate-50 inline-flex items-center justify-center gap-1.5 transition-colors disabled:opacity-60"
>
{campaigns.isFetchingNextPage && <Loader2Icon className="w-3 h-3 animate-spin" />}
Load more
</button>
</li>
)}
</ul>
)}
</div>
<footer className="px-3 h-12 border-t border-slate-200 flex items-center gap-2 shrink-0 bg-slate-50/30">
<span className="text-[11px] text-slate-400 min-w-0 truncate">
Adds today's members. Contacts already in the campaign are skipped.
</span>
<button
type="button"
onClick={requestClose}
disabled={busy}
className="ml-auto h-7 px-2.5 rounded-md text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={submit}
disabled={busy || !picked}
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
>
{busy ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <MegaphoneIcon className="w-3 h-3" />}
Add leads
</button>
</footer>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,63 @@
// "Add to segment" popover for the contacts selection bar: pins the selected
// contacts into a segment as manual includes (or, inside a segment's own
// list, excludes them / clears the override).
import React from "react";
import { Loader2Icon, LayersIcon } from "lucide-react";
import toast from "react-hot-toast";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuLabel,
PopoverMenuTrigger,
} from "@/components/ui/popover-menu";
import { useSegments, useSetSegmentMembers } from "@/lib/api/hooks/app/segments";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
export default function AddToSegmentMenu({ contacts, onDone }: { contacts: string[]; onDone?: () => void }) {
const segments = useSegments();
const set = useSetSegmentMembers();
async function add(id: string, name: string) {
try {
await set.mutateAsync({ id, contacts, mode: "include" });
toast.success(`Added ${contacts.length} contact${contacts.length === 1 ? "" : "s"} to ${name}`);
onDone?.();
} catch (err) {
toast.error(buildError(err as AppError));
}
}
const list = segments.data ?? [];
return (
<PopoverMenu side="top" align="center">
<PopoverMenuTrigger asChild>
<button
type="button"
disabled={set.isPending}
className="h-7 px-2.5 rounded text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{set.isPending ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <LayersIcon className="w-3 h-3" />}
<span className="hidden sm:inline">Segment</span>
</button>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={200}>
<PopoverMenuLabel>Add {contacts.length} to segment</PopoverMenuLabel>
{list.length === 0 && (
<div className="px-2.5 py-2 text-[11.5px] text-slate-400">No segments yet. Create one under Contacts &gt; Segments.</div>
)}
{list.map((s) => (
<PopoverMenuItem key={s.id} onSelect={() => add(s.id, s.name)}>
<span className="inline-flex items-center gap-2 min-w-0">
<span className="size-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
<span className="truncate">{s.name}</span>
</span>
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
);
}
@@ -0,0 +1,501 @@
// SegmentEditor: right-side drawer that creates or edits a segment: name,
// color, all/any match and the condition list, with a live "matches N
// contacts" preview fed by POST /segments/preview.
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Loader2Icon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import { Label, NumberInput, TextInput } from "@/components/ui/field";
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
import { DatePicker } from "@/components/ui/DatePicker";
import { Segmented } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox";
import CategoryPicker from "@/components/app/contacts/CategoryPicker";
import { CampaignMultiPicker, EnumMultiPicker, SegmentMultiPicker } from "./SegmentPickers";
import { useConfirm } from "@/hooks/context/confirm";
import {
useCreateSegment,
useSegmentFields,
useSegmentPreview,
useUpdateSegment,
} from "@/lib/api/hooks/app/segments";
import type Segment from "@/lib/api/models/app/segments/Segment";
import {
SEGMENT_OPERATORS,
VALUELESS_OPERATORS,
type SegmentCondition,
type SegmentFieldSpec,
type SegmentMatch,
} from "@/lib/api/models/app/segments/Segment";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { cn } from "@/lib/utils";
const COLORS = ["#0284c7", "#7c3aed", "#db2777", "#dc2626", "#ea580c", "#ca8a04", "#16a34a", "#0d9488", "#475569"];
interface Draft {
name: string;
description: string;
color: string;
match: SegmentMatch;
conditions: SegmentCondition[];
}
// Starting conditions for a new segment, e.g. saved from the filter panel.
export interface SegmentPreset {
conditions: SegmentCondition[];
}
function draftFrom(segment?: Segment | null, preset?: SegmentPreset | null): Draft {
const conditions = segment?.conditions ?? preset?.conditions ?? [];
return {
name: segment?.name ?? "",
description: segment?.description ?? "",
color: segment?.color ?? COLORS[0],
match: segment?.match ?? "all",
conditions: conditions.map((c) => ({ ...c, values: c.values ? [...c.values] : undefined })),
};
}
function sameDraft(a: Draft, b: Draft): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
// A condition the server would accept: a field, an operator, and a value
// whenever the operator wants one.
function complete(c: SegmentCondition): boolean {
if (!c.field || !c.operator) return false;
if (VALUELESS_OPERATORS.has(c.operator)) return true;
if (c.operator === "in" || c.operator === "not_in") return (c.values?.length ?? 0) > 0;
return (c.value ?? "").trim() !== "";
}
export default function SegmentEditor({
open,
onClose,
segment,
preset,
onSaved,
}: {
open: boolean;
onClose: () => void;
// Edit this segment; omit to create a new one.
segment?: Segment | null;
// Pre-filled conditions for a new segment; ignored when editing.
preset?: SegmentPreset | null;
onSaved?: (segment: Segment) => void;
}) {
const confirm = useConfirm();
const fields = useSegmentFields(open);
const create = useCreateSegment();
const update = useUpdateSegment();
const [draft, setDraft] = React.useState<Draft>(() => draftFrom(segment, preset));
const [initial, setInitial] = React.useState<Draft>(() => draftFrom(segment, preset));
React.useEffect(() => {
if (open) {
const d = draftFrom(segment, preset);
setDraft(d);
setInitial(d);
}
}, [open, segment, preset]);
const dirty = !sameDraft(draft, initial);
// Live count, debounced so typing a value does not fire a query per key.
const [debounced, setDebounced] = React.useState<Draft | null>(null);
React.useEffect(() => {
if (!open) return;
const t = setTimeout(() => setDebounced(draft), 350);
return () => clearTimeout(t);
}, [draft, open]);
const previewInput = React.useMemo(() => {
if (!debounced) return null;
const conds = debounced.conditions.filter(complete);
return { id: segment?.id, match: debounced.match, conditions: conds };
}, [debounced, segment?.id]);
const preview = useSegmentPreview(open ? previewInput : null);
const busy = create.isPending || update.isPending;
const requestClose = React.useCallback(() => {
if (busy) return;
if (dirty) {
confirm.show("Discard your changes to this segment?", async () => onClose());
return;
}
onClose();
}, [busy, dirty, confirm, onClose]);
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (document.querySelector("[data-floating], [role='alertdialog']")) return;
requestClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, requestClose]);
const incomplete = draft.conditions.filter((c) => !complete(c)).length;
const canSave = draft.name.trim() !== "" && incomplete === 0 && !busy;
const blocker =
draft.name.trim() === ""
? "Give the segment a name."
: incomplete > 0
? `${incomplete} condition${incomplete === 1 ? " is" : "s are"} missing a value.`
: null;
async function save() {
if (!canSave) return;
const body = {
name: draft.name.trim(),
description: draft.description.trim(),
color: draft.color,
match: draft.match,
conditions: draft.conditions,
};
try {
const saved = segment
? await update.mutateAsync({ id: segment.id, data: body })
: await create.mutateAsync(body);
toast.success(segment ? "Segment updated" : "Segment created");
setInitial(draft);
onSaved?.(saved);
onClose();
} catch (err) {
toast.error(buildError(err as AppError));
}
}
function setCondition(i: number, next: SegmentCondition) {
setDraft((d) => ({ ...d, conditions: d.conditions.map((c, j) => (j === i ? next : c)) }));
}
const specs = fields.data ?? [];
return (
<AnimatePresence>
{open && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onMouseDown={requestClose}
className="fixed inset-0 z-[100] flex justify-end bg-slate-900/30 backdrop-blur-[2px]"
>
<motion.aside
key="panel"
role="dialog"
aria-modal="true"
aria-label={segment ? "Edit segment" : "New segment"}
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", stiffness: 300, damping: 32 }}
onMouseDown={(e) => e.stopPropagation()}
className="flex flex-col bg-white w-[560px] max-w-[95%] h-full border-l border-slate-200 shadow-[-8px_0_24px_-12px_rgba(15,23,42,0.12)]"
>
<div className="h-12 px-4 border-b border-slate-200 flex items-center gap-3 shrink-0">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
{segment ? "Edit segment" : "New segment"}
</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12.5px] text-slate-700 inline-flex items-center gap-1.5">
{preview.isFetching ? (
<Loader2Icon className="w-3 h-3 animate-spin text-slate-400" />
) : preview.isError ? (
<span className="text-rose-600">Cannot count</span>
) : (
<>
Matches{" "}
<span className="font-mono tabular-nums text-slate-900">
{(preview.data ?? 0).toLocaleString()}
</span>{" "}
contact{preview.data === 1 ? "" : "s"}
</>
)}
</span>
<button
type="button"
onClick={requestClose}
aria-label="Close"
className="ml-auto size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
<XIcon className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="px-4 py-4 space-y-4 border-b border-slate-200/60">
<div className="flex gap-3">
<div className="flex-1">
<Label>Name</Label>
<TextInput
value={draft.name}
onChange={(v) => setDraft((d) => ({ ...d, name: v }))}
placeholder="Warm leads in fintech"
autoFocus={!segment}
className="w-full"
/>
</div>
<div>
<Label>Color</Label>
<div className="flex items-center gap-1 h-7">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setDraft((d) => ({ ...d, color: c }))}
aria-label={`Color ${c}`}
aria-pressed={draft.color === c}
className={cn(
"size-4 rounded-full border-2 transition-transform",
draft.color === c ? "border-slate-900 scale-110" : "border-transparent hover:scale-110",
)}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
</div>
<div>
<Label>Description</Label>
<TextInput
value={draft.description}
onChange={(v) => setDraft((d) => ({ ...d, description: v }))}
placeholder="What this audience is for (optional)"
className="w-full"
/>
</div>
</div>
<div className="px-4 py-4 space-y-3">
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Conditions</span>
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums">{draft.conditions.length}</span>
<div className="ml-auto flex items-center gap-2 text-[12px] text-slate-600">
<span>Match</span>
<Segmented<SegmentMatch>
value={draft.match}
onChange={(v) => setDraft((d) => ({ ...d, match: v }))}
options={[
{ value: "all", label: "all" },
{ value: "any", label: "any" },
]}
/>
</div>
</div>
{draft.conditions.length === 0 && (
<div className="rounded-md border border-dashed border-slate-200 px-3 py-4 text-center">
<p className="text-[12.5px] text-slate-900 font-medium">No conditions yet</p>
<p className="text-[11.5px] text-slate-400 mt-0.5">
Without conditions the segment only holds contacts you add by hand.
</p>
</div>
)}
<div className="space-y-2">
{draft.conditions.map((c, i) => (
<ConditionRow
key={i}
index={i}
condition={c}
specs={specs}
match={draft.match}
selfId={segment?.id}
onChange={(next) => setCondition(i, next)}
onRemove={() =>
setDraft((d) => ({ ...d, conditions: d.conditions.filter((_, j) => j !== i) }))
}
/>
))}
</div>
<button
type="button"
disabled={draft.conditions.length >= 50}
onClick={() =>
setDraft((d) => ({
...d,
conditions: [...d.conditions, { field: "", operator: "", value: "" }],
}))
}
className="h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 bg-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
>
<PlusIcon className="w-3 h-3" />
Add condition
</button>
</div>
</div>
<footer className="px-3 h-12 border-t border-slate-200 flex items-center gap-2 shrink-0 bg-slate-50/30">
<span className="text-[11px] text-slate-400 min-w-0 truncate">
{blocker ?? (segment ? "Changes apply to every list using this segment." : "Membership stays live as contacts change.")}
</span>
<button
type="button"
onClick={requestClose}
disabled={busy}
className="ml-auto h-7 px-2.5 rounded-md text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={save}
disabled={!canSave}
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
>
{busy && <Loader2Icon className="w-3 h-3 animate-spin" />}
{segment ? "Save segment" : "Create segment"}
</button>
</footer>
</motion.aside>
</motion.div>
)}
</AnimatePresence>
);
}
function ConditionRow({
index,
condition,
specs,
match,
selfId,
onChange,
onRemove,
}: {
index: number;
condition: SegmentCondition;
specs: SegmentFieldSpec[];
match: SegmentMatch;
selfId?: string;
onChange: (next: SegmentCondition) => void;
onRemove: () => void;
}) {
const spec = specs.find((s) => s.field === condition.field);
const fieldOptions = React.useMemo<SelectOption[]>(
() => specs.map((s) => ({ value: s.field, label: s.label, group: s.group })),
[specs],
);
const operators = spec ? SEGMENT_OPERATORS[spec.kind] : [];
const operatorOptions: SelectOption[] = operators.map((o) => ({ value: o.id, label: o.label }));
function pickField(field: string) {
const next = specs.find((s) => s.field === field);
const ops = next ? SEGMENT_OPERATORS[next.kind] : [];
onChange({ field, operator: ops[0]?.id ?? "", value: "", values: undefined });
}
function pickOperator(operator: string) {
onChange({ ...condition, operator, value: VALUELESS_OPERATORS.has(operator) ? "" : condition.value, values: condition.values });
}
return (
<div className="rounded-md border border-slate-200 bg-white p-2 space-y-1.5">
<div className="flex items-center gap-1.5">
<span className="w-8 shrink-0 text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
{index === 0 ? "If" : match === "all" ? "and" : "or"}
</span>
<SelectMenu
value={condition.field}
onChange={pickField}
options={fieldOptions}
placeholder="Pick a field…"
className="min-w-0 flex-1"
fullWidth
aria-label="Field"
/>
<SelectMenu
value={condition.operator}
onChange={pickOperator}
options={operatorOptions}
placeholder="Operator"
disabled={!spec}
className="min-w-0 flex-1"
fullWidth
aria-label="Operator"
/>
<button
type="button"
onClick={onRemove}
aria-label="Remove condition"
className="size-7 shrink-0 rounded-md text-slate-400 hover:text-red-600 hover:bg-red-50 inline-flex items-center justify-center transition-colors"
>
<Trash2Icon className="w-3.5 h-3.5" />
</button>
</div>
{spec && !VALUELESS_OPERATORS.has(condition.operator) && (
<div className="pl-[38px]">
<ValueInput spec={spec} condition={condition} selfId={selfId} onChange={onChange} />
</div>
)}
</div>
);
}
function ValueInput({
spec,
condition,
selfId,
onChange,
}: {
spec: SegmentFieldSpec;
condition: SegmentCondition;
selfId?: string;
onChange: (next: SegmentCondition) => void;
}) {
const values = condition.values ?? [];
const setValues = (next: string[]) => onChange({ ...condition, values: next });
const setValue = (next: string) => onChange({ ...condition, value: next });
switch (spec.kind) {
case "text":
return <TextInput value={condition.value ?? ""} onChange={setValue} placeholder="Value" className="w-full" />;
case "number":
return (
<NumberInput
value={Number(condition.value ?? 0) || 0}
onChange={(n) => setValue(String(Math.max(0, Math.round(n))))}
min={0}
className="w-40"
/>
);
case "date":
if (condition.operator === "within_days" || condition.operator === "not_within_days") {
return (
<NumberInput
value={Number(condition.value ?? 0) || 0}
onChange={(n) => setValue(String(Math.min(3650, Math.max(1, Math.round(n)))))}
min={1}
max={3650}
suffix="days"
className="w-40"
/>
);
}
return (
<DatePicker
value={(condition.value ?? "").slice(0, 10)}
onChange={setValue}
clearable={false}
placeholder="Pick a date"
className="w-48"
/>
);
case "enum":
return <EnumMultiPicker value={values} onChange={setValues} options={spec.options ?? []} />;
case "category":
return <CategoryPicker value={values} onChange={setValues} placeholder="Pick categories…" allowCreate={false} />;
case "campaign":
return <CampaignMultiPicker value={values} onChange={setValues} />;
case "segment":
return <SegmentMultiPicker value={values} onChange={setValues} exclude={selfId} />;
default:
return null;
}
}
@@ -0,0 +1,218 @@
// Multi-select pickers used by the segment condition builder: campaigns,
// segments and enum options. Same chip box + dropdown language as
// CategoryPicker, without inline creation.
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { CheckIcon, PlusIcon, XIcon } from "lucide-react";
import useClickOutside from "@/hooks/useClickOutside";
import useFlipPlacement from "@/hooks/useFlipPlacement";
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
import { useSegments } from "@/lib/api/hooks/app/segments";
import { cn } from "@/lib/utils";
export interface PickOption {
id: string;
label: string;
color?: string;
}
export function MultiPicker({
value,
onChange,
options,
placeholder = "Pick…",
searchable = true,
className,
}: {
value: string[];
onChange: (next: string[]) => void;
options: PickOption[];
placeholder?: string;
searchable?: boolean;
className?: string;
}) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const ref = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLDivElement>(null);
useClickOutside(ref, () => setOpen(false));
const placement = useFlipPlacement(triggerRef, open, 270);
const byId = React.useMemo(() => new Map(options.map((o) => [o.id, o])), [options]);
const chips = value.map((id) => byId.get(id) ?? { id, label: "Unknown" });
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.label.toLowerCase().includes(q));
}, [options, query]);
function toggle(id: string) {
onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]);
}
return (
<div ref={ref} className={cn("relative", className)}>
<div ref={triggerRef} className="rounded-md border border-slate-200 bg-white min-h-[28px]">
{chips.length === 0 ? (
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="w-full text-left px-2.5 h-7 text-[12px] text-slate-400 hover:text-slate-600"
>
{placeholder}
</button>
) : (
<div className="px-1.5 py-1 flex flex-wrap gap-1">
{chips.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1 h-5 pl-1.5 pr-1 rounded text-[11px] font-medium bg-slate-100 text-slate-700"
>
{c.color && <span className="size-2 rounded-full shrink-0" style={{ backgroundColor: c.color }} />}
<span className="truncate max-w-[140px]">{c.label}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggle(c.id);
}}
className="opacity-70 hover:opacity-100"
aria-label={`Remove ${c.label}`}
>
<XIcon className="w-2.5 h-2.5" />
</button>
</span>
))}
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[11px] font-medium border border-dashed border-slate-300 text-slate-500 hover:border-slate-400 hover:text-slate-700"
>
<PlusIcon className="w-2.5 h-2.5" />
Add
</button>
</div>
)}
</div>
<AnimatePresence>
{open && (
<motion.div
data-floating
initial={{ opacity: 0, y: placement === "top" ? 4 : -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: placement === "top" ? 4 : -4 }}
transition={{ duration: 0.12 }}
className={cn(
"absolute left-0 right-0 z-30 rounded-md border border-slate-200 bg-white shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)] overflow-hidden",
placement === "top" ? "bottom-full mb-1" : "top-full mt-1",
)}
>
{searchable && (
<div className="px-2 py-1.5 border-b border-slate-200">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
autoFocus
className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none"
/>
</div>
)}
<div className="max-h-56 overflow-y-auto py-1">
{filtered.length === 0 && (
<div className="px-3 py-3 text-[11.5px] text-slate-400 text-center">Nothing to pick.</div>
)}
{filtered.map((o) => {
const checked = value.includes(o.id);
return (
<button
key={o.id}
type="button"
onClick={() => toggle(o.id)}
className="w-full px-2.5 h-7 flex items-center gap-2 text-[12px] text-slate-700 hover:bg-slate-100 transition-colors"
>
<span
className={cn(
"size-3.5 rounded border flex items-center justify-center transition-colors shrink-0",
checked ? "border-slate-900 bg-slate-900" : "border-slate-300 bg-white",
)}
>
{checked && <CheckIcon className="w-2 h-2 text-white" />}
</span>
{o.color && <span className="size-2.5 rounded-full shrink-0" style={{ backgroundColor: o.color }} />}
<span className="truncate">{o.label}</span>
</button>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export function CampaignMultiPicker({ value, onChange }: { value: string[]; onChange: (next: string[]) => void }) {
const campaigns = useCampaigns({ query: "", folder: "", limit: 100 });
const options = React.useMemo<PickOption[]>(
() => campaigns.campaigns.map((c) => ({ id: c.id, label: c.name })),
[campaigns.campaigns],
);
// Walk every page once so the picker holds the whole workspace.
const { hasNextPage, isFetchingNextPage, fetchNextPage } = campaigns;
React.useEffect(() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return <MultiPicker value={value} onChange={onChange} options={options} placeholder="Pick campaigns…" />;
}
export function SegmentMultiPicker({
value,
onChange,
exclude,
}: {
value: string[];
onChange: (next: string[]) => void;
exclude?: string;
}) {
const segments = useSegments();
const options = React.useMemo<PickOption[]>(
() =>
(segments.data ?? [])
.filter((s) => s.id !== exclude)
.map((s) => ({ id: s.id, label: s.name, color: s.color })),
[segments.data, exclude],
);
return <MultiPicker value={value} onChange={onChange} options={options} placeholder="Pick segments…" />;
}
const ENUM_LABELS: Record<string, string> = {
unknown: "Unknown",
manual: "Added manually",
campaign: "Added from a campaign",
import: "Imported",
sheet_sync: "Google Sheets sync",
api: "API",
ai_assistant: "AI assistant",
valid: "Valid",
risky: "Risky",
invalid: "Invalid",
gmail: "Gmail",
outlook: "Outlook",
other: "Other",
};
export function EnumMultiPicker({
value,
onChange,
options,
}: {
value: string[];
onChange: (next: string[]) => void;
options: string[];
}) {
const opts = React.useMemo<PickOption[]>(() => options.map((o) => ({ id: o, label: ENUM_LABELS[o] ?? o })), [options]);
return <MultiPicker value={value} onChange={onChange} options={opts} placeholder="Pick values…" searchable={false} />;
}
@@ -0,0 +1,69 @@
// Turns the contact filter panel's state into segment conditions so a filter
// can be saved as a segment. Returns what could not be carried over.
import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts";
import type { SegmentCondition } from "@/lib/api/models/app/segments/Segment";
export interface SegmentPreset {
conditions: SegmentCondition[];
dropped: string[];
}
const TEXT_OPS: Record<string, string> = {
equal: "equals",
contains: "contains",
starts_with: "starts_with",
ends_with: "ends_with",
};
function isoDate(d: Date): string {
return new Date(d).toISOString().slice(0, 10);
}
export function filtersToSegment(f: SearchContacts, campaignID?: string): SegmentPreset {
const conditions: SegmentCondition[] = [];
const dropped: string[] = [];
for (const cf of f.filters) {
const name = cf.name.trim();
const op = TEXT_OPS[cf.type];
if (!name || !op) continue;
conditions.push({ field: `custom.${name}`, operator: op, value: cf.value });
}
if (f.category_ids && f.category_ids.length > 0) {
// The filter panel requires every category; one condition per id keeps that.
for (const id of f.category_ids) conditions.push({ field: "category", operator: "in", values: [id] });
}
if (f.segment_ids && f.segment_ids.length > 0) {
for (const id of f.segment_ids) conditions.push({ field: "segment", operator: "in", values: [id] });
}
if (f.subscribed !== undefined) {
conditions.push({ field: "subscribed", operator: f.subscribed ? "is_true" : "is_false" });
}
if (f.verification_status) conditions.push({ field: "verification_status", operator: "in", values: [f.verification_status] });
if (f.min_campaigns !== undefined) conditions.push({ field: "campaign_count", operator: "gte", value: String(f.min_campaigns) });
if (f.max_campaigns !== undefined) conditions.push({ field: "campaign_count", operator: "lte", value: String(f.max_campaigns) });
if (f.created_after) conditions.push({ field: "created_at", operator: "after", value: isoDate(f.created_after) });
if (f.created_before) conditions.push({ field: "created_at", operator: "before", value: isoDate(f.created_before) });
if (f.updated_after) conditions.push({ field: "updated_at", operator: "after", value: isoDate(f.updated_after) });
if (f.updated_before) conditions.push({ field: "updated_at", operator: "before", value: isoDate(f.updated_before) });
if (campaignID) conditions.push({ field: "campaign", operator: "in", values: [campaignID] });
// Engagement buckets become lifetime counters; the not_* forms only cover
// contacts that were sent something, like the filter does.
if (f.engagement) {
const positive = f.engagement.startsWith("not_") ? f.engagement.slice(4) : f.engagement;
const field = `emails_${positive}`;
if (f.engagement.startsWith("not_")) {
conditions.push({ field: "emails_sent", operator: "gte", value: "1" });
conditions.push({ field, operator: "equals", value: "0" });
} else {
conditions.push({ field, operator: "gte", value: "1" });
}
}
if (f.query.trim()) dropped.push("search text");
if (f.lead_status) dropped.push("lead status");
return { conditions, dropped };
}
@@ -8,9 +8,8 @@
// - since / until date range
// - sort: newest / oldest
//
// Sheet pattern matches ContactFilters (slim right-side panel,
// sticky header + footer, draft state mirrors parent until Apply
// so we don't refetch while the user is mid-build).
// Slim right-side sheet pattern: sticky header + footer, draft state mirrors parent until Apply
// so we do not refetch while the user is mid-build.
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
+2
View File
@@ -33,6 +33,8 @@ const labelMap: Record<string, string> = {
emails: "Accounts",
unibox: "Inbox",
contacts: "Contacts",
segments: "Segments",
categories: "Categories",
campaigns: "Campaigns",
analytics: "Analytics",
crm: "CRM",
@@ -5,6 +5,8 @@ const labelMap: Record<string, string> = {
app: 'Dashboard',
emails: 'Accounts',
contacts: 'Contacts',
segments: 'Segments',
categories: 'Categories',
campaigns: 'Campaigns',
unibox: 'Inbox',
analytics: 'Analytics',
+5 -1
View File
@@ -332,7 +332,10 @@ export function useRealtimeEvents() {
markSelfMutation(entityType, entityId)
}
const spine: Record<string, QueryKey[]> = {
contact: [['contacts']],
// Segment membership is computed from contact data, so a contact
// change moves segment counts too.
contact: [['contacts'], ['segments']],
segment: [['segments'], ['contacts', 'list']],
campaign: [['campaigns'], ['analytics']],
step: [['campaigns']],
// ['emails'] rather than ['emails', 'list']: it prefix-matches the
@@ -398,6 +401,7 @@ export function useRealtimeEvents() {
const keys = spine[entityType]
if (keys) invalidate(keys)
if (entityId && entityType === 'contact') invalidate([['contacts', entityId]])
if (entityId && entityType === 'segment') invalidate([['segments', entityId]])
if (entityId && entityType === 'campaign') invalidate([['campaigns', entityId]])
if (entityId && entityType === 'automation') invalidate([['automations', entityId]])
return
@@ -0,0 +1,81 @@
import Request from "../../Request";
import type Segment from "@/lib/api/models/app/segments/Segment";
import type {
ContactSegment,
SegmentOverride,
SegmentAddToCampaignResult,
SegmentFieldSpec,
SegmentMemberMode,
SegmentPreview,
SegmentWrite,
} from "@/lib/api/models/app/segments/Segment";
export async function listSegments(): Promise<Segment[]> {
const res = await Request<{ data: Segment[] }>({ method: "GET", url: "/segments", authorization: true });
return res.data ?? [];
}
export async function getSegment(id: string): Promise<Segment> {
return await Request<Segment>({ method: "GET", url: `/segments/${id}`, authorization: true });
}
export async function listSegmentFields(): Promise<SegmentFieldSpec[]> {
const res = await Request<{ data: SegmentFieldSpec[] }>({ method: "GET", url: "/segments/fields", authorization: true });
return res.data ?? [];
}
export async function createSegment(data: SegmentWrite): Promise<Segment> {
return await Request<Segment>({ method: "POST", url: "/segments", data, authorization: true });
}
export async function updateSegment(id: string, data: SegmentWrite): Promise<Segment> {
return await Request<Segment>({ method: "PATCH", url: `/segments/${id}`, data, authorization: true });
}
export async function deleteSegment(id: string): Promise<void> {
await Request<void>({ method: "DELETE", url: `/segments/${id}`, authorization: true });
}
export async function previewSegment(data: SegmentPreview): Promise<number> {
const res = await Request<{ contact_count: number }>({ method: "POST", url: "/segments/preview", data, authorization: true });
return res.contact_count;
}
export async function setSegmentMembers(id: string, contacts: string[], mode: SegmentMemberMode): Promise<number> {
const res = await Request<{ updated: number }>({
method: "POST",
url: `/segments/${id}/members`,
data: { contacts, mode },
authorization: true,
});
return res.updated;
}
export async function lookupSegmentMembers(id: string, contacts: string[]): Promise<Record<string, SegmentMemberMode>> {
const res = await Request<{ data: Record<string, SegmentMemberMode> }>({
method: "POST",
url: `/segments/${id}/members/lookup`,
data: { contacts },
authorization: true,
});
return res.data ?? {};
}
export async function listContactSegments(contactId: string): Promise<ContactSegment[]> {
const res = await Request<{ data: ContactSegment[] }>({ method: "GET", url: `/contacts/${contactId}/segments`, authorization: true });
return res.data ?? [];
}
export async function listSegmentOverrides(id: string): Promise<SegmentOverride[]> {
const res = await Request<{ data: SegmentOverride[] }>({ method: "GET", url: `/segments/${id}/overrides`, authorization: true });
return res.data ?? [];
}
export async function addSegmentToCampaign(id: string, campaignId: string): Promise<SegmentAddToCampaignResult> {
return await Request<SegmentAddToCampaignResult>({
method: "POST",
url: `/segments/${id}/add-to-campaign`,
data: { campaign_id: campaignId },
authorization: true,
});
}
+120
View File
@@ -0,0 +1,120 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
addSegmentToCampaign,
listContactSegments,
listSegmentOverrides,
createSegment,
deleteSegment,
getSegment,
listSegmentFields,
listSegments,
lookupSegmentMembers,
previewSegment,
setSegmentMembers,
updateSegment,
} from "@/lib/api/client/app/segments";
import type { SegmentMemberMode, SegmentPreview, SegmentWrite } from "@/lib/api/models/app/segments/Segment";
// Every segment read lives under ["segments"]: the realtime spine invalidates
// that prefix on any segment or contact mutation, since membership is live.
export function useSegments(enabled = true) {
return useQuery({ queryKey: ["segments", "list"], queryFn: listSegments, enabled });
}
export function useSegment(id: string | undefined) {
return useQuery({ queryKey: ["segments", id], queryFn: () => getSegment(id as string), enabled: !!id });
}
export function useSegmentFields(enabled = true) {
return useQuery({ queryKey: ["segments", "fields"], queryFn: listSegmentFields, enabled, staleTime: 5 * 60 * 1000 });
}
export function useSegmentPreview(preview: SegmentPreview | null) {
return useQuery({
queryKey: ["segments", "preview", preview],
queryFn: () => previewSegment(preview as SegmentPreview),
enabled: preview !== null,
retry: 0,
});
}
export function useSegmentMemberModes(id: string | undefined, contacts: string[]) {
return useQuery({
queryKey: ["segments", id, "members", contacts],
queryFn: () => lookupSegmentMembers(id as string, contacts),
enabled: !!id && contacts.length > 0,
});
}
// Keyed under ["contacts", id] so a contact mutation refreshes it, and under
// ["segments"] via the spine so a segment edit does too.
export function useContactSegments(contactId: string | undefined, enabled = true) {
return useQuery({
queryKey: ["contacts", contactId, "segments"],
queryFn: () => listContactSegments(contactId as string),
enabled: enabled && !!contactId,
staleTime: 30_000,
});
}
export function useSegmentOverrides(id: string | undefined, enabled = true) {
return useQuery({
queryKey: ["segments", id, "overrides"],
queryFn: () => listSegmentOverrides(id as string),
enabled: enabled && !!id,
});
}
function invalidateSegments(queryClient: ReturnType<typeof useQueryClient>) {
return queryClient.invalidateQueries({ queryKey: ["segments"] });
}
export function useCreateSegment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: SegmentWrite) => createSegment(data),
onSuccess: () => invalidateSegments(queryClient),
});
}
export function useUpdateSegment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: SegmentWrite }) => updateSegment(id, data),
// A changed definition moves rows in and out of the segment's contact list.
onSuccess: () =>
Promise.all([invalidateSegments(queryClient), queryClient.invalidateQueries({ queryKey: ["contacts", "list"] })]),
});
}
export function useDeleteSegment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteSegment(id),
onSuccess: () => invalidateSegments(queryClient),
});
}
export function useSetSegmentMembers() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, contacts, mode }: { id: string; contacts: string[]; mode: SegmentMemberMode }) =>
setSegmentMembers(id, contacts, mode),
// ["contacts"] as a whole: the list moves and each pinned contact's
// own segments panel changes.
onSuccess: () =>
Promise.all([invalidateSegments(queryClient), queryClient.invalidateQueries({ queryKey: ["contacts"] })]),
});
}
export function useAddSegmentToCampaign() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, campaignId }: { id: string; campaignId: string }) => addSegmentToCampaign(id, campaignId),
onSuccess: () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ["contacts"] }),
queryClient.invalidateQueries({ queryKey: ["campaigns"] }),
]),
});
}
@@ -5,6 +5,8 @@
export type SequenceActionType =
| "add_tag"
| "remove_tag"
| "add_to_segment"
| "remove_from_segment"
| "label_email"
| "unsubscribe"
| "create_task"
@@ -26,6 +28,9 @@ export interface SequenceAction {
type: SequenceActionType;
// add_tag / remove_tag — a contact category id
category_id?: string | null;
// add_to_segment / remove_from_segment — pins the contact into or out of
// a segment as a manual override
segment_id?: string | null;
// label_email — unibox conversation labels (same category registry as tags)
// applied to the thread the contact replied on. Reply-branch only; a no-op
// when the contact has not replied.
@@ -11,6 +11,8 @@ export default interface SearchContacts {
lead_status?: LeadStatus;
engagement?: LeadEngagement;
category_ids?: string[];
// Contact must be a member of ALL of these segments.
segment_ids?: string[];
min_campaigns?: number;
max_campaigns?: number;
subscribed?: boolean;
@@ -0,0 +1,141 @@
export type SegmentMatch = "all" | "any";
export type SegmentMemberMode = "include" | "exclude" | "auto";
export type SegmentFieldKind =
| "text"
| "enum"
| "bool"
| "date"
| "number"
| "category"
| "campaign"
| "segment";
export interface SegmentCondition {
field: string;
operator: string;
value?: string;
values?: string[];
}
export interface SegmentFieldSpec {
field: string;
label: string;
group: string;
kind: SegmentFieldKind;
options?: string[];
}
export default interface Segment {
id: string;
organization_id: string;
created_by?: string;
name: string;
description: string;
color: string;
match: SegmentMatch;
conditions: SegmentCondition[];
contact_count: number;
included_count: number;
excluded_count: number;
created_at: Date;
updated_at: Date;
}
export interface SegmentWrite {
name?: string;
description?: string;
color?: string;
match?: SegmentMatch;
conditions?: SegmentCondition[];
}
export interface SegmentPreview {
id?: string;
match: SegmentMatch;
conditions: SegmentCondition[];
}
// One segment as seen from a contact: member or not, plus any manual override.
export interface ContactSegment {
id: string;
name: string;
color: string;
mode?: "include" | "exclude";
member: boolean;
}
// A contact pinned into or out of a segment.
export interface SegmentOverride {
contact_id: string;
first_name: string;
last_name: string;
email: string;
company: string;
mode: "include" | "exclude";
created_at: Date;
}
export interface SegmentAddToCampaignResult {
campaign_id: string;
added: number;
members: number;
}
// Operators per field kind, mirrored from the backend catalog.
export const SEGMENT_OPERATORS: Record<SegmentFieldKind, { id: string; label: string }[]> = {
text: [
{ id: "equals", label: "is" },
{ id: "not_equals", label: "is not" },
{ id: "contains", label: "contains" },
{ id: "not_contains", label: "does not contain" },
{ id: "starts_with", label: "starts with" },
{ id: "ends_with", label: "ends with" },
{ id: "is_empty", label: "is empty" },
{ id: "is_not_empty", label: "is not empty" },
],
enum: [
{ id: "in", label: "is any of" },
{ id: "not_in", label: "is none of" },
],
bool: [
{ id: "is_true", label: "is yes" },
{ id: "is_false", label: "is no" },
],
date: [
{ id: "within_days", label: "in the last" },
{ id: "not_within_days", label: "not in the last" },
{ id: "after", label: "is after" },
{ id: "before", label: "is before" },
{ id: "is_empty", label: "never" },
{ id: "is_not_empty", label: "ever" },
],
number: [
{ id: "equals", label: "is" },
{ id: "not_equals", label: "is not" },
{ id: "gt", label: "is more than" },
{ id: "gte", label: "is at least" },
{ id: "lt", label: "is less than" },
{ id: "lte", label: "is at most" },
],
category: [
{ id: "in", label: "has any of" },
{ id: "not_in", label: "has none of" },
{ id: "is_empty", label: "has none" },
{ id: "is_not_empty", label: "has any" },
],
campaign: [
{ id: "in", label: "is in any of" },
{ id: "not_in", label: "is in none of" },
{ id: "is_empty", label: "is in no campaign" },
{ id: "is_not_empty", label: "is in a campaign" },
],
segment: [
{ id: "in", label: "is in any of" },
{ id: "not_in", label: "is in none of" },
],
};
// Operators that take no value at all.
export const VALUELESS_OPERATORS = new Set(["is_empty", "is_not_empty", "is_true", "is_false"]);
+16 -1
View File
@@ -12,6 +12,10 @@ import "@fontsource/poppins/700.css";
import RootAppLayout from './app/app/layout';
import AddressesPage from './app/app/emails/page';
import ContactsPage from './app/app/contacts/page';
import ContactsLayout from './app/app/contacts/layout';
import SegmentsPage from './app/app/contacts/segments/page';
import CategoriesPage from './app/app/contacts/categories/page';
import SegmentPage from './app/app/contacts/segments/[id]/page';
import CampaignsPage from './app/app/campaigns/page';
import CampaignLayout from './app/app/campaigns/[id]/layout';
import CampaignPreview from './app/app/campaigns/[id]/page';
@@ -235,7 +239,18 @@ const router = createBrowserRouter([
},
{
path: "contacts",
element: <ContactsPage />,
element: <ContactsLayout />,
children: [
{ index: true, element: <ContactsPage /> },
{
path: "segments",
children: [
{ index: true, element: <SegmentsPage /> },
{ path: ":id", element: <SegmentPage /> },
],
},
{ path: "categories", element: <CategoriesPage /> },
],
},
{
path: "campaigns",