Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions apps/api/src/services/SegmentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,11 @@ export class SegmentService {
return {segmentMemberships: {none: activeMembership}};
}

// For untracked dynamic segments, evaluate the condition recursively
// For untracked dynamic segments, evaluate the condition recursively. Negating it is safe:
// every data.* comparison but equals/notEquals null requires its key first
// (buildJsonFieldCondition), so it is never NULL. A value of another type than the filter (a
// number or an array for `contains`) still matches neither, because Prisma's NOT on a typed
// JSON filter also requires the type.
const nestedCondition = fromPrismaJson<FilterCondition>(referencedSegment.condition);
const nextVisited = new Set(visitedSegments).add(segmentId);
const nestedWhere = await this.buildConditionClause(projectId, nestedCondition, nextVisited);
Expand Down Expand Up @@ -940,7 +944,38 @@ export class SegmentService {
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
const path = jsonPath.split('.');
// Not exists = key is null / missing, represented as either DbNull or JsonNull
const missing: Prisma.ContactWhereInput = {data: {path, equals: Prisma.AnyNull}};

if (operator === 'notExists') {
return missing;
}
// Exists = key present with a non-null JSON value (neither DbNull nor JsonNull)
if (operator === 'exists') {
return {NOT: missing};
}

const comparison = this.buildJsonComparison(path, operator, value, unit);
// equals/notEquals null compare against a JSON null, as workflow conditions do
if (value === null && (operator === 'equals' || operator === 'notEquals')) {
return comparison;
}
// A comparison on a missing key is NULL in Postgres, and NOT NULL is still NULL, so a NOT
// around it (notMemberOfSegment) dropped contacts without the key. Requiring the key first
// makes every comparison true or false.
return {AND: [{NOT: missing}, comparison]};
}

