From fe2e0380378a426143b44fca3091650fbb36cf53 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Tue, 2 Dec 2025 09:03:05 +0100 Subject: [PATCH] Add better segmenting --- apps/api/src/controllers/Campaigns.ts | 17 +- apps/api/src/controllers/Contacts.ts | 7 +- apps/api/src/controllers/Segments.ts | 16 +- apps/api/src/controllers/Workflows.ts | 20 + apps/api/src/services/CampaignService.ts | 45 +- apps/api/src/services/ContactService.ts | 83 ++- apps/api/src/services/SegmentService.ts | 417 ++++++++--- .../src/services/WorkflowExecutionService.ts | 22 + apps/api/src/services/WorkflowService.ts | 273 ++++++- apps/landing/next-env.d.ts | 2 +- .../src/components/SegmentFilterBuilder.tsx | 691 ++++++++++++++++++ apps/web/src/components/WorkflowBuilder.tsx | 88 ++- apps/web/src/pages/segments/[id].tsx | 219 +----- apps/web/src/pages/segments/index.tsx | 20 +- apps/web/src/pages/segments/new.tsx | 388 +++------- apps/web/src/pages/workflows/[id].tsx | 470 +++++++++--- .../migration.sql | 4 +- packages/db/prisma/schema.prisma | 41 +- packages/shared/src/schemas/index.ts | 115 ++- packages/types/src/index.ts | 47 +- packages/ui/src/components/atoms/Command.tsx | 152 ++++ packages/ui/src/components/atoms/index.ts | 1 + .../components/molecules/ConfirmDialog.tsx | 2 +- 23 files changed, 2285 insertions(+), 855 deletions(-) create mode 100644 apps/web/src/components/SegmentFilterBuilder.tsx rename packages/db/prisma/migrations/{20251201125842_init => 20251201192357_init}/migration.sql (99%) create mode 100644 packages/ui/src/components/atoms/Command.tsx diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts index e487231..a5472ae 100644 --- a/apps/api/src/controllers/Campaigns.ts +++ b/apps/api/src/controllers/Campaigns.ts @@ -8,7 +8,6 @@ import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth} from '../middleware/auth.js'; import {CampaignService} from '../services/CampaignService.js'; import {DomainService} from '../services/DomainService.js'; -import {type SegmentFilter} from '../services/SegmentService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @Controller('campaigns') @@ -22,7 +21,7 @@ export class Campaigns { @CatchAsync private async create(req: Request, res: Response, next: NextFunction) { 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); // Validate audience-specific fields @@ -30,8 +29,8 @@ export class Campaigns { throw new HttpException(400, 'Segment ID is required for SEGMENT audience type'); } - if (audienceType === CampaignAudienceType.FILTERED && !audienceFilter) { - throw new HttpException(400, 'Audience filter is required for FILTERED audience type'); + if (audienceType === CampaignAudienceType.FILTERED && !audienceCondition) { + throw new HttpException(400, 'Audience condition is required for FILTERED audience type'); } // Verify domain ownership and verification @@ -46,7 +45,7 @@ export class Campaigns { fromName, replyTo, audienceType, - audienceFilter: audienceFilter as SegmentFilter[] | undefined, + audienceCondition, segmentId, }); @@ -118,7 +117,7 @@ export class Campaigns { private async update(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; 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; // 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'); } - if (audienceType === CampaignAudienceType.FILTERED && audienceFilter === undefined) { - throw new HttpException(400, 'Audience filter is required for FILTERED audience type'); + if (audienceType === CampaignAudienceType.FILTERED && audienceCondition === undefined) { + throw new HttpException(400, 'Audience condition is required for FILTERED audience type'); } // Verify domain ownership and verification if 'from' is being updated @@ -144,7 +143,7 @@ export class Campaigns { fromName, replyTo, audienceType, - audienceFilter, + audienceCondition, segmentId, }); diff --git a/apps/api/src/controllers/Contacts.ts b/apps/api/src/controllers/Contacts.ts index 714aeec..394b8f2 100644 --- a/apps/api/src/controllers/Contacts.ts +++ b/apps/api/src/controllers/Contacts.ts @@ -47,6 +47,7 @@ export class Contacts { /** * GET /contacts/fields * 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') @Middleware([requireAuth]) @@ -55,11 +56,11 @@ export class Contacts { const auth = res.locals.auth as AuthResponse; try { - const fields = await ContactService.getAvailableFields(auth.projectId!); + const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!); return res.status(200).json({ - fields, - count: fields.length, + fields: fieldsWithTypes, + count: fieldsWithTypes.length, }); } catch (error) { console.error('[CONTACTS] Failed to get available fields:', error); diff --git a/apps/api/src/controllers/Segments.ts b/apps/api/src/controllers/Segments.ts index 95c2508..89fe9e1 100644 --- a/apps/api/src/controllers/Segments.ts +++ b/apps/api/src/controllers/Segments.ts @@ -74,20 +74,20 @@ export class Segments { @CatchAsync public async create(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; - const {name, description, filters, trackMembership} = req.body; + const {name, description, condition, trackMembership} = req.body; if (!name) { return res.status(400).json({error: 'Name is required'}); } - if (!filters || !Array.isArray(filters)) { - return res.status(400).json({error: 'Filters must be an array'}); + if (!condition || typeof condition !== 'object') { + return res.status(400).json({error: 'Condition is required and must be an object'}); } const segment = await SegmentService.create(auth.projectId!, { name, description, - filters, + condition, trackMembership, }); @@ -104,20 +104,20 @@ export class Segments { public async update(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const segmentId = req.params.id; - const {name, description, filters, trackMembership} = req.body; + const {name, description, condition, trackMembership} = req.body; if (!segmentId) { return res.status(400).json({error: 'Segment ID is required'}); } - if (filters !== undefined && !Array.isArray(filters)) { - return res.status(400).json({error: 'Filters must be an array'}); + if (condition !== undefined && typeof condition !== 'object') { + return res.status(400).json({error: 'Condition must be an object'}); } const segment = await SegmentService.update(auth.projectId!, segmentId, { name, description, - filters, + condition, trackMembership, }); diff --git a/apps/api/src/controllers/Workflows.ts b/apps/api/src/controllers/Workflows.ts index 00fb10c..de6e44d 100644 --- a/apps/api/src/controllers/Workflows.ts +++ b/apps/api/src/controllers/Workflows.ts @@ -370,4 +370,24 @@ export class Workflows { 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); + } } diff --git a/apps/api/src/services/CampaignService.ts b/apps/api/src/services/CampaignService.ts index 964dcda..4374f7f 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -1,5 +1,6 @@ import type {Campaign, Contact, Prisma} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus} from '@plunk/db'; +import type {FilterCondition} from '@plunk/types'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; @@ -8,7 +9,7 @@ import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js'; import {DomainService} from './DomainService.js'; import {EmailService} from './EmailService.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) @@ -21,7 +22,7 @@ export interface CreateCampaignData { fromName?: string; replyTo?: string; audienceType: CampaignAudienceType; - audienceFilter?: SegmentFilter[]; + audienceCondition?: FilterCondition; segmentId?: string; } @@ -34,7 +35,7 @@ export interface UpdateCampaignData { fromName?: string; replyTo?: string; audienceType?: CampaignAudienceType; - audienceFilter?: SegmentFilter[]; + audienceCondition?: FilterCondition; segmentId?: string; } @@ -61,10 +62,10 @@ export class CampaignService { } } - // Validate filters if provided - if (data.audienceType === CampaignAudienceType.FILTERED && data.audienceFilter) { - // This will throw if filters are invalid - SegmentService.validateFilters(data.audienceFilter); + // Validate condition if provided + if (data.audienceType === CampaignAudienceType.FILTERED && data.audienceCondition) { + // This will throw if condition is invalid + SegmentService.validateCondition(data.audienceCondition); } // Create campaign @@ -79,7 +80,7 @@ export class CampaignService { fromName: data.fromName, replyTo: data.replyTo, audienceType: data.audienceType, - audienceFilter: (data.audienceFilter || null) as unknown as Prisma.InputJsonValue, + audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue, segmentId: data.segmentId, status: CampaignStatus.DRAFT, }, @@ -105,11 +106,11 @@ export class CampaignService { updateData.audienceType = data.audienceType; } - if (data.audienceFilter !== undefined) { - if (data.audienceFilter) { - SegmentService.validateFilters(data.audienceFilter); + if (data.audienceCondition !== undefined) { + if (data.audienceCondition) { + 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) { @@ -230,7 +231,7 @@ export class CampaignService { fromName: campaign.fromName, replyTo: campaign.replyTo, audienceType: campaign.audienceType, - audienceFilter: campaign.audienceFilter as Prisma.InputJsonValue, + audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue, segmentId: campaign.segmentId, status: CampaignStatus.DRAFT, totalRecipients: 0, @@ -587,14 +588,17 @@ export class CampaignService { return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere); case CampaignAudienceType.FILTERED: { - const filters = campaign.audienceFilter as unknown as SegmentFilter[]; - if (!filters || filters.length === 0) { - throw new HttpException(400, 'Audience filters are required for FILTERED audience type'); + const condition = campaign.audienceCondition as unknown as FilterCondition; + if (!condition) { + 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 { ...baseWhere, - AND: filters.map(filter => SegmentService.buildFilterCondition(filter)), + ...segmentWhere, }; } @@ -611,7 +615,7 @@ export class CampaignService { segmentId: string, baseWhere: Prisma.ContactWhereInput, ): Promise { - // Fetch the segment to get its filters + // Fetch the segment to get its condition const segment = await prisma.segment.findUnique({ where: {id: segmentId}, }); @@ -620,11 +624,12 @@ export class CampaignService { 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 { ...baseWhere, - AND: filters.map(filter => SegmentService.buildFilterCondition(filter)), + ...segmentWhere, }; } diff --git a/apps/api/src/services/ContactService.ts b/apps/api/src/services/ContactService.ts index 5e65bec..0002da8 100644 --- a/apps/api/src/services/ContactService.ts +++ b/apps/api/src/services/ContactService.ts @@ -330,29 +330,80 @@ export class ContactService { /** * Get all available contact fields for a project * 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 - * @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 { - // Standard fields - const standardFields = ['subscribed']; + public static async getAvailableFields( + projectId: string, + ): Promise> { + // 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 - // Use raw SQL to extract all keys from the JSON data column - const result = await prisma.$queryRaw>` - SELECT DISTINCT jsonb_object_keys(data) as key - FROM contacts - WHERE - "projectId" = ${projectId} - AND data IS NOT NULL - AND jsonb_typeof(data) = 'object' + // Get custom fields from the data JSON column with type inference + // Use raw SQL to extract all keys and sample values from the JSON data column + const result = await prisma.$queryRaw>` + WITH field_keys AS ( + SELECT DISTINCT jsonb_object_keys(data) as key + FROM contacts + WHERE + "projectId" = ${projectId} + 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.") - const customFields = result.map(row => `data.${row.key}`); + // Infer types from JSON types and sample values + 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)); } /** diff --git a/apps/api/src/services/SegmentService.ts b/apps/api/src/services/SegmentService.ts index 4bc2ee5..bfbe5d6 100644 --- a/apps/api/src/services/SegmentService.ts +++ b/apps/api/src/services/SegmentService.ts @@ -1,27 +1,13 @@ import {type Contact, Prisma, type Segment} from '@plunk/db'; +import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; import {EventService} from './EventService.js'; -export interface SegmentFilter { - field: string; // e.g., "email", "data.plan", "subscribed" - operator: - | 'equals' - | 'notEquals' - | 'contains' - | 'notContains' - | 'greaterThan' - | 'lessThan' - | 'greaterThanOrEqual' - | 'lessThanOrEqual' - | 'exists' - | 'notExists' - | 'within'; // For date ranges - value?: unknown; - unit?: 'days' | 'hours' | 'minutes'; // For 'within' operator -} +// Re-export types for use in other services +export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types'; export interface PaginatedContacts { 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( projectId: string, @@ -86,9 +72,9 @@ export class SegmentService { pageSize = 20, ): Promise { 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 [contacts, total] = await Promise.all([ @@ -118,15 +104,15 @@ export class SegmentService { data: { name: string; description?: string; - filters: SegmentFilter[]; + condition: FilterCondition; trackMembership?: boolean; }, ): Promise { - // Validate filters - this.validateFilters(data.filters); + // Validate condition + this.validateCondition(data.condition); // 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}); return prisma.segment.create({ @@ -134,7 +120,7 @@ export class SegmentService { projectId, name: data.name, description: data.description, - filters: data.filters as unknown as Prisma.JsonArray, + condition: data.condition as unknown as Prisma.InputJsonValue, trackMembership: data.trackMembership ?? false, memberCount, }, @@ -150,16 +136,16 @@ export class SegmentService { data: { name?: string; description?: string; - filters?: SegmentFilter[]; + condition?: FilterCondition; trackMembership?: boolean; }, ): Promise { // First verify segment exists and belongs to project await this.get(projectId, segmentId); - // Validate filters if provided - if (data.filters) { - this.validateFilters(data.filters); + // Validate condition if provided + if (data.condition) { + this.validateCondition(data.condition); } const updateData: Prisma.SegmentUpdateInput = {}; @@ -170,11 +156,11 @@ export class SegmentService { if (data.description !== undefined) { updateData.description = data.description; } - if (data.filters !== undefined) { - updateData.filters = data.filters as unknown as Prisma.JsonArray; + if (data.condition !== undefined) { + updateData.condition = data.condition as unknown as Prisma.InputJsonValue; - // Recompute member count when filters change - const where = this.buildWhereClause(projectId, data.filters); + // Recompute member count when condition changes + const where = this.buildWhereClause(projectId, data.condition); updateData.memberCount = await prisma.contact.count({where}); } if (data.trackMembership !== undefined) { @@ -222,8 +208,8 @@ export class SegmentService { */ public static async refreshMemberCount(projectId: string, segmentId: string): Promise { const segment = await this.get(projectId, segmentId); - const filters = segment.filters as unknown as SegmentFilter[]; - const where = this.buildWhereClause(projectId, filters); + const condition = segment.condition as unknown as FilterCondition; + const where = this.buildWhereClause(projectId, condition); const memberCount = await prisma.contact.count({where}); @@ -242,7 +228,7 @@ export class SegmentService { public static async refreshAllMemberCounts(projectId: string): Promise { const segments = await prisma.segment.findMany({ where: {projectId}, - select: {id: true, filters: true}, + select: {id: true, condition: true}, }); // Process in batches to avoid overwhelming the database @@ -253,8 +239,8 @@ export class SegmentService { await Promise.all( batch.map(async segment => { try { - const filters = segment.filters as unknown as SegmentFilter[]; - const where = this.buildWhereClause(projectId, filters); + const condition = segment.condition as unknown as FilterCondition; + const where = this.buildWhereClause(projectId, condition); const memberCount = await prisma.contact.count({where}); await prisma.segment.update({ @@ -283,8 +269,8 @@ export class SegmentService { throw new HttpException(400, 'Segment does not have membership tracking enabled'); } - const filters = segment.filters as unknown as SegmentFilter[]; - const where = this.buildWhereClause(projectId, filters); + const condition = segment.condition as unknown as FilterCondition; + const where = this.buildWhereClause(projectId, condition); // Get all matching contacts using cursor-based pagination to avoid memory issues const BATCH_SIZE = 1000; @@ -428,6 +414,18 @@ export class SegmentService { public static buildFilterCondition(filter: SegmentFilter): Prisma.ContactWhereInput { 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") if (field.startsWith('data.')) { 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 { - if (!Array.isArray(filters)) { - throw new HttpException(400, 'Filters must be an array'); + public static validateCondition(condition: FilterCondition): void { + if (!condition || typeof condition !== 'object') { + throw new HttpException(400, 'Condition must be an object'); } - if (filters.length === 0) { - throw new HttpException(400, 'At least one filter is required'); + if (!condition.logic || !['AND', 'OR'].includes(condition.logic)) { + throw new HttpException(400, 'Condition logic must be either "AND" or "OR"'); } - for (const filter of filters) { - if (!filter.field) { - throw new HttpException(400, 'Filter field is required'); - } + if (!Array.isArray(condition.groups) || condition.groups.length === 0) { + throw new HttpException(400, 'Condition must have at least one group'); + } - if (!filter.operator) { - throw new HttpException(400, 'Filter operator is required'); - } - - 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)'); - } + for (const group of condition.groups) { + this.validateGroup(group); } } /** - * Build Prisma where clause from segment filters + * Validate filter group (recursive) */ - private static buildWhereClause(projectId: string, filters: SegmentFilter[]): Prisma.ContactWhereInput { - const where: Prisma.ContactWhereInput = { - projectId, - AND: filters.map(filter => this.buildFilterCondition(filter)), - }; + private static validateGroup(group: FilterGroup): void { + if (!group || typeof group !== 'object') { + throw new HttpException(400, 'Group must be an object'); + } - 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}`); } } + + /** + * 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 = { + 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}`); + } + } } diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts index a28c041..ecf22ac 100644 --- a/apps/api/src/services/WorkflowExecutionService.ts +++ b/apps/api/src/services/WorkflowExecutionService.ts @@ -87,6 +87,17 @@ export class WorkflowExecutionService { 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); if (!step) { throw new HttpException(404, 'Step not found in workflow'); @@ -135,6 +146,17 @@ export class WorkflowExecutionService { 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) await prisma.workflowStepExecution.update({ where: {id: stepExecution.id}, diff --git a/apps/api/src/services/WorkflowService.ts b/apps/api/src/services/WorkflowService.ts index cb518b7..9ecbff0 100644 --- a/apps/api/src/services/WorkflowService.ts +++ b/apps/api/src/services/WorkflowService.ts @@ -31,6 +31,20 @@ export interface WorkflowExecutionWithDetails extends WorkflowExecution { } export class WorkflowService { + /** + * Check if a workflow has active executions + */ + private static async hasActiveExecutions(workflowId: string): Promise { + return prisma.workflowExecution.count({ + where: { + workflowId, + status: { + in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING], + }, + }, + }); + } + /** * Get all workflows for a project with pagination */ @@ -175,7 +189,26 @@ export class WorkflowService { }, ): Promise { // 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 = {}; @@ -299,7 +332,7 @@ export class WorkflowService { }, ): Promise { // 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 const step = await prisma.workflowStep.findUnique({ @@ -310,6 +343,26 @@ export class WorkflowService { 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 = {}; if (data.name !== undefined) updateData.name = data.name; @@ -333,10 +386,13 @@ export class WorkflowService { * Delete a workflow step */ public static async deleteStep(projectId: string, workflowId: string, stepId: string): Promise { - await this.get(projectId, workflowId); + const workflow = await this.get(projectId, workflowId); const step = await prisma.workflowStep.findUnique({ where: {id: stepId}, + include: { + outgoingTransitions: true, + }, }); 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.'); } - await prisma.workflowStep.delete({ - where: {id: stepId}, + // Check if workflow is enabled and has active executions on this step or downstream + 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(); + for (const s of allSteps) { + adjacencyMap.set( + s.id, + s.outgoingTransitions.map(t => t.toStepId), + ); + } + + // Find all downstream steps + const downstreamSteps = new Set([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(); + 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([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 */ public static async deleteTransition(projectId: string, workflowId: string, transitionId: string): Promise { + // Get workflow to check if it's enabled + const workflow = await this.get(projectId, workflowId); + // Verify transition exists and belongs to workflow const transition = await prisma.workflowTransition.findFirst({ where: { @@ -433,12 +601,70 @@ export class WorkflowService { workflow: {projectId}, }, }, + include: { + fromStep: true, + toStep: true, + }, }); if (!transition) { 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(); + 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([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({ where: {id: transitionId}, }); @@ -646,26 +872,55 @@ export class WorkflowService { data: { status: WorkflowExecutionStatus.CANCELLED, 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) */ public static async getAvailableFields(projectId: string, eventName?: string) { // Get contact fields (standard + custom data fields) - const contactFields = await ContactService.getAvailableFields(projectId); + const contactFieldsWithTypes = await ContactService.getAvailableFields(projectId); - // Add standard contact fields - const standardContactFields = ['contact.email', 'contact.subscribed']; + // Extract just the field names and prefix with 'contact.' + const contactFields = contactFieldsWithTypes.map(f => `contact.${f.field}`); // Get event fields by analyzing actual event data // This will only show fields that have been seen in actual events const eventFields = await EventService.getAvailableEventFields(projectId, eventName); // Combine all fields - const allFields = [...standardContactFields, ...contactFields, ...eventFields].sort(); + const allFields = [...contactFields, ...eventFields].sort(); return { fields: allFields, diff --git a/apps/landing/next-env.d.ts b/apps/landing/next-env.d.ts index 7996d35..1970904 100644 --- a/apps/landing/next-env.d.ts +++ b/apps/landing/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/apps/web/src/components/SegmentFilterBuilder.tsx b/apps/web/src/components/SegmentFilterBuilder.tsx new file mode 100644 index 0000000..31226f5 --- /dev/null +++ b/apps/web/src/components/SegmentFilterBuilder.tsx @@ -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([...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>((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); + + return ( +
+
+ {/* Field Selection */} +
+ + + + + + +
+ + setSearch(e.target.value)} + className="border-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0" + /> +
+
+ {Object.keys(filteredGroups).length === 0 ? ( +
+ No fields or events found. +
+ ) : ( + Object.entries(filteredGroups).map(([category, fields]) => ( +
+
+ {category} +
+ {fields.map(field => ( + + ))} +
+ )) + )} +
+
+
+
+ + {/* Operator Selection */} +
+ + +
+ + {/* Value Input */} +
+ + {!needsValue ? ( +
+ No value needed +
+ ) : needsUnit ? ( +
+ onChange({...filter, value: parseInt(e.target.value) || 0})} + className="text-sm flex-1" + min="1" + /> + +
+ ) : fieldType === 'boolean' ? ( + + ) : fieldType === 'number' ? ( + { + const val = e.target.value; + onChange({...filter, value: val === '' ? 0 : parseFloat(val) || 0}); + }} + className="text-sm" + placeholder="Enter number" + /> + ) : fieldType === 'date' ? ( + onChange({...filter, value: e.target.value})} + className="text-sm" + /> + ) : ( + onChange({...filter, value: e.target.value})} + className="text-sm" + placeholder="Enter value" + /> + )} +
+
+ + +
+ ); +} + +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 ( +
+
+
+ + Filter Group {depth > 0 && `(Nested)`} +
+ {onRemove && ( + + )} +
+ +
+ {group.filters.map((filter, index) => ( + updateFilter(index, f)} onRemove={() => removeFilter(index)} availableFields={availableFields} /> + ))} + + {group.conditions && ( +
+
+ Nested Conditions + +
+ +
+ )} + +
+ + {!group.conditions && ( + + )} +
+
+
+ ); +} + +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 ( +
+
+
+ Groups are combined with: + +
+
+ +
+ {condition.groups.map((group, index) => ( +
+ {index > 0 && ( +
+
{condition.logic}
+
+ )} + updateGroup(index, g)} + onRemove={condition.groups.length > 1 ? () => removeGroup(index) : undefined} + depth={depth} + availableFields={availableFields} + /> +
+ ))} +
+ + +
+ ); +} + +interface SegmentFilterBuilderProps { + condition: FilterCondition; + onChange: (condition: FilterCondition) => void; +} + +export function SegmentFilterBuilder({condition, onChange}: SegmentFilterBuilderProps) { + const {fields, loading} = useAvailableOptions(); + + return ( +
+
+
+

Filter Conditions

+

Build complex audience filters with AND/OR logic

+
+
+ {loading ? ( +
Loading available fields and events...
+ ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/WorkflowBuilder.tsx b/apps/web/src/components/WorkflowBuilder.tsx index a1db274..7b373db 100644 --- a/apps/web/src/components/WorkflowBuilder.tsx +++ b/apps/web/src/components/WorkflowBuilder.tsx @@ -261,7 +261,12 @@ function CustomNode({
🔀 - {data.config.field} + + {/* 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)} +
{data.config.operator} "{String(data.config.value)}" @@ -582,6 +587,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr name: `New ${stepType.toLowerCase().replace('_', ' ')}`, position: {x: 0, y: 0}, // Will be auto-positioned by dagre layout 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); }; + // 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(); + 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 () => { if (!stepToDelete) return; try { 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(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to delete step'); @@ -782,15 +821,42 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr - + {stepToDelete && (() => { + const affectedSteps = getAffectedSteps(stepToDelete); + const stepToDeleteData = steps.find(s => s.id === stepToDelete); + const downstreamSteps = affectedSteps.filter(s => s.id !== stepToDelete); + + return ( + 0 ? ( +
+

+ Deleting "{stepToDeleteData?.name}" will also delete {downstreamSteps.length} downstream{' '} + {downstreamSteps.length === 1 ? 'step' : 'steps'}: +

+
    + {downstreamSteps.map(step => ( +
  • + {step.name} ({step.type}) +
  • + ))} +
+

This action cannot be undone.

+
+ ) : ( + `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" + /> + ); + })()} ); } diff --git a/apps/web/src/pages/segments/[id].tsx b/apps/web/src/pages/segments/[id].tsx index 5b00180..d7a64e0 100644 --- a/apps/web/src/pages/segments/[id].tsx +++ b/apps/web/src/pages/segments/[id].tsx @@ -35,38 +35,9 @@ import {useRouter} from 'next/router'; import {useEffect, useState} from 'react'; import {toast} from 'sonner'; import useSWR from 'swr'; -import type {SegmentFilter} from '@plunk/types'; +import type {FilterCondition} from '@plunk/types'; 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; +import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder'; interface PaginatedContacts { contacts: Contact[]; @@ -76,6 +47,18 @@ interface PaginatedContacts { 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() { const router = useRouter(); const {id} = router.query; @@ -89,7 +72,10 @@ export default function SegmentDetailPage() { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [trackMembership, setTrackMembership] = useState(false); - const [filters, setFilters] = useState([]); + const [condition, setCondition] = useState({ + logic: 'AND', + groups: [{filters: [{field: 'subscribed', operator: 'equals', value: true}]}], + }); const [isSubmitting, setIsSubmitting] = useState(false); const [isComputing, setIsComputing] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); @@ -100,22 +86,13 @@ export default function SegmentDetailPage() { setName(segment.name); setDescription(segment.description || ''); 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]); - 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) => { - setFilters(filters.map((filter, i) => (i === index ? {...filter, ...updates} : filter))); - }; - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsSubmitting(true); @@ -124,7 +101,7 @@ export default function SegmentDetailPage() { await network.fetch('PATCH', `/segments/${id}`, { name, description: description || undefined, - filters, + condition, trackMembership, }); 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) { return ( @@ -297,139 +267,17 @@ export default function SegmentDetailPage() { - {/* Filters */} + {/* Filter Builder */} - -
-
- Filters - Define conditions to match contacts -
- -
-
- - {filters.map((filter, index) => ( -
-
- {/* Field */} -
- - - {filter.field === 'custom' && ( - updateFilter(index, {field: e.target.value})} - /> - )} -
- - {/* Operator */} -
- - -
- - {/* Value */} - {needsValue(filter.operator) && ( -
- - {filter.field === 'subscribed' ? ( - - ) : ( - updateFilter(index, {value: e.target.value})} - placeholder="Enter value" - /> - )} -
- )} - - {/* Unit */} - {needsUnit(filter.operator) && ( -
- - -
- )} -
- - -
- ))} + +
{/* Actions */}
-
@@ -536,7 +384,16 @@ export default function SegmentDetailPage() { Filters
- {Array.isArray(segment.filters) ? segment.filters.length : 0} + {countFilters(segment.condition as unknown as FilterCondition)} + +
+
+
+ + Groups +
+ + {(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
diff --git a/apps/web/src/pages/segments/index.tsx b/apps/web/src/pages/segments/index.tsx index 9b0f76d..f4c628a 100644 --- a/apps/web/src/pages/segments/index.tsx +++ b/apps/web/src/pages/segments/index.tsx @@ -10,6 +10,7 @@ import { ConfirmDialog, } from '@plunk/ui'; import type {Segment} from '@plunk/db'; +import type {FilterCondition} from '@plunk/types'; import {DashboardLayout} from '../../components/DashboardLayout'; import {network} from '../../lib/network'; import {AlertTriangle, Edit, Filter, Plus, Trash2, Users} from 'lucide-react'; @@ -19,6 +20,23 @@ import {useState} from 'react'; import {toast} from 'sonner'; 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() { // Limit to 50 segments to avoid loading thousands into the browser const { @@ -147,7 +165,7 @@ export default function SegmentsPage() { Filters - {Array.isArray(segment.filters) ? segment.filters.length : 0} + {countFiltersInCondition(segment.condition)} diff --git a/apps/web/src/pages/segments/new.tsx b/apps/web/src/pages/segments/new.tsx index 396b4f2..ccc183d 100644 --- a/apps/web/src/pages/segments/new.tsx +++ b/apps/web/src/pages/segments/new.tsx @@ -1,80 +1,32 @@ -import { - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@plunk/ui'; +import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label} from '@plunk/ui'; import {NextSeo} from 'next-seo'; import {DashboardLayout} from '../../components/DashboardLayout'; +import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder'; 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 {useRouter} from 'next/router'; import {useState} from 'react'; import {toast} from 'sonner'; -import type {SegmentFilter} from '@plunk/types'; +import type {FilterCondition} from '@plunk/types'; import type {Segment} from '@plunk/db'; 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() { const router = useRouter(); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [trackMembership, setTrackMembership] = useState(false); - const [filters, setFilters] = useState([{field: 'subscribed', operator: 'equals', value: true}]); + const [condition, setCondition] = useState({ + logic: 'AND', + groups: [ + { + filters: [{field: 'subscribed', operator: 'equals', value: true}], + }, + ], + }); 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) => { - setFilters(filters.map((filter, i) => (i === index ? {...filter, ...updates} : filter))); - }; - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsSubmitting(true); @@ -83,7 +35,7 @@ export default function NewSegmentPage() { await network.fetch('POST', '/segments', { name, description: description || undefined, - filters, + condition, trackMembership, }); 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 ( <> - -
- {/* Header */} -
- - - -
-

Create Segment

-

Define filters to automatically group contacts

-
-
- -
- {/* Basic Info */} - - - Segment Details - Give your segment a name and description - - -
- - setName(e.target.value)} - required - placeholder="e.g., Active Pro Users" - maxLength={100} - /> -
- -
- - setDescription(e.target.value)} - placeholder="e.g., Users on pro plan who have been active in the last 30 days" - maxLength={500} - /> -
- -
- setTrackMembership(e.target.checked)} - className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded" - /> -
- -

- When enabled, segment entry and exit events will be tracked for use in workflows and analytics -

-
-
-
-
- - {/* Filters */} - - -
-
- Filters - Define conditions to match contacts (all filters must match) -
- -
-
- - {filters.length === 0 ? ( -
- -

No filters defined. Add at least one filter.

- -
- ) : ( - filters.map((filter, index) => ( -
-
- {/* Field */} -
- - - {filter.field === 'custom' && ( - updateFilter(index, {field: e.target.value})} - /> - )} -
- - {/* Operator */} -
- - -
- - {/* Value */} - {needsValue(filter.operator) && ( -
- - {filter.field === 'subscribed' ? ( - - ) : ( - updateFilter(index, {value: e.target.value})} - placeholder="Enter value" - /> - )} -
- )} - - {/* Unit (for within operator) */} - {needsUnit(filter.operator) && ( -
- - -
- )} -
- - {/* Remove button */} - -
- )) - )} -
-
- - {/* Actions */} -
+ +
+ {/* Header */} +
- - +
+

Create Segment

+

Build complex audience filters with AND/OR logic

+
- -
-
+ +
+ {/* Basic Info */} + + + Segment Details + Give your segment a name and description + + +
+ + setName(e.target.value)} + required + placeholder="e.g., VIP Customers or Recent High Spenders" + maxLength={100} + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="e.g., Users on VIP plan OR recent signups who spent $1000+" + maxLength={500} + /> +
+ +
+ setTrackMembership(e.target.checked)} + className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded" + /> +
+ +

+ When enabled, segment entry and exit events will be tracked for use in workflows and analytics +

+
+
+
+
+ + {/* Filter Builder */} + + + + + + + {/* Actions */} +
+ + + + +
+
+
+ ); } diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index 755945b..dd0d1d4 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -6,6 +6,7 @@ import { CardDescription, CardHeader, CardTitle, + ConfirmDialog, Dialog, DialogContent, DialogFooter, @@ -55,10 +56,13 @@ interface PaginatedExecutions { export default function WorkflowEditorPage() { const router = useRouter(); 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 [showTestDialog, setShowTestDialog] = useState(false); const [editingStep, setEditingStep] = useState(null); + const [showCancelAllDialog, setShowCancelAllDialog] = useState(false); + const [executionToCancel, setExecutionToCancel] = useState(null); + const [isCancelling, setIsCancelling] = useState(false); const {data: workflow, mutate} = useSWR(id ? `/workflows/${id}` : null, { revalidateOnFocus: false, @@ -69,9 +73,181 @@ export default function WorkflowEditorPage() { {revalidateOnFocus: false}, ); + // Always fetch a summary of active executions to show warnings (regardless of enabled status) + const {data: activeExecutionsData} = useSWR( + id ? `/workflows/${id}/executions?page=1&pageSize=1&status=RUNNING` : null, + {revalidateOnFocus: false, refreshInterval: 10000}, + ); + + const {data: waitingExecutionsData} = useSWR( + 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 () => { if (!workflow) return; + // If trying to enable, validate first + if (!workflow.enabled) { + const validation = validateWorkflow(workflow); + if (!validation.valid) { + toast.error( +
+
Cannot enable workflow
+
    + {validation.errors.map((error, i) => ( +
  • {error}
  • + ))} +
+
, + {duration: 8000}, + ); + return; + } + } + try { await network.fetch('PATCH', `/workflows/${id}`, { enabled: !workflow.enabled, @@ -198,6 +374,79 @@ export default function WorkflowEditorPage() {
+ {/* Active Executions Warning Banner */} + {activeExecutionsCount > 0 && ( +
+
+
+ + + +
+
+

+ {workflow.enabled ? 'Workflow is active with running executions' : 'Workflow has active executions'} +

+
+

+ This workflow has {activeExecutionsCount} active execution + {activeExecutionsCount !== 1 ? 's' : ''}.{' '} + {!workflow.enabled && 'Even though the workflow is disabled, existing executions will continue. '} + To protect running workflows, you cannot: +

+
    +
  • Delete steps or transitions
  • +
  • Modify step configurations (email templates, conditions, etc.)
  • +
  • Change the workflow trigger
  • +
+

+ 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. +

+
+
+
+
+ )} + + {/* Validation Warning Banner */} + {!workflow.enabled && (() => { + const validation = validateWorkflow(workflow); + if (!validation.valid) { + return ( +
+
+
+ + + +
+
+

Workflow has validation errors

+
+

Fix the following issues before enabling this workflow:

+
    + {validation.errors.map((error, i) => ( +
  • {error}
  • + ))} +
+
+
+
+
+ ); + } + return null; + })()} + {/* Tabs */}
@@ -254,8 +491,17 @@ export default function WorkflowEditorPage() { ) : activeTab === 'executions' ? ( - Workflow Executions - View all executions of this workflow +
+
+ Workflow Executions + View and manage all executions of this workflow +
+ {activeExecutionsCount > 0 && ( + + )} +
{!executionsData?.executions.length ? ( @@ -283,6 +529,9 @@ export default function WorkflowEditorPage() { Started + + Actions + @@ -298,9 +547,13 @@ export default function WorkflowEditorPage() { ? 'bg-green-100 text-green-800' : execution.status === 'RUNNING' ? 'bg-blue-100 text-blue-800' - : execution.status === 'FAILED' - ? 'bg-red-100 text-red-800' - : 'bg-gray-100 text-gray-800' + : execution.status === 'WAITING' + ? 'bg-yellow-100 text-yellow-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} @@ -312,6 +565,17 @@ export default function WorkflowEditorPage() { {new Date(execution.startedAt).toLocaleString()} + + {(execution.status === 'RUNNING' || execution.status === 'WAITING') && ( + + )} + ))} @@ -320,105 +584,6 @@ export default function WorkflowEditorPage() { )}
- ) : activeTab === 'debug' ? ( -
- - - Debug Information - Raw workflow data for debugging - - - {/* Workflow Steps */} -
-

Steps

-
-                    {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,
-                    )}
-                  
-
- - {/* All Transitions */} -
-

All Transitions

-
-                    {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,
-                    )}
-                  
-
- - {/* Transition Analysis */} -
-

Transition Analysis

-
- {workflow.steps.map(step => { - if (step.outgoingTransitions.length === 0) return null; - return ( -
-
- {step.name} ({step.type}) -
-
- {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 ( -
- - {branch === 'yes' ? '✓ YES' : branch === 'no' ? '✗ NO' : '→'} - -
-
→ {toStep?.name || 'Unknown'}
-
- Priority: {t.priority} | ID: {t.id} -
-
-
- ); - })} -
-
- ); - })} -
-
-
-
-
) : null} @@ -441,6 +606,65 @@ export default function WorkflowEditorPage() { onSuccess={() => mutate()} /> )} + + {/* Cancel Single Execution Confirmation */} + !open && setExecutionToCancel(null)} + onConfirm={() => { + if (executionToCancel) { + return handleCancelExecution(executionToCancel); + } + }} + title="Cancel Execution" + description={ + executionToCancel && executionsData?.executions ? ( +
+

+ Are you sure you want to cancel the workflow execution for{' '} + + {executionsData.executions.find(e => e.id === executionToCancel)?.contact.email || 'this contact'} + + ? +

+

+ The contact will not receive any remaining emails or actions from this workflow. This action cannot + be undone. +

+
+ ) : ( + 'Are you sure you want to cancel this execution?' + ) + } + confirmText="Cancel Execution" + cancelText="Keep Running" + variant="destructive" + isLoading={isCancelling} + /> + + {/* Cancel All Executions Confirmation */} + +

+ Are you sure you want to cancel all {activeExecutionsCount} active execution + {activeExecutionsCount !== 1 ? 's' : ''}? +

+

+ All contacts currently in this workflow will be stopped and won't receive any remaining emails or + actions. This action cannot be undone. +

+ + } + confirmText={`Cancel ${activeExecutionsCount} Execution${activeExecutionsCount !== 1 ? 's' : ''}`} + cancelText="Keep Running" + variant="destructive" + isLoading={isCancelling} + /> )}
@@ -1146,8 +1370,16 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS | 'minutes', ); - // CONDITION fields - const [conditionField, setConditionField] = useState(String(config?.field || '')); + // CONDITION fields - handle both old format (object) and new format (string) + 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 [conditionValue, setConditionValue] = useState(String(config?.value ?? '')); const [availableFields, setAvailableFields] = useState([]); diff --git a/packages/db/prisma/migrations/20251201125842_init/migration.sql b/packages/db/prisma/migrations/20251201192357_init/migration.sql similarity index 99% rename from packages/db/prisma/migrations/20251201125842_init/migration.sql rename to packages/db/prisma/migrations/20251201192357_init/migration.sql index 4f97b76..d642687 100644 --- a/packages/db/prisma/migrations/20251201125842_init/migration.sql +++ b/packages/db/prisma/migrations/20251201192357_init/migration.sql @@ -122,7 +122,7 @@ CREATE TABLE "segments" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "description" TEXT, - "filters" JSONB NOT NULL, + "condition" JSONB NOT NULL, "trackMembership" BOOLEAN NOT NULL DEFAULT false, "memberCount" INTEGER NOT NULL DEFAULT 0, "projectId" TEXT NOT NULL, @@ -156,7 +156,7 @@ CREATE TABLE "campaigns" ( "fromName" TEXT, "replyTo" TEXT, "audienceType" "CampaignAudienceType" NOT NULL DEFAULT 'ALL', - "audienceFilter" JSONB, + "audienceCondition" JSONB, "segmentId" TEXT, "scheduledFor" TIMESTAMP(3), "totalRecipients" INTEGER NOT NULL DEFAULT 0, diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 709acef..8637fee 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -193,14 +193,29 @@ model Segment { name String description String? - // Filter conditions (evaluated dynamically) - filters Json - // Array of conditions with AND/OR logic: - // [ - // { field: "data.plan", operator: "equals", value: "FREE" }, - // { field: "subscribed", operator: "equals", value: true }, - // { field: "emails.openedAt", operator: "within", value: 30, unit: "days" } - // ] + // Filter condition (evaluated dynamically) + condition Json + // Nested filter structure with AND/OR logic: + // { + // logic: "OR", + // groups: [ + // { + // filters: [ + // { field: "data.plan", operator: "equals", value: "VIP" }, + // { field: "subscribed", operator: "equals", value: true } + // ] + // }, + // { + // filters: [ + // { field: "createdAt", operator: "within", value: 30, unit: "days" } + // ], + // conditions: { + // logic: "AND", + // groups: [...] + // } + // } + // ] + // } // Operators: equals, notEquals, contains, greaterThan, lessThan, within, exists, etc. // Track membership changes (enables segment entry/exit events) @@ -267,8 +282,8 @@ model Campaign { replyTo String? // Audience selection - audienceType CampaignAudienceType @default(ALL) - audienceFilter Json? // For FILTERED: manual filter conditions + audienceType CampaignAudienceType @default(ALL) + audienceCondition Json? // For FILTERED: manual filter condition with AND/OR logic (same structure as Segment.condition) segment Segment? @relation(fields: [segmentId], references: [id]) segmentId String? // For SEGMENT: reference to saved segment @@ -556,6 +571,10 @@ model Email { @@index([status]) @@index([createdAt]) @@index([projectId, sourceType, createdAt]) // For billing limit queries + @@index([contactId, openedAt]) // For email activity segment queries + @@index([contactId, clickedAt]) // For email activity segment queries + @@index([contactId, bouncedAt]) // For email activity segment queries + @@index([contactId, complainedAt]) // For email activity segment queries @@map("emails") } @@ -587,6 +606,8 @@ model Event { @@index([contactId]) @@index([emailId]) @@index([createdAt]) + @@index([projectId, contactId, name, createdAt]) // For event-based segment queries (fast!) + @@index([contactId, name, createdAt]) // For per-contact event lookups @@map("events") } diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index bb92f4e..1869978 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -71,75 +71,66 @@ export const ContactSchemas = { }), }; -export const SegmentSchemas = { - filter: z.object({ - field: z.string().min(1), - operator: z.enum([ - 'equals', - 'notEquals', - 'contains', - 'notContains', - 'greaterThan', - 'lessThan', - 'greaterThanOrEqual', - 'lessThanOrEqual', - 'exists', - 'notExists', - 'within', - ]), - value: z.any().optional(), - unit: z.enum(['days', 'hours', 'minutes']).optional(), +const segmentFilterSchema = z.object({ + field: z.string().min(1), + operator: z.enum([ + 'equals', + 'notEquals', + 'contains', + 'notContains', + 'greaterThan', + 'lessThan', + 'greaterThanOrEqual', + 'lessThanOrEqual', + 'exists', + 'notExists', + 'within', + 'triggered', + 'triggeredWithin', + 'notTriggered', + ]), + value: z.any().optional(), + unit: z.enum(['days', 'hours', 'minutes']).optional(), +}); + +type FilterGroup = { + filters: z.infer[]; + conditions?: FilterCondition; +}; + +type FilterCondition = { + logic: 'AND' | 'OR'; + groups: FilterGroup[]; +}; + +const filterGroupSchema: z.ZodType = z.lazy(() => + z.object({ + filters: z.array(segmentFilterSchema), + conditions: filterConditionSchema.optional(), }), +); + +const filterConditionSchema: z.ZodType = z.lazy(() => + z.object({ + logic: z.enum(['AND', 'OR']), + groups: z.array(filterGroupSchema).min(1), + }), +); + +export const SegmentSchemas = { + filter: segmentFilterSchema, + filterGroup: filterGroupSchema, + filterCondition: filterConditionSchema, create: z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), - filters: z.array( - z.object({ - field: z.string().min(1), - operator: z.enum([ - 'equals', - 'notEquals', - 'contains', - 'notContains', - 'greaterThan', - 'lessThan', - 'greaterThanOrEqual', - 'lessThanOrEqual', - 'exists', - 'notExists', - 'within', - ]), - value: z.any().optional(), - unit: z.enum(['days', 'hours', 'minutes']).optional(), - }), - ), + condition: filterConditionSchema, trackMembership: z.boolean().default(false), }), update: z.object({ name: z.string().min(1).max(100).optional(), description: z.string().max(500).optional(), - filters: z - .array( - z.object({ - field: z.string().min(1), - operator: z.enum([ - 'equals', - 'notEquals', - 'contains', - 'notContains', - 'greaterThan', - 'lessThan', - 'greaterThanOrEqual', - 'lessThanOrEqual', - 'exists', - 'notExists', - 'within', - ]), - value: z.any().optional(), - unit: z.enum(['days', 'hours', 'minutes']).optional(), - }), - ) - .optional(), + condition: filterConditionSchema.optional(), trackMembership: z.boolean().optional(), }), }; @@ -188,6 +179,7 @@ export const WorkflowSchemas = { position: jsonSchema, config: jsonSchema, templateId: uuid.optional(), + autoConnect: z.boolean().optional(), }), updateStep: z.object({ name: z.string().min(1).max(100).optional(), @@ -276,7 +268,7 @@ export const CampaignSchemas = { fromName: z.string().max(100).optional(), replyTo: email.optional(), audienceType: z.nativeEnum(CampaignAudienceType), - audienceFilter: jsonSchema.optional(), + audienceCondition: filterConditionSchema.optional(), segmentId: uuid.optional(), }), schedule: z.object({ @@ -291,6 +283,7 @@ export const CampaignSchemas = { fromName: z.string().max(100).optional(), replyTo: z.string().optional(), audienceType: z.nativeEnum(CampaignAudienceType).optional(), + audienceCondition: filterConditionSchema.optional(), segmentId: z.string().optional(), }), sendTest: z.object({ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d1dce4e..cbfb15c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,33 +1,52 @@ // Segment filter types +export type SegmentFilterOperator = + // Standard operators (for contact fields) + | 'equals' + | 'notEquals' + | 'contains' + | 'notContains' + | 'greaterThan' + | 'lessThan' + | 'greaterThanOrEqual' + | 'lessThanOrEqual' + | 'exists' + | 'notExists' + | 'within' + // Event-based operators + | 'triggered' // Event/email activity occurred (any time) + | 'triggeredWithin' // Event/email activity occurred within timeframe + | 'notTriggered'; // Event/email activity never occurred + +export type SegmentFilterLogic = 'AND' | 'OR'; + export interface SegmentFilter { field: string; - operator: - | 'equals' - | 'notEquals' - | 'contains' - | 'notContains' - | 'greaterThan' - | 'lessThan' - | 'greaterThanOrEqual' - | 'lessThanOrEqual' - | 'exists' - | 'notExists' - | 'within'; + operator: SegmentFilterOperator; value?: any; unit?: 'days' | 'hours' | 'minutes'; } +export interface FilterGroup { + filters: SegmentFilter[]; + conditions?: FilterCondition; +} + +export interface FilterCondition { + logic: SegmentFilterLogic; + groups: FilterGroup[]; +} + export interface CreateSegmentData { name: string; description?: string; - filters: SegmentFilter[]; + condition: FilterCondition; trackMembership?: boolean; } export interface UpdateSegmentData { name?: string; description?: string; - filters?: SegmentFilter[]; + condition?: FilterCondition; trackMembership?: boolean; } diff --git a/packages/ui/src/components/atoms/Command.tsx b/packages/ui/src/components/atoms/Command.tsx new file mode 100644 index 0000000..c5e286f --- /dev/null +++ b/packages/ui/src/components/atoms/Command.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +const CommandDialog = ({children, ...props}: React.ComponentProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = 'CommandShortcut'; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandSeparator, + CommandShortcut, +}; diff --git a/packages/ui/src/components/atoms/index.ts b/packages/ui/src/components/atoms/index.ts index 530dd03..9fb081f 100644 --- a/packages/ui/src/components/atoms/index.ts +++ b/packages/ui/src/components/atoms/index.ts @@ -5,6 +5,7 @@ export * from './Card'; export * from './Chart'; export * from './Checkbox'; export * from './Collapsible'; +export * from './Command'; export * from './Dialog'; export * from './DropdownMenu'; export * from './Form'; diff --git a/packages/ui/src/components/molecules/ConfirmDialog.tsx b/packages/ui/src/components/molecules/ConfirmDialog.tsx index a2cb6c7..ca9911e 100644 --- a/packages/ui/src/components/molecules/ConfirmDialog.tsx +++ b/packages/ui/src/components/molecules/ConfirmDialog.tsx @@ -8,7 +8,7 @@ export interface ConfirmDialogProps { onOpenChange: (open: boolean) => void; onConfirm: () => void | Promise; title: string; - description: string; + description: React.ReactNode; confirmText?: string; cancelText?: string; variant?: 'default' | 'destructive';