mirror of
https://github.com/xtr-dev/payload-automation.git
synced 2025-12-07 23:53:24 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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'
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
95
src/plugin/global-hook.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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: {}
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
@@ -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"
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
]
|
||||
})
|
||||
Reference in New Issue
Block a user