mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
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:
@@ -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,
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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/" />
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"advisor",
|
||||
"---Contacts and inbox---",
|
||||
"contacts-crm",
|
||||
"segments",
|
||||
"website-tracking",
|
||||
"unibox",
|
||||
"meetings",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -76,6 +76,7 @@ const (
|
||||
AuditEntityFolder AuditEntityType = "folder"
|
||||
AuditEntityTag AuditEntityType = "tag"
|
||||
AuditEntityCategory AuditEntityType = "category"
|
||||
AuditEntitySegment AuditEntityType = "segment"
|
||||
AuditEntitySubscription AuditEntityType = "subscription"
|
||||
AuditEntitySettings AuditEntityType = "settings"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 > 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 > 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";
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user