mirror of
https://github.com/xtr-dev/payload-automation.git
synced 2025-12-10 00:43:23 +00:00
Initial commit
This commit is contained in:
231
src/collections/Workflow.ts
Normal file
231
src/collections/Workflow.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
151
src/collections/WorkflowRuns.ts
Normal file
151
src/collections/WorkflowRuns.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
64
src/components/TriggerWorkflowButton.tsx
Normal file
64
src/components/TriggerWorkflowButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
288
src/core/trigger-custom-workflow.ts
Normal file
288
src/core/trigger-custom-workflow.ts
Normal 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
|
||||
}
|
||||
}
|
||||
609
src/core/workflow-executor.ts
Normal file
609
src/core/workflow-executor.ts
Normal 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
8
src/exports/client.ts
Normal 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
5
src/exports/fields.ts
Normal 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
1
src/exports/rsc.ts
Normal file
@@ -0,0 +1 @@
|
||||
// Server-side exports for workflow plugin
|
||||
6
src/exports/views.ts
Normal file
6
src/exports/views.ts
Normal 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
19
src/index.ts
Normal 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'
|
||||
25
src/plugin/config-types.ts
Normal file
25
src/plugin/config-types.ts
Normal 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
|
||||
}
|
||||
632
src/plugin/cron-scheduler.ts
Normal file
632
src/plugin/cron-scheduler.ts
Normal 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
88
src/plugin/index.ts
Normal 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
|
||||
}
|
||||
91
src/plugin/init-collection-hooks.ts
Normal file
91
src/plugin/init-collection-hooks.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/plugin/init-global-hooks.ts
Normal file
112
src/plugin/init-global-hooks.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
9
src/plugin/init-step-tasks.ts
Normal file
9
src/plugin/init-step-tasks.ts
Normal 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
165
src/plugin/init-webhook.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
56
src/plugin/init-workflow-hooks.ts
Normal file
56
src/plugin/init-workflow-hooks.ts
Normal 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
56
src/plugin/logger.ts
Normal 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
|
||||
}
|
||||
42
src/steps/create-document-handler.ts
Normal file
42
src/steps/create-document-handler.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/steps/create-document.ts
Normal file
56
src/steps/create-document.ts
Normal 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'>
|
||||
71
src/steps/delete-document-handler.ts
Normal file
71
src/steps/delete-document-handler.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/steps/delete-document.ts
Normal file
48
src/steps/delete-document.ts
Normal 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'>
|
||||
14
src/steps/http-request-handler.ts
Normal file
14
src/steps/http-request-handler.ts
Normal 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
20
src/steps/http-request.ts
Normal 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
13
src/steps/index.ts
Normal 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'
|
||||
60
src/steps/read-document-handler.ts
Normal file
60
src/steps/read-document-handler.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/steps/read-document.ts
Normal file
76
src/steps/read-document.ts
Normal 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'>
|
||||
56
src/steps/send-email-handler.ts
Normal file
56
src/steps/send-email-handler.ts
Normal 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
79
src/steps/send-email.ts
Normal 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'>
|
||||
47
src/steps/update-document-handler.ts
Normal file
47
src/steps/update-document-handler.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/steps/update-document.ts
Normal file
64
src/steps/update-document.ts
Normal 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'>
|
||||
Reference in New Issue
Block a user