Add better segmenting
This commit is contained in:
+2
-2
@@ -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,
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user