Add better segmenting

This commit is contained in:
Dries Augustyns
2025-12-02 09:03:05 +01:00
parent b521a405bb
commit fe2e038037
23 changed files with 2285 additions and 855 deletions
@@ -122,7 +122,7 @@ CREATE TABLE "segments" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"filters" JSONB NOT NULL,
"condition" JSONB NOT NULL,
"trackMembership" BOOLEAN NOT NULL DEFAULT false,
"memberCount" INTEGER NOT NULL DEFAULT 0,
"projectId" TEXT NOT NULL,
@@ -156,7 +156,7 @@ CREATE TABLE "campaigns" (
"fromName" TEXT,
"replyTo" TEXT,
"audienceType" "CampaignAudienceType" NOT NULL DEFAULT 'ALL',
"audienceFilter" JSONB,
"audienceCondition" JSONB,
"segmentId" TEXT,
"scheduledFor" TIMESTAMP(3),
"totalRecipients" INTEGER NOT NULL DEFAULT 0,
+31 -10
View File
@@ -193,14 +193,29 @@ model Segment {
name String
description String?
// Filter conditions (evaluated dynamically)
filters Json
// Array of conditions with AND/OR logic:
// [
// { field: "data.plan", operator: "equals", value: "FREE" },
// { field: "subscribed", operator: "equals", value: true },
// { field: "emails.openedAt", operator: "within", value: 30, unit: "days" }
// ]
// Filter condition (evaluated dynamically)
condition Json
// Nested filter structure with AND/OR logic:
// {
// logic: "OR",
// groups: [
// {
// filters: [
// { field: "data.plan", operator: "equals", value: "VIP" },
// { field: "subscribed", operator: "equals", value: true }
// ]
// },
// {
// filters: [
// { field: "createdAt", operator: "within", value: 30, unit: "days" }
// ],
// conditions: {
// logic: "AND",
// groups: [...]
// }
// }
// ]
// }
// Operators: equals, notEquals, contains, greaterThan, lessThan, within, exists, etc.
// Track membership changes (enables segment entry/exit events)
@@ -267,8 +282,8 @@ model Campaign {
replyTo String?
// Audience selection
audienceType CampaignAudienceType @default(ALL)
audienceFilter Json? // For FILTERED: manual filter conditions
audienceType CampaignAudienceType @default(ALL)
audienceCondition Json? // For FILTERED: manual filter condition with AND/OR logic (same structure as Segment.condition)
segment Segment? @relation(fields: [segmentId], references: [id])
segmentId String? // For SEGMENT: reference to saved segment
@@ -556,6 +571,10 @@ model Email {
@@index([status])
@@index([createdAt])
@@index([projectId, sourceType, createdAt]) // For billing limit queries
@@index([contactId, openedAt]) // For email activity segment queries
@@index([contactId, clickedAt]) // For email activity segment queries
@@index([contactId, bouncedAt]) // For email activity segment queries
@@index([contactId, complainedAt]) // For email activity segment queries
@@map("emails")
}
@@ -587,6 +606,8 @@ model Event {
@@index([contactId])
@@index([emailId])
@@index([createdAt])
@@index([projectId, contactId, name, createdAt]) // For event-based segment queries (fast!)
@@index([contactId, name, createdAt]) // For per-contact event lookups
@@map("events")
}
+54 -61
View File
@@ -71,75 +71,66 @@ export const ContactSchemas = {
}),
};
export const SegmentSchemas = {
filter: z.object({
field: z.string().min(1),
operator: z.enum([
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'exists',
'notExists',
'within',
]),
value: z.any().optional(),
unit: z.enum(['days', 'hours', 'minutes']).optional(),
const segmentFilterSchema = z.object({
field: z.string().min(1),
operator: z.enum([
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'exists',
'notExists',
'within',
'triggered',
'triggeredWithin',
'notTriggered',
]),
value: z.any().optional(),
unit: z.enum(['days', 'hours', 'minutes']).optional(),
});
type FilterGroup = {
filters: z.infer<typeof segmentFilterSchema>[];
conditions?: FilterCondition;
};
type FilterCondition = {
logic: 'AND' | 'OR';
groups: FilterGroup[];
};
const filterGroupSchema: z.ZodType<FilterGroup> = z.lazy(() =>
z.object({
filters: z.array(segmentFilterSchema),
conditions: filterConditionSchema.optional(),
}),
);
const filterConditionSchema: z.ZodType<FilterCondition> = z.lazy(() =>
z.object({
logic: z.enum(['AND', 'OR']),
groups: z.array(filterGroupSchema).min(1),
}),
);
export const SegmentSchemas = {
filter: segmentFilterSchema,
filterGroup: filterGroupSchema,
filterCondition: filterConditionSchema,
create: z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
filters: z.array(
z.object({
field: z.string().min(1),
operator: z.enum([
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'exists',
'notExists',
'within',
]),
value: z.any().optional(),
unit: z.enum(['days', 'hours', 'minutes']).optional(),
}),
),
condition: filterConditionSchema,
trackMembership: z.boolean().default(false),
}),
update: z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
filters: z
.array(
z.object({
field: z.string().min(1),
operator: z.enum([
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'exists',
'notExists',
'within',
]),
value: z.any().optional(),
unit: z.enum(['days', 'hours', 'minutes']).optional(),
}),
)
.optional(),
condition: filterConditionSchema.optional(),
trackMembership: z.boolean().optional(),
}),
};
@@ -188,6 +179,7 @@ export const WorkflowSchemas = {
position: jsonSchema,
config: jsonSchema,
templateId: uuid.optional(),
autoConnect: z.boolean().optional(),
}),
updateStep: z.object({
name: z.string().min(1).max(100).optional(),
@@ -276,7 +268,7 @@ export const CampaignSchemas = {
fromName: z.string().max(100).optional(),
replyTo: email.optional(),
audienceType: z.nativeEnum(CampaignAudienceType),
audienceFilter: jsonSchema.optional(),
audienceCondition: filterConditionSchema.optional(),
segmentId: uuid.optional(),
}),
schedule: z.object({
@@ -291,6 +283,7 @@ export const CampaignSchemas = {
fromName: z.string().max(100).optional(),
replyTo: z.string().optional(),
audienceType: z.nativeEnum(CampaignAudienceType).optional(),
audienceCondition: filterConditionSchema.optional(),
segmentId: z.string().optional(),
}),
sendTest: z.object({
+33 -14
View File
@@ -1,33 +1,52 @@
// Segment filter types
export type SegmentFilterOperator =
// Standard operators (for contact fields)
| 'equals'
| 'notEquals'
| 'contains'
| 'notContains'
| 'greaterThan'
| 'lessThan'
| 'greaterThanOrEqual'
| 'lessThanOrEqual'
| 'exists'
| 'notExists'
| 'within'
// Event-based operators
| 'triggered' // Event/email activity occurred (any time)
| 'triggeredWithin' // Event/email activity occurred within timeframe
| 'notTriggered'; // Event/email activity never occurred
export type SegmentFilterLogic = 'AND' | 'OR';
export interface SegmentFilter {
field: string;
operator:
| 'equals'
| 'notEquals'
| 'contains'
| 'notContains'
| 'greaterThan'
| 'lessThan'
| 'greaterThanOrEqual'
| 'lessThanOrEqual'
| 'exists'
| 'notExists'
| 'within';
operator: SegmentFilterOperator;
value?: any;
unit?: 'days' | 'hours' | 'minutes';
}
export interface FilterGroup {
filters: SegmentFilter[];
conditions?: FilterCondition;
}
export interface FilterCondition {
logic: SegmentFilterLogic;
groups: FilterGroup[];
}
export interface CreateSegmentData {
name: string;
description?: string;
filters: SegmentFilter[];
condition: FilterCondition;
trackMembership?: boolean;
}
export interface UpdateSegmentData {
name?: string;
description?: string;
filters?: SegmentFilter[];
condition?: FilterCondition;
trackMembership?: boolean;
}
@@ -0,0 +1,152 @@
'use client';
import * as React from 'react';
import {Command as CommandPrimitive} from 'cmdk';
import {Search} from 'lucide-react';
import {cn} from '../../lib';
import {Dialog, DialogContent} from './Dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({className, ...props}, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-white text-neutral-950',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({children, ...props}: React.ComponentProps<typeof Dialog>) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({className, ...props}, ref) => (
<div className="flex items-center border-b border-neutral-200 px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-neutral-500 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({className, ...props}, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm text-neutral-500"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({className, ...props}, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-neutral-950 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({className, ...props}, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-neutral-200', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({className, ...props}, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none text-neutral-900 aria-selected:bg-neutral-100 aria-selected:text-neutral-900 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:bg-neutral-50',
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-neutral-500',
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
CommandShortcut,
};
@@ -5,6 +5,7 @@ export * from './Card';
export * from './Chart';
export * from './Checkbox';
export * from './Collapsible';
export * from './Command';
export * from './Dialog';
export * from './DropdownMenu';
export * from './Form';
@@ -8,7 +8,7 @@ export interface ConfirmDialogProps {
onOpenChange: (open: boolean) => void;
onConfirm: () => void | Promise<void>;
title: string;
description: string;
description: React.ReactNode;
confirmText?: string;
cancelText?: string;
variant?: 'default' | 'destructive';