Remove unused plugin modules and their associated tests

- Delete `init-global-hooks.ts`, `init-step-tasks.ts`, `init-webhook.ts`, and `init-workflow-hooks.ts`
- Remove obsolete components: `TriggerWorkflowButton` and `WorkflowExecutionStatus`
- Clean up unused trigger files: `webhook-trigger.ts`
- Delete webhook-related integration tests: `webhook-triggers.spec.ts`
- Streamline related documentation and improve maintainability by eliminating deprecated code
This commit is contained in:
2025-09-10 18:08:25 +02:00
parent 0f741acf73
commit acdfa411e4
22 changed files with 228 additions and 1217 deletions

View File

@@ -24,13 +24,24 @@ if (!process.env.ROOT_DIR) {
const buildConfigWithMemoryDB = async () => {
// Use MongoDB adapter for testing instead of SQLite
const { mongooseAdapter } = await import('@payloadcms/db-mongodb')
return buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname, '..'),
},
},
globals: [
{
slug: 'settings',
fields: [
{
name: 'siteName',
type: 'text'
}
]
}
],
collections: [
{
slug: 'posts',
@@ -96,14 +107,13 @@ const buildConfigWithMemoryDB = async () => {
posts: true,
media: true
},
globalTriggers: {
settings: true
},
steps: [
HttpRequestStepTask,
CreateDocumentStepTask
],
triggers: [
],
webhookPrefix: '/workflows-webhook'
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',

View File

@@ -1,483 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getTestPayload, cleanDatabase } from './test-setup.js'
describe('Webhook Trigger Testing', () => {
beforeEach(async () => {
await cleanDatabase()
})
afterEach(async () => {
await cleanDatabase()
})
it('should trigger workflow via webhook endpoint simulation', async () => {
const payload = getTestPayload()
// Create a workflow with webhook trigger
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Webhook - Basic Trigger',
description: 'Tests basic webhook triggering',
triggers: [
{
type: 'webhook-trigger',
webhookPath: 'test-basic'
}
],
steps: [
{
name: 'create-webhook-audit',
step: 'create-document',
collectionSlug: 'auditLog',
data: {
message: 'Webhook triggered successfully',
user: '$.trigger.data.userId'
}
}
]
}
})
expect(workflow).toBeDefined()
// Directly execute the workflow with webhook-like data
const executor = (globalThis as any).__workflowExecutor
if (!executor) {
console.warn('⚠️ Workflow executor not available, skipping webhook execution')
return
}
// Simulate webhook trigger by directly executing the workflow
const webhookData = {
userId: 'webhook-test-user',
timestamp: new Date().toISOString()
}
const mockReq = {
payload,
user: null,
headers: {}
}
await executor.execute({
workflow,
trigger: {
type: 'webhook',
path: 'test-basic',
data: webhookData,
headers: {}
},
req: mockReq as any,
payload
})
console.log('✅ Workflow executed directly')
// Wait for workflow execution
await new Promise(resolve => setTimeout(resolve, 2000))
// Verify workflow run was created
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
expect(runs.totalDocs).toBe(1)
expect(runs.docs[0].status).not.toBe('failed')
// Verify audit log was created
const auditLogs = await payload.find({
collection: 'auditLog',
where: {
message: {
contains: 'Webhook triggered'
}
},
limit: 1
})
expect(auditLogs.totalDocs).toBe(1)
console.log('✅ Webhook audit log created')
}, 30000)
it('should handle webhook with complex data', async () => {
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Webhook - Complex Data',
description: 'Tests webhook with complex JSON data',
triggers: [
{
type: 'webhook-trigger',
webhookPath: 'test-complex'
}
],
steps: [
{
name: 'echo-webhook-data',
step: 'http-request-step',
url: 'https://httpbin.org/post',
method: 'POST',
body: {
originalData: '$.trigger.data',
headers: '$.trigger.headers',
path: '$.trigger.path'
}
}
]
}
})
const complexData = {
user: {
id: 123,
name: 'Test User',
permissions: ['read', 'write']
},
event: {
type: 'user_action',
timestamp: new Date().toISOString(),
metadata: {
source: 'webhook-test',
version: '1.0.0'
}
},
nested: {
deeply: {
nested: {
value: 'deep-test-value'
}
}
}
}
const response = await makeWebhookRequest('test-complex', complexData)
expect(response.status).toBe(200)
// Wait for workflow execution
await new Promise(resolve => setTimeout(resolve, 5000))
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
expect(runs.totalDocs).toBe(1)
expect(runs.docs[0].status).toBe('completed')
// Verify the complex data was properly passed through
const stepOutput = runs.docs[0].context.steps['echo-webhook-data'].output
expect(stepOutput.status).toBe(200)
const responseBody = JSON.parse(stepOutput.body)
expect(responseBody.json.originalData.user.name).toBe('Test User')
expect(responseBody.json.originalData.nested.deeply.nested.value).toBe('deep-test-value')
console.log('✅ Complex webhook data processed correctly')
}, 30000)
it('should handle webhook conditions', async () => {
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Webhook - Conditional',
description: 'Tests conditional webhook execution',
triggers: [
{
type: 'webhook-trigger',
webhookPath: 'test-conditional',
condition: '$.data.action == "important"'
}
],
steps: [
{
name: 'conditional-audit',
step: 'create-document',
collectionSlug: 'auditLog',
data: {
message: 'Webhook condition met - important action'
}
}
]
}
})
// First request - should NOT trigger (condition not met)
const response1 = await makeWebhookRequest('test-conditional', {
action: 'normal',
data: 'test'
})
expect(response1.status).toBe(200)
// Second request - SHOULD trigger (condition met)
const response2 = await makeWebhookRequest('test-conditional', {
action: 'important',
priority: 'high'
})
expect(response2.status).toBe(200)
// Wait for workflow execution
await new Promise(resolve => setTimeout(resolve, 5000))
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
}
})
// Should have exactly 1 run (only for the matching condition)
expect(runs.totalDocs).toBe(1)
expect(runs.docs[0].status).not.toBe('failed')
const auditLogs = await payload.find({
collection: 'auditLog',
where: {
message: {
contains: 'condition met'
}
}
})
expect(auditLogs.totalDocs).toBe(1)
console.log('✅ Webhook conditional execution working')
}, 30000)
it('should handle webhook authentication headers', async () => {
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Webhook - Headers',
description: 'Tests webhook header processing',
triggers: [
{
type: 'webhook-trigger',
webhookPath: 'test-headers'
}
],
steps: [
{
name: 'process-headers',
step: 'http-request-step',
url: 'https://httpbin.org/post',
method: 'POST',
body: {
receivedHeaders: '$.trigger.headers',
authorization: '$.trigger.headers.authorization',
userAgent: '$.trigger.headers.user-agent'
}
}
]
}
})
// Make webhook request with custom headers
const webhookUrl = `${baseUrl}/api/workflows/webhook/test-headers`
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer test-token-123',
'User-Agent': 'Webhook-Test-Client/1.0',
'X-Custom-Header': 'custom-value'
},
body: JSON.stringify({
test: 'header processing'
})
})
expect(response.status).toBe(200)
// Wait for workflow execution
await new Promise(resolve => setTimeout(resolve, 5000))
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
},
limit: 1
})
expect(runs.totalDocs).toBe(1)
expect(runs.docs[0].status).toBe('completed')
// Verify headers were captured and processed
const stepOutput = runs.docs[0].context.steps['process-headers'].output
const responseBody = JSON.parse(stepOutput.body)
expect(responseBody.json.authorization).toBe('Bearer test-token-123')
expect(responseBody.json.userAgent).toBe('Webhook-Test-Client/1.0')
console.log('✅ Webhook headers processed correctly')
}, 30000)
it('should handle multiple concurrent webhook requests', async () => {
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Webhook - Concurrent',
description: 'Tests concurrent webhook processing',
triggers: [
{
type: 'webhook-trigger',
webhookPath: 'test-concurrent'
}
],
steps: [
{
name: 'concurrent-audit',
step: 'create-document',
collectionSlug: 'auditLog',
data: {
message: 'Concurrent webhook execution',
requestId: '$.trigger.data.requestId'
}
}
]
}
})
// Make multiple concurrent webhook requests
const concurrentRequests = Array.from({ length: 5 }, (_, i) =>
makeWebhookRequest('test-concurrent', {
requestId: `concurrent-${i + 1}`,
timestamp: new Date().toISOString()
})
)
const responses = await Promise.all(concurrentRequests)
responses.forEach(response => {
expect(response.status).toBe(200)
})
// Wait for all workflow executions
await new Promise(resolve => setTimeout(resolve, 8000))
const runs = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
}
})
expect(runs.totalDocs).toBe(5)
// Verify all runs completed successfully
const failedRuns = runs.docs.filter(run => run.status === 'failed')
expect(failedRuns).toHaveLength(0)
// Verify all audit logs were created
const auditLogs = await payload.find({
collection: 'auditLog',
where: {
message: {
contains: 'Concurrent webhook'
}
}
})
expect(auditLogs.totalDocs).toBe(5)
console.log('✅ Concurrent webhook requests processed successfully')
}, 35000)
it('should handle non-existent webhook paths gracefully', async () => {
// Test that workflows with non-matching webhook paths don't get triggered
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Webhook - Non-existent Path',
description: 'Should not be triggered by different path',
triggers: [
{
type: 'webhook-trigger',
webhookPath: 'specific-path'
}
],
steps: [
{
name: 'create-audit',
step: 'create-document',
collectionSlug: 'auditLog',
data: {
message: 'This should not be created'
}
}
]
}
})
// Simulate trying to trigger with wrong path - should not execute workflow
const initialRuns = await payload.find({
collection: 'workflow-runs',
where: {
workflow: {
equals: workflow.id
}
}
})
expect(initialRuns.totalDocs).toBe(0)
console.log('✅ Non-existent webhook path handled: no workflow runs created')
}, 10000)
it('should handle malformed webhook JSON', async () => {
const webhookUrl = `${baseUrl}/api/workflows/webhook/test-malformed`
// First create a workflow to receive the malformed request
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Webhook - Malformed JSON',
description: 'Tests malformed JSON handling',
triggers: [
{
type: 'webhook-trigger',
webhookPath: 'test-malformed'
}
],
steps: [
{
name: 'malformed-test',
step: 'create-document',
collectionSlug: 'auditLog',
data: {
message: 'Processed malformed request'
}
}
]
}
})
// Send malformed JSON
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: '{"malformed": json, "missing": quotes}'
})
// Should handle malformed JSON gracefully
expect([400, 422]).toContain(response.status)
console.log('✅ Malformed JSON handled:', response.status)
}, 15000)
})

