Initial commit

This commit is contained in:
2025-08-22 21:09:48 +02:00
commit 2d84f535f4
68 changed files with 34807 additions and 0 deletions

231
src/collections/Workflow.ts Normal file
View File

@@ -0,0 +1,231 @@
import type {CollectionConfig, Field} from 'payload'
import type {WorkflowsPluginConfig} from "../plugin/config-types.js"
export const createWorkflowCollection: <T extends string>(options: WorkflowsPluginConfig<T>) => CollectionConfig = ({
collectionTriggers,
steps,
triggers
}) => ({
slug: 'workflows',
access: {
create: () => true,
delete: () => true,
read: () => true,
update: () => true,
},
admin: {
defaultColumns: ['name', 'updatedAt'],
description: 'Create and manage automated workflows.',
group: 'Automation',
useAsTitle: 'name',
},
fields: [
{
name: 'name',
type: 'text',
admin: {
description: 'Human-readable name for the workflow',
},
required: true,
},
{
name: 'description',
type: 'textarea',
admin: {
description: 'Optional description of what this workflow does',
},
},
{
name: 'triggers',
type: 'array',
fields: [
{
name: 'type',
type: 'select',
options: [
'collection-trigger',
'webhook-trigger',
'global-trigger',
'cron-trigger',
...(triggers || []).map(t => t.slug)
]
},
{
name: 'collection',
type: 'select',
admin: {
condition: (_, siblingData) => siblingData?.type === 'collection-trigger',
description: 'Collection that triggers the workflow',
},
options: Object.keys(collectionTriggers || {})
},
{
name: 'operation',
type: 'select',
admin: {
condition: (_, siblingData) => siblingData?.type === 'collection-trigger',
description: 'Collection operation that triggers the workflow',
},
options: [
'create',
'delete',
'read',
'update',
]
},
{
name: 'webhookPath',
type: 'text',
admin: {
condition: (_, siblingData) => siblingData?.type === 'webhook-trigger',
description: 'URL path for the webhook (e.g., "my-webhook"). Full URL will be /api/workflows/webhook/my-webhook',
},
validate: (value: any, {siblingData}: any) => {
if (siblingData?.type === 'webhook-trigger' && !value) {
return 'Webhook path is required for webhook triggers'
}
return true
}
},
{
name: 'global',
type: 'select',
admin: {
condition: (_, siblingData) => siblingData?.type === 'global-trigger',
description: 'Global that triggers the workflow',
},
options: [] // Will be populated dynamically based on available globals
},
{
name: 'globalOperation',
type: 'select',
admin: {
condition: (_, siblingData) => siblingData?.type === 'global-trigger',
description: 'Global operation that triggers the workflow',
},
options: [
'update'
]
},
{
name: 'cronExpression',
type: 'text',
admin: {
condition: (_, siblingData) => siblingData?.type === 'cron-trigger',
description: 'Cron expression for scheduled execution (e.g., "0 0 * * *" for daily at midnight)',
placeholder: '0 0 * * *'
},
validate: (value: any, {siblingData}: any) => {
if (siblingData?.type === 'cron-trigger' && !value) {
return 'Cron expression is required for cron triggers'
}
// Validate cron expression format if provided
if (siblingData?.type === 'cron-trigger' && value) {
// Basic format validation - should be 5 parts separated by spaces
const cronParts = value.trim().split(/\s+/)
if (cronParts.length !== 5) {
return 'Invalid cron expression format. Expected 5 parts: "minute hour day month weekday" (e.g., "0 9 * * 1")'
}
// Additional validation could use node-cron but we avoid dynamic imports here
// The main validation happens at runtime in the cron scheduler
}
return true
}
},
{
name: 'timezone',
type: 'text',
admin: {
condition: (_, siblingData) => siblingData?.type === 'cron-trigger',
description: 'Timezone for cron execution (e.g., "America/New_York", "Europe/London"). Defaults to UTC.',
placeholder: 'UTC'
},
defaultValue: 'UTC',
validate: (value: any, {siblingData}: any) => {
if (siblingData?.type === 'cron-trigger' && value) {
try {
// Test if timezone is valid by trying to create a date with it
new Intl.DateTimeFormat('en', {timeZone: value})
return true
} catch {
return `Invalid timezone: ${value}. Please use a valid IANA timezone identifier (e.g., "America/New_York", "Europe/London")`
}
}
return true
}
},
{
name: 'condition',
type: 'text',
admin: {
description: 'JSONPath expression that must evaluate to true for this trigger to execute the workflow (e.g., "$.doc.status == \'published\'")'
},
required: false
},
...(triggers || []).flatMap(t => (t.inputs || []).map(f => ({
...f,
admin: {
...(f.admin || {}),
condition: (...args) => args[1]?.type === t.slug && (
f.admin?.condition ?
f.admin.condition.call(this, ...args) :
true
),
},
} as Field)))
]
},
{
name: 'steps',
type: 'array',
fields: [
{
type: 'row',
fields: [
{
name: 'step',
type: 'select',
options: steps.map(t => t.slug)
},
{
name: 'name',
type: 'text',
}
]
},
{
name: 'input',
type: 'json',
required: false
},
{
name: 'dependencies',
type: 'text',
admin: {
description: 'Step names that must complete before this step can run'
},
hasMany: true,
required: false
},
{
name: 'condition',
type: 'text',
admin: {
description: 'JSONPath expression that must evaluate to true for this step to execute (e.g., "$.trigger.doc.status == \'published\'")'
},
required: false
},
],
}
],
versions: {
drafts: {
autosave: false,
},
maxPerDoc: 10,
},
})

View File

@@ -0,0 +1,151 @@
import type { CollectionConfig } from 'payload'
export const WorkflowRunsCollection: CollectionConfig = {
slug: 'workflow-runs',
access: {
create: () => true,
delete: () => true,
read: () => true,
update: () => true,
},
admin: {
defaultColumns: ['workflow', 'status', 'triggeredBy', 'startedAt', 'duration'],
group: 'Automation',
pagination: {
defaultLimit: 50,
},
useAsTitle: 'id',
},
fields: [
{
name: 'workflow',
type: 'relationship',
admin: {
description: 'Reference to the workflow that was executed',
},
relationTo: 'workflows',
required: true,
},
{
name: 'workflowVersion',
type: 'number',
admin: {
description: 'Version of the workflow that was executed',
},
required: true,
},
{
name: 'status',
type: 'select',
admin: {
description: 'Current execution status',
},
defaultValue: 'pending',
options: [
{
label: 'Pending',
value: 'pending',
},
{
label: 'Running',
value: 'running',
},
{
label: 'Completed',
value: 'completed',
},
{
label: 'Failed',
value: 'failed',
},
{
label: 'Cancelled',
value: 'cancelled',
},
],
required: true,
},
{
name: 'startedAt',
type: 'date',
admin: {
date: {
displayFormat: 'yyyy-MM-dd HH:mm:ss',
},
description: 'When execution began',
},
required: true,
},
{
name: 'completedAt',
type: 'date',
admin: {
date: {
displayFormat: 'yyyy-MM-dd HH:mm:ss',
},
description: 'When execution finished',
},
},
{
name: 'duration',
type: 'number',
admin: {
description: 'Total execution time in milliseconds',
readOnly: true,
},
},
{
name: 'context',
type: 'json'
},
{
name: 'inputs',
type: 'json',
admin: {
description: 'Input data provided when the workflow was triggered',
},
defaultValue: {},
required: true,
},
{
name: 'outputs',
type: 'json',
admin: {
description: 'Final output data from completed steps',
},
},
{
name: 'triggeredBy',
type: 'text',
admin: {
description: 'User, system, or trigger type that initiated execution',
},
required: true,
},
{
name: 'steps',
type: 'json',
admin: {
description: 'Array of step execution results',
},
defaultValue: [],
required: true,
},
{
name: 'error',
type: 'textarea',
admin: {
description: 'Error message if workflow execution failed',
},
},
{
name: 'logs',
type: 'json',
admin: {
description: 'Detailed execution logs',
},
defaultValue: [],
required: true,
},
],
}

View File

@@ -0,0 +1,64 @@
'use client'
import { Button, toast } from '@payloadcms/ui'
import { useState } from 'react'
interface TriggerWorkflowButtonProps {
workflowId: string
workflowName: string
triggerSlug?: string
}
export const TriggerWorkflowButton: React.FC<TriggerWorkflowButtonProps> = ({
workflowId,
workflowName,
triggerSlug = 'manual-trigger'
}) => {
const [loading, setLoading] = useState(false)
const handleTrigger = async () => {
setLoading(true)
try {
const response = await fetch('/api/workflows/trigger-custom', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workflowId,
triggerSlug,
data: {
triggeredAt: new Date().toISOString(),
source: 'admin-button'
}
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to trigger workflow')
}
const result = await response.json()
toast.success(`Workflow "${workflowName}" triggered successfully! Run ID: ${result.runId}`)
} catch (error) {
console.error('Error triggering workflow:', error)
toast.error(`Failed to trigger workflow: ${error instanceof Error ? error.message : 'Unknown error'}`)
} finally {
setLoading(false)
}
}
return (
<Button
onClick={handleTrigger}
disabled={loading}
size="small"
buttonStyle="secondary"
>
{loading ? 'Triggering...' : 'Trigger Workflow'}
</Button>
)
}

View File

