Add better segmenting
This commit is contained in:
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} "{String(data.config.value)}"
|
{data.config.operator} "{String(data.config.value)}"
|
||||||
@@ -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 "{stepToDeleteData?.name}" 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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[]>([]);
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user