diff --git a/apps/api/src/controllers/Workflows.ts b/apps/api/src/controllers/Workflows.ts index 2b8acb4..87e2916 100644 --- a/apps/api/src/controllers/Workflows.ts +++ b/apps/api/src/controllers/Workflows.ts @@ -149,6 +149,26 @@ export class Workflows { return res.status(204).send(); } + /** + * POST /workflows/:id/duplicate + * Duplicate a workflow (always disabled, no execution state) + */ + @Post(':id/duplicate') + @Middleware([requireAuth, requireEmailVerified]) + @CatchAsync + public async duplicate(req: Request, res: Response, _next: NextFunction) { + const auth = res.locals.auth; + const workflowId = req.params.id; + + if (!workflowId) { + return res.status(400).json({error: 'Workflow ID is required'}); + } + + const workflow = await WorkflowService.duplicate(auth.projectId!, workflowId); + + return res.status(201).json(workflow); + } + /** * POST /workflows/:id/steps * Add a step to a workflow diff --git a/apps/api/src/services/WorkflowService.ts b/apps/api/src/services/WorkflowService.ts index 157cc6e..407ddee 100644 --- a/apps/api/src/services/WorkflowService.ts +++ b/apps/api/src/services/WorkflowService.ts @@ -310,6 +310,72 @@ export class WorkflowService { await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId); } + /** + * Duplicate a workflow including all steps and transitions. + * The duplicate always starts disabled to prevent accidental triggering. + * Runtime execution state is intentionally not copied. + */ + public static async duplicate(projectId: string, workflowId: string): Promise { + const source = await this.get(projectId, workflowId); + + const transitions = await prisma.workflowTransition.findMany({ + where: {fromStep: {workflowId}}, + }); + + return prisma.$transaction(async tx => { + const newWorkflow = await tx.workflow.create({ + data: { + projectId, + name: `${source.name} (Copy)`, + description: source.description, + triggerType: source.triggerType, + triggerConfig: + source.triggerConfig === null + ? Prisma.JsonNull + : (source.triggerConfig as Prisma.InputJsonValue), + enabled: false, + allowReentry: source.allowReentry, + }, + }); + + const stepIdMap = new Map(); + + for (const step of source.steps) { + const created = await tx.workflowStep.create({ + data: { + workflowId: newWorkflow.id, + type: step.type, + name: step.name, + position: step.position as Prisma.InputJsonValue, + config: step.config as Prisma.InputJsonValue, + templateId: step.templateId, + }, + }); + stepIdMap.set(step.id, created.id); + } + + for (const transition of transitions) { + const fromStepId = stepIdMap.get(transition.fromStepId); + const toStepId = stepIdMap.get(transition.toStepId); + if (!fromStepId || !toStepId) continue; + + await tx.workflowTransition.create({ + data: { + fromStepId, + toStepId, + condition: + transition.condition === null + ? Prisma.JsonNull + : (transition.condition as Prisma.InputJsonValue), + priority: transition.priority, + }, + }); + } + + return newWorkflow; + }); + } + /** * Add a step to a workflow */ diff --git a/apps/web/src/pages/workflows/index.tsx b/apps/web/src/pages/workflows/index.tsx index 01d2226..f868235 100644 --- a/apps/web/src/pages/workflows/index.tsx +++ b/apps/web/src/pages/workflows/index.tsx @@ -23,7 +23,7 @@ import {EmptyState} from '@plunk/ui'; import {DashboardLayout} from '../../components/DashboardLayout'; import {network} from '../../lib/network'; import {formatRelativeTime} from '../../lib/dateUtils'; -import {Calendar, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon, X, Zap} from 'lucide-react'; +import {Calendar, Copy, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon, X, Zap} from 'lucide-react'; import {NextSeo} from 'next-seo'; import Link from 'next/link'; import {useEffect, useState} from 'react'; @@ -66,6 +66,16 @@ export default function WorkflowsPage() { } }; + const handleDuplicate = async (workflowId: string) => { + try { + await network.fetch('POST', `/workflows/${workflowId}/duplicate`); + toast.success('Workflow duplicated successfully'); + void mutate(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to duplicate workflow'); + } + }; + const handleToggleEnabled = async (workflowId: string, currentlyEnabled: boolean) => { try { await network.fetch('PATCH', `/workflows/${workflowId}`, { @@ -219,6 +229,14 @@ export default function WorkflowsPage() { +