@@ -0,0 +1,288 @@
import type { Payload, PayloadRequest } from 'payload'
import { initializeLogger } from '../plugin/logger.js'
import { type Workflow, WorkflowExecutor } from './workflow-executor.js'
export interface CustomTriggerOptions {
/**
* Data to pass to the workflow execution context
*/
data?: Record<string, unknown>
/**
* Optional PayloadRequest to use for the workflow execution
* If not provided, a minimal request will be created
*/
req?: PayloadRequest
/**
* The slug of the custom trigger to execute
*/
slug: string
/**
* Optional user information for tracking who triggered the workflow
*/
user?: {
email?: string
id?: string
}
}
export interface TriggerResult {
error?: string
runId: number | string
status: 'failed' | 'triggered'
workflowId: string
workflowName: string
}
/**
* Programmatically trigger workflows that have a matching custom trigger
*
* @example
* ```typescript
* // In your onInit or elsewhere in your code
* await triggerCustomWorkflow(payload, {
* slug: 'data-import',
* data: {
* source: 'external-api',
* recordCount: 100,
* importedAt: new Date().toISOString()
* }
* })
* ```
*/
export async function triggerCustomWorkflow(
payload: Payload,
options: CustomTriggerOptions
): Promise<TriggerResult[]> {
const { slug, data = {}, req, user } = options
const logger = initializeLogger(payload)
logger.info({
hasData: Object.keys(data).length > 0,
hasUser: !!user,
triggerSlug: slug
}, 'Triggering custom workflow')
try {
// Find workflows with matching custom trigger
const workflows = await payload.find({
collection: 'workflows',
depth: 2,
limit: 100,
where: {
'triggers.type': {
equals: slug
}
}
})
if (workflows.docs.length === 0) {
logger.warn({
triggerSlug: slug
}, 'No workflows found for custom trigger')
return []
}
logger.info({
triggerSlug: slug,
workflowCount: workflows.docs.length
}, 'Found workflows for custom trigger')
// Create a minimal request if not provided
const workflowReq = req || {
context: {},
headers: new Headers(),
payload,
user: user ? {
id: user.id,
collection: 'users',
email: user.email
} : undefined
} as PayloadRequest
// Create workflow executor
const executor = new WorkflowExecutor(payload, logger)
// Execute all matching workflows
const results: TriggerResult[] = []
for (const workflow of workflows.docs) {
try {
// Check if this workflow actually has the custom trigger
const triggers = workflow.triggers as Array<{type: string}>
const hasMatchingTrigger = triggers?.some(trigger => trigger.type === slug)
if (!hasMatchingTrigger) {
continue
}
logger.info({
triggerSlug: slug,
workflowId: workflow.id.toString(),
workflowName: workflow.name
}, 'Executing workflow with custom trigger')
// Create execution context
const context = {
steps: {},
trigger: {
type: slug,
data,
req: workflowReq,
triggeredAt: new Date().toISOString(),
user: (user || workflowReq.user) ? {
id: (user || workflowReq.user)?.id?.toString(),
email: (user || workflowReq.user)?.email
} : undefined
}
}
// Execute the workflow
await executor.execute(workflow as Workflow, context, workflowReq)
// Get the latest run for this workflow to get the run ID
const runs = await payload.find({
collection: 'workflow-runs',
limit: 1,
sort: '-createdAt',
where: {
workflow: {
equals: workflow.id
}
}
})
results.push({
runId: runs.docs[0]?.id?.toString() || 'unknown',
status: 'triggered',
workflowId: workflow.id.toString(),
workflowName: workflow.name as string
})
logger.info({
triggerSlug: slug,
workflowId: workflow.id.toString(),
workflowName: workflow.name
}, 'Workflow executed successfully')
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : 'Unknown error',
triggerSlug: slug,
workflowId: workflow.id.toString(),
workflowName: workflow.name
}, 'Failed to execute workflow')
results.push({
error: error instanceof Error ? error.message : 'Unknown error',
runId: 'failed',
status: 'failed',
workflowId: workflow.id.toString(),
workflowName: workflow.name as string
})
}
}
return results
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : 'Unknown error',
triggerSlug: slug
}, 'Failed to trigger custom workflows')
throw new Error(
`Failed to trigger custom workflows: ${
error instanceof Error ? error.message : 'Unknown error'
}`
)
}
}
/**
* Helper function to trigger a single workflow by ID with custom trigger data
* This is useful when you know exactly which workflow you want to trigger
*/
export async function triggerWorkflowById(
payload: Payload,
workflowId: string,
triggerSlug: string,
data?: Record<string, unknown>,
req?: PayloadRequest
): Promise<TriggerResult> {
const logger = initializeLogger(payload)
try {
const workflow = await payload.findByID({
id: workflowId,
collection: 'workflows',
depth: 2
})
if (!workflow) {
throw new Error(`Workflow ${workflowId} not found`)
}
// Verify the workflow has the specified custom trigger
const triggers = workflow.triggers as Array<{type: string}>
const hasMatchingTrigger = triggers?.some(trigger => trigger.type === triggerSlug)
if (!hasMatchingTrigger) {
throw new Error(`Workflow ${workflowId} does not have trigger ${triggerSlug}`)
}
// Create a minimal request if not provided
const workflowReq = req || {
context: {},
headers: new Headers(),
payload
} as PayloadRequest
// Create execution context
const context = {
steps: {},
trigger: {
type: triggerSlug,
data: data || {},
req: workflowReq,
triggeredAt: new Date().toISOString()
}
}
// Create executor and execute
const executor = new WorkflowExecutor(payload, logger)
await executor.execute(workflow as Workflow, context, workflowReq)
// Get the latest run to get the run ID
const runs = await payload.find({
collection: 'workflow-runs',
limit: 1,
sort: '-createdAt',
where: {
workflow: {
equals: workflow.id
}
}
})
return {
runId: runs.docs[0]?.id?.toString() || 'unknown',
status: 'triggered',
workflowId: workflow.id.toString(),
workflowName: workflow.name as string
}
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : 'Unknown error',
triggerSlug,
workflowId
}, 'Failed to trigger workflow by ID')
throw error
}
}

View File

