Workflow automation removes manual steps from business processes. Here is how to build a configurable system with approval flows.
Define Workflow Types
// types/workflow.ts
export type StepType = "action" | "condition" | "approval" | "notification" | "delay" | "webhook";
export type WorkflowStatus = "draft" | "active" | "paused" | "completed" | "failed";
export type StepStatus = "pending" | "in-progress" | "completed" | "skipped" | "failed";
export interface WorkflowStep {
id: string;
type: StepType;
name: string;
config: Record<string, unknown>;
nextStepId?: string;
onSuccess?: string;
onFailure?: string;
}
export interface Workflow {
id: string;
name: string;
description?: string;
trigger: {
type: "manual" | "webhook" | "schedule" | "event";
config: Record<string, unknown>;
};
steps: WorkflowStep[];
status: WorkflowStatus;
createdAt: string;
updatedAt: string;
}
export interface WorkflowExecution {
id: string;
workflowId: string;
status: WorkflowStatus;
currentStepId: string | null;
context: Record<string, unknown>;
stepResults: Record<string, StepResult>;
startedAt: string;
completedAt?: string;
}
export interface StepResult {
status: StepStatus;
output?: unknown;
error?: string;
startedAt: string;
completedAt?: string;
}
Workflow Engine
// lib/workflow/engine.ts
import type {
Workflow,
WorkflowExecution,
WorkflowStep,
StepResult,
} from "@/types/workflow";
type StepHandler = (
step: WorkflowStep,
context: Record<string, unknown>
) => Promise<{ success: boolean; output?: unknown; error?: string }>;
const stepHandlers: Record<string, StepHandler> = {
action: async (step, context) => {
const actionFn = actions[step.config.action as string];
if (!actionFn) return { success: false, error: `Unknown action: ${step.config.action}` };
const output = await actionFn(step.config, context);
return { success: true, output };
},
condition: async (step, context) => {
const field = step.config.field as string;
const operator = step.config.operator as string;
const value = step.config.value;
const actual = context[field];
let result = false;
switch (operator) {
case "equals": result = actual === value; break;
case "not_equals": result = actual !== value; break;
case "greater_than": result = (actual as number) > (value as number); break;
case "less_than": result = (actual as number) < (value as number); break;
case "contains": result = String(actual).includes(String(value)); break;
}
return { success: true, output: { result } };
},
approval: async (step, _context) => {
// Create approval request and wait
// In practice, this would create a record and pause execution
return {
success: true,
output: { status: "pending", approver: step.config.approver },
};
},
notification: async (step, context) => {
const template = step.config.template as string;
const recipient = step.config.recipient as string;
await sendNotification({
to: resolveValue(recipient, context) as string,
template,
data: context,
});
return { success: true };
},
webhook: async (step, context) => {
const url = step.config.url as string;
const method = (step.config.method as string) ?? "POST";
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(context),
});
if (!response.ok) {
return { success: false, error: `Webhook returned ${response.status}` };
}
const output = await response.json();
return { success: true, output };
},
delay: async (step) => {
const duration = step.config.duration as number; // milliseconds
// In production, schedule a delayed job instead of sleeping
return { success: true, output: { delayed: duration } };
},
};
export class WorkflowEngine {
async execute(
workflow: Workflow,
initialContext: Record<string, unknown> = {}
): Promise<WorkflowExecution> {
const execution: WorkflowExecution = {
id: crypto.randomUUID(),
workflowId: workflow.id,
status: "active",
currentStepId: workflow.steps[0]?.id ?? null,
context: { ...initialContext },
stepResults: {},
startedAt: new Date().toISOString(),
};
let currentStep = workflow.steps[0];
while (currentStep) {
execution.currentStepId = currentStep.id;
const result = await this.executeStep(currentStep, execution.context);
execution.stepResults[currentStep.id] = result;
if (result.status === "failed") {
// Try failure path
if (currentStep.onFailure) {
currentStep = workflow.steps.find((s) => s.id === currentStep!.onFailure) ?? undefined as any;
continue;
}
execution.status = "failed";
break;
}
// Merge output into context
if (result.output && typeof result.output === "object") {
Object.assign(execution.context, result.output);
}
// Determine next step
if (currentStep.type === "condition") {
const conditionResult = (result.output as { result: boolean })?.result;
const nextId = conditionResult
? currentStep.onSuccess
: currentStep.onFailure;
currentStep = nextId
? (workflow.steps.find((s) => s.id === nextId) ?? undefined as any)
: undefined as any;
} else if (currentStep.type === "approval" && (result.output as { status: string })?.status === "pending") {
// Pause execution for approval
execution.status = "paused";
break;
} else {
currentStep = currentStep.nextStepId
? (workflow.steps.find((s) => s.id === currentStep!.nextStepId) ?? undefined as any)
: undefined as any;
}
}
if (execution.status === "active") {
execution.status = "completed";
execution.completedAt = new Date().toISOString();
}
return execution;
}
private async executeStep(
step: WorkflowStep,
context: Record<string, unknown>
): Promise<StepResult> {
const handler = stepHandlers[step.type];
if (!handler) {
return {
status: "failed",
error: `No handler for step type: ${step.type}`,
startedAt: new Date().toISOString(),
};
}
const startedAt = new Date().toISOString();
try {
const result = await handler(step, context);
return {
status: result.success ? "completed" : "failed",
output: result.output,
error: result.error,
startedAt,
completedAt: new Date().toISOString(),
};
} catch (err) {
return {
status: "failed",
error: err instanceof Error ? err.message : "Unknown error",
startedAt,
completedAt: new Date().toISOString(),
};
}
}
}
// Placeholder functions
const actions: Record<string, (config: Record<string, unknown>, context: Record<string, unknown>) => Promise<unknown>> = {};
function resolveValue(template: string, context: Record<string, unknown>) {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => String(context[key] ?? ""));
}
async function sendNotification(_params: { to: string; template: string; data: unknown }) {}
API Routes
// app/api/workflows/[id]/execute/route.ts
import { NextRequest, NextResponse } from "next/server";
import { WorkflowEngine } from "@/lib/workflow/engine";
import { db } from "@/db";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const workflow = await db.query.workflows.findFirst({
where: (w, { eq }) => eq(w.id, id),
});
if (!workflow) {
return NextResponse.json({ error: "Workflow not found" }, { status: 404 });
}
const engine = new WorkflowEngine();
const execution = await engine.execute(workflow, body.context ?? {});
// Save execution
await db.insert(workflowExecutions).values(execution);
return NextResponse.json(execution);
}
Execution Timeline UI
"use client";
import type { WorkflowExecution, WorkflowStep, StepStatus } from "@/types/workflow";
interface ExecutionTimelineProps {
execution: WorkflowExecution;
steps: WorkflowStep[];
}
export function ExecutionTimeline({ execution, steps }: ExecutionTimelineProps) {
return (
<div className="space-y-0">
{steps.map((step, i) => {
const result = execution.stepResults[step.id];
const status: StepStatus = result?.status ?? "pending";
const isCurrent = execution.currentStepId === step.id;
return (
<div key={step.id} className="flex gap-3">
{/* Timeline connector */}
<div className="flex flex-col items-center">
<StepIcon status={status} isCurrent={isCurrent} />
{i < steps.length - 1 && (
<div className={`w-0.5 h-8 ${status === "completed" ? "bg-green-500" : "bg-border"}`} />
)}
</div>
{/* Step content */}
<div className="pb-6">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{step.name}</span>
<span className={`text-xs px-1.5 py-0.5 rounded-full ${statusStyles[status]}`}>
{status}
</span>
</div>
<span className="text-xs text-muted-foreground capitalize">{step.type}</span>
{result?.error && (
<p className="text-xs text-red-600 mt-1">{result.error}</p>
)}
{result?.completedAt && (
<p className="text-xs text-muted-foreground mt-0.5">
{new Date(result.completedAt).toLocaleTimeString()}
</p>
)}
</div>
</div>
);
})}
</div>
);
}
const statusStyles: Record<StepStatus, string> = {
pending: "bg-gray-100 text-gray-600",
"in-progress": "bg-blue-100 text-blue-700",
completed: "bg-green-100 text-green-700",
skipped: "bg-gray-100 text-gray-500",
failed: "bg-red-100 text-red-700",
};
function StepIcon({ status, isCurrent }: { status: StepStatus; isCurrent: boolean }) {
const base = "w-6 h-6 rounded-full flex items-center justify-center text-xs shrink-0";
if (isCurrent && status !== "completed") {
return <div className={`${base} bg-blue-500 text-white animate-pulse`}>...</div>;
}
switch (status) {
case "completed":
return (
<div className={`${base} bg-green-500 text-white`}>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
);
case "failed":
return <div className={`${base} bg-red-500 text-white`}>x</div>;
default:
return <div className={`${base} border-2 border-border`} />;
}
}
Need Business Process Automation?
We design and build workflow automation systems for businesses. Contact us to streamline your operations.