Contact Filter
A reusable, structured filter ({ operator, conditions[] }) applied to contacts.
Lives in two packages:
Frontend feature — apps/builder/src/features/contact-filter/ (Zod schemas,
UI config, React components). Barrel: index.ts.
Backend query builder — packages/database/src/queries/contact-filter/
(@chatbotx.io/database/queries). Shared by the builder app and the worker
so both resolve the same contacts. queries/contact-filter.ts is now a one-line
re-export barrel (export * from "./contact-filter/index") — the real code is the
14-file directory beside it:
| Concern |
File |
Per-field dispatch (buildConditionWhere) |
contact-filter/index.ts:412 |
| Negation/NULL predicates, 24h window |
contact-filter/predicates.ts (COLUMN_NEGATION_OPERATORS:21, contactInboxInteractedWithin24hSQL:27) |
| Operator inversion map |
contact-filter/field-value-predicates.ts (NEGATION_TO_POSITIVE:41) |
| Relation EXISTS subqueries |
contact-filter/relation-sets.ts (RELATION_SET_FILTERS:76, buildRelationSetWhere:131) |
| Custom / bot fields, CTWA, timezone, continent |
custom-field-predicates.ts, bot-field-predicates.ts, ctwa-retarget.ts, timezone.ts, continent.ts |
Architecture
contactFilterFields enum (partials/contact.ts) ← the field "universe" (~90)
│
CONTACT_FILTER_FIELD_DEFINITIONS (schema/definitions.ts) ← ~49 ACTIVE fields = single source of truth
│ (each: { field, schemaKind, optionSource })
├──► Zod condition schemas (schema/*.ts, via staticFieldFilter)
└──► UI FieldConfig[] (components/contact-filter-config.ts, getFieldConfigs)
Filter object → API `contactFilter` param → buildContactWhere / buildContactInboxContactFilterSQL
→ applyContactFilter → buildConditionWhere (switch per field) → Drizzle where / SQL
Filter shape (schema/index.ts):
contactFilterCriteriaSchema = { operator: "and" | "or", conditions: ContactFilterCondition[] }
// condition (static): { field, operator, value? }
// condition (boolean): { field, operator: "eq", value: "true"|"false" } | { field, operator: "isEmpty" }
// condition (custom): { field: "customField", customFieldId, valueType, operator, value? }
operator is top-level only — the schema is flat, no nested groups.
Operators + form-field types: packages/database/src/partials/custom-field.ts
(operatorTypes, FormFieldType).
Adding a new filter field
- Enum — add the field key to
contactFilterFields in
packages/database/src/partials/contact.ts.
- Definition — add one entry to
CONTACT_FILTER_FIELD_DEFINITIONS
(schema/definitions.ts) with schemaKind
(boolean|text|multiSelect|select|datetime|number) and optionSource
(none|languages|countries|continents|gender|contactSources|channels|inboxes|tags|flows).
This one entry auto-generates both the Zod condition schema and the UI config.
- Operator rules — TWO places (CRITICAL, must match):
- Zod validation:
STATIC_OPERATOR_RULES in schema/static-field-filter.ts
- UI enablement:
staticFieldRules in components/static-field-filter-config.ts
- Backend SQL — add a
case to buildConditionWhere
(packages/database/src/queries/contact-filter/index.ts:412; it takes
(condition, context)). Without it the field silently produces no condition
(the default: return {} branch).
- Options / group (if not
none) — wire the option source in
use-contact-filter-configs.ts / contact-filter-config.ts; group is assigned
by getContactFilterFieldGroup.
Where filter application lives
contactRepository.buildListWhere (aliases buildContactListWhere, packages/database/src/repositories/contact/list-where.ts:42)
is the one place applyContactFilter is called to build the where clause for
a contacts list/count — used by both the builder (private RSC) and the public
API, via contactService.list/count (packages/business/src/contact/list.ts).
.query.ts files never call applyContactFilter directly — that duplicates
the where-builder per caller, which is exactly what the service/repository
split exists to prevent (see .agents/rules/data-access.md). The worker's
export-contacts.ts still hand-builds its own where clause — a known
follow-up, not a pattern to extend.
Backend query builder (packages/database/src/queries/contact-filter/)
applyContactFilter(criteria) → maps conditions to { AND: [...] } or
{ OR: [...] }; buildConditionWhere(condition, context) switches on field.
buildContactWhere({ workspaceId, keyword?, contactFilter? }) → relational
where for contactModel.
buildContactInboxContactFilterSQL({ contactIdColumn, workspaceId, contactFilter })
→ contactId IN (SELECT id FROM Contact WHERE …) for ContactInbox-rooted queries.
ContactFilterCriteriaInput.conditions is unknown[] on purpose — the DB
package can't import the builder's Zod schema; each entry is Zod-validated at
the request boundary, then narrowed here.
CRITICAL invariants
- Two operator-rule sources must stay in sync —
STATIC_OPERATOR_RULES (Zod)
and staticFieldRules (UI). Editing one without the other lets the UI offer an
operator Zod rejects, or vice-versa.
- NULL / negation three-valued logic — negative and "is empty" operators must
also match rows where the value is NULL/absent (SQL
NOT (x = y) drops NULLs).
Preserve these:
COLUMN_NEGATION_OPERATORS (ne, notIn, notContains) ⇒
{ OR: [condition, { [col]: { isNull: true } }] }.
- date
ne ⇒ (col < dayStart OR col >= dayEnd OR col IS NULL).
- relation
isEmpty ⇒ NOT EXISTS; custom-field negation ⇒ NEGATION_TO_POSITIVE
- negated EXISTS.
(This is the fix behind commit "correct contact filter results for negative and
empty conditions" — do not regress it.)
- Relation fields render as correlated
RAW EXISTS — tags, source,
currentChannel, inbox go through RELATION_SET_FILTERS + buildRelationSetWhere.
relationsFilterToSQL does not understand nested relation filter fields, so
relation conditions must be RAW EXISTS subqueries correlated on the contact id.
- No forced/default-condition injection — there is no mechanism to seed a
hidden condition into a user's filter. Because the schema is flat (no nested
groups), injecting a forced condition would force resolving AND-vs-OR against the
user's own
operator. Enforce cross-cutting audience constraints in the
backend query instead, keyed off context — e.g. the broadcast 24h messaging
window keys off broadcast.subaction in the worker (see below), never a filter
param.
Hiding fields per context — excludeFields
ContactFilter / ContactFilterDialog / ContactListFilterPanel accept
excludeFields?: ContactFilterField[]. It removes the field from the "add
condition" list and prunes any existing condition referencing it
(lib/prune-conditions.ts → pruneExcludedConditions, run in a useEffect).
Broadcast policy example — apps/builder/src/features/broadcasts/lib/broadcast-filter-fields.ts:
getBroadcastExcludedFilterFields({ channel, subaction }) hides currentChannel
(+inbox for template sends, +interactedInLast24h for the two non-template
Messenger/WhatsApp subactions).
Shared 24h window predicate
contactInboxInteractedWithin24hSQL() (contact-filter/predicates.ts:27) is the single
source for lastIncomingMessageAt >= NOW() - INTERVAL '24 hours', used by:
- the
interactedInLast24h filter case (wrapped in a contact-level EXISTS), and
- the broadcast audience (
apps/worker/src/schedule/handlers/prepare-broadcast.ts)
- receiver-count preview (
countContactInboxes), gated by
requiresRecentInteractionWindow(subaction) (partials/broadcast.ts).
Consumers
- Contacts list —
apps/builder/src/features/contacts/ (count via
countContactInboxes, list via listContactInboxes).
- Conversations —
conversations/conversation-filter.tsx (excludes currentChannel).
- Broadcast audience —
broadcasts/create-broadcast-form.tsx (UI + count) →
persisted broadcast.contactFilter → worker prepare-broadcast.ts.
Common mistakes
- Adding a field to
CONTACT_FILTER_FIELD_DEFINITIONS but not implementing its
buildConditionWhere case → filter silently no-ops.
- Updating operators in only one of the two rule sources.
- Writing a negative/empty operator that drops NULL rows (breaks three-valued logic).
- Trying to inject a forced/default condition into the flat schema — put the
constraint in the query instead.
- Expecting nested
(A OR B) AND C — not supported; operator is top-level only.
Checklist for a filter change
1---2name: contact-filter3description: Work with the ChatbotX contact filter system — the shared filter model behind the contacts list, conversations, and broadcast audiences. Use when adding a filter field or operator, changing the filter UI, editing the SQL query builder, or enforcing an audience constraint. Covers the definitions single-source, the two operator-rule sources that must stay in sync, NULL / negation three-valued logic, relation EXISTS subqueries, and the excludeFields mechanism.4---56# Contact Filter78A reusable, structured filter (`{ operator, conditions[] }`) applied to contacts.9Lives in two packages:1011- **Frontend feature** — `apps/builder/src/features/contact-filter/` (Zod schemas,12 UI config, React components). Barrel: `index.ts`.13- **Backend query builder** — `packages/database/src/queries/contact-filter/`14 (`@chatbotx.io/database/queries`). Shared by the builder app **and** the worker15 so both resolve the same contacts. **`queries/contact-filter.ts` is now a one-line16 re-export barrel** (`export * from "./contact-filter/index"`) — the real code is the17 14-file directory beside it:1819 | Concern | File |20 |---|---|21 | Per-field dispatch (`buildConditionWhere`) | `contact-filter/index.ts:412` |22 | Negation/NULL predicates, 24h window | `contact-filter/predicates.ts` (`COLUMN_NEGATION_OPERATORS:21`, `contactInboxInteractedWithin24hSQL:27`) |23 | Operator inversion map | `contact-filter/field-value-predicates.ts` (`NEGATION_TO_POSITIVE:41`) |24 | Relation EXISTS subqueries | `contact-filter/relation-sets.ts` (`RELATION_SET_FILTERS:76`, `buildRelationSetWhere:131`) |25 | Custom / bot fields, CTWA, timezone, continent | `custom-field-predicates.ts`, `bot-field-predicates.ts`, `ctwa-retarget.ts`, `timezone.ts`, `continent.ts` |2627## Architecture2829```30contactFilterFields enum (partials/contact.ts) ← the field "universe" (~90)31 │32CONTACT_FILTER_FIELD_DEFINITIONS (schema/definitions.ts) ← ~49 ACTIVE fields = single source of truth33 │ (each: { field, schemaKind, optionSource })34 ├──► Zod condition schemas (schema/*.ts, via staticFieldFilter)35 └──► UI FieldConfig[] (components/contact-filter-config.ts, getFieldConfigs)3637Filter object → API `contactFilter` param → buildContactWhere / buildContactInboxContactFilterSQL38 → applyContactFilter → buildConditionWhere (switch per field) → Drizzle where / SQL39```4041**Filter shape** (`schema/index.ts`):4243```ts44contactFilterCriteriaSchema = { operator: "and" | "or", conditions: ContactFilterCondition[] }45// condition (static): { field, operator, value? }46// condition (boolean): { field, operator: "eq", value: "true"|"false" } | { field, operator: "isEmpty" }47// condition (custom): { field: "customField", customFieldId, valueType, operator, value? }48```49`operator` is **top-level only** — the schema is **flat, no nested groups**.50Operators + form-field types: `packages/database/src/partials/custom-field.ts`51(`operatorTypes`, `FormFieldType`).5253## Adding a new filter field54551. **Enum** — add the field key to `contactFilterFields` in56 `packages/database/src/partials/contact.ts`.572. **Definition** — add one entry to `CONTACT_FILTER_FIELD_DEFINITIONS`58 (`schema/definitions.ts`) with `schemaKind`59 (`boolean|text|multiSelect|select|datetime|number`) and `optionSource`60 (`none|languages|countries|continents|gender|contactSources|channels|inboxes|tags|flows`).61 This one entry auto-generates **both** the Zod condition schema and the UI config.623. **Operator rules — TWO places (CRITICAL, must match):**63 - Zod validation: `STATIC_OPERATOR_RULES` in `schema/static-field-filter.ts`64 - UI enablement: `staticFieldRules` in `components/static-field-filter-config.ts`654. **Backend SQL** — add a `case` to `buildConditionWhere`66 (`packages/database/src/queries/contact-filter/index.ts:412`; it takes67 `(condition, context)`). Without it the field silently produces **no condition**68 (the `default: return {}` branch).695. **Options / group** (if not `none`) — wire the option source in70 `use-contact-filter-configs.ts` / `contact-filter-config.ts`; group is assigned71 by `getContactFilterFieldGroup`.7273## Where filter application lives7475`contactRepository.buildListWhere` (aliases `buildContactListWhere`, `packages/database/src/repositories/contact/list-where.ts:42`)76is the one place `applyContactFilter` is called to build the where clause for77a contacts list/count — used by both the builder (private RSC) and the public78API, via `contactService.list`/`count` (`packages/business/src/contact/list.ts`).79`.query.ts` files never call `applyContactFilter` directly — that duplicates80the where-builder per caller, which is exactly what the service/repository81split exists to prevent (see `.agents/rules/data-access.md`). The worker's82`export-contacts.ts` still hand-builds its own where clause — a known83follow-up, not a pattern to extend.8485## Backend query builder (`packages/database/src/queries/contact-filter/`)8687- `applyContactFilter(criteria)` → maps `conditions` to `{ AND: [...] }` or88 `{ OR: [...] }`; `buildConditionWhere(condition, context)` switches on `field`.89- `buildContactWhere({ workspaceId, keyword?, contactFilter? })` → relational90 where for `contactModel`.91- `buildContactInboxContactFilterSQL({ contactIdColumn, workspaceId, contactFilter })`92 → `contactId IN (SELECT id FROM Contact WHERE …)` for ContactInbox-rooted queries.93- `ContactFilterCriteriaInput.conditions` is `unknown[]` on purpose — the DB94 package can't import the builder's Zod schema; each entry is Zod-validated at95 the request boundary, then narrowed here.9697## CRITICAL invariants9899- **Two operator-rule sources must stay in sync** — `STATIC_OPERATOR_RULES` (Zod)100 and `staticFieldRules` (UI). Editing one without the other lets the UI offer an101 operator Zod rejects, or vice-versa.102- **NULL / negation three-valued logic** — negative and "is empty" operators must103 also match rows where the value is NULL/absent (SQL `NOT (x = y)` drops NULLs).104 Preserve these:105 - `COLUMN_NEGATION_OPERATORS` (`ne`, `notIn`, `notContains`) ⇒106 `{ OR: [condition, { [col]: { isNull: true } }] }`.107 - date `ne` ⇒ `(col < dayStart OR col >= dayEnd OR col IS NULL)`.108 - relation `isEmpty` ⇒ `NOT EXISTS`; custom-field negation ⇒ `NEGATION_TO_POSITIVE`109 + negated EXISTS.110 (This is the fix behind commit *"correct contact filter results for negative and111 empty conditions"* — do not regress it.)112- **Relation fields render as correlated `RAW` EXISTS** — `tags`, `source`,113 `currentChannel`, `inbox` go through `RELATION_SET_FILTERS` + `buildRelationSetWhere`.114 `relationsFilterToSQL` does **not** understand nested relation filter fields, so115 relation conditions must be `RAW` EXISTS subqueries correlated on the contact id.116- **No forced/default-condition injection** — there is no mechanism to seed a117 hidden condition into a user's filter. Because the schema is flat (no nested118 groups), injecting a forced condition would force resolving AND-vs-OR against the119 user's own `operator`. **Enforce cross-cutting audience constraints in the120 backend query instead**, keyed off context — e.g. the broadcast 24h messaging121 window keys off `broadcast.subaction` in the worker (see below), never a filter122 param.123124## Hiding fields per context — `excludeFields`125126`ContactFilter` / `ContactFilterDialog` / `ContactListFilterPanel` accept127`excludeFields?: ContactFilterField[]`. It removes the field from the "add128condition" list **and** prunes any existing condition referencing it129(`lib/prune-conditions.ts` → `pruneExcludedConditions`, run in a `useEffect`).130131Broadcast policy example — `apps/builder/src/features/broadcasts/lib/broadcast-filter-fields.ts`:132`getBroadcastExcludedFilterFields({ channel, subaction })` hides `currentChannel`133(+`inbox` for template sends, +`interactedInLast24h` for the two non-template134Messenger/WhatsApp subactions).135136## Shared 24h window predicate137138`contactInboxInteractedWithin24hSQL()` (`contact-filter/predicates.ts:27`) is the single139source for `lastIncomingMessageAt >= NOW() - INTERVAL '24 hours'`, used by:140- the `interactedInLast24h` filter case (wrapped in a contact-level EXISTS), and141- the broadcast audience (`apps/worker/src/schedule/handlers/prepare-broadcast.ts`)142 + receiver-count preview (`countContactInboxes`), gated by143 `requiresRecentInteractionWindow(subaction)` (`partials/broadcast.ts`).144145## Consumers146147- **Contacts list** — `apps/builder/src/features/contacts/` (count via148 `countContactInboxes`, list via `listContactInboxes`).149- **Conversations** — `conversations/conversation-filter.tsx` (excludes `currentChannel`).150- **Broadcast audience** — `broadcasts/create-broadcast-form.tsx` (UI + count) →151 persisted `broadcast.contactFilter` → worker `prepare-broadcast.ts`.152153## Common mistakes154155- Adding a field to `CONTACT_FILTER_FIELD_DEFINITIONS` but not implementing its156 `buildConditionWhere` case → filter silently no-ops.157- Updating operators in only one of the two rule sources.158- Writing a negative/empty operator that drops NULL rows (breaks three-valued logic).159- Trying to inject a forced/default condition into the flat schema — put the160 constraint in the query instead.161- Expecting nested `(A OR B) AND C` — not supported; `operator` is top-level only.162163## Checklist for a filter change164165- [ ] Field key in `contactFilterFields` enum166- [ ] Entry in `CONTACT_FILTER_FIELD_DEFINITIONS`167- [ ] Operator rules updated in **both** `STATIC_OPERATOR_RULES` and `staticFieldRules`168- [ ] `buildConditionWhere` case implemented (with NULL/negation handling)169- [ ] Option source + group wired (if `optionSource !== "none"`)170- [ ] Tests: `apps/builder/__tests__/contact-filter-*.test.ts` and171 `packages/database/__tests__/contact-filter.test.ts`172- [ ] `pnpm lint` + `check-types` for `builder` and `@chatbotx.io/database`