@@ -0,0 +1,609 @@
import type { Payload, PayloadRequest } from 'payload'
import { JSONPath } from 'jsonpath-plus'
export type Workflow = {
_version?: number
id: string
name: string
steps: WorkflowStep[]
triggers: WorkflowTrigger[]
}
export type WorkflowStep = {
condition?: string
dependencies?: string[]
input?: null | Record<string, unknown>
name: string
step: string
}
export interface WorkflowTrigger {
collection?: string
condition?: string
global?: string
globalOperation?: string
operation?: string
type: string
webhookPath?: string
}
export interface ExecutionContext {
steps: Record<string, {
error?: string
input: unknown
output: unknown
state: 'failed' | 'pending' | 'running' | 'succeeded'
}>
trigger: {
collection?: string
data?: unknown
doc?: unknown
headers?: Record<string, string>
operation?: string
path?: string
previousDoc?: unknown
req?: PayloadRequest
triggeredAt?: string
type: string
user?: {
collection?: string
email?: string
id?: string
}
}
}
export class WorkflowExecutor {
constructor(
private payload: Payload,
private logger: Payload['logger']
) {}
/**
* Evaluate a step condition using JSONPath
*/
private evaluateStepCondition(condition: string, context: ExecutionContext): boolean {
return this.evaluateCondition(condition, context)
}
/**
* Execute a single workflow step
*/
private async executeStep(
step: WorkflowStep,
stepIndex: number,
context: ExecutionContext,
req: PayloadRequest,
workflowRunId?: number | string
): Promise<void> {
const stepName = step.name || 'step-' + stepIndex
this.logger.info({
hasStep: 'step' in step,
step: JSON.stringify(step),
stepName
}, 'Executing step')
// Check step condition if present
if (step.condition) {
const conditionMet = this.evaluateStepCondition(step.condition, context)
if (!conditionMet) {
this.logger.info({
condition: step.condition,
stepName
}, 'Step condition not met, skipping')
// Mark step as completed but skipped
context.steps[stepName] = {
error: undefined,
input: undefined,
output: { reason: 'Condition not met', skipped: true },
state: 'succeeded'
}
// Update workflow run context if needed
if (workflowRunId) {
await this.updateWorkflowRunContext(workflowRunId, context, req)
}
return
}
this.logger.info({
condition: step.condition,
stepName
}, 'Step condition met, proceeding with execution')
}
// Initialize step context
context.steps[stepName] = {
error: undefined,
input: undefined,
output: undefined,
state: 'running'
}
// Move taskSlug declaration outside try block so it's accessible in catch
const taskSlug = step.step // Use the 'step' field for task type
try {
// Resolve input data using JSONPath
const resolvedInput = this.resolveStepInput(step.input || {}, context)
context.steps[stepName].input = resolvedInput
if (!taskSlug) {
throw new Error(`Step ${stepName} is missing a task type`)
}
this.logger.info({
hasInput: !!resolvedInput,
hasReq: !!req,
stepName,
taskSlug
}, 'Queueing task')
const job = await this.payload.jobs.queue({
input: resolvedInput,
req,
task: taskSlug
})
// Run the job immediately
await this.payload.jobs.run({
limit: 1,
req
})
// Get the job result
const completedJob = await this.payload.findByID({
id: job.id,
collection: 'payload-jobs',
req
})
const taskStatus = completedJob.taskStatus?.[completedJob.taskSlug]?.[completedJob.totalTried]
const isComplete = taskStatus?.complete === true
const hasError = completedJob.hasError || !isComplete
// Extract error information from job if available
let errorMessage: string | undefined
if (hasError) {
// Try to get error from the latest log entry
if (completedJob.log && completedJob.log.length > 0) {
const latestLog = completedJob.log[completedJob.log.length - 1]
errorMessage = latestLog.error?.message || latestLog.error
}
// Fallback to top-level error
if (!errorMessage && completedJob.error) {
errorMessage = completedJob.error.message || completedJob.error
}
// Final fallback to generic message
if (!errorMessage) {
errorMessage = `Task ${taskSlug} failed without detailed error information`
}
}
const result: {
error: string | undefined
output: unknown
state: 'failed' | 'succeeded'
} = {
error: errorMessage,
output: taskStatus?.output || {},
state: isComplete ? 'succeeded' : 'failed'
}
// Store the output and error
context.steps[stepName].output = result.output
context.steps[stepName].state = result.state
if (result.error) {
context.steps[stepName].error = result.error
}
this.logger.debug({context}, 'Step execution context')
if (result.state !== 'succeeded') {
throw new Error(result.error || `Step ${stepName} failed`)
}
this.logger.info({
output: result.output,
stepName
}, 'Step completed')
// Update workflow run with current step results if workflowRunId is provided
if (workflowRunId) {
await this.updateWorkflowRunContext(workflowRunId, context, req)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
context.steps[stepName].state = 'failed'
context.steps[stepName].error = errorMessage
this.logger.error({
error: errorMessage,
input: context.steps[stepName].input,
stepName,
taskSlug
}, 'Step execution failed')
// Update workflow run with current step results if workflowRunId is provided
if (workflowRunId) {
try {
await this.updateWorkflowRunContext(workflowRunId, context, req)
} catch (updateError) {
this.logger.error({
error: updateError instanceof Error ? updateError.message : 'Unknown error',
stepName
}, 'Failed to update workflow run context after step failure')
}
}
throw error
}
}
/**
* Resolve step execution order based on dependencies
*/
private resolveExecutionOrder(steps: WorkflowStep[]): WorkflowStep[][] {
const stepMap = new Map<string, WorkflowStep>()
const dependencyGraph = new Map<string, string[]>()
const indegree = new Map<string, number>()
// Build the step map and dependency graph
for (const step of steps) {
const stepName = step.name || `step-${steps.indexOf(step)}`
const dependencies = step.dependencies || []
stepMap.set(stepName, { ...step, name: stepName, dependencies })
dependencyGraph.set(stepName, dependencies)
indegree.set(stepName, dependencies.length)
}
// Topological sort to determine execution batches
const executionBatches: WorkflowStep[][] = []
const processed = new Set<string>()
while (processed.size < steps.length) {
const currentBatch: WorkflowStep[] = []
// Find all steps with no remaining dependencies
for (const [stepName, inDegree] of indegree.entries()) {
if (inDegree === 0 && !processed.has(stepName)) {
const step = stepMap.get(stepName)
if (step) {
currentBatch.push(step)
}
}
}
if (currentBatch.length === 0) {
throw new Error('Circular dependency detected in workflow steps')
}
executionBatches.push(currentBatch)
// Update indegrees for next iteration
for (const step of currentBatch) {
processed.add(step.name)
// Reduce indegree for steps that depend on completed steps
for (const [otherStepName, dependencies] of dependencyGraph.entries()) {
if (dependencies.includes(step.name) && !processed.has(otherStepName)) {
indegree.set(otherStepName, (indegree.get(otherStepName) || 0) - 1)
}
}
}
}
return executionBatches
}
/**
* Resolve step input using JSONPath expressions
*/
private resolveStepInput(config: Record<string, unknown>, context: ExecutionContext): Record<string, unknown> {
const resolved: Record<string, unknown> = {}
for (const [key, value] of Object.entries(config)) {
if (typeof value === 'string' && value.startsWith('$')) {
// This is a JSONPath expression
try {
const result = JSONPath({
json: context,
path: value,
wrap: false
})
resolved[key] = result
} catch (error) {
this.logger.warn({
error: error instanceof Error ? error.message : 'Unknown error',
key,
path: value
}, 'Failed to resolve JSONPath')
resolved[key] = value // Keep original value if resolution fails
}
} else if (typeof value === 'object' && value !== null) {
// Recursively resolve nested objects
resolved[key] = this.resolveStepInput(value as Record<string, unknown>, context)
} else {
// Keep literal values as-is
resolved[key] = value
}
}
return resolved
}
/**
* Update workflow run with current context
*/
private async updateWorkflowRunContext(
workflowRunId: number | string,
context: ExecutionContext,
req: PayloadRequest
): Promise<void> {
const serializeContext = () => ({
steps: context.steps,
trigger: {
type: context.trigger.type,
collection: context.trigger.collection,
data: context.trigger.data,
doc: context.trigger.doc,
operation: context.trigger.operation,
previousDoc: context.trigger.previousDoc,
triggeredAt: context.trigger.triggeredAt,
user: context.trigger.req?.user
}
})
await this.payload.update({
id: workflowRunId,
collection: 'workflow-runs',
data: {
context: serializeContext()
},
req
})
}
/**
* Evaluate a condition using JSONPath
*/
public evaluateCondition(condition: string, context: ExecutionContext): boolean {
try {
const result = JSONPath({
json: context,
path: condition,
wrap: false
})
// Handle different result types
if (Array.isArray(result)) {
return result.length > 0 && Boolean(result[0])
}
return Boolean(result)
} catch (error) {
this.logger.warn({
condition,
error: error instanceof Error ? error.message : 'Unknown error'
}, 'Failed to evaluate condition')
// If condition evaluation fails, assume false
return false
}
}
/**
* Execute a workflow with the given context
*/
async execute(workflow: Workflow, context: ExecutionContext, req: PayloadRequest): Promise<void> {
this.logger.info({
workflowId: workflow.id,
workflowName: workflow.name
}, 'Starting workflow execution')
const serializeContext = () => ({
steps: context.steps,
trigger: {
type: context.trigger.type,
collection: context.trigger.collection,
data: context.trigger.data,
doc: context.trigger.doc,
operation: context.trigger.operation,
previousDoc: context.trigger.previousDoc,
triggeredAt: context.trigger.triggeredAt,
user: context.trigger.req?.user
}
})
// Create a workflow run record
const workflowRun = await this.payload.create({
collection: 'workflow-runs',
data: {
context: serializeContext(),
startedAt: new Date().toISOString(),
status: 'running',
triggeredBy: context.trigger.req?.user?.email || 'system',
workflow: workflow.id,
workflowVersion: workflow._version || 1
},
req
})
try {
// Resolve execution order based on dependencies
const executionBatches = this.resolveExecutionOrder(workflow.steps)
this.logger.info({
batchSizes: executionBatches.map(batch => batch.length),
totalBatches: executionBatches.length
}, 'Resolved step execution order')
// Execute each batch in sequence, but steps within each batch in parallel
for (let batchIndex = 0; batchIndex < executionBatches.length; batchIndex++) {
const batch = executionBatches[batchIndex]
this.logger.info({
batchIndex,
stepCount: batch.length,
stepNames: batch.map(s => s.name)
}, 'Executing batch')
// Execute all steps in this batch in parallel
const batchPromises = batch.map((step, stepIndex) =>
this.executeStep(step, stepIndex, context, req, workflowRun.id)
)
// Wait for all steps in the current batch to complete
await Promise.all(batchPromises)
this.logger.info({
batchIndex,
stepCount: batch.length
}, 'Batch completed')
}
// Update workflow run as completed
await this.payload.update({
id: workflowRun.id,
collection: 'workflow-runs',
data: {
completedAt: new Date().toISOString(),
context: serializeContext(),
status: 'completed'
},
req
})
this.logger.info({
runId: workflowRun.id,
workflowId: workflow.id,
workflowName: workflow.name
}, 'Workflow execution completed')
} catch (error) {
// Update workflow run as failed
await this.payload.update({
id: workflowRun.id,
collection: 'workflow-runs',
data: {
completedAt: new Date().toISOString(),
context: serializeContext(),
error: error instanceof Error ? error.message : 'Unknown error',
status: 'failed'
},
req
})
this.logger.error({
error: error instanceof Error ? error.message : 'Unknown error',
runId: workflowRun.id,
workflowId: workflow.id,
workflowName: workflow.name
}, 'Workflow execution failed')
throw error
}
}
/**
* Find and execute workflows triggered by a collection operation
*/
async executeTriggeredWorkflows(
collection: string,
operation: 'create' | 'delete' | 'read' | 'update',
doc: unknown,
previousDoc: unknown,
req: PayloadRequest
): Promise<void> {
try {
// Find workflows with matching triggers
const workflows = await this.payload.find({
collection: 'workflows',
depth: 2, // Include steps and triggers
limit: 100,
req
})
for (const workflow of workflows.docs) {
// Check if this workflow has a matching trigger
const triggers = workflow.triggers as Array<{
collection: string
condition?: string
operation: string
type: string
}>
const matchingTriggers = triggers?.filter(trigger =>
trigger.type === 'collection-trigger' &&
trigger.collection === collection &&
trigger.operation === operation
) || []
for (const trigger of matchingTriggers) {
// Create execution context for condition evaluation
const context: ExecutionContext = {
steps: {},
trigger: {
type: 'collection',
collection,
doc,
operation,
previousDoc,
req
}
}
// Check trigger condition if present
if (trigger.condition) {
const conditionMet = this.evaluateCondition(trigger.condition, context)
if (!conditionMet) {
this.logger.info({
collection,
condition: trigger.condition,
operation,
workflowId: workflow.id,
workflowName: workflow.name
}, 'Trigger condition not met, skipping workflow')
continue
}
this.logger.info({
collection,
condition: trigger.condition,
operation,
workflowId: workflow.id,
workflowName: workflow.name
}, 'Trigger condition met')
}
this.logger.info({
collection,
operation,
workflowId: workflow.id,
workflowName: workflow.name
}, 'Triggering workflow')
// Execute the workflow
await this.execute(workflow as Workflow, context, req)
}
}
} catch (error) {
this.logger.error({ error: error instanceof Error ? error.message : 'Unknown error' }, 'Workflow execution failed')
this.logger.error({
collection,
error: error instanceof Error ? error.message : 'Unknown error',
operation
}, 'Failed to execute triggered workflows')
}
}
}

8
src/exports/client.ts Normal file
View File

@@ -0,0 +1,8 @@
// Client-side components that may have CSS imports or PayloadCMS UI dependencies
// These are separated to avoid CSS import errors during Node.js type generation
export { TriggerWorkflowButton } from '../components/TriggerWorkflowButton.js'
// Future client components can be added here:
// export { default as WorkflowDashboard } from '../components/WorkflowDashboard/index.js'
// export { default as WorkflowBuilder } from '../components/WorkflowBuilder/index.js'

5
src/exports/fields.ts Normal file
View File

@@ -0,0 +1,5 @@
// Field exports for workflow plugin
// Currently no custom fields, but this export exists for future extensibility
export const WorkflowFields = {
// Custom workflow fields can be added here in the future
}

1
src/exports/rsc.ts Normal file
View File

@@ -0,0 +1 @@
// Server-side exports for workflow plugin

6
src/exports/views.ts Normal file
View File

@@ -0,0 +1,6 @@
// View exports for workflow plugin
// Currently no custom views, but this export exists for future extensibility
// export { default as WorkflowDashboard } from '../components/WorkflowDashboard/index.js'
// export { default as WorkflowBuilder } from '../components/WorkflowBuilder/index.js'
// export { default as WorkflowStepsField } from '../components/WorkflowStepsField/index.js'

19
src/index.ts Normal file
View File

@@ -0,0 +1,19 @@
export { triggerCustomWorkflow, triggerWorkflowById } from './core/trigger-custom-workflow.js'
export type { CustomTriggerOptions, TriggerResult } from './core/trigger-custom-workflow.js'
export { WorkflowExecutor } from './core/workflow-executor.js'
export type { ExecutionContext, Workflow, WorkflowStep, WorkflowTrigger } from './core/workflow-executor.js'
export type { WorkflowsPluginConfig } from './plugin/config-types.js'
export { workflowsPlugin } from './plugin/index.js'
// Export all step tasks
export {
CreateDocumentStepTask,
DeleteDocumentStepTask,
HttpRequestStepTask,
ReadDocumentStepTask,
SendEmailStepTask,
UpdateDocumentStepTask
} from './steps/index.js'
// UI components are exported via separate client export to avoid CSS import issues during type generation
// Use: import { TriggerWorkflowButton } from '@xtr-dev/payload-automation/client'

View File

@@ -0,0 +1,25 @@
import type {Field, TaskConfig} from "payload"
export type CollectionTriggerConfigCrud = {
create?: true
delete?: true
read?: true
update?: true
}
export type CollectionTriggerConfig = CollectionTriggerConfigCrud | true
export type CustomTriggerConfig = {
inputs?: Field[]
slug: string,
}
export type WorkflowsPluginConfig<TSlug extends string> = {
collectionTriggers: {
[key in TSlug]?: CollectionTriggerConfig
}
enabled?: boolean
steps: TaskConfig<string>[],
triggers?: CustomTriggerConfig[]
webhookPrefix?: string
}

View File

@@ -0,0 +1,632 @@
import type {Config, Payload, TaskConfig} from 'payload'
import * as cron from 'node-cron'
import {type Workflow, WorkflowExecutor} from '../core/workflow-executor.js'
import {getConfigLogger} from './logger.js'
/**
* Generate dynamic cron tasks for all workflows with cron triggers
* This is called at config time to register all scheduled tasks
*/
export function generateCronTasks(config: Config): void {
const logger = getConfigLogger()
// Note: We can't query the database at config time, so we'll need a different approach
// We'll create a single task that handles all cron-triggered workflows
const cronTask: TaskConfig = {
slug: 'workflow-cron-executor',
handler: async ({ input, req }) => {
const { cronExpression, timezone, workflowId } = input as {
cronExpression?: string
timezone?: string
workflowId: string
}
const logger = req.payload.logger.child({ plugin: '@xtr-dev/payload-automation' })
try {
// Get the workflow
const workflow = await req.payload.findByID({
id: workflowId,
collection: 'workflows',
depth: 2,
req
})
if (!workflow) {
throw new Error(`Workflow ${workflowId} not found`)
}
// Create execution context for cron trigger
const context = {
steps: {},
trigger: {
type: 'cron',
req,
triggeredAt: new Date().toISOString()
}
}
// Create executor
const executor = new WorkflowExecutor(req.payload, logger)
// Find the matching cron trigger and check its condition if present
const triggers = workflow.triggers as Array<{
condition?: string
cronExpression?: string
timezone?: string
type: string
}>
const matchingTrigger = triggers?.find(trigger =>
trigger.type === 'cron-trigger' &&
trigger.cronExpression === cronExpression
)
// Check trigger condition if present
if (matchingTrigger?.condition) {
const conditionMet = executor.evaluateCondition(matchingTrigger.condition, context)
if (!conditionMet) {
logger.info({
condition: matchingTrigger.condition,
cronExpression,
workflowId,
workflowName: workflow.name
}, 'Cron trigger condition not met, skipping workflow execution')
// Re-queue for next execution but don't run workflow
if (cronExpression) {
void requeueCronJob(workflowId, cronExpression, timezone, req.payload, logger)
}
return {
output: {
executedAt: new Date().toISOString(),
status: 'skipped',
reason: 'Condition not met',
workflowId
},
state: 'succeeded'
}
}
logger.info({
condition: matchingTrigger.condition,
cronExpression,
workflowId,
workflowName: workflow.name
}, 'Cron trigger condition met')
}
// Execute the workflow
await executor.execute(workflow as Workflow, context, req)
// Re-queue the job for the next scheduled execution if cronExpression is provided
if (cronExpression) {
void requeueCronJob(workflowId, cronExpression, timezone, req.payload, logger)
}
return {
output: {
executedAt: new Date().toISOString(),
status: 'completed',
workflowId
},
state: 'succeeded'
}
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : 'Unknown error',
workflowId
}, 'Cron job execution failed')
// Re-queue even on failure to ensure continuity (unless it's a validation error)
if (cronExpression && !(error instanceof Error && error.message.includes('Invalid cron'))) {
void requeueCronJob(workflowId, cronExpression, timezone, req.payload, logger)
.catch((requeueError) => {
logger.error({
error: requeueError instanceof Error ? requeueError.message : 'Unknown error',
workflowId
}, 'Failed to re-queue cron job after execution failure')
})
}
return {
output: {
error: error instanceof Error ? error.message : 'Unknown error',
workflowId
},
state: 'failed'
}
}
}
}
// Add the cron task to config if not already present
if (!config.jobs) {
config.jobs = { tasks: [] }
}
if (!config.jobs.tasks) {
config.jobs.tasks = []
}
if (!config.jobs.tasks.find(task => task.slug === cronTask.slug)) {
logger.debug(`Registering cron executor task: ${cronTask.slug}`)
config.jobs.tasks.push(cronTask)
} else {
logger.debug(`Cron executor task ${cronTask.slug} already registered, skipping`)
}
}
/**
* Register cron jobs for workflows with cron triggers
* This is called at runtime after PayloadCMS is initialized
*/
export async function registerCronJobs(payload: Payload, logger: Payload['logger']): Promise<void> {
try {
// Find all workflows with cron triggers
const workflows = await payload.find({
collection: 'workflows',
depth: 0,
limit: 1000,
where: {
'triggers.type': {
equals: 'cron-trigger'
}
}
})
logger.info(`Found ${workflows.docs.length} workflows with cron triggers`)
for (const workflow of workflows.docs) {
const triggers = workflow.triggers as Array<{
cronExpression?: string
timezone?: string
type: string
}>
// Find all cron triggers for this workflow
const cronTriggers = triggers?.filter(t => t.type === 'cron-trigger') || []
for (const trigger of cronTriggers) {
if (trigger.cronExpression) {
try {
// Validate cron expression before queueing
if (!validateCronExpression(trigger.cronExpression)) {
logger.error({
cronExpression: trigger.cronExpression,
workflowId: workflow.id,
workflowName: workflow.name
}, 'Invalid cron expression format')
continue
}
// Validate timezone if provided
if (trigger.timezone) {
try {
// Test if timezone is valid by trying to create a date with it
new Intl.DateTimeFormat('en', { timeZone: trigger.timezone })
} catch {
logger.error({
timezone: trigger.timezone,
workflowId: workflow.id,
workflowName: workflow.name
}, 'Invalid timezone specified')
continue
}
}
// Calculate next execution time
const nextExecution = getNextCronTime(trigger.cronExpression, trigger.timezone)
// Queue the job
await payload.jobs.queue({
input: { cronExpression: trigger.cronExpression, timezone: trigger.timezone, workflowId: workflow.id },
task: 'workflow-cron-executor',
waitUntil: nextExecution
})
logger.info({
cronExpression: trigger.cronExpression,
nextExecution: nextExecution.toISOString(),
timezone: trigger.timezone || 'UTC',
workflowId: workflow.id,
workflowName: workflow.name
}, 'Queued initial cron job for workflow')
} catch (error) {
logger.error({
cronExpression: trigger.cronExpression,
error: error instanceof Error ? error.message : 'Unknown error',
timezone: trigger.timezone,
workflowId: workflow.id,
workflowName: workflow.name
}, 'Failed to queue cron job')
}
} else {
logger.warn({
workflowId: workflow.id,
workflowName: workflow.name
}, 'Cron trigger found but no cron expression specified')
}
}
}
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : 'Unknown error'
}, 'Failed to register cron jobs')
}
}
/**
* Validate a cron expression
*/
export function validateCronExpression(cronExpression: string): boolean {
return cron.validate(cronExpression)
}
/**
* Calculate the next time a cron expression should run
*/
function getNextCronTime(cronExpression: string, timezone?: string): Date {
if (!validateCronExpression(cronExpression)) {
throw new Error(`Invalid cron expression: ${cronExpression}`)
}
const now = new Date()
const options: { timezone?: string } = timezone ? { timezone } : {}
// Create a task to find the next execution time
const task = cron.schedule(cronExpression, () => {}, {
...options
})
// Parse cron expression parts
const cronParts = cronExpression.trim().split(/\s+/)
if (cronParts.length !== 5) {
void task.destroy()
throw new Error(`Invalid cron format: ${cronExpression}. Expected 5 parts.`)
}
const [minutePart, hourPart, dayPart, monthPart, weekdayPart] = cronParts
// Calculate next execution with proper lookahead for any schedule frequency
// Start from next minute and look ahead systematically
let testTime = new Date(now.getTime() + 60 * 1000) // Start 1 minute from now
testTime.setSeconds(0, 0) // Reset seconds and milliseconds
// Maximum iterations to prevent infinite loops (covers ~2 years)
const maxIterations = 2 * 365 * 24 * 60 // 2 years worth of minutes
let iterations = 0
while (iterations < maxIterations) {
const minute = testTime.getMinutes()
const hour = testTime.getHours()
const dayOfMonth = testTime.getDate()
const month = testTime.getMonth() + 1
const dayOfWeek = testTime.getDay()
if (matchesCronPart(minute, minutePart) &&
matchesCronPart(hour, hourPart) &&
matchesCronPart(dayOfMonth, dayPart) &&
matchesCronPart(month, monthPart) &&
matchesCronPart(dayOfWeek, weekdayPart)) {
void task.destroy()
return testTime
}
// Increment time intelligently based on cron pattern
testTime = incrementTimeForCronPattern(testTime, cronParts)
iterations++
}
void task.destroy()
throw new Error(`Could not calculate next execution time for cron expression: ${cronExpression} within reasonable timeframe`)
}
/**
* Intelligently increment time based on cron pattern to avoid unnecessary iterations
*/
function incrementTimeForCronPattern(currentTime: Date, cronParts: string[]): Date {
const [minutePart, hourPart, _dayPart, _monthPart, _weekdayPart] = cronParts
const nextTime = new Date(currentTime)
// If minute is specific (not wildcard), we can jump to next hour
if (minutePart !== '*' && !minutePart.includes('/')) {
const targetMinute = getNextValidCronValue(currentTime.getMinutes(), minutePart)
if (targetMinute <= currentTime.getMinutes()) {
// Move to next hour
nextTime.setHours(nextTime.getHours() + 1, targetMinute, 0, 0)
} else {
nextTime.setMinutes(targetMinute, 0, 0)
}
return nextTime
}
// If hour is specific and we're past it, jump to next day
if (hourPart !== '*' && !hourPart.includes('/')) {
const targetHour = getNextValidCronValue(currentTime.getHours(), hourPart)
if (targetHour <= currentTime.getHours()) {
// Move to next day
nextTime.setDate(nextTime.getDate() + 1)
nextTime.setHours(targetHour, 0, 0, 0)
} else {
nextTime.setHours(targetHour, 0, 0, 0)
}
return nextTime
}
// Default: increment by 1 minute
nextTime.setTime(nextTime.getTime() + 60 * 1000)
return nextTime
}
/**
* Get the next valid value for a cron part
*/
function getNextValidCronValue(currentValue: number, cronPart: string): number {
if (cronPart === '*') {return currentValue + 1}
// Handle specific values and ranges
const values = parseCronPart(cronPart)
return values.find(v => v > currentValue) || values[0]
}
/**
* Parse a cron part into an array of valid values
*/
function parseCronPart(cronPart: string): number[] {
if (cronPart === '*') {return []}
const values: number[] = []
// Handle comma-separated values
if (cronPart.includes(',')) {
cronPart.split(',').forEach(part => {
values.push(...parseCronPart(part.trim()))
})
return values.sort((a, b) => a - b)
}
// Handle ranges
if (cronPart.includes('-')) {
const [start, end] = cronPart.split('-').map(n => parseInt(n, 10))
for (let i = start; i <= end; i++) {
values.push(i)
}
return values
}
// Handle step values
if (cronPart.includes('/')) {
const [range, step] = cronPart.split('/')
const stepNum = parseInt(step, 10)
if (range === '*') {
// For wildcards with steps, return empty - handled elsewhere
return []
}
const baseValues = parseCronPart(range)
return baseValues.filter((_, index) => index % stepNum === 0)
}
// Single value
values.push(parseInt(cronPart, 10))
return values
}
/**
* Check if a value matches a cron expression part
*/
function matchesCronPart(value: number, cronPart: string): boolean {
if (cronPart === '*') {return true}
// Handle step values (e.g., */5)
if (cronPart.includes('/')) {
const [range, step] = cronPart.split('/')
const stepNum = parseInt(step, 10)
if (range === '*') {
return value % stepNum === 0
}
}
// Handle ranges (e.g., 1-5)
if (cronPart.includes('-')) {
const [start, end] = cronPart.split('-').map(n => parseInt(n, 10))
return value >= start && value <= end
}
// Handle comma-separated values (e.g., 1,3,5)
if (cronPart.includes(',')) {
const values = cronPart.split(',').map(n => parseInt(n, 10))
return values.includes(value)
}
// Handle single value
const cronValue = parseInt(cronPart, 10)
return value === cronValue
}
/**
* Handle re-queueing of cron jobs after they execute
* This ensures the job runs again at the next scheduled time
*/
export async function requeueCronJob(
workflowId: string,
cronExpression: string,
timezone: string | undefined,
payload: Payload,
logger: Payload['logger']
): Promise<void> {
try {
// Queue the job to run at the next scheduled time
await payload.jobs.queue({
input: { cronExpression, timezone, workflowId },
task: 'workflow-cron-executor',
waitUntil: getNextCronTime(cronExpression, timezone)
})
logger.debug({
nextRun: getNextCronTime(cronExpression, timezone),
timezone: timezone || 'UTC',
workflowId
}, 'Re-queued cron job')
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : 'Unknown error',
workflowId
}, 'Failed to re-queue cron job')
}
}
/**
* Register or update cron jobs for a specific workflow
*/
export async function updateWorkflowCronJobs(
workflowId: string,
payload: Payload,
logger: Payload['logger']
): Promise<void> {
try {
// First, cancel any existing cron jobs for this workflow
cancelWorkflowCronJobs(workflowId, payload, logger)
// Get the workflow
const workflow = await payload.findByID({
id: workflowId,
collection: 'workflows',
depth: 0
})
if (!workflow) {
logger.warn({ workflowId }, 'Workflow not found for cron job update')
return
}
const triggers = workflow.triggers as Array<{
cronExpression?: string
timezone?: string
type: string
}>
// Find all cron triggers for this workflow
const cronTriggers = triggers?.filter(t => t.type === 'cron-trigger') || []
if (cronTriggers.length === 0) {
logger.debug({ workflowId }, 'No cron triggers found for workflow')
return
}
let scheduledJobs = 0
for (const trigger of cronTriggers) {
if (trigger.cronExpression) {
try {
// Validate cron expression before queueing
if (!validateCronExpression(trigger.cronExpression)) {
logger.error({
cronExpression: trigger.cronExpression,
workflowId,
workflowName: workflow.name
}, 'Invalid cron expression format')
continue
}
// Validate timezone if provided
if (trigger.timezone) {
try {
new Intl.DateTimeFormat('en', { timeZone: trigger.timezone })
} catch {
logger.error({
timezone: trigger.timezone,
workflowId,
workflowName: workflow.name
}, 'Invalid timezone specified')
continue
}
}
// Calculate next execution time
const nextExecution = getNextCronTime(trigger.cronExpression, trigger.timezone)
// Queue the job
await payload.jobs.queue({
input: { cronExpression: trigger.cronExpression, timezone: trigger.timezone, workflowId },
task: 'workflow-cron-executor',
waitUntil: nextExecution
})
scheduledJobs++
logger.info({
cronExpression: trigger.cronExpression,
nextExecution: nextExecution.toISOString(),
timezone: trigger.timezone || 'UTC',
workflowId,
workflowName: workflow.name
}, 'Scheduled cron job for workflow')
} catch (error) {
logger.error({
cronExpression: trigger.cronExpression,
error: error instanceof Error ? error.message : 'Unknown error',
timezone: trigger.timezone,
workflowId,
workflowName: workflow.name
}, 'Failed to schedule cron job')
}
}
}
if (scheduledJobs > 0) {
logger.info({ scheduledJobs, workflowId }, 'Updated cron jobs for workflow')
}
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : 'Unknown error',
workflowId
}, 'Failed to update workflow cron jobs')
}
}
/**
* Cancel all cron jobs for a specific workflow
*/
export function cancelWorkflowCronJobs(
workflowId: string,
payload: Payload,
logger: Payload['logger']
): void {
try {
// Note: PayloadCMS job system doesn't have a built-in way to cancel specific jobs by input
// This is a limitation we need to work around
// For now, we log that we would cancel jobs for this workflow
logger.debug({ workflowId }, 'Would cancel existing cron jobs for workflow (PayloadCMS limitation: cannot selectively cancel jobs)')
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : 'Unknown error',
workflowId
}, 'Failed to cancel workflow cron jobs')
}
}
/**
* Remove cron jobs for a deleted workflow
*/
export function removeWorkflowCronJobs(
workflowId: string,
payload: Payload,
logger: Payload['logger']
): void {
try {
cancelWorkflowCronJobs(workflowId, payload, logger)
logger.info({ workflowId }, 'Removed cron jobs for deleted workflow')
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : 'Unknown error',
workflowId
}, 'Failed to remove workflow cron jobs')
}
}

