diff --git a/apps/api/src/services/WorkflowService.ts b/apps/api/src/services/WorkflowService.ts index f0a314c..5ac8168 100644 --- a/apps/api/src/services/WorkflowService.ts +++ b/apps/api/src/services/WorkflowService.ts @@ -33,20 +33,6 @@ 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 */ @@ -400,8 +386,7 @@ export class WorkflowService { if (activeExecutions > 0) { // Only allow safe changes: name and position updates - const hasCriticalChanges = - data.config !== undefined || data.templateId !== undefined; + const hasCriticalChanges = data.config !== undefined || data.templateId !== undefined; if (hasCriticalChanges) { throw new HttpException( @@ -930,10 +915,7 @@ export class WorkflowService { /** * Cancel all active executions for a workflow */ - public static async cancelAllExecutions( - projectId: string, - workflowId: string, - ): Promise<{cancelled: number}> { + public static async cancelAllExecutions(projectId: string, workflowId: string): Promise<{cancelled: number}> { // Verify workflow exists and belongs to project await this.get(projectId, workflowId); @@ -959,22 +941,49 @@ export class WorkflowService { * 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) + // Get contact fields with types (standard + custom data fields) const contactFieldsWithTypes = await ContactService.getAvailableFields(projectId); - // Extract just the field names and prefix with 'contact.' - const contactFields = contactFieldsWithTypes.map(f => `contact.${f.field}`); + // Build typed field list with 'contact.' prefix + const contactFields = contactFieldsWithTypes.map(f => ({ + field: `contact.${f.field}`, + type: f.type, + category: f.field.startsWith('data.') ? 'Custom Data' : 'Contact Fields', + })); // 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); + // Event fields are treated as dynamic (unknown type at runtime) + const eventFieldNames = await EventService.getAvailableEventFields(projectId, eventName); + const eventFields = eventFieldNames.map(field => ({ + field, + type: 'string' as const, // Event fields default to string, can contain any JSON value + category: 'Event Data', + })); // Combine all fields - const allFields = [...contactFields, ...eventFields].sort(); + const allFields = [...contactFields, ...eventFields].sort((a, b) => a.field.localeCompare(b.field)); + + // Also return legacy format for backwards compatibility + const fieldNames = allFields.map(f => f.field); return { - fields: allFields, + fields: fieldNames, + typedFields: allFields, count: allFields.length, }; } + + /** + * 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], + }, + }, + }); + } } diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index 19f563b..324e149 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -828,9 +828,32 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo const [conditionField, setConditionField] = useState(''); const [conditionOperator, setConditionOperator] = useState('equals'); const [conditionValue, setConditionValue] = useState(''); - const [availableFields, setAvailableFields] = useState([]); + const [availableFields, setAvailableFields] = useState>([]); const [loadingFields, setLoadingFields] = useState(false); + // Get current field type for smart operator filtering + const currentFieldType = availableFields.find(f => f.field === conditionField)?.type || 'string'; + + // Get valid operators based on field type + const getOperatorsForType = (fieldType: string) => { + const allOperators = [ + {value: 'equals', label: 'Equals', types: ['string', 'number', 'boolean', 'date']}, + {value: 'notEquals', label: 'Not Equals', types: ['string', 'number', 'boolean', 'date']}, + {value: 'contains', label: 'Contains', types: ['string']}, + {value: 'notContains', label: 'Does not contain', types: ['string']}, + {value: 'greaterThan', label: 'Greater than', types: ['number', 'date']}, + {value: 'lessThan', label: 'Less than', types: ['number', 'date']}, + {value: 'greaterThanOrEqual', label: 'Greater than or equal', types: ['number', 'date']}, + {value: 'lessThanOrEqual', label: 'Less than or equal', types: ['number', 'date']}, + {value: 'exists', label: 'Exists', types: ['string', 'number', 'boolean', 'date']}, + {value: 'notExists', label: 'Does not exist', types: ['string', 'number', 'boolean', 'date']}, + ]; + return allOperators.filter(op => op.types.includes(fieldType)); + }; + + const validOperators = getOperatorsForType(currentFieldType); + const needsValue = !['exists', 'notExists'].includes(conditionOperator); + // WAIT_FOR_EVENT fields const [eventName, setEventName] = useState(''); const [eventTimeoutAmount, setEventTimeoutAmount] = useState('1'); @@ -869,12 +892,17 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo // Pass eventName as query param to filter event fields const url = eventName ? `/workflows/fields?eventName=${encodeURIComponent(eventName)}` : '/workflows/fields'; - const response = await network.fetch<{fields: string[]}>('GET', url); - setAvailableFields(response.fields); + const response = await network.fetch<{ + fields: string[]; + typedFields: Array<{field: string; type: string; category: string}>; + }>('GET', url); + setAvailableFields( + response.typedFields || response.fields.map(f => ({field: f, type: 'string', category: 'Unknown'})), + ); // Set default field if available - if (response.fields.length > 0 && !conditionField) { - setConditionField(response.fields[0]!); + if (response.typedFields && response.typedFields.length > 0 && !conditionField) { + setConditionField(response.typedFields[0]!.field); } } catch (error) { console.error('Failed to fetch available fields:', error); @@ -889,6 +917,26 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo // eslint-disable-next-line react-hooks/exhaustive-deps }, [type, open, workflow]); + // Handle condition field change - reset operator if not valid for new type + const handleConditionFieldChange = (newField: string) => { + const newFieldType = availableFields.find(f => f.field === newField)?.type || 'string'; + const newValidOperators = getOperatorsForType(newFieldType); + + setConditionField(newField); + + // Reset operator if current one is not valid for new field type + if (!newValidOperators.some(op => op.value === conditionOperator)) { + setConditionOperator('equals'); + } + + // Reset value when switching to boolean + if (newFieldType === 'boolean') { + setConditionValue('true'); + } else if (currentFieldType === 'boolean' && newFieldType !== 'boolean') { + setConditionValue(''); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsSubmitting(true); @@ -1196,15 +1244,32 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo ) : availableFields.length > 0 ? ( <> - - {availableFields.map(field => ( - - {field} - + {/* Group fields by category */} + {Object.entries( + availableFields.reduce>((acc, field) => { + if (!acc[field.category]) acc[field.category] = []; + acc[field.category]!.push(field); + return acc; + }, {}), + ).map(([category, fields]) => ( +
+
{category}
+ {fields.map(field => ( + +
+ {field.field.replace('contact.', '').replace('data.', '')} + + {field.type} + +
+
+ ))} +
))}
@@ -1221,7 +1286,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo value={conditionField} onChange={e => setConditionField(e.target.value)} required - placeholder="e.g., subscribed or data.plan" + placeholder="e.g., contact.subscribed or contact.data.plan" />

