Initial push of Plunk Next

This commit is contained in:
Dries Augustyns
2025-12-01 09:56:56 +01:00
parent 07cea20262
commit ff1876d580
566 changed files with 89036 additions and 28423 deletions
+651
View File
@@ -0,0 +1,651 @@
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db';
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js';
export interface PaginatedWorkflows {
workflows: Workflow[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface WorkflowWithDetails extends Workflow {
steps: (WorkflowStep & {
template?: {id: string; name: string} | null;
outgoingTransitions: WorkflowTransition[];
incomingTransitions: WorkflowTransition[];
})[];
}
export interface WorkflowExecutionWithDetails extends WorkflowExecution {
workflow: Workflow;
contact: {id: string; email: string};
currentStep?: WorkflowStep | null;
stepExecutions: WorkflowStepExecution[];
}
export class WorkflowService {
/**
* Get all workflows for a project with pagination
*/
public static async list(projectId: string, page = 1, pageSize = 20, search?: string): Promise<PaginatedWorkflows> {
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 {
workflows: 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<WorkflowWithDetails> {
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<Workflow> {
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);
}
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<Workflow> {
// Verify workflow exists and belongs to project
await this.get(projectId, workflowId);
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 updated = await prisma.workflow.update({
where: {id: workflowId},
data: updateData,
});
// Invalidate workflow cache if enabled status changed or workflow is enabled
if (data.enabled !== undefined || updated.enabled) {
await EventService.invalidateWorkflowCache(projectId);
}
return updated;
}
/**
* Delete a workflow
*/
public static async delete(projectId: string, workflowId: string): Promise<void> {
// Verify workflow exists and belongs to project
const workflow = await this.get(projectId, workflowId);
await prisma.workflow.delete({
where: {id: workflowId},
});
// Invalidate workflow cache if workflow was enabled
if (workflow.enabled) {
await EventService.invalidateWorkflowCache(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<WorkflowStep> {
// 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: data.position as Prisma.InputJsonValue,
config: data.config as Prisma.InputJsonValue,
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<WorkflowStep> {
// First verify workflow belongs to project
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');
}
const updateData: Prisma.WorkflowStepUpdateInput = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.position !== undefined) updateData.position = data.position as Prisma.InputJsonValue;
if (data.config !== undefined) updateData.config = data.config as Prisma.InputJsonValue;
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<void> {
await this.get(projectId, workflowId);
const step = await prisma.workflowStep.findUnique({
where: {id: stepId},
});
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.');
}
await prisma.workflowStep.delete({
where: {id: stepId},
});
}
/**
* 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<WorkflowTransition> {
// 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<string, unknown>)
: 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: conditionObj.branch as Prisma.InputJsonValue,
},
},
});
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<void> {
// Verify transition exists and belongs to workflow
const transition = await prisma.workflowTransition.findFirst({
where: {
id: transitionId,
fromStep: {
workflowId,
workflow: {projectId},
},
},
});
if (!transition) {
throw new HttpException(404, 'Transition not found');
}
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<WorkflowExecution> {
// 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
const {WorkflowExecutionService} = await import('./WorkflowExecutionService.js');
WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => {
console.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<WorkflowExecutionWithDetails> {
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<WorkflowExecution> {
// Verify execution exists
await this.getExecution(projectId, workflowId, executionId);
return prisma.workflowExecution.update({
where: {id: executionId},
data: {
status: WorkflowExecutionStatus.CANCELLED,
completedAt: new Date(),
},
});
}
}