88
src/plugin/index.ts Normal file
View File

@@ -0,0 +1,88 @@
import type {Config} from 'payload'
import type {WorkflowsPluginConfig} from "./config-types.js"
import {createWorkflowCollection} from '../collections/Workflow.js'
import {WorkflowRunsCollection} from '../collections/WorkflowRuns.js'
import {WorkflowExecutor} from '../core/workflow-executor.js'
import {generateCronTasks, registerCronJobs} from './cron-scheduler.js'
import {initCollectionHooks} from "./init-collection-hooks.js"
import {initGlobalHooks} from "./init-global-hooks.js"
import {initStepTasks} from "./init-step-tasks.js"
import {initWebhookEndpoint} from "./init-webhook.js"
import {initWorkflowHooks} from './init-workflow-hooks.js'
import {getConfigLogger, initializeLogger} from './logger.js'
export {getLogger} from './logger.js'
const applyCollectionsConfig = <T extends string>(pluginOptions: WorkflowsPluginConfig<T>, config: Config) => {
// Add workflow collections
if (!config.collections) {
config.collections = []
}
config.collections.push(
createWorkflowCollection(pluginOptions),
WorkflowRunsCollection
)
}
export const workflowsPlugin =
<TSlug extends string>(pluginOptions: WorkflowsPluginConfig<TSlug>) =>
(config: Config): Config => {
// If the plugin is disabled, return config unchanged
if (pluginOptions.enabled === false) {
return config
}
applyCollectionsConfig<TSlug>(pluginOptions, config)
if (!config.jobs) {
config.jobs = {tasks: []}
}
const configLogger = getConfigLogger()
// Generate cron tasks for workflows with cron triggers
generateCronTasks(config)
for (const step of pluginOptions.steps) {
if (!config.jobs?.tasks?.find(task => task.slug === step.slug)) {
configLogger.debug(`Registering task: ${step.slug}`)
config.jobs?.tasks?.push(step)
} else {
configLogger.debug(`Task ${step.slug} already registered, skipping`)
}
}
// Initialize webhook endpoint
initWebhookEndpoint(config, pluginOptions.webhookPrefix || 'webhook')
// Set up onInit to register collection hooks and initialize features
const incomingOnInit = config.onInit
config.onInit = async (payload) => {
// Execute any existing onInit functions first
if (incomingOnInit) {
await incomingOnInit(payload)
}
// Initialize the logger with the payload instance
const logger = initializeLogger(payload)
// Create workflow executor instance
const executor = new WorkflowExecutor(payload, logger)
// Initialize hooks
initCollectionHooks(pluginOptions, payload, logger, executor)
initGlobalHooks(payload, logger, executor)
initWorkflowHooks(payload, logger)
initStepTasks(pluginOptions, payload, logger)
// Register cron jobs for workflows with cron triggers
await registerCronJobs(payload, logger)
logger.info('Plugin initialized successfully')
}
return config
}

