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
+8 -9
View File
@@ -8,7 +8,6 @@ import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js'; import {requireAuth} from '../middleware/auth.js';
import {CampaignService} from '../services/CampaignService.js'; import {CampaignService} from '../services/CampaignService.js';
import {DomainService} from '../services/DomainService.js'; import {DomainService} from '../services/DomainService.js';
import {type SegmentFilter} from '../services/SegmentService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('campaigns') @Controller('campaigns')
@@ -22,7 +21,7 @@ export class Campaigns {
@CatchAsync @CatchAsync
private async create(req: Request, res: Response, next: NextFunction) { private async create(req: Request, res: Response, next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceFilter, segmentId} = const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
CampaignSchemas.create.parse(req.body); CampaignSchemas.create.parse(req.body);
// Validate audience-specific fields // Validate audience-specific fields
@@ -30,8 +29,8 @@ export class Campaigns {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type'); throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
} }
if (audienceType === CampaignAudienceType.FILTERED && !audienceFilter) { if (audienceType === CampaignAudienceType.FILTERED && !audienceCondition) {
throw new HttpException(400, 'Audience filter is required for FILTERED audience type'); throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
} }
// Verify domain ownership and verification // Verify domain ownership and verification
@@ -46,7 +45,7 @@ export class Campaigns {
fromName, fromName,
replyTo, replyTo,
audienceType, audienceType,
audienceFilter: audienceFilter as SegmentFilter[] | undefined, audienceCondition,
segmentId, segmentId,
}); });
@@ -118,7 +117,7 @@ export class Campaigns {
private async update(req: Request, res: Response, next: NextFunction) { private async update(req: Request, res: Response, next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
const {id} = req.params; const {id} = req.params;
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceFilter, segmentId} = const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
req.body; req.body;
// Validate audience-specific fields if audienceType is being updated // Validate audience-specific fields if audienceType is being updated
@@ -126,8 +125,8 @@ export class Campaigns {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type'); throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
} }
if (audienceType === CampaignAudienceType.FILTERED && audienceFilter === undefined) { if (audienceType === CampaignAudienceType.FILTERED && audienceCondition === undefined) {
throw new HttpException(400, 'Audience filter is required for FILTERED audience type'); throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
} }
// Verify domain ownership and verification if 'from' is being updated // Verify domain ownership and verification if 'from' is being updated
@@ -144,7 +143,7 @@ export class Campaigns {
fromName, fromName,
replyTo, replyTo,
audienceType, audienceType,
audienceFilter, audienceCondition,
segmentId, segmentId,
}); });
+4 -3
View File
@@ -47,6 +47,7 @@ export class Contacts {
/** /**
* GET /contacts/fields * GET /contacts/fields
* Get all available contact fields (both standard and custom fields from data JSON) * Get all available contact fields (both standard and custom fields from data JSON)
* Returns field names with inferred types (string, number, boolean, date)
*/ */
@Get('fields') @Get('fields')
@Middleware([requireAuth]) @Middleware([requireAuth])
@@ -55,11 +56,11 @@ export class Contacts {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
try { try {
const fields = await ContactService.getAvailableFields(auth.projectId!); const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!);
return res.status(200).json({ return res.status(200).json({
fields, fields: fieldsWithTypes,
count: fields.length, count: fieldsWithTypes.length,
}); });
} catch (error) { } catch (error) {
console.error('[CONTACTS] Failed to get available fields:', error); console.error('[CONTACTS] Failed to get available fields:', error);
+8 -8
View File
@@ -74,20 +74,20 @@ export class Segments {
@CatchAsync @CatchAsync
public async create(req: Request, res: Response, next: NextFunction) { public async create(req: Request, res: Response, next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
const {name, description, filters, trackMembership} = req.body; const {name, description, condition, trackMembership} = req.body;
if (!name) { if (!name) {
return res.status(400).json({error: 'Name is required'}); return res.status(400).json({error: 'Name is required'});
} }
if (!filters || !Array.isArray(filters)) { if (!condition || typeof condition !== 'object') {
return res.status(400).json({error: 'Filters must be an array'}); return res.status(400).json({error: 'Condition is required and must be an object'});
} }
const segment = await SegmentService.create(auth.projectId!, { const segment = await SegmentService.create(auth.projectId!, {
name, name,
description, description,
filters, condition,
trackMembership, trackMembership,
}); });
@@ -104,20 +104,20 @@ export class Segments {
public async update(req: Request, res: Response, next: NextFunction) { public async update(req: Request, res: Response, next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
const segmentId = req.params.id; const segmentId = req.params.id;
const {name, description, filters, trackMembership} = req.body; const {name, description, condition, trackMembership} = req.body;
if (!segmentId) { if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'}); return res.status(400).json({error: 'Segment ID is required'});
} }
if (filters !== undefined && !Array.isArray(filters)) { if (condition !== undefined && typeof condition !== 'object') {
return res.status(400).json({error: 'Filters must be an array'}); return res.status(400).json({error: 'Condition must be an object'});
} }
const segment = await SegmentService.update(auth.projectId!, segmentId, { const segment = await SegmentService.update(auth.projectId!, segmentId, {
name, name,
description, description,
filters, condition,
trackMembership, trackMembership,
}); });
+20
View File
@@ -370,4 +370,24 @@ export class Workflows {
return res.status(200).json(execution); return res.status(200).json(execution);
} }
/**
* POST /workflows/:id/executions/cancel-all
* Cancel all active executions for a workflow
*/
@Post(':id/executions/cancel-all')
@Middleware([requireAuth])
@CatchAsync
public async cancelAllExecutions(req: Request, res: Response, next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const result = await WorkflowService.cancelAllExecutions(auth.projectId!, workflowId);
return res.status(200).json(result);
}
} }
+25 -20
View File
@@ -1,5 +1,6 @@
import type {Campaign, Contact, Prisma} from '@plunk/db'; import type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
@@ -8,7 +9,7 @@ import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
import {DomainService} from './DomainService.js'; import {DomainService} from './DomainService.js';
import {EmailService} from './EmailService.js'; import {EmailService} from './EmailService.js';
import {QueueService} from './QueueService.js'; import {QueueService} from './QueueService.js';
import {type SegmentFilter, SegmentService} from './SegmentService.js'; import {SegmentService} from './SegmentService.js';
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance) const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
@@ -21,7 +22,7 @@ export interface CreateCampaignData {
fromName?: string; fromName?: string;
replyTo?: string; replyTo?: string;
audienceType: CampaignAudienceType; audienceType: CampaignAudienceType;
audienceFilter?: SegmentFilter[]; audienceCondition?: FilterCondition;
segmentId?: string; segmentId?: string;
} }
@@ -34,7 +35,7 @@ export interface UpdateCampaignData {
fromName?: string; fromName?: string;
replyTo?: string; replyTo?: string;
audienceType?: CampaignAudienceType; audienceType?: CampaignAudienceType;
audienceFilter?: SegmentFilter[]; audienceCondition?: FilterCondition;
segmentId?: string; segmentId?: string;
} }
@@ -61,10 +62,10 @@ export class CampaignService {
} }
} }
// Validate filters if provided // Validate condition if provided
if (data.audienceType === CampaignAudienceType.FILTERED && data.audienceFilter) { if (data.audienceType === CampaignAudienceType.FILTERED && data.audienceCondition) {
// This will throw if filters are invalid // This will throw if condition is invalid
SegmentService.validateFilters(data.audienceFilter); SegmentService.validateCondition(data.audienceCondition);
} }
// Create campaign // Create campaign
@@ -79,7 +80,7 @@ export class CampaignService {
fromName: data.fromName, fromName: data.fromName,
replyTo: data.replyTo, replyTo: data.replyTo,
audienceType: data.audienceType, audienceType: data.audienceType,
audienceFilter: (data.audienceFilter || null) as unknown as Prisma.InputJsonValue, audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
segmentId: data.segmentId, segmentId: data.segmentId,
status: CampaignStatus.DRAFT, status: CampaignStatus.DRAFT,
}, },
@@ -105,11 +106,11 @@ export class CampaignService {
updateData.audienceType = data.audienceType; updateData.audienceType = data.audienceType;
} }
if (data.audienceFilter !== undefined) { if (data.audienceCondition !== undefined) {
if (data.audienceFilter) { if (data.audienceCondition) {
SegmentService.validateFilters(data.audienceFilter); SegmentService.validateCondition(data.audienceCondition);
} }
updateData.audienceFilter = (data.audienceFilter || null) as unknown as Prisma.InputJsonValue; updateData.audienceCondition = (data.audienceCondition || null) as unknown as Prisma.InputJsonValue;
} }
if (data.segmentId !== undefined) { if (data.segmentId !== undefined) {
@@ -230,7 +231,7 @@ export class CampaignService {
fromName: campaign.fromName, fromName: campaign.fromName,
replyTo: campaign.replyTo, replyTo: campaign.replyTo,
audienceType: campaign.audienceType, audienceType: campaign.audienceType,
audienceFilter: campaign.audienceFilter as Prisma.InputJsonValue, audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
segmentId: campaign.segmentId, segmentId: campaign.segmentId,
status: CampaignStatus.DRAFT, status: CampaignStatus.DRAFT,
totalRecipients: 0, totalRecipients: 0,
@@ -587,14 +588,17 @@ export class CampaignService {
return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere); return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere);
case CampaignAudienceType.FILTERED: { case CampaignAudienceType.FILTERED: {
const filters = campaign.audienceFilter as unknown as SegmentFilter[]; const condition = campaign.audienceCondition as unknown as FilterCondition;
if (!filters || filters.length === 0) { if (!condition) {
throw new HttpException(400, 'Audience filters are required for FILTERED audience type'); throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
} }
// Use the SegmentService to build the where clause from the condition
const segmentWhere = SegmentService.buildConditionClause(condition);
return { return {
...baseWhere, ...baseWhere,
AND: filters.map(filter => SegmentService.buildFilterCondition(filter)), ...segmentWhere,
}; };
} }
@@ -611,7 +615,7 @@ export class CampaignService {
segmentId: string, segmentId: string,
baseWhere: Prisma.ContactWhereInput, baseWhere: Prisma.ContactWhereInput,
): Promise<Prisma.ContactWhereInput> { ): Promise<Prisma.ContactWhereInput> {
// Fetch the segment to get its filters // Fetch the segment to get its condition
const segment = await prisma.segment.findUnique({ const segment = await prisma.segment.findUnique({
where: {id: segmentId}, where: {id: segmentId},
}); });
@@ -620,11 +624,12 @@ export class CampaignService {
throw new HttpException(404, 'Segment not found'); throw new HttpException(404, 'Segment not found');
} }
const filters = segment.filters as unknown as SegmentFilter[]; const condition = segment.condition as unknown as FilterCondition;
const segmentWhere = SegmentService.buildConditionClause(condition);
return { return {
...baseWhere, ...baseWhere,
AND: filters.map(filter => SegmentService.buildFilterCondition(filter)), ...segmentWhere,
}; };
} }
+67 -16
View File
@@ -330,29 +330,80 @@ export class ContactService {
/** /**
* Get all available contact fields for a project * Get all available contact fields for a project
* Returns both standard fields and custom fields from the data JSON column * Returns both standard fields and custom fields from the data JSON column
* Now includes type information inferred from actual data
* *
* @param projectId - The project ID to filter contacts * @param projectId - The project ID to filter contacts
* @returns Array of field names (e.g., ["subscribed", "data.plan", "data.firstName"]) * @returns Array of field objects with name and type
*/ */
public static async getAvailableFields(projectId: string): Promise<string[]> { public static async getAvailableFields(
// Standard fields projectId: string,
const standardFields = ['subscribed']; ): Promise<Array<{field: string; type: 'string' | 'number' | 'boolean' | 'date'}>> {
// Standard fields with known types
const standardFields = [
{field: 'email', type: 'string' as const},
{field: 'subscribed', type: 'boolean' as const},
{field: 'createdAt', type: 'date' as const},
{field: 'updatedAt', type: 'date' as const},
];
// Get custom fields from the data JSON column // Get custom fields from the data JSON column with type inference
// Use raw SQL to extract all keys from the JSON data column // Use raw SQL to extract all keys and sample values from the JSON data column
const result = await prisma.$queryRaw<Array<{key: string}>>` const result = await prisma.$queryRaw<Array<{key: string; sample_value: string; json_type: string}>>`
SELECT DISTINCT jsonb_object_keys(data) as key WITH field_keys AS (
FROM contacts SELECT DISTINCT jsonb_object_keys(data) as key
WHERE FROM contacts
"projectId" = ${projectId} WHERE
AND data IS NOT NULL "projectId" = ${projectId}
AND jsonb_typeof(data) = 'object' AND data IS NOT NULL
AND jsonb_typeof(data) = 'object'
),
field_samples AS (
SELECT
fk.key,
jsonb_typeof(c.data->fk.key) as json_type,
(c.data->>fk.key) as sample_value
FROM field_keys fk
CROSS JOIN LATERAL (
SELECT data
FROM contacts
WHERE
"projectId" = ${projectId}
AND data ? fk.key
AND data->fk.key IS NOT NULL
LIMIT 1
) c
)
SELECT
key,
sample_value,
json_type
FROM field_samples
`; `;
// Combine standard fields with custom fields (prefixed with "data.") // Infer types from JSON types and sample values
const customFields = result.map(row => `data.${row.key}`); const customFields = result.map(row => {
let type: 'string' | 'number' | 'boolean' | 'date' = 'string';
return [...standardFields, ...customFields].sort(); // PostgreSQL jsonb_typeof returns: "object", "array", "string", "number", "boolean", "null"
if (row.json_type === 'boolean') {
type = 'boolean';
} else if (row.json_type === 'number') {
type = 'number';
} else if (row.json_type === 'string' && row.sample_value) {
// Try to detect dates (ISO 8601 format)
const dateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?)?$/;
if (dateRegex.test(row.sample_value)) {
type = 'date';
}
}
return {
field: `data.${row.key}`,
type,
};
});
return [...standardFields, ...customFields].sort((a, b) => a.field.localeCompare(b.field));
} }
/** /**
+317 -100
View File
@@ -1,27 +1,13 @@
import {type Contact, Prisma, type Segment} from '@plunk/db'; import {type Contact, Prisma, type Segment} from '@plunk/db';
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js'; import {EventService} from './EventService.js';
export interface SegmentFilter { // Re-export types for use in other services
field: string; // e.g., "email", "data.plan", "subscribed" export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
operator:
| 'equals'
| 'notEquals'
| 'contains'
| 'notContains'
| 'greaterThan'
| 'lessThan'
| 'greaterThanOrEqual'
| 'lessThanOrEqual'
| 'exists'
| 'notExists'
| 'within'; // For date ranges
value?: unknown;
unit?: 'days' | 'hours' | 'minutes'; // For 'within' operator
}
export interface PaginatedContacts { export interface PaginatedContacts {
contacts: Contact[]; contacts: Contact[];
@@ -77,7 +63,7 @@ export class SegmentService {
} }
/** /**
* Get contacts that match a segment's filters * Get contacts that match a segment's condition
*/ */
public static async getContacts( public static async getContacts(
projectId: string, projectId: string,
@@ -86,9 +72,9 @@ export class SegmentService {
pageSize = 20, pageSize = 20,
): Promise<PaginatedContacts> { ): Promise<PaginatedContacts> {
const segment = await this.get(projectId, segmentId); const segment = await this.get(projectId, segmentId);
const filters = segment.filters as unknown as SegmentFilter[]; const condition = segment.condition as unknown as FilterCondition;
const where = this.buildWhereClause(projectId, filters); const where = this.buildWhereClause(projectId, condition);
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const [contacts, total] = await Promise.all([ const [contacts, total] = await Promise.all([
@@ -118,15 +104,15 @@ export class SegmentService {
data: { data: {
name: string; name: string;
description?: string; description?: string;
filters: SegmentFilter[]; condition: FilterCondition;
trackMembership?: boolean; trackMembership?: boolean;
}, },
): Promise<Segment> { ): Promise<Segment> {
// Validate filters // Validate condition
this.validateFilters(data.filters); this.validateCondition(data.condition);
// Compute initial member count // Compute initial member count
const where = this.buildWhereClause(projectId, data.filters); const where = this.buildWhereClause(projectId, data.condition);
const memberCount = await prisma.contact.count({where}); const memberCount = await prisma.contact.count({where});
return prisma.segment.create({ return prisma.segment.create({
@@ -134,7 +120,7 @@ export class SegmentService {
projectId, projectId,
name: data.name, name: data.name,
description: data.description, description: data.description,
filters: data.filters as unknown as Prisma.JsonArray, condition: data.condition as unknown as Prisma.InputJsonValue,
trackMembership: data.trackMembership ?? false, trackMembership: data.trackMembership ?? false,
memberCount, memberCount,
}, },
@@ -150,16 +136,16 @@ export class SegmentService {
data: { data: {
name?: string; name?: string;
description?: string; description?: string;
filters?: SegmentFilter[]; condition?: FilterCondition;
trackMembership?: boolean; trackMembership?: boolean;
}, },
): Promise<Segment> { ): Promise<Segment> {
// First verify segment exists and belongs to project // First verify segment exists and belongs to project
await this.get(projectId, segmentId); await this.get(projectId, segmentId);
// Validate filters if provided // Validate condition if provided
if (data.filters) { if (data.condition) {
this.validateFilters(data.filters); this.validateCondition(data.condition);
} }
const updateData: Prisma.SegmentUpdateInput = {}; const updateData: Prisma.SegmentUpdateInput = {};
@@ -170,11 +156,11 @@ export class SegmentService {
if (data.description !== undefined) { if (data.description !== undefined) {
updateData.description = data.description; updateData.description = data.description;
} }
if (data.filters !== undefined) { if (data.condition !== undefined) {
updateData.filters = data.filters as unknown as Prisma.JsonArray; updateData.condition = data.condition as unknown as Prisma.InputJsonValue;
// Recompute member count when filters change // Recompute member count when condition changes
const where = this.buildWhereClause(projectId, data.filters); const where = this.buildWhereClause(projectId, data.condition);
updateData.memberCount = await prisma.contact.count({where}); updateData.memberCount = await prisma.contact.count({where});
} }
if (data.trackMembership !== undefined) { if (data.trackMembership !== undefined) {
@@ -222,8 +208,8 @@ export class SegmentService {
*/ */
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> { public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
const segment = await this.get(projectId, segmentId); const segment = await this.get(projectId, segmentId);
const filters = segment.filters as unknown as SegmentFilter[]; const condition = segment.condition as unknown as FilterCondition;
const where = this.buildWhereClause(projectId, filters); const where = this.buildWhereClause(projectId, condition);
const memberCount = await prisma.contact.count({where}); const memberCount = await prisma.contact.count({where});
@@ -242,7 +228,7 @@ export class SegmentService {
public static async refreshAllMemberCounts(projectId: string): Promise<void> { public static async refreshAllMemberCounts(projectId: string): Promise<void> {
const segments = await prisma.segment.findMany({ const segments = await prisma.segment.findMany({
where: {projectId}, where: {projectId},
select: {id: true, filters: true}, select: {id: true, condition: true},
}); });
// Process in batches to avoid overwhelming the database // Process in batches to avoid overwhelming the database
@@ -253,8 +239,8 @@ export class SegmentService {
await Promise.all( await Promise.all(
batch.map(async segment => { batch.map(async segment => {
try { try {
const filters = segment.filters as unknown as SegmentFilter[]; const condition = segment.condition as unknown as FilterCondition;
const where = this.buildWhereClause(projectId, filters); const where = this.buildWhereClause(projectId, condition);
const memberCount = await prisma.contact.count({where}); const memberCount = await prisma.contact.count({where});
await prisma.segment.update({ await prisma.segment.update({
@@ -283,8 +269,8 @@ export class SegmentService {
throw new HttpException(400, 'Segment does not have membership tracking enabled'); throw new HttpException(400, 'Segment does not have membership tracking enabled');
} }
const filters = segment.filters as unknown as SegmentFilter[]; const condition = segment.condition as unknown as FilterCondition;
const where = this.buildWhereClause(projectId, filters); const where = this.buildWhereClause(projectId, condition);
// Get all matching contacts using cursor-based pagination to avoid memory issues // Get all matching contacts using cursor-based pagination to avoid memory issues
const BATCH_SIZE = 1000; const BATCH_SIZE = 1000;
@@ -428,6 +414,18 @@ export class SegmentService {
public static buildFilterCondition(filter: SegmentFilter): Prisma.ContactWhereInput { public static buildFilterCondition(filter: SegmentFilter): Prisma.ContactWhereInput {
const {field, operator, value, unit} = filter; const {field, operator, value, unit} = filter;
// Handle event-based filters (e.g., "event.upgrade", "event.purchase")
if (field.startsWith('event.')) {
const eventName = field.substring(6); // Remove "event." prefix
return this.buildEventCondition(eventName, operator, value, unit);
}
// Handle email activity filters (e.g., "email.opened", "email.clicked")
if (field.startsWith('email.')) {
const activity = field.substring(6); // Remove "email." prefix
return this.buildEmailActivityCondition(activity, operator, value, unit);
}
// Handle JSON field paths (e.g., "data.plan") // Handle JSON field paths (e.g., "data.plan")
if (field.startsWith('data.')) { if (field.startsWith('data.')) {
const jsonPath = field.substring(5); // Remove "data." prefix const jsonPath = field.substring(5); // Remove "data." prefix
@@ -449,78 +447,163 @@ export class SegmentService {
} }
/** /**
* Validate segment filters * Validate segment condition (recursive)
*/ */
public static validateFilters(filters: SegmentFilter[]): void { public static validateCondition(condition: FilterCondition): void {
if (!Array.isArray(filters)) { if (!condition || typeof condition !== 'object') {
throw new HttpException(400, 'Filters must be an array'); throw new HttpException(400, 'Condition must be an object');
} }
if (filters.length === 0) { if (!condition.logic || !['AND', 'OR'].includes(condition.logic)) {
throw new HttpException(400, 'At least one filter is required'); throw new HttpException(400, 'Condition logic must be either "AND" or "OR"');
} }
for (const filter of filters) { if (!Array.isArray(condition.groups) || condition.groups.length === 0) {
if (!filter.field) { throw new HttpException(400, 'Condition must have at least one group');
throw new HttpException(400, 'Filter field is required'); }
}
if (!filter.operator) { for (const group of condition.groups) {
throw new HttpException(400, 'Filter operator is required'); this.validateGroup(group);
}
const validOperators = [
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'exists',
'notExists',
'within',
];
if (!validOperators.includes(filter.operator)) {
throw new HttpException(400, `Invalid operator: ${filter.operator}`);
}
// Validate that operators that need a value have one
const operatorsNeedingValue = [
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'within',
];
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
throw new HttpException(400, `Operator "${filter.operator}" requires a value`);
}
// Validate unit for "within" operator
if (filter.operator === 'within' && !filter.unit) {
throw new HttpException(400, '"within" operator requires a unit (days, hours, or minutes)');
}
} }
} }
/** /**
* Build Prisma where clause from segment filters * Validate filter group (recursive)
*/ */
private static buildWhereClause(projectId: string, filters: SegmentFilter[]): Prisma.ContactWhereInput { private static validateGroup(group: FilterGroup): void {
const where: Prisma.ContactWhereInput = { if (!group || typeof group !== 'object') {
projectId, throw new HttpException(400, 'Group must be an object');
AND: filters.map(filter => this.buildFilterCondition(filter)), }
};
return where; if (!Array.isArray(group.filters)) {
throw new HttpException(400, 'Group filters must be an array');
}
// Groups can have filters, nested conditions, or both
const hasFilters = group.filters.length > 0;
const hasConditions = group.conditions !== undefined;
if (!hasFilters && !hasConditions) {
throw new HttpException(400, 'Group must have at least one filter or nested condition');
}
// Validate all filters in the group
for (const filter of group.filters) {
this.validateFilter(filter);
}
// Recursively validate nested conditions
if (group.conditions) {
this.validateCondition(group.conditions);
}
}
/**
* Validate individual filter
*/
private static validateFilter(filter: SegmentFilter): void {
if (!filter.field) {
throw new HttpException(400, 'Filter field is required');
}
if (!filter.operator) {
throw new HttpException(400, 'Filter operator is required');
}
const validOperators = [
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'exists',
'notExists',
'within',
'triggered',
'triggeredWithin',
'notTriggered',
];
if (!validOperators.includes(filter.operator)) {
throw new HttpException(400, `Invalid operator: ${filter.operator}`);
}
// Validate that operators that need a value have one
const operatorsNeedingValue = [
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'within',
'triggeredWithin',
];
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
throw new HttpException(400, `Operator "${filter.operator}" requires a value`);
}
// Validate unit for time-based operators
if (['within', 'triggeredWithin'].includes(filter.operator) && !filter.unit) {
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
}
}
/**
* Build Prisma where clause from filter condition (entry point)
*/
private static buildWhereClause(projectId: string, condition: FilterCondition): Prisma.ContactWhereInput {
return {
projectId,
...this.buildConditionClause(condition),
};
}
/**
* Build Prisma clause from filter condition (recursive)
*/
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
if (condition.logic === 'AND') {
return {AND: groupClauses};
} else {
return {OR: groupClauses};
}
}
/**
* Build Prisma clause from filter group (recursive)
*/
private static buildGroupClause(group: FilterGroup): Prisma.ContactWhereInput {
const clauses: Prisma.ContactWhereInput[] = [];
// Add filter conditions from this group
if (group.filters.length > 0) {
clauses.push(...group.filters.map(filter => this.buildFilterCondition(filter)));
}
// Add nested condition if present
if (group.conditions) {
clauses.push(this.buildConditionClause(group.conditions));
}
// All conditions within a group are combined with AND
if (clauses.length === 0) {
return {}; // Empty group returns empty where clause
}
if (clauses.length === 1) {
return clauses[0]!; // Safe to use non-null assertion since we checked length
}
return {AND: clauses};
} }
/** /**
@@ -649,4 +732,138 @@ export class SegmentService {
throw new HttpException(400, `Unsupported time unit: ${unit}`); throw new HttpException(400, `Unsupported time unit: ${unit}`);
} }
} }
/**
* Build condition for event-based filters
* Uses Prisma relations to efficiently query contacts who triggered specific events
*/
private static buildEventCondition(
eventName: string,
operator: string,
value: unknown,
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
switch (operator) {
case 'triggered':
// Contact has triggered this event at any time
return {
events: {
some: {
name: eventName,
},
},
};
case 'triggeredWithin': {
// Contact has triggered this event within the specified timeframe
if (!unit) {
throw new HttpException(400, 'Unit is required for "triggeredWithin" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const since = new Date(now.getTime() - milliseconds);
return {
events: {
some: {
name: eventName,
createdAt: {
gte: since,
},
},
},
};
}
case 'notTriggered':
// Contact has never triggered this event
return {
events: {
none: {
name: eventName,
},
},
};
default:
throw new HttpException(400, `Unsupported operator for event field: ${operator}`);
}
}
/**
* Build condition for email activity filters
* Uses Prisma relations to efficiently query contacts based on email engagement
*/
private static buildEmailActivityCondition(
activity: string,
operator: string,
value: unknown,
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
// Map activity names to Email model fields
const fieldMap: Record<string, string> = {
opened: 'openedAt',
clicked: 'clickedAt',
bounced: 'bouncedAt',
complained: 'complainedAt',
sent: 'sentAt',
delivered: 'deliveredAt',
};
const field = fieldMap[activity];
if (!field) {
throw new HttpException(400, `Unsupported email activity: ${activity}`);
}
switch (operator) {
case 'triggered':
// Contact has this email activity at any time
return {
emails: {
some: {
[field]: {
not: null,
},
},
},
};
case 'triggeredWithin': {
// Contact has this email activity within the specified timeframe
if (!unit) {
throw new HttpException(400, 'Unit is required for "triggeredWithin" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const since = new Date(now.getTime() - milliseconds);
return {
emails: {
some: {
[field]: {
gte: since,
},
},
},
};
}
case 'notTriggered':
// Contact has never had this email activity
return {
emails: {
none: {
[field]: {
not: null,
},
},
},
};
default:
throw new HttpException(400, `Unsupported operator for email activity field: ${operator}`);
}
}
} }
@@ -87,6 +87,17 @@ export class WorkflowExecutionService {
return; return;
} }
// Check if workflow is disabled
// Note: We allow running executions to complete even if workflow is disabled
// This prevents disruption to contacts who are already in the workflow
// Only NEW executions are prevented when workflow is disabled (see startExecution in WorkflowService)
if (!execution.workflow.enabled) {
console.info(
`[WORKFLOW] Workflow ${execution.workflow.id} (${execution.workflow.name}) is disabled, but allowing execution ${executionId} to continue`,
);
// Allow execution to continue - no action needed
}
const step = execution.workflow.steps.find(s => s.id === stepId); const step = execution.workflow.steps.find(s => s.id === stepId);
if (!step) { if (!step) {
throw new HttpException(404, 'Step not found in workflow'); throw new HttpException(404, 'Step not found in workflow');
@@ -135,6 +146,17 @@ export class WorkflowExecutionService {
return; return;
} }
// Check if the workflow execution is now in WAITING state (DELAY steps do this)
// If so, the step has already been handled and queued - don't process next steps
const updatedExecution = await prisma.workflowExecution.findUnique({
where: {id: execution.id},
});
if (updatedExecution?.status === WorkflowExecutionStatus.WAITING) {
// Workflow is waiting (DELAY step has queued the next step) - don't process next steps now
return;
}
// Mark step as completed (for normal steps that complete immediately) // Mark step as completed (for normal steps that complete immediately)
await prisma.workflowStepExecution.update({ await prisma.workflowStepExecution.update({
where: {id: stepExecution.id}, where: {id: stepExecution.id},
+264 -9
View File
@@ -31,6 +31,20 @@ export interface WorkflowExecutionWithDetails extends WorkflowExecution {
} }
export class WorkflowService { export class WorkflowService {
/**
* Check if a workflow has active executions
*/
private static async hasActiveExecutions(workflowId: string): Promise<number> {
return prisma.workflowExecution.count({
where: {
workflowId,
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
});
}
/** /**
* Get all workflows for a project with pagination * Get all workflows for a project with pagination
*/ */
@@ -175,7 +189,26 @@ export class WorkflowService {
}, },
): Promise<Workflow> { ): Promise<Workflow> {
// Verify workflow exists and belongs to project // Verify workflow exists and belongs to project
await this.get(projectId, workflowId); const workflow = await this.get(projectId, workflowId);
// Check if workflow is enabled and has active executions
if (workflow.enabled) {
const activeExecutions = await this.hasActiveExecutions(workflowId);
if (activeExecutions > 0) {
// Block changes to trigger configuration while executions are running
const hasCriticalChanges = data.triggerType !== undefined || data.triggerConfig !== undefined;
if (hasCriticalChanges) {
throw new HttpException(
409,
`Cannot modify workflow trigger while workflow has ${activeExecutions} active execution(s). ` +
'Please disable the workflow first or wait for executions to complete. ' +
'You can still update name, description, and re-entry settings.',
);
}
}
}
const updateData: Prisma.WorkflowUpdateInput = {}; const updateData: Prisma.WorkflowUpdateInput = {};
@@ -299,7 +332,7 @@ export class WorkflowService {
}, },
): Promise<WorkflowStep> { ): Promise<WorkflowStep> {
// First verify workflow belongs to project // First verify workflow belongs to project
await this.get(projectId, workflowId); const workflow = await this.get(projectId, workflowId);
// Then verify step exists and belongs to workflow // Then verify step exists and belongs to workflow
const step = await prisma.workflowStep.findUnique({ const step = await prisma.workflowStep.findUnique({
@@ -310,6 +343,26 @@ export class WorkflowService {
throw new HttpException(404, 'Workflow step not found'); throw new HttpException(404, 'Workflow step not found');
} }
// Check if workflow is enabled and has active executions
if (workflow.enabled) {
const activeExecutions = await this.hasActiveExecutions(workflowId);
if (activeExecutions > 0) {
// Only allow safe changes: name and position updates
const hasCriticalChanges =
data.config !== undefined || data.templateId !== undefined;
if (hasCriticalChanges) {
throw new HttpException(
409,
`Cannot modify step configuration while workflow has ${activeExecutions} active execution(s). ` +
'Please disable the workflow first or wait for executions to complete. ' +
'You can still update the step name and position.',
);
}
}
}
const updateData: Prisma.WorkflowStepUpdateInput = {}; const updateData: Prisma.WorkflowStepUpdateInput = {};
if (data.name !== undefined) updateData.name = data.name; if (data.name !== undefined) updateData.name = data.name;
@@ -333,10 +386,13 @@ export class WorkflowService {
* Delete a workflow step * Delete a workflow step
*/ */
public static async deleteStep(projectId: string, workflowId: string, stepId: string): Promise<void> { public static async deleteStep(projectId: string, workflowId: string, stepId: string): Promise<void> {
await this.get(projectId, workflowId); const workflow = await this.get(projectId, workflowId);
const step = await prisma.workflowStep.findUnique({ const step = await prisma.workflowStep.findUnique({
where: {id: stepId}, where: {id: stepId},
include: {
outgoingTransitions: true,
},
}); });
if (step?.workflowId !== workflowId) { if (step?.workflowId !== workflowId) {
@@ -348,8 +404,117 @@ export class WorkflowService {
throw new HttpException(400, 'Cannot delete the trigger step. Every workflow must have a trigger.'); throw new HttpException(400, 'Cannot delete the trigger step. Every workflow must have a trigger.');
} }
await prisma.workflowStep.delete({ // Check if workflow is enabled and has active executions on this step or downstream
where: {id: stepId}, if (workflow.enabled) {
// Check if any active executions are currently on this step
const executionsOnStep = await prisma.workflowExecution.count({
where: {
workflowId,
currentStepId: stepId,
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
});
if (executionsOnStep > 0) {
throw new HttpException(
409,
`Cannot delete step "${step.name}" while ${executionsOnStep} execution(s) are currently on this step. ` +
'Please disable the workflow first or wait for executions to complete.',
);
}
// Also check downstream steps for active executions
const allSteps = await prisma.workflowStep.findMany({
where: {workflowId},
include: {outgoingTransitions: true},
});
// Build adjacency map
const adjacencyMap = new Map<string, string[]>();
for (const s of allSteps) {
adjacencyMap.set(
s.id,
s.outgoingTransitions.map(t => t.toStepId),
);
}
// Find all downstream steps
const downstreamSteps = new Set<string>([stepId]);
const queue = [stepId];
while (queue.length > 0) {
const currentStepId = queue.shift()!;
const outgoingStepIds = adjacencyMap.get(currentStepId) || [];
for (const nextStepId of outgoingStepIds) {
if (!downstreamSteps.has(nextStepId)) {
downstreamSteps.add(nextStepId);
queue.push(nextStepId);
}
}
}
// Check if any active executions are on downstream steps
const executionsOnDownstream = await prisma.workflowExecution.count({
where: {
workflowId,
currentStepId: {in: Array.from(downstreamSteps)},
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
});
if (executionsOnDownstream > 0) {
throw new HttpException(
409,
`Cannot delete step "${step.name}" while ${executionsOnDownstream} execution(s) are on downstream steps. ` +
'Deleting this step would orphan those executions. ' +
'Please disable the workflow first or wait for executions to complete.',
);
}
}
// Find all downstream steps that need to be deleted (cascade)
// First, get all steps and transitions for this workflow to build a graph
const allSteps = await prisma.workflowStep.findMany({
where: {workflowId},
include: {outgoingTransitions: true},
});
// Build adjacency map for efficient traversal
const adjacencyMap = new Map<string, string[]>();
for (const s of allSteps) {
adjacencyMap.set(
s.id,
s.outgoingTransitions.map(t => t.toStepId),
);
}
// Use BFS to traverse the workflow graph and find all downstream steps
const stepsToDelete = new Set<string>([stepId]);
const queue = [stepId];
while (queue.length > 0) {
const currentStepId = queue.shift()!;
const outgoingStepIds = adjacencyMap.get(currentStepId) || [];
for (const nextStepId of outgoingStepIds) {
if (!stepsToDelete.has(nextStepId)) {
stepsToDelete.add(nextStepId);
queue.push(nextStepId);
}
}
}
// Delete all affected steps (Prisma will cascade delete the transitions)
await prisma.workflowStep.deleteMany({
where: {
id: {in: Array.from(stepsToDelete)},
workflowId, // Safety check to ensure we only delete steps from this workflow
},
}); });
} }
@@ -424,6 +589,9 @@ export class WorkflowService {
* Delete a transition * Delete a transition
*/ */
public static async deleteTransition(projectId: string, workflowId: string, transitionId: string): Promise<void> { public static async deleteTransition(projectId: string, workflowId: string, transitionId: string): Promise<void> {
// Get workflow to check if it's enabled
const workflow = await this.get(projectId, workflowId);
// Verify transition exists and belongs to workflow // Verify transition exists and belongs to workflow
const transition = await prisma.workflowTransition.findFirst({ const transition = await prisma.workflowTransition.findFirst({
where: { where: {
@@ -433,12 +601,70 @@ export class WorkflowService {
workflow: {projectId}, workflow: {projectId},
}, },
}, },
include: {
fromStep: true,
toStep: true,
},
}); });
if (!transition) { if (!transition) {
throw new HttpException(404, 'Transition not found'); throw new HttpException(404, 'Transition not found');
} }
// Check if workflow is enabled and has active executions that could be affected
if (workflow.enabled) {
// Get all steps that would become orphaned by removing this transition
const allSteps = await prisma.workflowStep.findMany({
where: {workflowId},
include: {outgoingTransitions: true, incomingTransitions: true},
});
// Build adjacency map without this transition
const adjacencyMap = new Map<string, string[]>();
for (const s of allSteps) {
adjacencyMap.set(
s.id,
s.outgoingTransitions.filter(t => t.id !== transitionId).map(t => t.toStepId),
);
}
// Find all steps reachable from the toStep (downstream)
const downstreamSteps = new Set<string>([transition.toStepId]);
const queue = [transition.toStepId];
while (queue.length > 0) {
const currentStepId = queue.shift()!;
const outgoingStepIds = adjacencyMap.get(currentStepId) || [];
for (const nextStepId of outgoingStepIds) {
if (!downstreamSteps.has(nextStepId)) {
downstreamSteps.add(nextStepId);
queue.push(nextStepId);
}
}
}
// Check if any active executions are on the toStep or downstream steps
const executionsAffected = await prisma.workflowExecution.count({
where: {
workflowId,
currentStepId: {in: Array.from(downstreamSteps)},
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
});
if (executionsAffected > 0) {
throw new HttpException(
409,
`Cannot delete transition from "${transition.fromStep.name}" to "${transition.toStep.name}" ` +
`while ${executionsAffected} execution(s) are on affected steps. ` +
'Please disable the workflow first or wait for executions to complete.',
);
}
}
await prisma.workflowTransition.delete({ await prisma.workflowTransition.delete({
where: {id: transitionId}, where: {id: transitionId},
}); });
@@ -646,26 +872,55 @@ export class WorkflowService {
data: { data: {
status: WorkflowExecutionStatus.CANCELLED, status: WorkflowExecutionStatus.CANCELLED,
completedAt: new Date(), completedAt: new Date(),
exitReason: 'Cancelled by user',
}, },
}); });
} }
/**
* Cancel all active executions for a workflow
*/
public static async cancelAllExecutions(
projectId: string,
workflowId: string,
): Promise<{cancelled: number}> {
// Verify workflow exists and belongs to project
await this.get(projectId, workflowId);
// Cancel all running and waiting executions
const result = await prisma.workflowExecution.updateMany({
where: {
workflowId,
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
data: {
status: WorkflowExecutionStatus.CANCELLED,
completedAt: new Date(),
exitReason: 'Cancelled by user (bulk cancel)',
},
});
return {cancelled: result.count};
}
/** /**
* Get all available fields for workflow conditions (contact fields + event fields) * Get all available fields for workflow conditions (contact fields + event fields)
*/ */
public static async getAvailableFields(projectId: string, eventName?: string) { public static async getAvailableFields(projectId: string, eventName?: string) {
// Get contact fields (standard + custom data fields) // Get contact fields (standard + custom data fields)
const contactFields = await ContactService.getAvailableFields(projectId); const contactFieldsWithTypes = await ContactService.getAvailableFields(projectId);
// Add standard contact fields // Extract just the field names and prefix with 'contact.'
const standardContactFields = ['contact.email', 'contact.subscribed']; const contactFields = contactFieldsWithTypes.map(f => `contact.${f.field}`);
// Get event fields by analyzing actual event data // Get event fields by analyzing actual event data
// This will only show fields that have been seen in actual events // This will only show fields that have been seen in actual events
const eventFields = await EventService.getAvailableEventFields(projectId, eventName); const eventFields = await EventService.getAvailableEventFields(projectId, eventName);
// Combine all fields // Combine all fields
const allFields = [...standardContactFields, ...contactFields, ...eventFields].sort(); const allFields = [...contactFields, ...eventFields].sort();
return { return {
fields: allFields, fields: allFields,
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
@@ -0,0 +1,691 @@
import {Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui';
import type {FilterCondition, FilterGroup, SegmentFilter, SegmentFilterOperator} from '@plunk/types';
import {Plus, Trash2, GripVertical, Check, ChevronsUpDown, Search} from 'lucide-react';
import {useState, useEffect} from 'react';
import {network} from '../lib/network';
const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
{value: 'equals', label: 'Equals'},
{value: 'notEquals', label: 'Not equals'},
{value: 'contains', label: 'Contains'},
{value: 'notContains', label: 'Does not contain'},
{value: 'greaterThan', label: 'Greater than'},
{value: 'lessThan', label: 'Less than'},
{value: 'greaterThanOrEqual', label: 'Greater than or equal to'},
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
{value: 'exists', label: 'Exists'},
{value: 'notExists', label: 'Does not exist'},
{value: 'within', label: 'Within (time)'},
];
const EVENT_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
{value: 'triggered', label: 'Ever occurred'},
{value: 'triggeredWithin', label: 'Occurred within'},
{value: 'notTriggered', label: 'Never occurred'},
];
const TIME_UNITS = [
{value: 'minutes', label: 'Minutes'},
{value: 'hours', label: 'Hours'},
{value: 'days', label: 'Days'},
] as const;
const STANDARD_FIELDS = [
// Contact fields
{value: 'email', label: 'Email', type: 'string', category: 'Contact Fields'},
{value: 'subscribed', label: 'Subscribed', type: 'boolean', category: 'Contact Fields'},
{value: 'createdAt', label: 'Created At', type: 'date', category: 'Contact Fields'},
{value: 'updatedAt', label: 'Updated At', type: 'date', category: 'Contact Fields'},
] as const;
interface FieldOption {
value: string;
label: string;
type: 'string' | 'number' | 'boolean' | 'date' | 'event' | 'email';
category: 'Contact Fields' | 'Custom Data' | 'Events' | 'Email Activity';
}
// Hook to fetch available fields and events
function useAvailableOptions() {
const [fields, setFields] = useState<FieldOption[]>([...STANDARD_FIELDS]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchOptions = async () => {
try {
// Fetch contact fields with types
const fieldsData = await network.fetch<{
fields: Array<{field: string; type: 'string' | 'number' | 'boolean' | 'date'}>;
}>('GET', '/contacts/fields');
// Fetch event names
const eventsData = await network.fetch<{eventNames: string[]}>('GET', '/events/names');
// Build field options from typed fields
const typedFields: FieldOption[] = (fieldsData.fields || []).map(f => {
const isCustomData = f.field.startsWith('data.');
return {
value: f.field,
label: isCustomData ? f.field.replace('data.', '') : f.field,
type: f.type,
category: isCustomData ? ('Custom Data' as const) : ('Contact Fields' as const),
};
});
// Build event options
const eventOptions: FieldOption[] = [];
const emailOptions: FieldOption[] = [];
(eventsData.eventNames || []).forEach((name: string) => {
if (name.startsWith('email.')) {
emailOptions.push({
value: name,
label: name.replace('email.', '').replace(/([A-Z])/g, ' $1').trim(),
type: 'event' as const,
category: 'Email Activity' as const,
});
} else {
eventOptions.push({
value: name,
label: name,
type: 'event' as const,
category: 'Events' as const,
});
}
});
setFields([...typedFields, ...eventOptions, ...emailOptions]);
} catch (error) {
console.error('Failed to fetch available fields and events:', error);
} finally {
setLoading(false);
}
};
fetchOptions();
}, []);
return {fields, loading};
}
interface FilterRowProps {
filter: SegmentFilter;
onChange: (filter: SegmentFilter) => void;
onRemove: () => void;
availableFields: FieldOption[];
}
function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const needsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(filter.operator);
const needsUnit = ['within', 'triggeredWithin'].includes(filter.operator);
// Get field type from available fields
const fieldOption = availableFields.find(f => f.value === filter.field);
const fieldType = fieldOption?.type || 'string';
const isEventOrEmailActivity = fieldType === 'event' || fieldType === 'email';
// Get operators based on field type
const getOperators = () => {
if (isEventOrEmailActivity) {
return EVENT_OPERATORS;
}
// Filter operators based on field type
if (fieldType === 'boolean') {
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'exists', 'notExists'].includes(op.value)
);
}
if (fieldType === 'number' || fieldType === 'date') {
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'exists', 'notExists', 'within'].includes(op.value)
);
}
// String type - no within operator, no comparison operators
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value)
);
};
const handleFieldChange = (value: string) => {
const selectedField = availableFields.find(f => f.value === value);
const newFieldType = selectedField?.type || 'string';
const isEvent = newFieldType === 'event' || newFieldType === 'email';
const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'notTriggered'].includes(filter.operator);
// Determine default operator and value based on new field type
let newOperator = filter.operator;
let newValue: any = undefined;
let newUnit: 'days' | 'hours' | 'minutes' | undefined = undefined;
if (isEvent && !currentOperatorIsEvent) {
// Switching to event field
newOperator = 'triggered';
newValue = undefined;
newUnit = undefined;
} else if (!isEvent && currentOperatorIsEvent) {
// Switching from event to non-event field
newOperator = 'equals';
newValue = getDefaultValueForType(newFieldType);
newUnit = undefined;
} else if (fieldType !== newFieldType) {
// Field type changed (e.g., date to boolean, number to string)
// Check if current operator is valid for new type
const validOperators = getOperatorsForType(newFieldType, isEvent);
const isOperatorValid = validOperators.some(op => op.value === filter.operator);
if (!isOperatorValid) {
newOperator = 'equals';
}
// Reset value to appropriate default for new type
newValue = getDefaultValueForType(newFieldType);
// Always clear unit when changing field types, even if operator is still valid
// This handles cases like switching from date "within" to string field
newUnit = undefined;
// If the new operator doesn't support units but we had them, ensure value is appropriate
const newOperatorNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
if (!newOperatorNeedsUnit) {
// Convert numeric value back to appropriate type for the field
newValue = getDefaultValueForType(newFieldType);
}
}
onChange({
field: value,
operator: newOperator,
value: newValue,
unit: newUnit,
});
setOpen(false);
setSearch('');
};
// Helper to get default value based on field type
const getDefaultValueForType = (type: string) => {
switch (type) {
case 'boolean':
return true;
case 'number':
return 0;
case 'date':
return '';
default:
return '';
}
};
// Helper to get valid operators for a field type
const getOperatorsForType = (type: string, isEvent: boolean) => {
if (isEvent) {
return EVENT_OPERATORS;
}
if (type === 'boolean') {
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'exists', 'notExists'].includes(op.value)
);
}
if (type === 'number' || type === 'date') {
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'exists', 'notExists', 'within'].includes(op.value)
);
}
// String type - no within operator, no comparison operators
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value)
);
};
// Get label for selected field
const getFieldLabel = () => {
const field = availableFields.find(f => f.value === filter.field);
return field?.label || filter.field;
};
// Group fields by category for display
const groupedFields = availableFields.reduce<Record<string, FieldOption[]>>((acc, field) => {
if (!acc[field.category]) {
acc[field.category] = [];
}
acc[field.category]!.push(field);
return acc;
}, {});
// Filter fields based on search
const filteredGroups = Object.entries(groupedFields).reduce((acc, [category, fields]) => {
const filtered = fields.filter(f =>
f.label.toLowerCase().includes(search.toLowerCase()) ||
f.value.toLowerCase().includes(search.toLowerCase())
);
if (filtered.length > 0) {
acc[category] = filtered;
}
return acc;
}, {} as Record<string, FieldOption[]>);
return (
<div className="flex items-start gap-2 p-3 bg-neutral-50 rounded-lg border border-neutral-200">
<div className="flex-1 grid grid-cols-3 gap-3">
{/* Field Selection */}
<div className="space-y-1.5">
<Label className="text-xs text-neutral-600">Field</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between text-sm font-normal"
>
<span className="truncate">{getFieldLabel()}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0" align="start">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="Search fields, events, or email activity..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
<div className="max-h-[300px] overflow-y-auto p-1">
{Object.keys(filteredGroups).length === 0 ? (
<div className="py-6 text-center text-sm text-neutral-500">
No fields or events found.
</div>
) : (
Object.entries(filteredGroups).map(([category, fields]) => (
<div key={category} className="py-1">
<div className="px-2 py-1.5 text-xs font-semibold text-neutral-500">
{category}
</div>
{fields.map(field => (
<button
key={field.value}
onClick={() => handleFieldChange(field.value)}
className="w-full flex items-center rounded-sm px-2 py-1.5 text-sm hover:bg-neutral-100 cursor-pointer text-left"
>
<Check
className={`mr-2 h-4 w-4 ${
filter.field === field.value ? 'opacity-100' : 'opacity-0'
}`}
/>
<div className="flex flex-col flex-1">
<div className="flex items-center gap-2">
<span className="text-neutral-900">{field.label}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-600 font-mono">
{field.type}
</span>
</div>
{field.value !== field.label && (
<span className="text-xs text-neutral-500">{field.value}</span>
)}
</div>
</button>
))}
</div>
))
)}
</div>
</PopoverContent>
</Popover>
</div>
{/* Operator Selection */}
<div className="space-y-1.5">
<Label className="text-xs text-neutral-600">Operator</Label>
<Select
value={filter.operator}
onValueChange={(v: SegmentFilterOperator) => {
const newOperator = v;
const oldOperator = filter.operator;
// Check if we're switching between operators that need different value types
const oldNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(oldOperator);
const newNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(newOperator);
const oldNeedsUnit = ['within', 'triggeredWithin'].includes(oldOperator);
const newNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
let updatedFilter: SegmentFilter = {...filter, operator: newOperator};
// Clear value if new operator doesn't need one
if (!newNeedsValue && oldNeedsValue) {
updatedFilter.value = undefined;
}
// Clear or set unit appropriately
if (!newNeedsUnit && oldNeedsUnit) {
updatedFilter.unit = undefined;
} else if (newNeedsUnit && !oldNeedsUnit) {
updatedFilter.unit = 'days';
// Set default numeric value if needed
if (typeof updatedFilter.value !== 'number') {
updatedFilter.value = 7;
}
}
// If switching to an operator that needs a value but we don't have one, set default
if (newNeedsValue && !oldNeedsValue) {
updatedFilter.value = getDefaultValueForType(fieldType);
}
onChange(updatedFilter);
}}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{getOperators().map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Value Input */}
<div className="space-y-1.5">
<Label className="text-xs text-neutral-600">Value</Label>
{!needsValue ? (
<div className="h-9 flex items-center text-sm text-neutral-400 px-3 bg-neutral-100 rounded border border-neutral-200">
No value needed
</div>
) : needsUnit ? (
<div className="flex gap-1">
<Input
type="number"
value={filter.value as number}
onChange={e => onChange({...filter, value: parseInt(e.target.value) || 0})}
className="text-sm flex-1"
min="1"
/>
<Select value={filter.unit || 'days'} onValueChange={(v: 'days' | 'hours' | 'minutes') => onChange({...filter, unit: v})}>
<SelectTrigger className="text-sm w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map(unit => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : fieldType === 'boolean' ? (
<Select value={String(filter.value ?? 'true')} onValueChange={v => onChange({...filter, value: v === 'true'})}>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : fieldType === 'number' ? (
<Input
type="number"
value={typeof filter.value === 'number' ? filter.value : ''}
onChange={e => {
const val = e.target.value;
onChange({...filter, value: val === '' ? 0 : parseFloat(val) || 0});
}}
className="text-sm"
placeholder="Enter number"
/>
) : fieldType === 'date' ? (
<Input
type="date"
value={filter.value ? String(filter.value).split('T')[0] : ''}
onChange={e => onChange({...filter, value: e.target.value})}
className="text-sm"
/>
) : (
<Input
type="text"
value={String(filter.value ?? '')}
onChange={e => onChange({...filter, value: e.target.value})}
className="text-sm"
placeholder="Enter value"
/>
)}
</div>
</div>
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="mt-6 text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
}
interface FilterGroupComponentProps {
group: FilterGroup;
onChange: (group: FilterGroup) => void;
onRemove?: () => void;
depth?: number;
availableFields: FieldOption[];
}
function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFields}: FilterGroupComponentProps) {
const addFilter = () => {
onChange({
...group,
filters: [...group.filters, {field: 'email', operator: 'contains', value: ''}],
});
};
const updateFilter = (index: number, filter: SegmentFilter) => {
onChange({
...group,
filters: group.filters.map((f, i) => (i === index ? filter : f)),
});
};
const removeFilter = (index: number) => {
onChange({
...group,
filters: group.filters.filter((_, i) => i !== index),
});
};
const addNestedCondition = () => {
onChange({
...group,
conditions: {
logic: 'AND',
groups: [{filters: [{field: 'email', operator: 'contains', value: ''}]}],
},
});
};
const updateNestedCondition = (condition: FilterCondition) => {
onChange({
...group,
conditions: condition,
});
};
const removeNestedCondition = () => {
const {conditions, ...rest} = group;
onChange(rest);
};
const bgColors = ['bg-white', 'bg-blue-50/50', 'bg-purple-50/50', 'bg-green-50/50'];
const borderColors = ['border-neutral-300', 'border-blue-300', 'border-purple-300', 'border-green-300'];
return (
<div className={`p-4 rounded-lg border-2 ${borderColors[depth % borderColors.length]} ${bgColors[depth % bgColors.length]}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-neutral-700">Filter Group {depth > 0 && `(Nested)`}</span>
</div>
{onRemove && (
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<div className="space-y-3">
{group.filters.map((filter, index) => (
<FilterRow key={index} filter={filter} onChange={f => updateFilter(index, f)} onRemove={() => removeFilter(index)} availableFields={availableFields} />
))}
{group.conditions && (
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-neutral-600 uppercase tracking-wide">Nested Conditions</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={removeNestedCondition}
className="h-6 text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
>
Remove nested
</Button>
</div>
<FilterConditionComponent condition={group.conditions} onChange={updateNestedCondition} depth={depth + 1} availableFields={availableFields} />
</div>
)}
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" size="sm" onClick={addFilter} className="flex-1">
<Plus className="h-3 w-3 mr-1" />
Add Filter
</Button>
{!group.conditions && (
<Button type="button" variant="outline" size="sm" onClick={addNestedCondition} className="flex-1">
<Plus className="h-3 w-3 mr-1" />
Add Nested Condition
</Button>
)}
</div>
</div>
</div>
);
}
interface FilterConditionComponentProps {
condition: FilterCondition;
onChange: (condition: FilterCondition) => void;
depth?: number;
availableFields: FieldOption[];
}
function FilterConditionComponent({condition, onChange, depth = 0, availableFields}: FilterConditionComponentProps) {
const addGroup = () => {
onChange({
...condition,
groups: [...condition.groups, {filters: [{field: 'email', operator: 'contains', value: ''}]}],
});
};
const updateGroup = (index: number, group: FilterGroup) => {
onChange({
...condition,
groups: condition.groups.map((g, i) => (i === index ? group : g)),
});
};
const removeGroup = (index: number) => {
onChange({
...condition,
groups: condition.groups.filter((_, i) => i !== index),
});
};
const toggleLogic = () => {
onChange({
...condition,
logic: condition.logic === 'AND' ? 'OR' : 'AND',
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-600">Groups are combined with:</span>
<Button
type="button"
variant={condition.logic === 'AND' ? 'default' : 'outline'}
size="sm"
onClick={toggleLogic}
className="font-mono font-bold"
>
{condition.logic}
</Button>
</div>
</div>
<div className="space-y-4">
{condition.groups.map((group, index) => (
<div key={index}>
{index > 0 && (
<div className="flex items-center justify-center my-2">
<div className="px-3 py-1 bg-neutral-900 text-white text-xs font-bold font-mono rounded-full">{condition.logic}</div>
</div>
)}
<FilterGroupComponent
group={group}
onChange={g => updateGroup(index, g)}
onRemove={condition.groups.length > 1 ? () => removeGroup(index) : undefined}
depth={depth}
availableFields={availableFields}
/>
</div>
))}
</div>
<Button type="button" variant="outline" onClick={addGroup} className="w-full">
<Plus className="h-4 w-4 mr-2" />
Add Group
</Button>
</div>
);
}
interface SegmentFilterBuilderProps {
condition: FilterCondition;
onChange: (condition: FilterCondition) => void;
}
export function SegmentFilterBuilder({condition, onChange}: SegmentFilterBuilderProps) {
const {fields, loading} = useAvailableOptions();
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-neutral-900">Filter Conditions</h3>
<p className="text-sm text-neutral-500 mt-1">Build complex audience filters with AND/OR logic</p>
</div>
</div>
{loading ? (
<div className="text-sm text-neutral-500 py-4">Loading available fields and events...</div>
) : (
<FilterConditionComponent condition={condition} onChange={onChange} availableFields={fields} />
)}
</div>
);
}
+77 -11
View File
@@ -261,7 +261,12 @@ function CustomNode({
<div className="text-xs text-neutral-600"> <div className="text-xs text-neutral-600">
<div className="flex items-center gap-1 mb-1"> <div className="flex items-center gap-1 mb-1">
<span className="font-medium">🔀</span> <span className="font-medium">🔀</span>
<span className="font-mono text-[10px] truncate">{data.config.field}</span> <span className="font-mono text-[10px] truncate">
{/* Handle both legacy format {field, type} and new format (string) */}
{typeof data.config.field === 'object' && data.config.field !== null && 'field' in data.config.field
? String(data.config.field.field)
: String(data.config.field)}
</span>
</div> </div>
<div className="text-[10px] text-neutral-500 ml-4"> <div className="text-[10px] text-neutral-500 ml-4">
{data.config.operator} &quot;{String(data.config.value)}&quot; {data.config.operator} &quot;{String(data.config.value)}&quot;
@@ -582,6 +587,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
name: `New ${stepType.toLowerCase().replace('_', ' ')}`, name: `New ${stepType.toLowerCase().replace('_', ' ')}`,
position: {x: 0, y: 0}, // Will be auto-positioned by dagre layout position: {x: 0, y: 0}, // Will be auto-positioned by dagre layout
config: {}, config: {},
autoConnect: false, // We manually create transitions to preserve branch information
}, },
); );
@@ -625,12 +631,45 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
window.dispatchEvent(event); window.dispatchEvent(event);
}; };
// Get all steps that will be affected by deleting a step (the step itself + all downstream steps)
const getAffectedSteps = useCallback(
(stepId: string): typeof steps => {
const affected = new Set<string>();
const queue = [stepId];
// BFS to find all downstream steps
while (queue.length > 0) {
const currentId = queue.shift()!;
if (affected.has(currentId)) continue;
affected.add(currentId);
const currentStep = steps.find(s => s.id === currentId);
if (currentStep?.outgoingTransitions) {
for (const transition of currentStep.outgoingTransitions) {
if (!affected.has(transition.toStepId)) {
queue.push(transition.toStepId);
}
}
}
}
return steps.filter(s => affected.has(s.id));
},
[steps],
);
const handleDeleteStep = async () => { const handleDeleteStep = async () => {
if (!stepToDelete) return; if (!stepToDelete) return;
try { try {
await network.fetch('DELETE', `/workflows/${workflowId}/steps/${stepToDelete}`); await network.fetch('DELETE', `/workflows/${workflowId}/steps/${stepToDelete}`);
toast.success('Step deleted'); const affectedSteps = getAffectedSteps(stepToDelete);
if (affectedSteps.length > 1) {
toast.success(`Deleted ${affectedSteps.length} steps`);
} else {
toast.success('Step deleted');
}
onUpdate(); onUpdate();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete step'); toast.error(error instanceof Error ? error.message : 'Failed to delete step');
@@ -782,15 +821,42 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<ConfirmDialog {stepToDelete && (() => {
open={showDeleteDialog} const affectedSteps = getAffectedSteps(stepToDelete);
onOpenChange={setShowDeleteDialog} const stepToDeleteData = steps.find(s => s.id === stepToDelete);
onConfirm={handleDeleteStep} const downstreamSteps = affectedSteps.filter(s => s.id !== stepToDelete);
title="Delete Step"
description="Are you sure you want to delete this step?" return (
confirmText="Delete" <ConfirmDialog
variant="destructive" open={showDeleteDialog}
/> onOpenChange={setShowDeleteDialog}
onConfirm={handleDeleteStep}
title="Delete Step"
description={
downstreamSteps.length > 0 ? (
<div className="space-y-3">
<p>
Deleting &quot;{stepToDeleteData?.name}&quot; will also delete {downstreamSteps.length} downstream{' '}
{downstreamSteps.length === 1 ? 'step' : 'steps'}:
</p>
<ul className="list-disc list-inside text-sm text-neutral-600 max-h-32 overflow-y-auto bg-neutral-50 p-3 rounded border border-neutral-200">
{downstreamSteps.map(step => (
<li key={step.id}>
{step.name} ({step.type})
</li>
))}
</ul>
<p className="text-sm font-medium text-red-600">This action cannot be undone.</p>
</div>
) : (
`Are you sure you want to delete "${stepToDeleteData?.name}"? This action cannot be undone.`
)
}
confirmText={downstreamSteps.length > 0 ? `Delete ${affectedSteps.length} Steps` : 'Delete'}
variant="destructive"
/>
);
})()}
</> </>
); );
} }
+38 -181
View File
@@ -35,38 +35,9 @@ import {useRouter} from 'next/router';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import useSWR from 'swr'; import useSWR from 'swr';
import type {SegmentFilter} from '@plunk/types'; import type {FilterCondition} from '@plunk/types';
import {SegmentSchemas} from '@plunk/shared'; import {SegmentSchemas} from '@plunk/shared';
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
const FILTER_OPERATORS = [
{value: 'equals', label: 'Equals'},
{value: 'notEquals', label: 'Not equals'},
{value: 'contains', label: 'Contains'},
{value: 'notContains', label: 'Does not contain'},
{value: 'greaterThan', label: 'Greater than'},
{value: 'lessThan', label: 'Less than'},
{value: 'greaterThanOrEqual', label: 'Greater than or equal to'},
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
{value: 'exists', label: 'Exists'},
{value: 'notExists', label: 'Does not exist'},
{value: 'within', label: 'Within (time)'},
] as const;
const TIME_UNITS = [
{value: 'minutes', label: 'Minutes'},
{value: 'hours', label: 'Hours'},
{value: 'days', label: 'Days'},
] as const;
const FIELD_PRESETS = [
{value: 'email', label: 'Email', type: 'string'},
{value: 'subscribed', label: 'Subscribed', type: 'boolean'},
{value: 'createdAt', label: 'Created At', type: 'date'},
{value: 'updatedAt', label: 'Updated At', type: 'date'},
{value: 'data.firstName', label: 'First Name (custom)', type: 'string'},
{value: 'data.lastName', label: 'Last Name (custom)', type: 'string'},
{value: 'data.plan', label: 'Plan (custom)', type: 'string'},
] as const;
interface PaginatedContacts { interface PaginatedContacts {
contacts: Contact[]; contacts: Contact[];
@@ -76,6 +47,18 @@ interface PaginatedContacts {
totalPages: number; totalPages: number;
} }
// Count total filters in a condition (recursive)
function countFilters(condition: FilterCondition): number {
let count = 0;
for (const group of condition.groups) {
count += group.filters.length;
if (group.conditions) {
count += countFilters(group.conditions);
}
}
return count;
}
export default function SegmentDetailPage() { export default function SegmentDetailPage() {
const router = useRouter(); const router = useRouter();
const {id} = router.query; const {id} = router.query;
@@ -89,7 +72,10 @@ export default function SegmentDetailPage() {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [trackMembership, setTrackMembership] = useState(false); const [trackMembership, setTrackMembership] = useState(false);
const [filters, setFilters] = useState<SegmentFilter[]>([]); const [condition, setCondition] = useState<FilterCondition>({
logic: 'AND',
groups: [{filters: [{field: 'subscribed', operator: 'equals', value: true}]}],
});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isComputing, setIsComputing] = useState(false); const [isComputing, setIsComputing] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -100,22 +86,13 @@ export default function SegmentDetailPage() {
setName(segment.name); setName(segment.name);
setDescription(segment.description || ''); setDescription(segment.description || '');
setTrackMembership(segment.trackMembership); setTrackMembership(segment.trackMembership);
setFilters((segment.filters as unknown as SegmentFilter[]) || []); setCondition((segment.condition as unknown as FilterCondition) || {
logic: 'AND',
groups: [{filters: [{field: 'subscribed', operator: 'equals', value: true}]}],
});
} }
}, [segment]); }, [segment]);
const addFilter = () => {
setFilters([...filters, {field: 'email', operator: 'contains', value: ''}]);
};
const removeFilter = (index: number) => {
setFilters(filters.filter((_, i) => i !== index));
};
const updateFilter = (index: number, updates: Partial<SegmentFilter>) => {
setFilters(filters.map((filter, i) => (i === index ? {...filter, ...updates} : filter)));
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsSubmitting(true); setIsSubmitting(true);
@@ -124,7 +101,7 @@ export default function SegmentDetailPage() {
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, { await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
name, name,
description: description || undefined, description: description || undefined,
filters, condition,
trackMembership, trackMembership,
}); });
toast.success('Segment updated successfully'); toast.success('Segment updated successfully');
@@ -167,13 +144,6 @@ export default function SegmentDetailPage() {
} }
}; };
const needsValue = (operator: string) => {
return !['exists', 'notExists'].includes(operator);
};
const needsUnit = (operator: string) => {
return operator === 'within';
};
if (isLoading) { if (isLoading) {
return ( return (
@@ -297,139 +267,17 @@ export default function SegmentDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Filters */} {/* Filter Builder */}
<Card> <Card>
<CardHeader> <CardContent className="pt-6">
<div className="flex items-center justify-between"> <SegmentFilterBuilder condition={condition} onChange={setCondition} />
<div>
<CardTitle>Filters</CardTitle>
<CardDescription>Define conditions to match contacts</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={addFilter}>
<Plus className="h-4 w-4" />
Add Filter
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{filters.map((filter, index) => (
<div key={index} className="flex items-start gap-2 p-4 border border-neutral-200 rounded-lg">
<div className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3">
{/* Field */}
<div>
<Label className="text-xs">Field</Label>
<Select value={filter.field} onValueChange={value => updateFilter(index, {field: value})}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_PRESETS.map(preset => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
<SelectItem value="custom">Custom field...</SelectItem>
</SelectContent>
</Select>
{filter.field === 'custom' && (
<Input
type="text"
placeholder="e.g., data.customField"
className="mt-2"
onChange={e => updateFilter(index, {field: e.target.value})}
/>
)}
</div>
{/* Operator */}
<div>
<Label className="text-xs">Operator</Label>
<Select
value={filter.operator}
onValueChange={value => updateFilter(index, {operator: value as SegmentFilter['operator']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Value */}
{needsValue(filter.operator) && (
<div>
<Label className="text-xs">Value</Label>
{filter.field === 'subscribed' ? (
<Select
value={filter.value?.toString()}
onValueChange={value => updateFilter(index, {value: value === 'true'})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
type="text"
value={filter.value ?? ''}
onChange={e => updateFilter(index, {value: e.target.value})}
placeholder="Enter value"
/>
)}
</div>
)}
{/* Unit */}
{needsUnit(filter.operator) && (
<div>
<Label className="text-xs">Unit</Label>
<Select
value={filter.unit ?? 'days'}
onValueChange={value => updateFilter(index, {unit: value as SegmentFilter['unit']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map(unit => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeFilter(index)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 mt-6"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</CardContent> </CardContent>
</Card> </Card>
{/* Actions */} {/* Actions */}
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<Button type="submit" disabled={isSubmitting || filters.length === 0}> <Button type="submit" disabled={isSubmitting}>
<Save className="h-4 w-4" /> <Save className="h-4 w-4 mr-2" />
{isSubmitting ? 'Saving...' : 'Save Changes'} {isSubmitting ? 'Saving...' : 'Save Changes'}
</Button> </Button>
</div> </div>
@@ -536,7 +384,16 @@ export default function SegmentDetailPage() {
<span className="text-sm text-neutral-600">Filters</span> <span className="text-sm text-neutral-600">Filters</span>
</div> </div>
<span className="text-lg font-semibold text-neutral-900"> <span className="text-lg font-semibold text-neutral-900">
{Array.isArray(segment.filters) ? segment.filters.length : 0} {countFilters(segment.condition as unknown as FilterCondition)}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Groups</span>
</div>
<span className="text-lg font-semibold text-neutral-900">
{(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
</span> </span>
</div> </div>
</CardContent> </CardContent>
+19 -1
View File
@@ -10,6 +10,7 @@ import {
ConfirmDialog, ConfirmDialog,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Segment} from '@plunk/db'; import type {Segment} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {AlertTriangle, Edit, Filter, Plus, Trash2, Users} from 'lucide-react'; import {AlertTriangle, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
@@ -19,6 +20,23 @@ import {useState} from 'react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import useSWR from 'swr'; import useSWR from 'swr';
// Helper function to count total filters in a condition
function countFiltersInCondition(condition: unknown): number {
if (!condition || typeof condition !== 'object') return 0;
const cond = condition as FilterCondition;
if (!cond.groups || !Array.isArray(cond.groups)) return 0;
return cond.groups.reduce((total, group) => {
let count = group.filters?.length || 0;
// Recursively count nested conditions
if (group.conditions) {
count += countFiltersInCondition(group.conditions);
}
return total + count;
}, 0);
}
export default function SegmentsPage() { export default function SegmentsPage() {
// Limit to 50 segments to avoid loading thousands into the browser // Limit to 50 segments to avoid loading thousands into the browser
const { const {
@@ -147,7 +165,7 @@ export default function SegmentsPage() {
<span className="text-sm text-neutral-600">Filters</span> <span className="text-sm text-neutral-600">Filters</span>
</div> </div>
<span className="text-sm font-medium text-neutral-900"> <span className="text-sm font-medium text-neutral-900">
{Array.isArray(segment.filters) ? segment.filters.length : 0} {countFiltersInCondition(segment.condition)}
</span> </span>
</div> </div>
+99 -289
View File
@@ -1,80 +1,32 @@
import { import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label} from '@plunk/ui';
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {ArrowLeft, Filter, Plus, Save, Trash2} from 'lucide-react'; import {ArrowLeft, Save} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
import {useState} from 'react'; import {useState} from 'react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import type {SegmentFilter} from '@plunk/types'; import type {FilterCondition} from '@plunk/types';
import type {Segment} from '@plunk/db'; import type {Segment} from '@plunk/db';
import {SegmentSchemas} from '@plunk/shared'; import {SegmentSchemas} from '@plunk/shared';
const FILTER_OPERATORS = [
{value: 'equals', label: 'Equals'},
{value: 'notEquals', label: 'Not equals'},
{value: 'contains', label: 'Contains'},
{value: 'notContains', label: 'Does not contain'},
{value: 'greaterThan', label: 'Greater than'},
{value: 'lessThan', label: 'Less than'},
{value: 'greaterThanOrEqual', label: 'Greater than or equal to'},
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
{value: 'exists', label: 'Exists'},
{value: 'notExists', label: 'Does not exist'},
{value: 'within', label: 'Within (time)'},
] as const;
const TIME_UNITS = [
{value: 'minutes', label: 'Minutes'},
{value: 'hours', label: 'Hours'},
{value: 'days', label: 'Days'},
] as const;
const FIELD_PRESETS = [
{value: 'email', label: 'Email', type: 'string'},
{value: 'subscribed', label: 'Subscribed', type: 'boolean'},
{value: 'createdAt', label: 'Created At', type: 'date'},
{value: 'updatedAt', label: 'Updated At', type: 'date'},
{value: 'data.firstName', label: 'First Name (custom)', type: 'string'},
{value: 'data.lastName', label: 'Last Name (custom)', type: 'string'},
{value: 'data.plan', label: 'Plan (custom)', type: 'string'},
] as const;
export default function NewSegmentPage() { export default function NewSegmentPage() {
const router = useRouter(); const router = useRouter();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [trackMembership, setTrackMembership] = useState(false); const [trackMembership, setTrackMembership] = useState(false);
const [filters, setFilters] = useState<SegmentFilter[]>([{field: 'subscribed', operator: 'equals', value: true}]); const [condition, setCondition] = useState<FilterCondition>({
logic: 'AND',
groups: [
{
filters: [{field: 'subscribed', operator: 'equals', value: true}],
},
],
});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const addFilter = () => {
setFilters([...filters, {field: 'email', operator: 'contains', value: ''}]);
};
const removeFilter = (index: number) => {
setFilters(filters.filter((_, i) => i !== index));
};
const updateFilter = (index: number, updates: Partial<SegmentFilter>) => {
setFilters(filters.map((filter, i) => (i === index ? {...filter, ...updates} : filter)));
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsSubmitting(true); setIsSubmitting(true);
@@ -83,7 +35,7 @@ export default function NewSegmentPage() {
await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', { await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
name, name,
description: description || undefined, description: description || undefined,
filters, condition,
trackMembership, trackMembership,
}); });
toast.success('Segment created successfully'); toast.success('Segment created successfully');
@@ -95,241 +47,99 @@ export default function NewSegmentPage() {
} }
}; };
const needsValue = (operator: string) => {
return !['exists', 'notExists'].includes(operator);
};
const needsUnit = (operator: string) => {
return operator === 'within';
};
return ( return (
<> <>
<NextSeo title="Create Segment" /> <NextSeo title="Create Segment" />
<DashboardLayout> <DashboardLayout>
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/segments">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
<p className="text-neutral-500 mt-1">Define filters to automatically group contacts</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Segment Details</CardTitle>
<CardDescription>Give your segment a name and description</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Segment Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="e.g., Active Pro Users"
maxLength={100}
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="e.g., Users on pro plan who have been active in the last 30 days"
maxLength={500}
/>
</div>
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<input
id="trackMembership"
type="checkbox"
checked={trackMembership}
onChange={e => setTrackMembership(e.target.checked)}
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
/>
<div className="flex-1">
<Label htmlFor="trackMembership" className="font-medium cursor-pointer">
Track membership changes
</Label>
<p className="text-xs text-neutral-500 mt-1">
When enabled, segment entry and exit events will be tracked for use in workflows and analytics
</p>
</div>
</div>
</CardContent>
</Card>
{/* Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Filters</CardTitle>
<CardDescription>Define conditions to match contacts (all filters must match)</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={addFilter}>
<Plus className="h-4 w-4" />
Add Filter
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{filters.length === 0 ? (
<div className="text-center py-8">
<Filter className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<p className="text-neutral-500 mb-4">No filters defined. Add at least one filter.</p>
<Button type="button" variant="outline" onClick={addFilter}>
<Plus className="h-4 w-4" />
Add First Filter
</Button>
</div>
) : (
filters.map((filter, index) => (
<div key={index} className="flex items-start gap-2 p-4 border border-neutral-200 rounded-lg">
<div className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3">
{/* Field */}
<div>
<Label className="text-xs">Field</Label>
<Select value={filter.field} onValueChange={value => updateFilter(index, {field: value})}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_PRESETS.map(preset => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
<SelectItem value="custom">Custom field...</SelectItem>
</SelectContent>
</Select>
{filter.field === 'custom' && (
<Input
type="text"
placeholder="e.g., data.customField"
className="mt-2"
onChange={e => updateFilter(index, {field: e.target.value})}
/>
)}
</div>
{/* Operator */}
<div>
<Label className="text-xs">Operator</Label>
<Select
value={filter.operator}
onValueChange={value => updateFilter(index, {operator: value as SegmentFilter['operator']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Value */}
{needsValue(filter.operator) && (
<div>
<Label className="text-xs">Value</Label>
{filter.field === 'subscribed' ? (
<Select
value={filter.value?.toString()}
onValueChange={value => updateFilter(index, {value: value === 'true'})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
type="text"
value={filter.value ?? ''}
onChange={e => updateFilter(index, {value: e.target.value})}
placeholder="Enter value"
/>
)}
</div>
)}
{/* Unit (for within operator) */}
{needsUnit(filter.operator) && (
<div>
<Label className="text-xs">Unit</Label>
<Select
value={filter.unit ?? 'days'}
onValueChange={value => updateFilter(index, {unit: value as SegmentFilter['unit']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map(unit => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* Remove button */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeFilter(index)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 mt-6"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-2">
<Link href="/segments"> <Link href="/segments">
<Button type="button" variant="outline" disabled={isSubmitting}> <Button variant="outline" size="sm">
Cancel <ArrowLeft className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<Button type="submit" disabled={isSubmitting || filters.length === 0}> <div>
<Save className="h-4 w-4" /> <h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
{isSubmitting ? 'Creating...' : 'Create Segment'} <p className="text-neutral-500 mt-1">Build complex audience filters with AND/OR logic</p>
</Button> </div>
</div> </div>
</form>
</div> <form onSubmit={handleSubmit} className="space-y-6">
</DashboardLayout> {/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Segment Details</CardTitle>
<CardDescription>Give your segment a name and description</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Segment Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="e.g., VIP Customers or Recent High Spenders"
maxLength={100}
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="e.g., Users on VIP plan OR recent signups who spent $1000+"
maxLength={500}
/>
</div>
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<input
id="trackMembership"
type="checkbox"
checked={trackMembership}
onChange={e => setTrackMembership(e.target.checked)}
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
/>
<div className="flex-1">
<Label htmlFor="trackMembership" className="font-medium cursor-pointer">
Track membership changes
</Label>
<p className="text-xs text-neutral-500 mt-1">
When enabled, segment entry and exit events will be tracked for use in workflows and analytics
</p>
</div>
</div>
</CardContent>
</Card>
{/* Filter Builder */}
<Card>
<CardContent className="pt-6">
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-2">
<Link href="/segments">
<Button type="button" variant="outline" disabled={isSubmitting}>
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
<Save className="h-4 w-4 mr-2" />
{isSubmitting ? 'Creating...' : 'Create Segment'}
</Button>
</div>
</form>
</div>
</DashboardLayout>
</> </>
); );
} }
+351 -119
View File
@@ -6,6 +6,7 @@ import {
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
ConfirmDialog,
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
@@ -55,10 +56,13 @@ interface PaginatedExecutions {
export default function WorkflowEditorPage() { export default function WorkflowEditorPage() {
const router = useRouter(); const router = useRouter();
const {id} = router.query; const {id} = router.query;
const [activeTab, setActiveTab] = useState<'builder' | 'executions' | 'debug'>('builder'); const [activeTab, setActiveTab] = useState<'builder' | 'executions'>('builder');
const [showSettingsDialog, setShowSettingsDialog] = useState(false); const [showSettingsDialog, setShowSettingsDialog] = useState(false);
const [showTestDialog, setShowTestDialog] = useState(false); const [showTestDialog, setShowTestDialog] = useState(false);
const [editingStep, setEditingStep] = useState<WorkflowStep | null>(null); const [editingStep, setEditingStep] = useState<WorkflowStep | null>(null);
const [showCancelAllDialog, setShowCancelAllDialog] = useState(false);
const [executionToCancel, setExecutionToCancel] = useState<string | null>(null);
const [isCancelling, setIsCancelling] = useState(false);
const {data: workflow, mutate} = useSWR<WorkflowWithDetails>(id ? `/workflows/${id}` : null, { const {data: workflow, mutate} = useSWR<WorkflowWithDetails>(id ? `/workflows/${id}` : null, {
revalidateOnFocus: false, revalidateOnFocus: false,
@@ -69,9 +73,181 @@ export default function WorkflowEditorPage() {
{revalidateOnFocus: false}, {revalidateOnFocus: false},
); );
// Always fetch a summary of active executions to show warnings (regardless of enabled status)
const {data: activeExecutionsData} = useSWR<PaginatedExecutions>(
id ? `/workflows/${id}/executions?page=1&pageSize=1&status=RUNNING` : null,
{revalidateOnFocus: false, refreshInterval: 10000},
);
const {data: waitingExecutionsData} = useSWR<PaginatedExecutions>(
id ? `/workflows/${id}/executions?page=1&pageSize=1&status=WAITING` : null,
{revalidateOnFocus: false, refreshInterval: 10000},
);
// Check for active executions
const activeExecutionsCount = (activeExecutionsData?.total || 0) + (waitingExecutionsData?.total || 0);
// Handler for cancelling a single execution
const handleCancelExecution = async (executionId: string) => {
setIsCancelling(true);
try {
await network.fetch('DELETE', `/workflows/${id}/executions/${executionId}`);
toast.success('Execution cancelled successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to cancel execution');
} finally {
setIsCancelling(false);
setExecutionToCancel(null);
}
};
// Handler for cancelling all executions
const handleCancelAllExecutions = async () => {
setIsCancelling(true);
try {
const result = await network.fetch<{cancelled: number}>('POST', `/workflows/${id}/executions/cancel-all`);
toast.success(`Successfully cancelled ${result.cancelled} execution(s)`);
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to cancel executions');
} finally {
setIsCancelling(false);
setShowCancelAllDialog(false);
}
};
// Validate workflow configuration
const validateWorkflow = (workflow: WorkflowWithDetails): {valid: boolean; errors: string[]} => {
const errors: string[] = [];
// Check if there are any steps
if (workflow.steps.length === 0) {
errors.push('Workflow must have at least one step');
return {valid: false, errors};
}
// Validate each step
workflow.steps.forEach(step => {
const config = step.config && typeof step.config === 'object' && !Array.isArray(step.config) ? step.config : {};
switch (step.type) {
case 'SEND_EMAIL':
if (!step.templateId) {
errors.push(`"${step.name}" step is missing an email template`);
}
break;
case 'DELAY':
if (!config.amount || !config.unit) {
errors.push(`"${step.name}" step is missing delay configuration (amount or unit)`);
}
break;
case 'CONDITION':
// Extract field name from both legacy format (object) and new format (string)
let fieldValue = '';
if (config.field) {
if (typeof config.field === 'object' && config.field !== null && 'field' in config.field) {
fieldValue = String(config.field.field || '');
} else {
fieldValue = String(config.field);
}
}
if (!fieldValue || !config.operator) {
errors.push(`"${step.name}" step is missing condition configuration (field or operator)`);
}
// Check if value is required for this operator
const operatorNeedsValue = !['exists', 'notExists'].includes(String(config.operator || ''));
if (operatorNeedsValue && (config.value === undefined || config.value === null || config.value === '')) {
errors.push(`"${step.name}" step is missing a value for the condition`);
}
break;
case 'WAIT_FOR_EVENT':
if (!config.eventName) {
errors.push(`"${step.name}" step is missing event name`);
}
break;
case 'WEBHOOK':
if (!config.url) {
errors.push(`"${step.name}" step is missing webhook URL`);
}
break;
case 'UPDATE_CONTACT':
if (!config.updates || (typeof config.updates === 'object' && Object.keys(config.updates).length === 0)) {
errors.push(`"${step.name}" step is missing contact updates`);
}
break;
}
});
// Check for orphaned steps (steps with no incoming or outgoing transitions, except TRIGGER and EXIT)
const triggerSteps = workflow.steps.filter(s => s.type === 'TRIGGER');
const exitSteps = workflow.steps.filter(s => s.type === 'EXIT');
workflow.steps.forEach(step => {
if (step.type !== 'TRIGGER' && step.type !== 'EXIT') {
const hasIncoming = step.incomingTransitions && step.incomingTransitions.length > 0;
const hasOutgoing = step.outgoingTransitions && step.outgoingTransitions.length > 0;
if (!hasIncoming && !hasOutgoing) {
errors.push(`"${step.name}" step is not connected to the workflow`);
}
}
});
// Check if there's a TRIGGER step
if (triggerSteps.length === 0) {
errors.push('Workflow must have a trigger step');
}
// Check for CONDITION steps that don't have both yes and no branches
workflow.steps.forEach(step => {
if (step.type === 'CONDITION' && step.outgoingTransitions) {
const hasYesBranch = step.outgoingTransitions.some(t => {
const condition = t.condition;
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes';
});
const hasNoBranch = step.outgoingTransitions.some(t => {
const condition = t.condition;
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no';
});
if (!hasYesBranch || !hasNoBranch) {
errors.push(`"${step.name}" condition step must have both YES and NO branches connected`);
}
}
});
return {valid: errors.length === 0, errors};
};
const handleToggleEnabled = async () => { const handleToggleEnabled = async () => {
if (!workflow) return; if (!workflow) return;
// If trying to enable, validate first
if (!workflow.enabled) {
const validation = validateWorkflow(workflow);
if (!validation.valid) {
toast.error(
<div>
<div className="font-semibold mb-1">Cannot enable workflow</div>
<ul className="list-disc list-inside text-sm">
{validation.errors.map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>,
{duration: 8000},
);
return;
}
}
try { try {
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${id}`, { await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${id}`, {
enabled: !workflow.enabled, enabled: !workflow.enabled,
@@ -198,6 +374,79 @@ export default function WorkflowEditorPage() {
</div> </div>
</div> </div>
{/* Active Executions Warning Banner */}
{activeExecutionsCount > 0 && (
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 rounded-r-lg">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-blue-800">
{workflow.enabled ? 'Workflow is active with running executions' : 'Workflow has active executions'}
</h3>
<div className="mt-2 text-sm text-blue-700">
<p>
This workflow has <strong>{activeExecutionsCount}</strong> active execution
{activeExecutionsCount !== 1 ? 's' : ''}.{' '}
{!workflow.enabled && 'Even though the workflow is disabled, existing executions will continue. '}
To protect running workflows, you cannot:
</p>
<ul className="list-disc list-inside space-y-1 mt-2">
<li>Delete steps or transitions</li>
<li>Modify step configurations (email templates, conditions, etc.)</li>
<li>Change the workflow trigger</li>
</ul>
<p className="mt-2">
You can still rename steps and adjust their position. To make configuration changes, wait for
executions to complete or cancel them from the Executions tab.
</p>
</div>
</div>
</div>
</div>
)}
{/* Validation Warning Banner */}
{!workflow.enabled && (() => {
const validation = validateWorkflow(workflow);
if (!validation.valid) {
return (
<div className="bg-amber-50 border-l-4 border-amber-400 p-4 rounded-r-lg">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-amber-800">Workflow has validation errors</h3>
<div className="mt-2 text-sm text-amber-700">
<p className="mb-2">Fix the following issues before enabling this workflow:</p>
<ul className="list-disc list-inside space-y-1">
{validation.errors.map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>
</div>
</div>
</div>
);
}
return null;
})()}
{/* Tabs */} {/* Tabs */}
<div className="border-b border-neutral-200"> <div className="border-b border-neutral-200">
<nav className="-mb-px flex space-x-8"> <nav className="-mb-px flex space-x-8">
@@ -221,18 +470,6 @@ export default function WorkflowEditorPage() {
> >
Executions Executions
</button> </button>
{process.env.NODE_ENV === 'development' && (
<button
onClick={() => setActiveTab('debug')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'debug'
? 'border-neutral-900 text-neutral-900'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
Debug
</button>
)}
</nav> </nav>
</div> </div>
@@ -254,8 +491,17 @@ export default function WorkflowEditorPage() {
) : activeTab === 'executions' ? ( ) : activeTab === 'executions' ? (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Workflow Executions</CardTitle> <div className="flex items-center justify-between">
<CardDescription>View all executions of this workflow</CardDescription> <div>
<CardTitle>Workflow Executions</CardTitle>
<CardDescription>View and manage all executions of this workflow</CardDescription>
</div>
{activeExecutionsCount > 0 && (
<Button variant="outline" onClick={() => setShowCancelAllDialog(true)}>
Cancel All Active ({activeExecutionsCount})
</Button>
)}
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!executionsData?.executions.length ? ( {!executionsData?.executions.length ? (
@@ -283,6 +529,9 @@ export default function WorkflowEditorPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Started Started
</th> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-neutral-200"> <tbody className="bg-white divide-y divide-neutral-200">
@@ -298,9 +547,13 @@ export default function WorkflowEditorPage() {
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: execution.status === 'RUNNING' : execution.status === 'RUNNING'
? 'bg-blue-100 text-blue-800' ? 'bg-blue-100 text-blue-800'
: execution.status === 'FAILED' : execution.status === 'WAITING'
? 'bg-red-100 text-red-800' ? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800' : execution.status === 'FAILED'
? 'bg-red-100 text-red-800'
: execution.status === 'CANCELLED'
? 'bg-gray-100 text-gray-800'
: 'bg-gray-100 text-gray-800'
}`} }`}
> >
{execution.status} {execution.status}
@@ -312,6 +565,17 @@ export default function WorkflowEditorPage() {
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
{new Date(execution.startedAt).toLocaleString()} {new Date(execution.startedAt).toLocaleString()}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{(execution.status === 'RUNNING' || execution.status === 'WAITING') && (
<button
onClick={() => setExecutionToCancel(execution.id)}
className="text-red-600 hover:text-red-900 disabled:opacity-50"
disabled={isCancelling}
>
Cancel
</button>
)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -320,105 +584,6 @@ export default function WorkflowEditorPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
) : activeTab === 'debug' ? (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Debug Information</CardTitle>
<CardDescription>Raw workflow data for debugging</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Workflow Steps */}
<div>
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Steps</h3>
<pre className="bg-neutral-50 p-4 rounded-lg text-xs overflow-x-auto">
{JSON.stringify(
workflow.steps.map(s => ({
id: s.id,
type: s.type,
name: s.name,
position: s.position,
config: s.config,
templateId: s.templateId,
})),
null,
2,
)}
</pre>
</div>
{/* All Transitions */}
<div>
<h3 className="text-sm font-semibold text-neutral-900 mb-3">All Transitions</h3>
<pre className="bg-neutral-50 p-4 rounded-lg text-xs overflow-x-auto">
{JSON.stringify(
workflow.steps.flatMap(step =>
step.outgoingTransitions.map(t => ({
id: t.id,
fromStepId: t.fromStepId,
fromStepName: step.name,
toStepId: t.toStepId,
toStepName: workflow.steps.find(s => s.id === t.toStepId)?.name,
condition: t.condition,
priority: t.priority,
})),
),
null,
2,
)}
</pre>
</div>
{/* Transition Analysis */}
<div>
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Transition Analysis</h3>
<div className="space-y-3">
{workflow.steps.map(step => {
if (step.outgoingTransitions.length === 0) return null;
return (
<div key={step.id} className="border border-neutral-200 rounded-lg p-3">
<div className="font-medium text-sm text-neutral-900 mb-2">
{step.name} ({step.type})
</div>
<div className="space-y-1 text-xs">
{step.outgoingTransitions.map(t => {
const toStep = workflow.steps.find(s => s.id === t.toStepId);
const branch =
t.condition && typeof t.condition === 'object' && 'branch' in t.condition
? t.condition.branch
: undefined;
return (
<div
key={t.id}
className={`flex items-start gap-2 ${
branch === 'yes'
? 'text-green-700 bg-green-50'
: branch === 'no'
? 'text-red-700 bg-red-50'
: 'text-neutral-700 bg-neutral-50'
} p-2 rounded`}
>
<span className="font-mono flex-shrink-0">
{branch === 'yes' ? '✓ YES' : branch === 'no' ? '✗ NO' : '→'}
</span>
<div className="flex-1">
<div> {toStep?.name || 'Unknown'}</div>
<div className="text-neutral-500 mt-1">
Priority: {t.priority} | ID: {t.id}
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
</div>
) : null} ) : null}
</div> </div>
@@ -441,6 +606,65 @@ export default function WorkflowEditorPage() {
onSuccess={() => mutate()} onSuccess={() => mutate()}
/> />
)} )}
{/* Cancel Single Execution Confirmation */}
<ConfirmDialog
open={!!executionToCancel}
onOpenChange={open => !open && setExecutionToCancel(null)}
onConfirm={() => {
if (executionToCancel) {
return handleCancelExecution(executionToCancel);
}
}}
title="Cancel Execution"
description={
executionToCancel && executionsData?.executions ? (
<div className="space-y-2">
<p>
Are you sure you want to cancel the workflow execution for{' '}
<strong>
{executionsData.executions.find(e => e.id === executionToCancel)?.contact.email || 'this contact'}
</strong>
?
</p>
<p className="text-sm text-neutral-600">
The contact will not receive any remaining emails or actions from this workflow. This action cannot
be undone.
</p>
</div>
) : (
'Are you sure you want to cancel this execution?'
)
}
confirmText="Cancel Execution"
cancelText="Keep Running"
variant="destructive"
isLoading={isCancelling}
/>
{/* Cancel All Executions Confirmation */}
<ConfirmDialog
open={showCancelAllDialog}
onOpenChange={setShowCancelAllDialog}
onConfirm={handleCancelAllExecutions}
title="Cancel All Active Executions"
description={
<div className="space-y-2">
<p>
Are you sure you want to cancel all <strong>{activeExecutionsCount}</strong> active execution
{activeExecutionsCount !== 1 ? 's' : ''}?
</p>
<p className="text-sm text-neutral-600">
All contacts currently in this workflow will be stopped and won&apos;t receive any remaining emails or
actions. This action cannot be undone.
</p>
</div>
}
confirmText={`Cancel ${activeExecutionsCount} Execution${activeExecutionsCount !== 1 ? 's' : ''}`}
cancelText="Keep Running"
variant="destructive"
isLoading={isCancelling}
/>
</> </>
)} )}
</DashboardLayout> </DashboardLayout>
@@ -1146,8 +1370,16 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
| 'minutes', | 'minutes',
); );
// CONDITION fields // CONDITION fields - handle both old format (object) and new format (string)
const [conditionField, setConditionField] = useState(String(config?.field || '')); const [conditionField, setConditionField] = useState(() => {
if (!config?.field) return '';
// Handle case where field is an object like {field: 'email', type: 'string'} (legacy format)
if (typeof config.field === 'object' && config.field !== null && 'field' in config.field) {
return String(config.field.field || '');
}
// Handle new format where field is just a string
return String(config.field);
});
const [conditionOperator, setConditionOperator] = useState(String(config?.operator || 'equals')); const [conditionOperator, setConditionOperator] = useState(String(config?.operator || 'equals'));
const [conditionValue, setConditionValue] = useState(String(config?.value ?? '')); const [conditionValue, setConditionValue] = useState(String(config?.value ?? ''));
const [availableFields, setAvailableFields] = useState<string[]>([]); const [availableFields, setAvailableFields] = useState<string[]>([]);
@@ -122,7 +122,7 @@ CREATE TABLE "segments" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"description" TEXT, "description" TEXT,
"filters" JSONB NOT NULL, "condition" JSONB NOT NULL,
"trackMembership" BOOLEAN NOT NULL DEFAULT false, "trackMembership" BOOLEAN NOT NULL DEFAULT false,
"memberCount" INTEGER NOT NULL DEFAULT 0, "memberCount" INTEGER NOT NULL DEFAULT 0,
"projectId" TEXT NOT NULL, "projectId" TEXT NOT NULL,
@@ -156,7 +156,7 @@ CREATE TABLE "campaigns" (
"fromName" TEXT, "fromName" TEXT,
"replyTo" TEXT, "replyTo" TEXT,
"audienceType" "CampaignAudienceType" NOT NULL DEFAULT 'ALL', "audienceType" "CampaignAudienceType" NOT NULL DEFAULT 'ALL',
"audienceFilter" JSONB, "audienceCondition" JSONB,
"segmentId" TEXT, "segmentId" TEXT,
"scheduledFor" TIMESTAMP(3), "scheduledFor" TIMESTAMP(3),
"totalRecipients" INTEGER NOT NULL DEFAULT 0, "totalRecipients" INTEGER NOT NULL DEFAULT 0,
+31 -10
View File
@@ -193,14 +193,29 @@ model Segment {
name String name String
description String? description String?
// Filter conditions (evaluated dynamically) // Filter condition (evaluated dynamically)
filters Json condition Json
// Array of conditions with AND/OR logic: // Nested filter structure with AND/OR logic:
// [ // {
// { field: "data.plan", operator: "equals", value: "FREE" }, // logic: "OR",
// { field: "subscribed", operator: "equals", value: true }, // groups: [
// { field: "emails.openedAt", operator: "within", value: 30, unit: "days" } // {
// ] // 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. // Operators: equals, notEquals, contains, greaterThan, lessThan, within, exists, etc.
// Track membership changes (enables segment entry/exit events) // Track membership changes (enables segment entry/exit events)
@@ -267,8 +282,8 @@ model Campaign {
replyTo String? replyTo String?
// Audience selection // Audience selection
audienceType CampaignAudienceType @default(ALL) audienceType CampaignAudienceType @default(ALL)
audienceFilter Json? // For FILTERED: manual filter conditions audienceCondition Json? // For FILTERED: manual filter condition with AND/OR logic (same structure as Segment.condition)
segment Segment? @relation(fields: [segmentId], references: [id]) segment Segment? @relation(fields: [segmentId], references: [id])
segmentId String? // For SEGMENT: reference to saved segment segmentId String? // For SEGMENT: reference to saved segment
@@ -556,6 +571,10 @@ model Email {
@@index([status]) @@index([status])
@@index([createdAt]) @@index([createdAt])
@@index([projectId, sourceType, createdAt]) // For billing limit queries @@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") @@map("emails")
} }
@@ -587,6 +606,8 @@ model Event {
@@index([contactId]) @@index([contactId])
@@index([emailId]) @@index([emailId])
@@index([createdAt]) @@index([createdAt])
@@index([projectId, contactId, name, createdAt]) // For event-based segment queries (fast!)
@@index([contactId, name, createdAt]) // For per-contact event lookups
@@map("events") @@map("events")
} }
+54 -61
View File
@@ -71,75 +71,66 @@ export const ContactSchemas = {
}), }),
}; };
export const SegmentSchemas = { const segmentFilterSchema = z.object({
filter: z.object({ field: z.string().min(1),
field: z.string().min(1), operator: z.enum([
operator: z.enum([ 'equals',
'equals', 'notEquals',
'notEquals', 'contains',
'contains', 'notContains',
'notContains', 'greaterThan',
'greaterThan', 'lessThan',
'lessThan', 'greaterThanOrEqual',
'greaterThanOrEqual', 'lessThanOrEqual',
'lessThanOrEqual', 'exists',
'exists', 'notExists',
'notExists', 'within',
'within', 'triggered',
]), 'triggeredWithin',
value: z.any().optional(), 'notTriggered',
unit: z.enum(['days', 'hours', 'minutes']).optional(), ]),
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({ create: z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
filters: z.array( condition: filterConditionSchema,
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(),
}),
),
trackMembership: z.boolean().default(false), trackMembership: z.boolean().default(false),
}), }),
update: z.object({ update: z.object({
name: z.string().min(1).max(100).optional(), name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
filters: z condition: filterConditionSchema.optional(),
.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(),
trackMembership: z.boolean().optional(), trackMembership: z.boolean().optional(),
}), }),
}; };
@@ -188,6 +179,7 @@ export const WorkflowSchemas = {
position: jsonSchema, position: jsonSchema,
config: jsonSchema, config: jsonSchema,
templateId: uuid.optional(), templateId: uuid.optional(),
autoConnect: z.boolean().optional(),
}), }),
updateStep: z.object({ updateStep: z.object({
name: z.string().min(1).max(100).optional(), name: z.string().min(1).max(100).optional(),
@@ -276,7 +268,7 @@ export const CampaignSchemas = {
fromName: z.string().max(100).optional(), fromName: z.string().max(100).optional(),
replyTo: email.optional(), replyTo: email.optional(),
audienceType: z.nativeEnum(CampaignAudienceType), audienceType: z.nativeEnum(CampaignAudienceType),
audienceFilter: jsonSchema.optional(), audienceCondition: filterConditionSchema.optional(),
segmentId: uuid.optional(), segmentId: uuid.optional(),
}), }),
schedule: z.object({ schedule: z.object({
@@ -291,6 +283,7 @@ export const CampaignSchemas = {
fromName: z.string().max(100).optional(), fromName: z.string().max(100).optional(),
replyTo: z.string().optional(), replyTo: z.string().optional(),
audienceType: z.nativeEnum(CampaignAudienceType).optional(), audienceType: z.nativeEnum(CampaignAudienceType).optional(),
audienceCondition: filterConditionSchema.optional(),
segmentId: z.string().optional(), segmentId: z.string().optional(),
}), }),
sendTest: z.object({ sendTest: z.object({
+33 -14
View File
@@ -1,33 +1,52 @@
// Segment filter types // 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 { export interface SegmentFilter {
field: string; field: string;
operator: operator: SegmentFilterOperator;
| 'equals'
| 'notEquals'
| 'contains'
| 'notContains'
| 'greaterThan'
| 'lessThan'
| 'greaterThanOrEqual'
| 'lessThanOrEqual'
| 'exists'
| 'notExists'
| 'within';
value?: any; value?: any;
unit?: 'days' | 'hours' | 'minutes'; unit?: 'days' | 'hours' | 'minutes';
} }
export interface FilterGroup {
filters: SegmentFilter[];
conditions?: FilterCondition;
}
export interface FilterCondition {
logic: SegmentFilterLogic;
groups: FilterGroup[];
}
export interface CreateSegmentData { export interface CreateSegmentData {
name: string; name: string;
description?: string; description?: string;
filters: SegmentFilter[]; condition: FilterCondition;
trackMembership?: boolean; trackMembership?: boolean;
} }
export interface UpdateSegmentData { export interface UpdateSegmentData {
name?: string; name?: string;
description?: string; description?: string;
filters?: SegmentFilter[]; condition?: FilterCondition;
trackMembership?: boolean; 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 './Chart';
export * from './Checkbox'; export * from './Checkbox';
export * from './Collapsible'; export * from './Collapsible';
export * from './Command';
export * from './Dialog'; export * from './Dialog';
export * from './DropdownMenu'; export * from './DropdownMenu';
export * from './Form'; export * from './Form';
@@ -8,7 +8,7 @@ export interface ConfirmDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onConfirm: () => void | Promise<void>; onConfirm: () => void | Promise<void>;
title: string; title: string;
description: string; description: React.ReactNode;
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
variant?: 'default' | 'destructive'; variant?: 'default' | 'destructive';