Add better segmenting

This commit is contained in:
Dries Augustyns
2025-12-02 09:03:05 +01:00
parent b521a405bb
commit fe2e038037
23 changed files with 2285 additions and 855 deletions
+264 -9
View File
@@ -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<number> {
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<Workflow> {
// 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<WorkflowStep> {
// 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<void> {
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<string, string[]>();
for (const s of allSteps) {
adjacencyMap.set(
s.id,
s.outgoingTransitions.map(t => t.toStepId),
);
}
// Find all downstream steps
const downstreamSteps = new Set<string>([stepId]);
const queue = [stepId];
while (queue.length > 0) {
const currentStepId = queue.shift()!;
const outgoingStepIds = adjacencyMap.get(currentStepId) || [];
for (const nextStepId of outgoingStepIds) {
if (!downstreamSteps.has(nextStepId)) {
downstreamSteps.add(nextStepId);
queue.push(nextStepId);
}
}
}
// Check if any active executions are on downstream steps
const executionsOnDownstream = await prisma.workflowExecution.count({
where: {
workflowId,
currentStepId: {in: Array.from(downstreamSteps)},
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
});
if (executionsOnDownstream > 0) {
throw new HttpException(
409,
`Cannot delete step "${step.name}" while ${executionsOnDownstream} execution(s) are on downstream steps. ` +
'Deleting this step would orphan those executions. ' +
'Please disable the workflow first or wait for executions to complete.',
);
}
}
// Find all downstream steps that need to be deleted (cascade)
// First, get all steps and transitions for this workflow to build a graph
const allSteps = await prisma.workflowStep.findMany({
where: {workflowId},
include: {outgoingTransitions: true},
});
// Build adjacency map for efficient traversal
const adjacencyMap = new Map<string, string[]>();
for (const s of allSteps) {
adjacencyMap.set(
s.id,
s.outgoingTransitions.map(t => t.toStepId),
);
}
// Use BFS to traverse the workflow graph and find all downstream steps
const stepsToDelete = new Set<string>([stepId]);
const queue = [stepId];
while (queue.length > 0) {
const currentStepId = queue.shift()!;
const outgoingStepIds = adjacencyMap.get(currentStepId) || [];
for (const nextStepId of outgoingStepIds) {
if (!stepsToDelete.has(nextStepId)) {
stepsToDelete.add(nextStepId);
queue.push(nextStepId);
}
}
}
// Delete all affected steps (Prisma will cascade delete the transitions)
await prisma.workflowStep.deleteMany({
where: {
id: {in: Array.from(stepsToDelete)},
workflowId, // Safety check to ensure we only delete steps from this workflow
},
});
}
@@ -424,6 +589,9 @@ export class WorkflowService {
* Delete a transition
*/
public static async deleteTransition(projectId: string, workflowId: string, transitionId: string): Promise<void> {
// Get workflow to check if it's enabled
const workflow = await this.get(projectId, workflowId);
// Verify transition exists and belongs to workflow
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<string, string[]>();
for (const s of allSteps) {
adjacencyMap.set(
s.id,
s.outgoingTransitions.filter(t => t.id !== transitionId).map(t => t.toStepId),
);
}
// Find all steps reachable from the toStep (downstream)
const downstreamSteps = new Set<string>([transition.toStepId]);
const queue = [transition.toStepId];
while (queue.length > 0) {
const currentStepId = queue.shift()!;
const outgoingStepIds = adjacencyMap.get(currentStepId) || [];
for (const nextStepId of outgoingStepIds) {
if (!downstreamSteps.has(nextStepId)) {
downstreamSteps.add(nextStepId);
queue.push(nextStepId);
}
}
}
// Check if any active executions are on the toStep or downstream steps
const executionsAffected = await prisma.workflowExecution.count({
where: {
workflowId,
currentStepId: {in: Array.from(downstreamSteps)},
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
});
if (executionsAffected > 0) {
throw new HttpException(
409,
`Cannot delete transition from "${transition.fromStep.name}" to "${transition.toStep.name}" ` +
`while ${executionsAffected} execution(s) are on affected steps. ` +
'Please disable the workflow first or wait for executions to complete.',
);
}
}
await prisma.workflowTransition.delete({
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,