View File

@@ -0,0 +1,91 @@
import type {Payload} from "payload"
import type {Logger} from "pino"
import type { WorkflowExecutor } from "../core/workflow-executor.js"
import type {CollectionTriggerConfigCrud, WorkflowsPluginConfig} from "./config-types.js"
export function initCollectionHooks<T extends string>(pluginOptions: WorkflowsPluginConfig<T>, payload: Payload, logger: Payload['logger'], executor: WorkflowExecutor) {
// Add hooks to configured collections
for (const [collectionSlug, triggerConfig] of Object.entries(pluginOptions.collectionTriggers)) {
if (!triggerConfig) {
continue
}
const collection = payload.collections[collectionSlug as T]
const crud: CollectionTriggerConfigCrud = triggerConfig === true ? {
create: true,
delete: true,
read: true,
update: true,
} : triggerConfig
if (!collection.config.hooks) {
collection.config.hooks = {} as typeof collection.config.hooks
}
if (crud.update || crud.create) {
collection.config.hooks.afterChange = collection.config.hooks.afterChange || []
collection.config.hooks.afterChange.push(async (change) => {
const operation = change.operation as 'create' | 'update'
logger.debug({
collection: change.collection.slug,
operation,
}, 'Collection hook triggered')
// Execute workflows for this trigger
await executor.executeTriggeredWorkflows(
change.collection.slug,
operation,
change.doc,
change.previousDoc,
change.req
)
})
}
if (crud.read) {
collection.config.hooks.afterRead = collection.config.hooks.afterRead || []
collection.config.hooks.afterRead.push(async (change) => {
logger.debug({
collection: change.collection.slug,
operation: 'read',
}, 'Collection hook triggered')
// Execute workflows for this trigger
await executor.executeTriggeredWorkflows(
change.collection.slug,
'read',
change.doc,
undefined,
change.req
)
})
}
if (crud.delete) {
collection.config.hooks.afterDelete = collection.config.hooks.afterDelete || []
collection.config.hooks.afterDelete.push(async (change) => {
logger.debug({
collection: change.collection.slug,
operation: 'delete',
}, 'Collection hook triggered')
// Execute workflows for this trigger
await executor.executeTriggeredWorkflows(
change.collection.slug,
'delete',
change.doc,
undefined,
change.req
)
})
}
if (collection) {
logger.info({collectionSlug}, 'Collection hooks registered')
} else {
logger.warn({collectionSlug}, 'Collection not found for trigger configuration')
}
}
}