No fields found in contacts. Enter a field manually. @@ -1237,34 +1302,63 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo - Equals - Not Equals - Contains - Not Contains - Greater Than - Less Than - Greater Than or Equal - Less Than or Equal - Exists - Not Exists + {validOperators.map(op => ( + + {op.label} + + ))} + {currentFieldType && ( +

+ Showing operators for{' '} + {currentFieldType} fields +

+ )} -
- - setConditionValue(e.target.value)} - required - placeholder="e.g., true, false, premium, 100" - /> -

- Enter: true/false for booleans, numbers for comparisons, or text for strings -

-
+ {needsValue && ( +
+ + {currentFieldType === 'boolean' ? ( + + ) : currentFieldType === 'number' ? ( + setConditionValue(e.target.value)} + required + placeholder="e.g., 100" + /> + ) : currentFieldType === 'date' ? ( + setConditionValue(e.target.value)} + required + /> + ) : ( + setConditionValue(e.target.value)} + required + placeholder="e.g., premium, active" + /> + )} +
+ )} )} @@ -1485,9 +1579,52 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS }); const [conditionOperator, setConditionOperator] = useState(String(config?.operator || 'equals')); const [conditionValue, setConditionValue] = useState(String(config?.value ?? '')); - const [availableFields, setAvailableFields] = useState([]); + const [availableFields, setAvailableFields] = useState>([]); const [loadingFields, setLoadingFields] = useState(false); + // Get current field type for smart operator filtering + const currentFieldType = availableFields.find(f => f.field === conditionField)?.type || 'string'; + + // Get valid operators based on field type + const getOperatorsForType = (fieldType: string) => { + const allOperators = [ + {value: 'equals', label: 'Equals', types: ['string', 'number', 'boolean', 'date']}, + {value: 'notEquals', label: 'Not Equals', types: ['string', 'number', 'boolean', 'date']}, + {value: 'contains', label: 'Contains', types: ['string']}, + {value: 'notContains', label: 'Does not contain', types: ['string']}, + {value: 'greaterThan', label: 'Greater than', types: ['number', 'date']}, + {value: 'lessThan', label: 'Less than', types: ['number', 'date']}, + {value: 'greaterThanOrEqual', label: 'Greater than or equal', types: ['number', 'date']}, + {value: 'lessThanOrEqual', label: 'Less than or equal', types: ['number', 'date']}, + {value: 'exists', label: 'Exists', types: ['string', 'number', 'boolean', 'date']}, + {value: 'notExists', label: 'Does not exist', types: ['string', 'number', 'boolean', 'date']}, + ]; + return allOperators.filter(op => op.types.includes(fieldType)); + }; + + const validOperators = getOperatorsForType(currentFieldType); + const needsValue = !['exists', 'notExists'].includes(conditionOperator); + + // Handle condition field change - reset operator if not valid for new type + const handleConditionFieldChange = (newField: string) => { + const newFieldType = availableFields.find(f => f.field === newField)?.type || 'string'; + const newValidOperators = getOperatorsForType(newFieldType); + + setConditionField(newField); + + // Reset operator if current one is not valid for new field type + if (!newValidOperators.some(op => op.value === conditionOperator)) { + setConditionOperator('equals'); + } + + // Reset value when switching to boolean + if (newFieldType === 'boolean') { + setConditionValue('true'); + } else if (currentFieldType === 'boolean' && newFieldType !== 'boolean') { + setConditionValue(''); + } + }; + // WAIT_FOR_EVENT fields const [eventName, setEventName] = useState(String(config?.eventName || '')); const [eventTimeoutAmount, setEventTimeoutAmount] = useState(() => { @@ -1545,8 +1682,13 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS // Pass eventName as query param to filter event fields const url = eventName ? `/workflows/fields?eventName=${encodeURIComponent(eventName)}` : '/workflows/fields'; - const response = await network.fetch<{fields: string[]}>('GET', url); - setAvailableFields(response.fields); + const response = await network.fetch<{ + fields: string[]; + typedFields: Array<{field: string; type: string; category: string}>; + }>('GET', url); + setAvailableFields( + response.typedFields || response.fields.map(f => ({field: f, type: 'string', category: 'Unknown'})), + ); } catch (error) { console.error('Failed to fetch available fields:', error); setAvailableFields([]); @@ -1814,15 +1956,32 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS ) : availableFields.length > 0 ? ( <> - - {availableFields.map(field => ( - - {field} - + {/* Group fields by category */} + {Object.entries( + availableFields.reduce>((acc, field) => { + if (!acc[field.category]) acc[field.category] = []; + acc[field.category]!.push(field); + return acc; + }, {}), + ).map(([category, fields]) => ( +
+
{category}
+ {fields.map(field => ( + +
+ {field.field.replace('contact.', '').replace('data.', '')} + + {field.type} + +
+
+ ))} +
))}
@@ -1839,7 +1998,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS value={conditionField} onChange={e => setConditionField(e.target.value)} required - placeholder="e.g., subscribed or data.plan" + placeholder="e.g., contact.subscribed or contact.data.plan" />

No fields found in contacts. Enter a field manually. @@ -1855,34 +2014,63 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS - Equals - Not Equals - Contains - Not Contains - Greater Than - Less Than - Greater Than or Equal - Less Than or Equal - Exists - Not Exists + {validOperators.map(op => ( + + {op.label} + + ))} + {currentFieldType && ( +

+ Showing operators for{' '} + {currentFieldType} fields +

+ )} -
- - setConditionValue(e.target.value)} - required - placeholder="e.g., true, false, premium, 100" - /> -

- Enter: true/false for booleans, numbers for comparisons, or text for strings -

-
+ {needsValue && ( +
+ + {currentFieldType === 'boolean' ? ( + + ) : currentFieldType === 'number' ? ( + setConditionValue(e.target.value)} + required + placeholder="e.g., 100" + /> + ) : currentFieldType === 'date' ? ( + setConditionValue(e.target.value)} + required + /> + ) : ( + setConditionValue(e.target.value)} + required + placeholder="e.g., premium, active" + /> + )} +
+ )} )}