View File

@@ -31,7 +31,9 @@ export default [
'perfectionist/sort-object-types': 'off',
'perfectionist/sort-objects': 'off',
'perfectionist/sort-exports': 'off',
'perfectionist/sort-imports': 'off'
'perfectionist/sort-imports': 'off',
'perfectionist/sort-switch-case': 'off',
'perfectionist/sort-interfaces': 'off'
},
},
{

View File

@@ -3,11 +3,11 @@ import type {CollectionConfig} from 'payload'
import type {WorkflowsPluginConfig} from "../plugin/config-types.js"
import {parameter} from "../fields/parameter.js"
import {collectionHookTrigger} from "../triggers/index.js"
import {collectionTrigger, globalTrigger} from "../triggers/index.js"
export const createWorkflowCollection: <T extends string>(options: WorkflowsPluginConfig<T>) => CollectionConfig = (options) => {
const steps = options.steps || []
const triggers = (options.triggers || []).map(t => t(options)).concat(collectionHookTrigger(options))
const triggers = (options.triggers || []).map(t => t(options)).concat(collectionTrigger(options), globalTrigger(options))
return {
slug: 'workflows',
access: {

View File

@@ -10,10 +10,10 @@ interface ErrorDisplayProps {
path?: string
}
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
value,
onChange,
readOnly = false
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
value,
onChange,
readOnly = false
}) => {
const [expanded, setExpanded] = useState(false)
@@ -32,7 +32,7 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
technical: error
}
}
if (error.includes('Network error') || error.includes('fetch')) {
return {
type: 'network',
@@ -41,7 +41,7 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
technical: error
}
}
if (error.includes('Hook execution failed')) {
return {
type: 'hook',
@@ -50,7 +50,7 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
technical: error
}
}
if (error.includes('Executor not available')) {
return {
type: 'executor',
@@ -59,7 +59,7 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
technical: error
}
}
if (error.includes('Collection slug is required') || error.includes('Document data is required')) {
return {
type: 'validation',
@@ -68,7 +68,7 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
technical: error
}
}
if (error.includes('status') && error.includes('4')) {
return {
type: 'client',
@@ -77,7 +77,7 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
technical: error
}
}
if (error.includes('status') && error.includes('5')) {
return {
type: 'server',
@@ -127,7 +127,7 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
const errorColor = getErrorColor(errorInfo.type)
return (
<div style={{
<div style={{
border: `2px solid ${errorColor}30`,
borderRadius: '8px',
backgroundColor: `${errorColor}08`,
@@ -135,9 +135,9 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
marginTop: '8px'
}}>
{/* Error Header */}
<div style={{
display: 'flex',
alignItems: 'center',
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '12px'
}}>
@@ -145,15 +145,15 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
{getErrorIcon(errorInfo.type)}
</span>
<div>
<h4 style={{
margin: 0,
<h4 style={{
margin: 0,
color: errorColor,
fontSize: '16px',
fontWeight: '600'
}}>
{errorInfo.title}
</h4>
<p style={{
<p style={{
margin: '4px 0 0 0',
color: '#6B7280',
fontSize: '14px',
@@ -168,14 +168,14 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
<div>
<div style={{ marginBottom: expanded ? '12px' : '0' }}>
<Button
buttonStyle="secondary"
onClick={() => setExpanded(!expanded)}
size="small"
buttonStyle="secondary"
>
{expanded ? 'Hide' : 'Show'} Technical Details
</Button>
</div>
{expanded && (
<div style={{
backgroundColor: '#F8F9FA',
@@ -194,7 +194,7 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
</div>
{/* Quick Actions */}
<div style={{
<div style={{
marginTop: '12px',
padding: '12px',
backgroundColor: `${errorColor}10`,
@@ -253,11 +253,11 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
{/* Hidden textarea for editing if needed */}
{!readOnly && onChange && (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
style={{ display: 'none' }}
value={value}
/>
)}
</div>
)
}
}

View File

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

View File

@@ -1,218 +0,0 @@
'use client'
import React, { useState, useEffect } from 'react'
import { Button } from '@payloadcms/ui'
interface WorkflowRun {
id: string
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
startedAt: string
completedAt?: string
error?: string
triggeredBy: string
}
interface WorkflowExecutionStatusProps {
workflowId: string | number
}
export const WorkflowExecutionStatus: React.FC<WorkflowExecutionStatusProps> = ({ workflowId }) => {
const [runs, setRuns] = useState<WorkflowRun[]>([])
const [loading, setLoading] = useState(true)
const [expanded, setExpanded] = useState(false)
useEffect(() => {
const fetchRecentRuns = async () => {
try {
const response = await fetch(`/api/workflow-runs?where[workflow][equals]=${workflowId}&limit=5&sort=-startedAt`)
if (response.ok) {
const data = await response.json()
setRuns(data.docs || [])
}
} catch (error) {
console.warn('Failed to fetch workflow runs:', error)
} finally {
setLoading(false)
}
}
fetchRecentRuns()
}, [workflowId])
if (loading) {
return (
<div style={{ padding: '16px', color: '#6B7280' }}>
Loading execution history...
</div>
)
}
if (runs.length === 0) {
return null
}
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending': return '⏳'
case 'running': return '🔄'
case 'completed': return '✅'
case 'failed': return '❌'
case 'cancelled': return '⏹️'
default: return '❓'
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'pending': return '#6B7280'
case 'running': return '#3B82F6'
case 'completed': return '#10B981'
case 'failed': return '#EF4444'
case 'cancelled': return '#F59E0B'
default: return '#6B7280'
}
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
if (diffMs < 60000) { // Less than 1 minute
return 'Just now'
} else if (diffMs < 3600000) { // Less than 1 hour
return `${Math.floor(diffMs / 60000)} min ago`
} else if (diffMs < 86400000) { // Less than 1 day
return `${Math.floor(diffMs / 3600000)} hrs ago`
} else {
return date.toLocaleDateString()
}
}
const getDuration = (startedAt: string, completedAt?: string) => {
const start = new Date(startedAt)
const end = completedAt ? new Date(completedAt) : new Date()
const diffMs = end.getTime() - start.getTime()
if (diffMs < 1000) return '<1s'
if (diffMs < 60000) return `${Math.floor(diffMs / 1000)}s`
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m ${Math.floor((diffMs % 60000) / 1000)}s`
return `${Math.floor(diffMs / 3600000)}h ${Math.floor((diffMs % 3600000) / 60000)}m`
}
const recentRun = runs[0]
const recentStatus = getStatusIcon(recentRun.status)
const recentColor = getStatusColor(recentRun.status)
return (
<div style={{
border: '1px solid #E5E7EB',
borderRadius: '8px',
backgroundColor: '#FAFAFA'
}}>
{/* Summary Header */}
<div style={{
padding: '16px',
borderBottom: expanded ? '1px solid #E5E7EB' : 'none',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '20px' }}>{recentStatus}</span>
<div>
<div style={{ fontWeight: '600', color: recentColor }}>
Last run: {recentRun.status}
</div>
<div style={{ fontSize: '13px', color: '#6B7280' }}>
{formatDate(recentRun.startedAt)} Duration: {getDuration(recentRun.startedAt, recentRun.completedAt)}
</div>
</div>
</div>
<Button
onClick={() => setExpanded(!expanded)}
size="small"
buttonStyle="secondary"
>
{expanded ? 'Hide' : 'Show'} History ({runs.length})
</Button>
</div>
{/* Detailed History */}
{expanded && (
<div style={{ padding: '16px' }}>
<h4 style={{ margin: '0 0 12px 0', fontSize: '14px', fontWeight: '600' }}>
Recent Executions
</h4>
{runs.map((run, index) => (
<div
key={run.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
marginBottom: index < runs.length - 1 ? '8px' : '0',
backgroundColor: 'white',
border: '1px solid #E5E7EB',
borderRadius: '6px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '16px' }}>
{getStatusIcon(run.status)}
</span>
<div>
<div style={{
fontSize: '13px',
fontWeight: '500',
color: getStatusColor(run.status)
}}>
{run.status.charAt(0).toUpperCase() + run.status.slice(1)}
</div>
<div style={{ fontSize: '12px', color: '#6B7280' }}>
{formatDate(run.startedAt)} {run.triggeredBy}
</div>
</div>
</div>
<div style={{
fontSize: '12px',
color: '#6B7280',
textAlign: 'right'
}}>
<div>
{getDuration(run.startedAt, run.completedAt)}
</div>
{run.error && (
<div style={{ color: '#EF4444', marginTop: '2px' }}>
Error
</div>
)}
</div>
</div>
))}
<div style={{
marginTop: '12px',
textAlign: 'center'
}}>
<Button
onClick={() => {
// Navigate to workflow runs filtered by this workflow
window.location.href = `/admin/collections/workflow-runs?where[workflow][equals]=${workflowId}`
}}
size="small"
buttonStyle="secondary"
>
View All Runs
</Button>
</div>
</div>
)}
</div>
)
}

View File

@@ -12,7 +12,6 @@ export type PayloadWorkflow = {
parameters?: {
collectionSlug?: null | string
operation?: null | string
webhookPath?: null | string
global?: null | string
globalOperation?: null | string
[key: string]: unknown
@@ -43,54 +42,8 @@ export type WorkflowTrigger = {
} & NonNullable<PayloadWorkflow['triggers']>[0]
export interface ExecutionContext {
steps: Record<string, {
error?: string
input: unknown
output: unknown
state: 'failed' | 'pending' | 'running' | 'succeeded'
_startTime?: number
executionInfo?: {
completed: boolean
success: boolean
executedAt: string
duration: number
failureReason?: string
}
errorDetails?: {
stepId: string
errorType: string
duration: number
attempts: number
finalError: string
context: {
url?: string
method?: string
timeout?: number
statusCode?: number
headers?: Record<string, string>
[key: string]: any
}
timestamp: string
}
}>
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
}
[key: string]: any
}
steps: Record<string, any>
trigger: Record<string, any>
}
export class WorkflowExecutor {
@@ -229,7 +182,7 @@ export class WorkflowExecutor {
}
}
}
// Also extract from nested parameters object if it exists
if (step.parameters && typeof step.parameters === 'object') {
Object.assign(inputFields, step.parameters)

View File

@@ -1,7 +1,6 @@
// 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'
export { StatusCell } from '../components/StatusCell.js'
export { ErrorDisplay } from '../components/ErrorDisplay.js'

View File

@@ -1,7 +1,7 @@
import {WorkflowExecutor} from "../core/workflow-executor.js"
export const createCollectionTriggerHook = (collectionSlug: string, hookType: string) => {
return async (args: HookArgs) => {
return async (args: any) => {
const req = 'req' in args ? args.req :
'args' in args ? args.args.req :
undefined

View File

@@ -1,17 +1,21 @@
import type {CollectionConfig, TaskConfig} from "payload"
import type {CollectionConfig, GlobalConfig, TaskConfig} from "payload"
import type {Trigger} from "../triggers/types.js"
export type TriggerConfig = (config: WorkflowsPluginConfig) => Trigger
export type WorkflowsPluginConfig<TSlug extends string = string> = {
export type WorkflowsPluginConfig<TSlug extends string = string, TGlobal extends string = string> = {
collectionTriggers?: {
[key in TSlug]?: {
[key in keyof CollectionConfig['hooks']]?: true
} | true
}
globalTriggers?: {
[key in TGlobal]?: {
[key in keyof GlobalConfig['hooks']]?: true
} | true
}
enabled?: boolean
steps: TaskConfig<string>[]
triggers?: TriggerConfig[]
webhookPrefix?: string
}

95
src/plugin/global-hook.ts Normal file
View File

@@ -0,0 +1,95 @@
import {WorkflowExecutor} from '../core/workflow-executor.js'
export const createGlobalTriggerHook = (globalSlug: string, hookType: string) => {
return async function payloadGlobalAutomationHook(args: any) {
const req = 'req' in args ? args.req :
'args' in args ? args.args.req :
undefined
if (!req) {
throw new Error('No request object found in global hook arguments')
}
const payload = req.payload
const logger = payload.logger
try {
logger.info({
global: globalSlug,
hookType,
operation: hookType
}, 'Global automation hook triggered')
// Create executor on-demand
const executor = new WorkflowExecutor(payload, logger)
logger.debug('Executing triggered global workflows...')
// Find workflows with matching global triggers
const {docs: workflows} = await payload.find({
collection: 'workflows',
depth: 2,
limit: 100,
where: {
'triggers.parameters.global': {
equals: globalSlug
},
'triggers.parameters.operation': {
equals: hookType
},
'triggers.type': {
equals: 'global-hook'
}
}
})
// Execute each matching workflow
for (const workflow of workflows) {
// Create execution context
const context = {
steps: {},
trigger: {
...args,
type: 'global',
global: globalSlug,
operation: hookType,
req
}
}
try {
await executor.execute(workflow, context, req)
logger.info({
workflowId: workflow.id,
global: globalSlug,
hookType
}, 'Global workflow executed successfully')
} catch (error) {
logger.error({
workflowId: workflow.id,
global: globalSlug,
hookType,
error: error instanceof Error ? error.message : 'Unknown error'
}, 'Global workflow execution failed')
// Don't throw to prevent breaking the original operation
}
}
logger.info({
global: globalSlug,
hookType
}, 'Global workflow execution completed successfully')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error({
global: globalSlug,
hookType,
error: errorMessage,
errorStack: error instanceof Error ? error.stack : undefined
}, 'Global hook execution failed')
// Don't throw to prevent breaking the original operation
}
}
}

View File

@@ -5,12 +5,9 @@ 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 {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'
import {createCollectionTriggerHook} from "./collection-hook.js"
import {createGlobalTriggerHook} from "./global-hook.js"
export {getLogger} from './logger.js'
@@ -114,6 +111,69 @@ export const workflowsPlugin =
}
}
// Handle global triggers similarly to collection triggers
if (config.globals && pluginOptions.globalTriggers) {
for (const [globalSlug, triggerConfig] of Object.entries(pluginOptions.globalTriggers)) {
if (!triggerConfig) {
continue
}
// Find the global config that matches
const globalIndex = config.globals.findIndex(g => g.slug === globalSlug)
if (globalIndex === -1) {
logger.warn(`Global '${globalSlug}' not found in config.globals`)
continue
}
const global = config.globals[globalIndex]
// Initialize hooks if needed
if (!global.hooks) {
global.hooks = {}
}
// Determine which hooks to register based on config
const hooksToRegister = triggerConfig === true
? {
afterChange: true,
afterRead: true,
}
: triggerConfig
// Register each configured hook
Object.entries(hooksToRegister).forEach(([hookName, enabled]) => {
if (!enabled) {
return
}
const hookKey = hookName as keyof typeof global.hooks
// Initialize the hook array if needed
if (!global.hooks![hookKey]) {
global.hooks![hookKey] = []
}
// Create the automation hook for this specific global and hook type
const automationHook = createGlobalTriggerHook(globalSlug, hookKey)
// Mark it for debugging
Object.defineProperty(automationHook, '__isAutomationHook', {
value: true,
enumerable: false
})
Object.defineProperty(automationHook, '__hookType', {
value: hookKey,
enumerable: false
})
// Add the hook to the global
;(global.hooks![hookKey] as Array<unknown>).push(automationHook)
logger.debug(`Registered ${hookKey} hook for global '${globalSlug}'`)
})
}
}
if (!config.jobs) {
config.jobs = {tasks: []}
}
@@ -124,8 +184,6 @@ export const workflowsPlugin =
}
}
// Initialize webhook endpoint
initWebhookEndpoint(config, pluginOptions.webhookPrefix || 'webhook')
// Set up onInit to initialize features
const incomingOnInit = config.onInit
@@ -139,19 +197,8 @@ export const workflowsPlugin =
const logger = initializeLogger(payload)
logger.info('Logger initialized with payload instance')
// Log collection trigger configuration
logger.info(`Plugin configuration: ${Object.keys(pluginOptions.collectionTriggers || {}).length} collection triggers, ${pluginOptions.steps?.length || 0} steps`)
logger.info('Initializing global hooks...')
// Create executor for global hooks
const executor = new WorkflowExecutor(payload, logger)
initGlobalHooks(payload, logger, executor)
logger.info('Initializing workflow hooks...')
initWorkflowHooks(payload, logger)
logger.info('Initializing step tasks...')
initStepTasks(pluginOptions, payload, logger)
// Log trigger configuration
logger.info(`Plugin configuration: ${Object.keys(pluginOptions.collectionTriggers || {}).length} collection triggers, ${Object.keys(pluginOptions.globalTriggers || {}).length} global triggers, ${pluginOptions.steps?.length || 0} steps`)
logger.info('Plugin initialized successfully - all hooks registered')
}

View File

@@ -1,112 +0,0 @@
import type { Payload, PayloadRequest } from "payload"
import type { Logger } from "pino"
import type { WorkflowExecutor, PayloadWorkflow } 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 PayloadWorkflow, context, req)
}
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : 'Unknown error',
globalSlug,
operation
}, 'Failed to execute triggered global workflows')
}
}

View File

@@ -1,20 +0,0 @@
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) }, 'Step tasks were registered during config phase')
// Verify that the tasks are available in the job system
const availableTasks = payload.config.jobs?.tasks?.map(t => t.slug) || []
const pluginTasks = pluginOptions.steps.map(s => s.slug)
pluginTasks.forEach(taskSlug => {
if (availableTasks.includes(taskSlug)) {
logger.info({ taskSlug }, 'Step task confirmed available in job system')
} else {
logger.error({ taskSlug }, 'Step task not found in job system - this will cause execution failures')
}
})
}

View File

@@ -1,173 +0,0 @@
import type {Config, PayloadRequest} from 'payload'
import {type PayloadWorkflow, 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}`
// 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 a 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
parameters?: {
webhookPath?: string
[key: string]: any
}
}>
const matchingTrigger = triggers?.find(trigger =>
trigger.type === 'webhook-trigger' &&
trigger.parameters?.webhookPath === path
)
// Check trigger condition if present
if (matchingTrigger?.condition) {
logger.debug({
condition: matchingTrigger.condition,
path,
webhookData: JSON.stringify(webhookData).substring(0, 200),
headers: Object.keys(context.trigger.headers || {}),
workflowId: workflow.id,
workflowName: workflow.name
}, 'Evaluating webhook trigger condition')
const conditionMet = executor.evaluateCondition(matchingTrigger.condition, context)
if (!conditionMet) {
logger.info({
condition: matchingTrigger.condition,
path,
webhookDataSnapshot: JSON.stringify(webhookData).substring(0, 200),
workflowId: workflow.id,
workflowName: workflow.name
}, 'Webhook trigger condition not met, skipping workflow')
return { reason: 'Condition not met', status: 'skipped', workflowId: workflow.id }
}
logger.info({
condition: matchingTrigger.condition,
path,
webhookDataSnapshot: JSON.stringify(webhookData).substring(0, 200),
workflowId: workflow.id,
workflowName: workflow.name
}, 'Webhook trigger condition met')
}
// Execute the workflow
await executor.execute(workflow as PayloadWorkflow, 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 the 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]
}
}

View File

@@ -1,12 +0,0 @@
import type { Payload } from 'payload'
/**
* Initialize hooks for the workflows collection
* Currently minimal - can be extended for future workflow management features
*/
export function initWorkflowHooks(payload: Payload, logger: Payload['logger']): void {
// Future workflow hooks can be added here
// For example: workflow validation, cleanup, statistics, etc.
logger.debug('Workflow hooks initialized')
}

View File

@@ -72,7 +72,7 @@ describe('WorkflowExecutor', () => {
describe('resolveStepInput', () => {
it('should resolve all JSONPath expressions in step config', () => {
const config = {
url: '$.trigger.webhook.url',
url: '$.trigger.data.url',
message: 'Static message',
data: {
id: '$.trigger.doc.id',
@@ -83,7 +83,7 @@ describe('WorkflowExecutor', () => {
const context = {
trigger: {
doc: { id: 'doc-123', title: 'Doc Title' },
webhook: { url: 'https://example.com/webhook' }
data: { url: 'https://example.com/webhook' }
},
steps: {}
}

View File

@@ -1,6 +1,6 @@
import type {TriggerConfig} from '../plugin/config-types.js'
export const collectionHookTrigger: TriggerConfig = ({collectionTriggers}) => ({
export const collectionTrigger: TriggerConfig = ({collectionTriggers}) => ({
slug: 'collection-hook',
parameters: [
{

View File

@@ -1,7 +1,7 @@
import type {TriggerConfig} from '../plugin/config-types.js'
export const globalTrigger: TriggerConfig = () => ({
slug: 'global',
export const globalTrigger: TriggerConfig = ({globalTriggers}) => ({
slug: 'global-hook',
parameters: [
{
name: 'global',
@@ -9,16 +9,20 @@ export const globalTrigger: TriggerConfig = () => ({
admin: {
description: 'Global that triggers the workflow',
},
options: [], // Will be populated dynamically based on available globals
options: Object.keys(globalTriggers || {}),
},
{
name: 'globalOperation',
name: 'operation',
type: 'select',
admin: {
description: 'Global operation that triggers the workflow',
description: 'Global hook that triggers the workflow',
},
options: [
'update'
"afterChange",
"afterRead",
"beforeChange",
"beforeRead",
"beforeValidate"
],
}
]

View File

@@ -1,3 +1,2 @@
export { collectionHookTrigger } from './collection-hook-trigger.js'
export { collectionTrigger } from './collection-trigger.js'
export { globalTrigger } from './global-trigger.js'
export { webhookTrigger } from './webhook-trigger.js'

View File

@@ -1,20 +0,0 @@
import type {TriggerConfig} from '../plugin/config-types.js'
export const webhookTrigger: TriggerConfig = () => ({
slug: 'webhook',
parameters: [
{
name: 'webhookPath',
type: 'text',
admin: {
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' && !value && !siblingData?.parameters?.webhookPath) {
return 'Webhook path is required for webhook triggers'
}
return true
},
}
]
})