View File

@@ -0,0 +1,112 @@
import type { Payload, PayloadRequest } from "payload"
import type { Logger } from "pino"
import type { WorkflowExecutor, Workflow } from "../core/workflow-executor.js"
export function initGlobalHooks(payload: Payload, logger: Payload['logger'], executor: WorkflowExecutor) {
// Get all globals from the config
const globals = payload.config.globals || []
for (const globalConfig of globals) {
const globalSlug = globalConfig.slug
// Add afterChange hook to global
if (!globalConfig.hooks) {
globalConfig.hooks = {
afterChange: [],
afterRead: [],
beforeChange: [],
beforeRead: [],
beforeValidate: []
}
}
if (!globalConfig.hooks.afterChange) {
globalConfig.hooks.afterChange = []
}
globalConfig.hooks.afterChange.push(async (change) => {
logger.debug({
global: globalSlug,
operation: 'update'
}, 'Global hook triggered')
// Execute workflows for this global trigger
await executeTriggeredGlobalWorkflows(
globalSlug,
'update',
change.doc,
change.previousDoc,
change.req,
payload,
logger,
executor
)
})
logger.info({ globalSlug }, 'Global hooks registered')
}
}
async function executeTriggeredGlobalWorkflows(
globalSlug: string,
operation: 'update',
doc: Record<string, any>,
previousDoc: Record<string, any>,
req: PayloadRequest,
payload: Payload,
logger: Payload['logger'],
executor: WorkflowExecutor
): Promise<void> {
try {
// Find workflows with matching global triggers
const workflows = await payload.find({
collection: 'workflows',
depth: 2,
limit: 100,
req,
where: {
'triggers.global': {
equals: globalSlug
},
'triggers.globalOperation': {
equals: operation
},
'triggers.type': {
equals: 'global-trigger'
}
}
})
for (const workflow of workflows.docs) {
logger.info({
globalSlug,
operation,
workflowId: workflow.id,
workflowName: workflow.name
}, 'Triggering global workflow')
// Create execution context
const context = {
steps: {},
trigger: {
type: 'global',
doc,
global: globalSlug,
operation,
previousDoc,
req
}
}
// Execute the workflow
await executor.execute(workflow as Workflow, context, req)
}
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : 'Unknown error',
globalSlug,
operation
}, 'Failed to execute triggered global workflows')
}
}

View File

@@ -0,0 +1,9 @@
import type {Payload} from "payload"
import type {Logger} from "pino"
import type {WorkflowsPluginConfig} from "./config-types.js"
export function initStepTasks<T extends string>(pluginOptions: WorkflowsPluginConfig<T>, payload: Payload, logger: Payload['logger']) {
logger.info({ stepCount: pluginOptions.steps.length, steps: pluginOptions.steps.map(s => s.slug) }, 'Initializing step tasks')
}

165
src/plugin/init-webhook.ts Normal file
View File