/**
* Compare a JSON field that may be missing; buildJsonFieldCondition requires it first, except for
* equals/notEquals null
*/
private static buildJsonComparison(
path: string[],
operator: string,
value: unknown,
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
switch (operator) {
case 'equals':
// For date strings, compare only the date portion (ignore time)
Expand Down Expand Up @@ -978,18 +1013,6 @@ export class SegmentService {
return {data: {path, gte: value as Prisma.InputJsonValue}};
case 'lessThanOrEqual':
return {data: {path, lte: value as Prisma.InputJsonValue}};
case 'exists':
// Exists = key present with a non-null JSON value (neither DbNull nor JsonNull)
return {
NOT: {
OR: [{data: {path, equals: Prisma.DbNull}}, {data: {path, equals: Prisma.JsonNull}}],
},
};
case 'notExists':
// Not exists = key is null / missing, represented as either DbNull or JsonNull
return {
OR: [{data: {path, equals: Prisma.DbNull}}, {data: {path, equals: Prisma.JsonNull}}],
};
case 'within': {
// Note: Requires JSON date fields in ISO 8601 format for proper comparison
if (!unit) {
Expand Down
163 changes: 155 additions & 8 deletions apps/api/src/services/__tests__/SegmentService.segmentFilters.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import {randomUUID} from 'node:crypto';
import {beforeEach, describe, expect, it} from 'vitest';
import {CampaignAudienceType} from '@plunk/db';
import {CampaignAudienceType, Prisma} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
import {CampaignService} from '../CampaignService';
import {SegmentService} from '../SegmentService';
import {type SegmentFilter, SegmentService} from '../SegmentService';
import {factories, getPrismaClient} from '../../../../../test/helpers';

/**
* Filters on membership of another segment (`segment.<id>` with memberOfSegment and
* notMemberOfSegment), for each kind of segment they can point at.
* notMemberOfSegment), for each kind of segment they can point at, and how `data.*` filters read
* a contact without the field.
*/
describe('SegmentService - segment membership filters', () => {
const prisma = getPrismaClient();
Expand Down Expand Up @@ -94,16 +96,161 @@ describe('SegmentService - segment membership filters', () => {
});

describe('pointing at a dynamic segment that does not track membership', () => {
let contacts: Awaited<ReturnType<typeof seedPlans>>;

/** Contacts with and without `plan` and `company`, including both ways of having no data at all */
async function seedPlans() {
const contact = (data?: Prisma.InputJsonValue | typeof Prisma.JsonNull) =>
prisma.contact.create({data: {projectId, email: `contact-${randomUUID()}@test.com`, data}});
const [pro, free, acme, empty, keyNull, jsonNull, dbNull] = await Promise.all([
contact({plan: 'pro'}),
contact({plan: 'free'}),
contact({company: 'Acme Corp'}),
contact({}),
contact({plan: null, company: null}),
// What ContactService.create stores for a contact without data
contact(Prisma.JsonNull),
contact(),
]);
return {
pro: pro.id,
free: free.id,
acme: acme.id,
empty: empty.id,
keyNull: keyNull.id,
jsonNull: jsonNull.id,
dbNull: dbNull.id,
};
}

const all = () => Object.values(contacts).sort();
const allBut = (...ids: string[]) => all().filter(id => !ids.includes(id));
const referenced = (filters: SegmentFilter[]) => factories.createSegment(projectId, {filters});

beforeEach(async () => {
contacts = await seedPlans();
});

it('applies its filter', async () => {
const pro = await factories.createContact({projectId, data: {plan: 'pro'}});
const segment = await referenced([{field: 'data.plan', operator: 'equals', value: 'pro'}]);

expect(await matches(segment.id, 'memberOfSegment')).toEqual([contacts.pro]);
expect(await matches(segment.id, 'notMemberOfSegment')).toEqual(allBut(contacts.pro));
});

it.each([
['notEquals', {field: 'data.plan', operator: 'notEquals', value: 'pro'}, ['free']],
['contains', {field: 'data.company', operator: 'contains', value: 'Acme'}, ['acme']],
['notContains', {field: 'data.company', operator: 'notContains', value: 'Acme'}, []],
['exists', {field: 'data.plan', operator: 'exists'}, ['pro', 'free']],
] as const)('counts contacts without the field as outside a %s segment', async (_name, filter, members) => {
const segment = await referenced([filter as SegmentFilter]);
const memberIds = members.map(name => contacts[name]).sort();

expect(await matches(segment.id, 'memberOfSegment')).toEqual(memberIds);
expect(await matches(segment.id, 'notMemberOfSegment')).toEqual(allBut(...memberIds));
});

it('reads a chain of untracked dynamic segments like the filter it ends in', async () => {
const inner = await referenced([{field: 'data.plan', operator: 'notEquals', value: 'pro'}]);
const outer = await referenced([{field: `segment.${inner.id}`, operator: 'notMemberOfSegment'}]);

expect(await matches(outer.id, 'memberOfSegment')).toEqual(allBut(contacts.free));
expect(await matches(outer.id, 'notMemberOfSegment')).toEqual([contacts.free]);
});

it('keeps a value of another type out of both', async () => {
// Prisma's NOT on a typed JSON filter also requires the type: a number neither contains
// nor does not contain a text. Known gap, left on purpose (see the comment in SegmentService).
const numeric = await prisma.contact.create({
data: {projectId, email: `contact-${randomUUID()}@test.com`, data: {company: 5}},
});
const segment = await referenced([{field: 'data.company', operator: 'contains', value: 'Acme'}]);

expect(await matches(segment.id, 'memberOfSegment')).not.toContain(numeric.id);
expect(await matches(segment.id, 'notMemberOfSegment')).not.toContain(numeric.id);
});

it.each([
['next to another filter', [{field: 'subscribed', operator: 'equals', value: true}], false],
['alone in its group', [], true],
] as const)(
'saves and applies what "Include contacts without this field" builds, %s',
async (_name, others, letsInUnsubscribed) => {
// The dashboard button's shape: the filter ORed with its field having no value, nested in its group.
// An unsubscribed contact without a plan shows the OR stays inside the group, ANDed with the rest.
const unsubscribed = await prisma.contact.create({
data: {projectId, email: `contact-${randomUUID()}@test.com`, subscribed: false},
});
const segment = await SegmentService.create(projectId, {
name: 'Not pro, or no plan',
condition: {
logic: 'AND',
groups: [
{
filters: [...others] as SegmentFilter[],
conditions: {
logic: 'OR',
groups: [
{filters: [{field: 'data.plan', operator: 'notEquals', value: 'pro'}]},
{filters: [{field: 'data.plan', operator: 'notExists'}]},
],
},
},
],
},
});

const {data} = await SegmentService.getContacts(projectId, segment.id);
const expected = [...allBut(contacts.pro), ...(letsInUnsubscribed ? [unsubscribed.id] : [])].sort();
expect(data.map(contact => contact.id).sort()).toEqual(expected);
},
);
});

describe('a data field set to null', () => {
it('does not match notEquals, like a workflow condition', async () => {
const free = await factories.createContact({projectId, data: {plan: 'free'}});
const referenced = await factories.createSegment(projectId, {
filters: [{field: 'data.plan', operator: 'equals', value: 'pro'}],
await factories.createContact({projectId, data: {plan: null}});
const segment = await SegmentService.create(projectId, {
name: 'Not pro',
condition: {logic: 'AND', groups: [{filters: [{field: 'data.plan', operator: 'notEquals', value: 'pro'}]}]},
});

expect(await matches(referenced.id, 'memberOfSegment')).toEqual([pro.id]);
expect(await matches(referenced.id, 'notMemberOfSegment')).toEqual([free.id]);
const {data} = await SegmentService.getContacts(projectId, segment.id);
expect(data.map(contact => contact.id)).toEqual([free.id]);
});

it('still matches equals null, like a workflow condition', async () => {
await factories.createContact({projectId, data: {plan: 'free'}});
const keyNull = await factories.createContact({projectId, data: {plan: null}});
const segment = await SegmentService.create(projectId, {
name: 'Plan is null',
condition: {logic: 'AND', groups: [{filters: [{field: 'data.plan', operator: 'equals', value: null}]}]},
});

const {data} = await SegmentService.getContacts(projectId, segment.id);
expect(data.map(contact => contact.id)).toEqual([keyNull.id]);
});
});

it.each([
['exists', false],
['notExists', true],
] as const)('reads a contact without any data as %s → %s', async (operator, matched) => {
const ids = await Promise.all(
[Prisma.JsonNull, Prisma.DbNull].map(
async data =>
(await prisma.contact.create({data: {projectId, email: `contact-${randomUUID()}@test.com`, data}})).id,
),
);
const segment = await SegmentService.create(projectId, {
name: `Plan ${operator}`,
condition: {logic: 'AND', groups: [{filters: [{field: 'data.plan', operator}]}]},
});

const {data} = await SegmentService.getContacts(projectId, segment.id);
expect(data.map(contact => contact.id).sort()).toEqual(matched ? ids.sort() : []);
});

it('rejects a segment of another project', async () => {
Expand Down
12 changes: 8 additions & 4 deletions apps/wiki/content/docs/guides/segment-filters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ For `email` and any `data.<key>` that holds a string.
| Operator | What it does |
| ------------- | --------------------------------------------------------- |
| `equals` | Exact match. Case-insensitive on `email`, exact on `data.*`. |
| `notEquals` | Negation of `equals`. |
| `notEquals` | Has a value, and it's a different one. |
| `contains` | Case-insensitive substring match. |
| `notContains` | Negation of `contains`. |
| `notContains` | Has a value that doesn't include this text. |

### Yes/no fields

Expand All @@ -76,7 +76,7 @@ For `subscribed` and any `data.<key>` that holds `true` or `false`.
| Operator | What it does |
| ----------- | --------------------------- |
| `equals` | Match true or false. |
| `notEquals` | Negation. |
| `notEquals` | Has the other value. |

### Dates and timestamps

Expand All @@ -96,6 +96,10 @@ For custom date fields (`data.<key>`), `within` and `olderThan` only work when t

For any `data.<key>` that holds a number: `equals`, `notEquals`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`. No `unit` is needed.

### Contacts without the field

Every comparison on a `data.<key>` matches only contacts that have that key (except `equals` / `notEquals` with a `null` value, which compare against an explicit `null`). `notEquals` and `notContains` too: `plan notEquals "pro"` leaves out contacts with no `plan`, and `vip notEquals true` leaves out contacts with no `vip`. To include them, combine the filter in a nested `OR` condition with `notExists` on the same key. Filters in a group are always combined with `AND`, so the `OR` has to be nested in that group; a separate `OR` group at the top would also let in contacts the group's other filters leave out.

### Field existence

For any `data.<key>`, regardless of value type.
Expand Down Expand Up @@ -124,7 +128,7 @@ For `segment.<segmentId>`, where `<segmentId>` is the ID of another segment in y
| Operator | What it does |
| --------------------- | --------------------------------------------------------- |
| `memberOfSegment` | Contact is currently in the referenced segment. |
| `notMemberOfSegment` | Contact is not in the referenced segment. |
| `notMemberOfSegment` | Contact is not in the referenced segment. A contact without a field doesn't match that segment's comparisons on it. |

## Quick reference: what to pass with each operator

Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/operators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ export const WORKFLOW_CONDITION_OPERATORS = [
* - Does NOT match missing fields (undefined/null)
* - Use notExists operator if you want to find missing fields
* - Use OR[notEquals, notExists] if you want both
* - Segments: every data.* comparison but equals/notEquals null requires its key first, so it is
* never NULL and a notMemberOfSegment of a dynamic segment counts contacts without the key as
* outside it
*
* 3. CONTAINS (substring)
* - ONLY matches when field EXISTS and contains substring
Expand Down
Loading