import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db'; import {Prisma, WorkflowExecutionStatus} from '@plunk/db'; import type {PaginatedResponse, WorkflowExecutionWithDetails, WorkflowWithDetails} from '@plunk/types'; import {toPrismaJson} from '@plunk/types'; import signale from 'signale'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; import {ContactService} from './ContactService.js'; import {EventService} from './EventService.js'; import {NtfyService} from './NtfyService.js'; import {WorkflowExecutionService} from './WorkflowExecutionService.js'; export class WorkflowService { /** * Get all workflows for a project with pagination */ public static async list( projectId: string, page = 1, pageSize = 20, search?: string, ): Promise> { const skip = (page - 1) * pageSize; const where: Prisma.WorkflowWhereInput = { projectId, ...(search ? { OR: [ {name: {contains: search, mode: 'insensitive' as const}}, {description: {contains: search, mode: 'insensitive' as const}}, ], } : {}), }; const [workflows, total] = await Promise.all([ prisma.workflow.findMany({ where, skip, take: pageSize, orderBy: {createdAt: 'desc'}, include: { _count: { select: { steps: true, executions: true, }, }, }, }), prisma.workflow.count({where}), ]); return { data: workflows as Workflow[], total, page, pageSize, totalPages: Math.ceil(total / pageSize), }; } /** * Get a single workflow by ID with all steps and transitions */ public static async get(projectId: string, workflowId: string): Promise { const workflow = await prisma.workflow.findFirst({ where: { id: workflowId, projectId, }, include: { steps: { include: { template: { select: { id: true, name: true, }, }, outgoingTransitions: true, incomingTransitions: true, }, orderBy: {createdAt: 'asc'}, }, }, }); if (!workflow) { throw new HttpException(404, 'Workflow not found'); } return workflow; } /** * Create a new workflow */ public static async create( projectId: string, data: { name: string; description?: string; eventName: string; enabled?: boolean; allowReentry?: boolean; }, ): Promise { if (!data.eventName?.trim()) { throw new HttpException(400, 'Event name is required'); } const workflow = await prisma.$transaction(async tx => { const newWorkflow = await tx.workflow.create({ data: { projectId, name: data.name, description: data.description, triggerType: 'EVENT', triggerConfig: {eventName: data.eventName.trim()}, enabled: data.enabled ?? false, allowReentry: data.allowReentry ?? false, }, }); await tx.workflowStep.create({ data: { workflowId: newWorkflow.id, type: 'TRIGGER', name: `Trigger: ${data.eventName.trim()}`, position: {x: 100, y: 100}, config: {eventName: data.eventName.trim()}, }, }); return newWorkflow; }); if (workflow.enabled) { await EventService.invalidateWorkflowCache(projectId); } // Get project name for notification const project = await prisma.project.findUnique({ where: {id: projectId}, select: {name: true}, }); if (project) { // Send notification about workflow creation await NtfyService.notifyWorkflowCreated(workflow.name, project.name, projectId); } return workflow; } /** * Update a workflow */ public static async update( projectId: string, workflowId: string, data: { name?: string; description?: string; triggerType?: Workflow['triggerType']; triggerConfig?: Prisma.JsonValue; enabled?: boolean; allowReentry?: boolean; }, ): Promise { // Verify workflow exists and belongs to project 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.', ); } } } // Use transaction to update workflow and TRIGGER step atomically const updated = await prisma.$transaction(async tx => { const updateData: Prisma.WorkflowUpdateInput = {}; if (data.name !== undefined) updateData.name = data.name; if (data.description !== undefined) updateData.description = data.description; if (data.triggerType !== undefined) updateData.triggerType = data.triggerType; if (data.triggerConfig !== undefined) { updateData.triggerConfig = data.triggerConfig === null ? Prisma.JsonNull : data.triggerConfig; } if (data.enabled !== undefined) updateData.enabled = data.enabled; if (data.allowReentry !== undefined) updateData.allowReentry = data.allowReentry; const updatedWorkflow = await tx.workflow.update({ where: {id: workflowId}, data: updateData, include: { project: { select: {name: true}, }, }, }); // If triggerConfig changed and it's an EVENT trigger, update the TRIGGER step if (data.triggerConfig !== undefined && updatedWorkflow.triggerType === 'EVENT') { const newTriggerConfig = data.triggerConfig as {eventName?: string} | null; const eventName = newTriggerConfig?.eventName; if (eventName) { // Find the TRIGGER step const triggerStep = await tx.workflowStep.findFirst({ where: { workflowId: workflowId, type: 'TRIGGER', }, }); if (triggerStep) { // Update TRIGGER step config and name to match await tx.workflowStep.update({ where: {id: triggerStep.id}, data: { name: `Trigger: ${eventName}`, config: {eventName}, }, }); } } } return updatedWorkflow; }); // Invalidate workflow cache if enabled status changed or workflow is enabled if (data.enabled !== undefined || updated.enabled) { await EventService.invalidateWorkflowCache(projectId); } // Also invalidate cache if triggerConfig changed on an enabled workflow if (data.triggerConfig !== undefined && updated.enabled) { await EventService.invalidateWorkflowCache(projectId); } // Send notification if enabled status changed if (data.enabled !== undefined && data.enabled !== workflow.enabled) { if (data.enabled) { await NtfyService.notifyWorkflowEnabled(updated.name, updated.project.name, projectId); } else { await NtfyService.notifyWorkflowDisabled(updated.name, updated.project.name, projectId); } } return updated; } /** * Delete a workflow */ public static async delete(projectId: string, workflowId: string): Promise { // Verify workflow exists and belongs to project - get with project name const workflow = await prisma.workflow.findUnique({ where: {id: workflowId, projectId}, include: { project: { select: {name: true}, }, }, }); if (!workflow) { throw new HttpException(404, 'Workflow not found'); } // Check if workflow has active executions const activeExecutions = await this.hasActiveExecutions(workflowId); if (activeExecutions > 0) { throw new HttpException( 409, `Cannot delete workflow: it has ${activeExecutions} active execution(s). Please wait for them to complete or cancel them first.`, ); } await prisma.workflow.delete({ where: {id: workflowId}, }); // Invalidate workflow cache if workflow was enabled if (workflow.enabled) { await EventService.invalidateWorkflowCache(projectId); } // Send notification about workflow deletion await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId); } /** * Add a step to a workflow */ public static async addStep( projectId: string, workflowId: string, data: { type: WorkflowStep['type']; name: string; position: Prisma.JsonValue; config: Prisma.JsonValue; templateId?: string; autoConnect?: boolean; // If true, automatically connect from the last step }, ): Promise { // Verify workflow exists and belongs to project const workflow = await this.get(projectId, workflowId); // Prevent adding duplicate TRIGGER steps if (data.type === 'TRIGGER') { const existingTrigger = workflow.steps.find(step => step.type === 'TRIGGER'); if (existingTrigger) { throw new HttpException(400, 'Workflow already has a trigger step. Only one trigger is allowed per workflow.'); } } // Create the new step const newStep = await prisma.workflowStep.create({ data: { workflowId, type: data.type, name: data.name, position: toPrismaJson(data.position), config: toPrismaJson(data.config), templateId: data.templateId, }, }); // Auto-connect: If enabled (default true), create a transition from the last step to this new step // This is useful for linear workflows where steps are added sequentially const shouldAutoConnect = data.autoConnect !== false; // Default to true if (shouldAutoConnect && workflow.steps.length > 0) { // Find the last step that doesn't have any outgoing transitions (the "leaf" step) // This is typically the most recently added step const stepsWithoutOutgoing = workflow.steps.filter(step => step.outgoingTransitions.length === 0); if (stepsWithoutOutgoing.length > 0) { // Connect from the last leaf step to the new step const lastStep = stepsWithoutOutgoing[stepsWithoutOutgoing.length - 1]; if (lastStep) { await prisma.workflowTransition.create({ data: { fromStepId: lastStep.id, toStepId: newStep.id, priority: 0, }, }); } } } return newStep; } /** * Update a workflow step */ public static async updateStep( projectId: string, workflowId: string, stepId: string, data: { name?: string; position?: Prisma.JsonValue; config?: Prisma.JsonValue; templateId?: string | null; }, ): Promise { // First verify workflow belongs to project const workflow = await this.get(projectId, workflowId); // Then verify step exists and belongs to workflow const step = await prisma.workflowStep.findUnique({ where: {id: stepId}, }); if (step?.workflowId !== workflowId) { 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; if (data.position !== undefined) updateData.position = toPrismaJson(data.position); if (data.config !== undefined) updateData.config = toPrismaJson(data.config); if (data.templateId !== undefined) { if (data.templateId === null) { updateData.template = {disconnect: true}; } else { updateData.template = {connect: {id: data.templateId}}; } } return prisma.workflowStep.update({ where: {id: stepId}, data: updateData, }); } /** * Delete a workflow step */ public static async deleteStep(projectId: string, workflowId: string, stepId: string): Promise { const workflow = await this.get(projectId, workflowId); const step = await prisma.workflowStep.findUnique({ where: {id: stepId}, include: { outgoingTransitions: true, }, }); if (step?.workflowId !== workflowId) { throw new HttpException(404, 'Workflow step not found'); } // Prevent deletion of TRIGGER steps if (step.type === 'TRIGGER') { throw new HttpException(400, 'Cannot delete the trigger step. Every workflow must have a trigger.'); } // 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 }, }); } /** * Create a transition between two steps */ public static async createTransition( projectId: string, workflowId: string, data: { fromStepId: string; toStepId: string; condition?: Prisma.JsonValue; priority?: number; }, ): Promise { // Verify both steps belong to the workflow const steps = await prisma.workflowStep.findMany({ where: { id: {in: [data.fromStepId, data.toStepId]}, workflowId, workflow: {projectId}, }, }); if (steps.length !== 2) { throw new HttpException(404, 'One or both steps not found'); } const fromStep = steps.find(s => s.id === data.fromStepId); // For CONDITION steps, validate that this branch doesn't already have a transition if (fromStep?.type === 'CONDITION' && data.condition) { const conditionObj = typeof data.condition === 'object' && data.condition !== null ? (data.condition as Record) : null; if (conditionObj && 'branch' in conditionObj) { // Check if a transition with this branch already exists const existingTransition = await prisma.workflowTransition.findFirst({ where: { fromStepId: data.fromStepId, condition: { path: ['branch'], equals: toPrismaJson(conditionObj.branch), }, }, }); if (existingTransition) { throw new HttpException( 400, `A transition for the "${conditionObj.branch}" branch already exists from this step`, ); } } } const newTransition = await prisma.workflowTransition.create({ data: { fromStepId: data.fromStepId, toStepId: data.toStepId, condition: data.condition ?? Prisma.JsonNull, priority: data.priority ?? 0, }, }); return newTransition; } /** * 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: { id: transitionId, fromStep: { workflowId, 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}, }); } /** * Start a workflow execution for a contact */ public static async startExecution( projectId: string, workflowId: string, contactId: string, context?: Prisma.JsonValue, ): Promise { // Verify workflow exists, is enabled, and belongs to project const workflow = await this.get(projectId, workflowId); if (!workflow.enabled) { throw new HttpException(400, 'Workflow is not enabled'); } // Verify contact belongs to project const contact = await prisma.contact.findFirst({ where: { id: contactId, projectId, }, }); if (!contact) { throw new HttpException(404, 'Contact not found'); } // Check re-entry rules if (!workflow.allowReentry) { // If re-entry is not allowed, check if contact has ANY execution (regardless of status) const existingExecution = await prisma.workflowExecution.findFirst({ where: { workflowId, contactId, }, }); if (existingExecution) { throw new HttpException( 409, `Workflow does not allow re-entry. Contact already has execution (${existingExecution.status})`, ); } } else { // If re-entry is allowed, only check if there's a currently RUNNING execution const runningExecution = await prisma.workflowExecution.findFirst({ where: { workflowId, contactId, status: WorkflowExecutionStatus.RUNNING, }, }); if (runningExecution) { throw new HttpException(409, 'Workflow is already running for this contact'); } } // Find the trigger step const triggerStep = workflow.steps.find(step => step.type === 'TRIGGER'); if (!triggerStep) { throw new HttpException(400, 'Workflow has no trigger step'); } // Create workflow execution const execution = await prisma.workflowExecution.create({ data: { workflowId, contactId, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep.id, context: context ?? Prisma.JsonNull, }, }); // Start executing the workflow asynchronously // Don't await - let it run in background WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => { signale.error('Error executing workflow:', error); }); return execution; } /** * Get workflow executions with filtering */ public static async listExecutions( projectId: string, workflowId: string, page = 1, pageSize = 20, status?: WorkflowExecutionStatus, ) { // Verify workflow belongs to project await this.get(projectId, workflowId); const skip = (page - 1) * pageSize; const where: Prisma.WorkflowExecutionWhereInput = { workflowId, ...(status ? {status} : {}), }; const [executions, total] = await Promise.all([ prisma.workflowExecution.findMany({ where, skip, take: pageSize, orderBy: {startedAt: 'desc'}, include: { contact: { select: { id: true, email: true, }, }, currentStep: { select: { id: true, name: true, type: true, }, }, }, }), prisma.workflowExecution.count({where}), ]); return { executions, total, page, pageSize, totalPages: Math.ceil(total / pageSize), }; } /** * Get a single execution with details */ public static async getExecution( projectId: string, workflowId: string, executionId: string, ): Promise { const execution = await prisma.workflowExecution.findFirst({ where: { id: executionId, workflowId, workflow: {projectId}, }, include: { workflow: true, contact: { select: { id: true, email: true, }, }, currentStep: true, stepExecutions: { include: { step: { select: { id: true, name: true, type: true, }, }, }, orderBy: {createdAt: 'asc'}, }, }, }); if (!execution) { throw new HttpException(404, 'Workflow execution not found'); } return execution; } /** * Cancel a workflow execution */ public static async cancelExecution( projectId: string, workflowId: string, executionId: string, ): Promise { // Verify execution exists await this.getExecution(projectId, workflowId, executionId); return prisma.workflowExecution.update({ where: {id: executionId}, 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 with types (standard + custom data fields) const contactFieldsWithTypes = await ContactService.getAvailableFields(projectId); // 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 // 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((a, b) => a.field.localeCompare(b.field)); // Also return legacy format for backwards compatibility const fieldNames = allFields.map(f => f.field); return { 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], }, }, }); } }