@@ -0,0 +1,165 @@
import type {Config, PayloadRequest} from 'payload'
import {type Workflow, WorkflowExecutor} from '../core/workflow-executor.js'
import {getConfigLogger, initializeLogger} from './logger.js'
export function initWebhookEndpoint(config: Config, webhookPrefix = 'webhook'): void {
const logger = getConfigLogger()
// Ensure the prefix starts with a slash
const normalizedPrefix = webhookPrefix.startsWith('/') ? webhookPrefix : `/${webhookPrefix}`
logger.debug(`Adding webhook endpoint to config with prefix: ${normalizedPrefix}`)
logger.debug('Current config.endpoints length:', config.endpoints?.length || 0)
// Define webhook endpoint
const webhookEndpoint = {
handler: async (req: PayloadRequest) => {
const {path} = req.routeParams as { path: string }
const webhookData = req.body || {}
logger.debug('Webhook endpoint handler called, path: ' + path)
try {
// Find workflows with matching webhook triggers
const workflows = await req.payload.find({
collection: 'workflows',
depth: 2,
limit: 100,
req,
where: {
'triggers.type': {
equals: 'webhook-trigger'
},
'triggers.webhookPath': {
equals: path
}
}
})
if (workflows.docs.length === 0) {
return new Response(
JSON.stringify({error: 'No workflows found for this webhook path'}),
{
headers: {'Content-Type': 'application/json'},
status: 404
}
)
}
// Create workflow executor for this request
const logger = initializeLogger(req.payload)
const executor = new WorkflowExecutor(req.payload, logger)
const executionPromises = workflows.docs.map(async (workflow) => {
try {
// Create execution context for the webhook trigger
const context = {
steps: {},
trigger: {
type: 'webhook',
data: webhookData,
headers: Object.fromEntries(req.headers?.entries() || []),
path,
req
}
}
// Find the matching trigger and check its condition if present
const triggers = workflow.triggers as Array<{
condition?: string
type: string
webhookPath?: string
}>
const matchingTrigger = triggers?.find(trigger =>
trigger.type === 'webhook-trigger' &&
trigger.webhookPath === path
)
// Check trigger condition if present
if (matchingTrigger?.condition) {
const conditionMet = executor.evaluateCondition(matchingTrigger.condition, context)
if (!conditionMet) {
logger.info({
condition: matchingTrigger.condition,
path,
workflowId: workflow.id,
workflowName: workflow.name
}, 'Webhook trigger condition not met, skipping workflow')
return { status: 'skipped', workflowId: workflow.id, reason: 'Condition not met' }
}
logger.info({
condition: matchingTrigger.condition,
path,
workflowId: workflow.id,
workflowName: workflow.name
}, 'Webhook trigger condition met')
}
// Execute the workflow
await executor.execute(workflow as Workflow, context, req)
return { status: 'triggered', workflowId: workflow.id }
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
status: 'failed',
workflowId: workflow.id
}
}
})
const results = await Promise.allSettled(executionPromises)
const resultsData = results.map((result, index) => {
const baseResult = { workflowId: workflows.docs[index].id }
if (result.status === 'fulfilled') {
return { ...baseResult, ...result.value }
} else {
return { ...baseResult, error: result.reason, status: 'failed' }
}
})
return new Response(
JSON.stringify({
message: `Triggered ${workflows.docs.length} workflow(s)`,
results: resultsData
}),
{
headers: { 'Content-Type': 'application/json' },
status: 200
}
)
} catch (error) {
return new Response(
JSON.stringify({
details: error instanceof Error ? error.message : 'Unknown error',
error: 'Failed to process webhook'
}),
{
headers: { 'Content-Type': 'application/json' },
status: 500
}
)
}
},
method: 'post' as const,
path: `${normalizedPrefix}/:path`
}
// Check if webhook endpoint already exists to avoid duplicates
const existingEndpoint = config.endpoints?.find(endpoint =>
endpoint.path === webhookEndpoint.path && endpoint.method === webhookEndpoint.method
)
if (!existingEndpoint) {
// Combine existing endpoints with the webhook endpoint
config.endpoints = [...(config.endpoints || []), webhookEndpoint]
logger.debug(`Webhook endpoint added at path: ${webhookEndpoint.path}`)
logger.debug('New config.endpoints length:', config.endpoints.length)
} else {
logger.debug(`Webhook endpoint already exists at path: ${webhookEndpoint.path}`)
}
}

View File

@@ -0,0 +1,56 @@
import type {Payload} from 'payload'
import {updateWorkflowCronJobs, removeWorkflowCronJobs} from './cron-scheduler.js'
/**
* Initialize hooks for the workflows collection itself
* to manage cron jobs when workflows are created/updated
*/
export function initWorkflowHooks(payload: Payload, logger: Payload['logger']): void {
// Add afterChange hook to workflows collection to update cron jobs
const workflowsCollection = payload.collections.workflows
if (!workflowsCollection) {
logger.warn('Workflows collection not found, cannot initialize workflow hooks')
return
}
// Add afterChange hook to register/update cron jobs
if (!workflowsCollection.config.hooks?.afterChange) {
if (!workflowsCollection.config.hooks) {
// @ts-expect-error - hooks object will be populated by Payload
workflowsCollection.config.hooks = {}
}
workflowsCollection.config.hooks.afterChange = []
}
workflowsCollection.config.hooks.afterChange.push(async ({ doc, operation }) => {
if (operation === 'create' || operation === 'update') {
logger.debug({
operation,
workflowId: doc.id,
workflowName: doc.name
}, 'Workflow changed, updating cron jobs selectively')
// Update cron jobs for this specific workflow only
await updateWorkflowCronJobs(doc.id, payload, logger)
}
})
// Add afterDelete hook to clean up cron jobs
if (!workflowsCollection.config.hooks?.afterDelete) {
workflowsCollection.config.hooks.afterDelete = []
}
workflowsCollection.config.hooks.afterDelete.push(async ({ doc }) => {
logger.debug({
workflowId: doc.id,
workflowName: doc.name
}, 'Workflow deleted, removing cron jobs')
// Remove cron jobs for the deleted workflow
removeWorkflowCronJobs(doc.id, payload, logger)
})
logger.info('Workflow hooks initialized for cron job management')
}

56
src/plugin/logger.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { Payload } from 'payload'
// Global logger instance - use Payload's logger type
let pluginLogger: Payload['logger'] | null = null
/**
* Simple config-time logger for use during plugin configuration
* Uses console with plugin prefix since Payload logger isn't available yet
*/
const configLogger = {
debug: (message: string, ...args: any[]) => {
if (process.env.NODE_ENV === 'development') {
console.log(`[payload-automation] ${message}`, ...args)
}
},
error: (message: string, ...args: any[]) => {
console.error(`[payload-automation] ${message}`, ...args)
},
info: (message: string, ...args: any[]) => {
console.log(`[payload-automation] ${message}`, ...args)
},
warn: (message: string, ...args: any[]) => {
console.warn(`[payload-automation] ${message}`, ...args)
}
}
/**
* Get a logger for config-time use (before Payload initialization)
*/
export function getConfigLogger() {
return configLogger
}
/**
* Initialize the plugin logger using Payload's Pino instance
* This creates a child logger with plugin identification
*/
export function initializeLogger(payload: Payload): Payload['logger'] {
// Create a child logger with plugin identification
pluginLogger = payload.logger.child({
plugin: '@xtr-dev/payload-automation'
})
return pluginLogger
}
/**
* Get the plugin logger instance
* Throws error if not initialized
*/
export function getLogger(): Payload['logger'] {
if (!pluginLogger) {
throw new Error('@xtr-dev/payload-automation: Logger not initialized. Make sure the plugin is properly configured.')
}
return pluginLogger
}

View File

@@ -0,0 +1,42 @@
import type { TaskHandler } from "payload"
export const createDocumentHandler: TaskHandler<'create-document'> = async ({ input, req }) => {
if (!input) {
throw new Error('No input provided')
}
const { collection, data, draft, locale } = input
if (!collection || typeof collection !== 'string') {
throw new Error('Collection slug is required')
}
if (!data) {
throw new Error('Document data is required')
}
try {
const parsedData = typeof data === 'string' ? JSON.parse(data) : data
const result = await req.payload.create({
collection,
data: parsedData,
draft: draft || false,
locale: locale || undefined,
req
})
return {
output: {
id: result.id,
doc: result
},
state: 'succeeded'
}
} catch (error) {
return {
errorMessage: error instanceof Error ? error.message : 'Failed to create document',
state: 'failed'
}
}
}

View File

@@ -0,0 +1,56 @@
import type { TaskConfig } from "payload"
import { createDocumentHandler } from "./create-document-handler.js"
export const CreateDocumentStepTask = {
slug: 'create-document',
handler: createDocumentHandler,
inputSchema: [
{
name: 'collection',
type: 'text',
admin: {
description: 'The collection slug to create a document in'
},
required: true
},
{
name: 'data',
type: 'json',
admin: {
description: 'The document data to create'
},
required: true
},
{
name: 'draft',
type: 'checkbox',
admin: {
description: 'Create as draft (if collection has drafts enabled)'
}
},
{
name: 'locale',
type: 'text',
admin: {
description: 'Locale for the document (if localization is enabled)'
}
}
],
outputSchema: [
{
name: 'doc',
type: 'json',
admin: {
description: 'The created document'
}
},
{
name: 'id',
type: 'text',
admin: {
description: 'The ID of the created document'
}
}
]
} satisfies TaskConfig<'create-document'>

View File

@@ -0,0 +1,71 @@
import type { TaskHandler } from "payload"
export const deleteDocumentHandler: TaskHandler<'delete-document'> = async ({ input, req }) => {
if (!input) {
throw new Error('No input provided')
}
const { id, collection, where } = input
if (!collection || typeof collection !== 'string') {
throw new Error('Collection slug is required')
}
try {
// If ID is provided, delete by ID
if (id) {
const result = await req.payload.delete({
id: id.toString(),
collection,
req
})
return {
output: {
deletedCount: 1,
doc: result
},
state: 'succeeded'
}
}
// Otherwise, delete multiple documents
if (!where) {
throw new Error('Either ID or where conditions must be provided')
}
const parsedWhere = typeof where === 'string' ? JSON.parse(where) : where
// First find the documents to delete
const toDelete = await req.payload.find({
collection,
limit: 1000, // Set a reasonable limit
req,
where: parsedWhere
})
// Delete each document
const deleted = []
for (const doc of toDelete.docs) {
const result = await req.payload.delete({
id: doc.id,
collection,
req
})
deleted.push(result)
}
return {
output: {
deletedCount: deleted.length,
doc: deleted
},
state: 'succeeded'
}
} catch (error) {
return {
errorMessage: error instanceof Error ? error.message : 'Failed to delete document(s)',
state: 'failed'
}
}
}

View File

@@ -0,0 +1,48 @@
import type { TaskConfig } from "payload"
import { deleteDocumentHandler } from "./delete-document-handler.js"
export const DeleteDocumentStepTask = {
slug: 'delete-document',
handler: deleteDocumentHandler,
inputSchema: [
{
name: 'collection',
type: 'text',
admin: {
description: 'The collection slug to delete from'
},
required: true
},
{
name: 'id',
type: 'text',
admin: {
description: 'The ID of a specific document to delete (leave empty to delete multiple)'
}
},
{
name: 'where',
type: 'json',
admin: {
description: 'Query conditions to find documents to delete (used when ID is not provided)'
}
}
],
outputSchema: [
{
name: 'doc',
type: 'json',
admin: {
description: 'The deleted document(s)'
}
},
{
name: 'deletedCount',
type: 'number',
admin: {
description: 'Number of documents deleted'
}
}
]
} satisfies TaskConfig<'delete-document'>

View File

@@ -0,0 +1,14 @@
import type {TaskHandler} from "payload"
export const httpStepHandler: TaskHandler<'http-request-step'> = async ({input}) => {
if (!input) {
throw new Error('No input provided')
}
const response = await fetch(input.url)
return {
output: {
response: await response.text()
},
state: response.ok ? 'succeeded' : undefined
}
}

20
src/steps/http-request.ts Normal file
View File

@@ -0,0 +1,20 @@
import type {TaskConfig} from "payload"
import {httpStepHandler} from "./http-request-handler.js"
export const HttpRequestStepTask = {
slug: 'http-request-step',
handler: httpStepHandler,
inputSchema: [
{
name: 'url',
type: 'text',
}
],
outputSchema: [
{
name: 'response',
type: 'textarea',
}
]
} satisfies TaskConfig<'http-request-step'>

13
src/steps/index.ts Normal file
View File

@@ -0,0 +1,13 @@
export { CreateDocumentStepTask } from './create-document.js'
export { createDocumentHandler } from './create-document-handler.js'
export { DeleteDocumentStepTask } from './delete-document.js'
export { deleteDocumentHandler } from './delete-document-handler.js'
export { HttpRequestStepTask } from './http-request.js'
export { httpStepHandler } from './http-request-handler.js'
export { ReadDocumentStepTask } from './read-document.js'
export { readDocumentHandler } from './read-document-handler.js'
export { SendEmailStepTask } from './send-email.js'
export { sendEmailHandler } from './send-email-handler.js'
export { UpdateDocumentStepTask } from './update-document.js'
export { updateDocumentHandler } from './update-document-handler.js'

View File

@@ -0,0 +1,60 @@
import type { TaskHandler } from "payload"
export const readDocumentHandler: TaskHandler<'read-document'> = async ({ input, req }) => {
if (!input) {
throw new Error('No input provided')
}
const { id, collection, depth, limit, locale, sort, where } = input
if (!collection || typeof collection !== 'string') {
throw new Error('Collection slug is required')
}
try {
// If ID is provided, find by ID
if (id) {
const result = await req.payload.findByID({
id: id.toString(),
collection,
depth: typeof depth === 'number' ? depth : undefined,
locale: locale || undefined,
req
})
return {
output: {
doc: result,
totalDocs: 1
},
state: 'succeeded'
}
}
// Otherwise, find multiple documents
const parsedWhere = where ? (typeof where === 'string' ? JSON.parse(where) : where) : {}
const result = await req.payload.find({
collection,
depth: typeof depth === 'number' ? depth : undefined,
limit: typeof limit === 'number' ? limit : 10,
locale: locale || undefined,
req,
sort: sort || undefined,
where: parsedWhere
})
return {
output: {
doc: result.docs,
totalDocs: result.totalDocs
},
state: 'succeeded'
}
} catch (error) {
return {
errorName: error instanceof Error ? error.message : 'Failed to read document(s)',
state: 'failed'
}
}
}

View File

@@ -0,0 +1,76 @@
import type { TaskConfig } from "payload"
import { readDocumentHandler } from "./read-document-handler.js"
export const ReadDocumentStepTask = {
slug: 'read-document',
handler: readDocumentHandler,
inputSchema: [
{
name: 'collection',
type: 'text',
admin: {
description: 'The collection slug to read from'
},
required: true
},
{
name: 'id',
type: 'text',
admin: {
description: 'The ID of a specific document to read (leave empty to find multiple)'
}
},
{
name: 'where',
type: 'json',
admin: {
description: 'Query conditions to find documents (used when ID is not provided)'
}
},
{
name: 'limit',
type: 'number',
admin: {
description: 'Maximum number of documents to return (default: 10)'
}
},
{
name: 'sort',
type: 'text',
admin: {
description: 'Field to sort by (prefix with - for descending order)'
}
},
{
name: 'locale',
type: 'text',
admin: {
description: 'Locale for the document (if localization is enabled)'
}
},
{
name: 'depth',
type: 'number',
admin: {
description: 'Depth of relationships to populate (0-10)'
}
}
],
outputSchema: [
{
name: 'doc',
type: 'json',
admin: {
description: 'The document(s) found'
}
},
{
name: 'totalDocs',
type: 'number',
admin: {
description: 'Total number of documents matching the query'
}
}
]
} satisfies TaskConfig<'read-document'>

View File

@@ -0,0 +1,56 @@
import type { TaskHandler } from "payload"
export const sendEmailHandler: TaskHandler<'send-email'> = async ({ input, req }) => {
if (!input) {
throw new Error('No input provided')
}
const { bcc, cc, from, html, subject, text, to } = input
if (!to || typeof to !== 'string') {
throw new Error('Recipient email address (to) is required')
}
if (!subject || typeof subject !== 'string') {
throw new Error('Subject is required')
}
if (!text && !html) {
throw new Error('Either text or html content is required')
}
try {
// Use Payload's email functionality
const emailData = {
bcc: Array.isArray(bcc) ? bcc.filter(email => typeof email === 'string') : undefined,
cc: Array.isArray(cc) ? cc.filter(email => typeof email === 'string') : undefined,
from: typeof from === 'string' ? from : undefined,
html: typeof html === 'string' ? html : undefined,
subject,
text: typeof text === 'string' ? text : undefined,
to
}
// Clean up undefined values
Object.keys(emailData).forEach(key => {
if (emailData[key as keyof typeof emailData] === undefined) {
delete emailData[key as keyof typeof emailData]
}
})
const result = await req.payload.sendEmail(emailData)
return {
output: {
messageId: (result && typeof result === 'object' && 'messageId' in result) ? result.messageId : 'unknown',
response: typeof result === 'object' ? JSON.stringify(result) : String(result)
},
state: 'succeeded'
}
} catch (error) {
return {
errorMessage: error instanceof Error ? error.message : 'Failed to send email',
state: 'failed'
}
}
}

79
src/steps/send-email.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { TaskConfig } from "payload"
import { sendEmailHandler } from "./send-email-handler.js"
export const SendEmailStepTask = {
slug: 'send-email',
handler: sendEmailHandler,
inputSchema: [
{
name: 'to',
type: 'text',
admin: {
description: 'Recipient email address'
},
required: true
},
{
name: 'from',
type: 'text',
admin: {
description: 'Sender email address (optional, uses default if not provided)'
}
},
{
name: 'subject',
type: 'text',
admin: {
description: 'Email subject line'
},
required: true
},
{
name: 'text',
type: 'textarea',
admin: {
description: 'Plain text email content'
}
},
{
name: 'html',
type: 'textarea',
admin: {
description: 'HTML email content (optional)'
}
},
{
name: 'cc',
type: 'text',
admin: {
description: 'CC recipients'
},
hasMany: true
},
{
name: 'bcc',
type: 'text',
admin: {
description: 'BCC recipients'
},
hasMany: true
}
],
outputSchema: [
{
name: 'messageId',
type: 'text',
admin: {
description: 'Email message ID from the mail server'
}
},
{
name: 'response',
type: 'text',
admin: {
description: 'Response from the mail server'
}
}
]
} satisfies TaskConfig<'send-email'>

View File

@@ -0,0 +1,47 @@
import type { TaskHandler } from "payload"
export const updateDocumentHandler: TaskHandler<'update-document'> = async ({ input, req }) => {
if (!input) {
throw new Error('No input provided')
}
const { id, collection, data, draft, locale } = input
if (!collection || typeof collection !== 'string') {
throw new Error('Collection slug is required')
}
if (!id) {
throw new Error('Document ID is required')
}
if (!data) {
throw new Error('Update data is required')
}
try {
const parsedData = typeof data === 'string' ? JSON.parse(data) : data
const result = await req.payload.update({
id: id.toString(),
collection,
data: parsedData,
draft: draft || false,
locale: locale || undefined,
req
})
return {
output: {
id: result.id,
doc: result
},
state: 'succeeded'
}
} catch (error) {
return {
errorName: error instanceof Error ? error.message : 'Failed to update document',
state: 'failed'
}
}
}

View File

@@ -0,0 +1,64 @@
import type { TaskConfig } from "payload"
import { updateDocumentHandler } from "./update-document-handler.js"
export const UpdateDocumentStepTask = {
slug: 'update-document',
handler: updateDocumentHandler,
inputSchema: [
{
name: 'collection',
type: 'text',
admin: {
description: 'The collection slug to update a document in'
},
required: true
},
{
name: 'id',
type: 'text',
admin: {
description: 'The ID of the document to update'
},
required: true
},
{
name: 'data',
type: 'json',
admin: {
description: 'The data to update the document with'
},
required: true
},
{
name: 'draft',
type: 'checkbox',
admin: {
description: 'Update as draft (if collection has drafts enabled)'
}
},
{
name: 'locale',
type: 'text',
admin: {
description: 'Locale for the document (if localization is enabled)'
}
}
],
outputSchema: [
{
name: 'doc',
type: 'json',
admin: {
description: 'The updated document'
}
},
{
name: 'id',
type: 'text',
admin: {
description: 'The ID of the updated document'
}
}
]
} satisfies TaskConfig<'update-document'>