mirror of
https://github.com/xtr-dev/payload-automation.git
synced 2025-12-11 09:13:24 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8eedaba9ed | |||
| 2bc01f30f8 | |||
| 3e9ff10076 | |||
| e204d1241a | |||
| 0fb23cb425 | |||
| 45c5847f5a | |||
| a8ae877039 | |||
| b7b40c400b | |||
| ab5b26c42c | |||
| c47197223c |
@@ -1,486 +0,0 @@
|
||||
# PayloadCMS Automation Plugin - Code Review
|
||||
|
||||
**Date:** January 4, 2025
|
||||
**Plugin:** `@xtr-dev/payload-automation` v0.0.22
|
||||
**Reviewer:** Claude Code Review System
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The `@xtr-dev/payload-automation` plugin is a **well-architected** PayloadCMS extension that provides comprehensive workflow automation capabilities. It successfully enables users to create visual workflows without writing code, featuring a robust execution engine, multiple trigger types, and a variety of step implementations. The codebase demonstrates strong engineering practices with proper TypeScript usage, modular architecture, and comprehensive testing.
|
||||
|
||||
**Overall Rating: 8.5/10** - Production-ready with recommended enhancements.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### ✅ **Strengths**
|
||||
|
||||
**1. Modular Plugin Architecture**
|
||||
- Clean separation between plugin configuration (`src/plugin/`), workflow logic (`src/core/`), collections (`src/collections/`), and steps (`src/steps/`)
|
||||
- Proper PayloadCMS plugin pattern with configuration-time and runtime initialization
|
||||
- Multiple export paths for different use cases (client, server, fields, views, RSC)
|
||||
|
||||
**2. Sophisticated Workflow Execution Engine**
|
||||
- **Topological sorting** for dependency resolution enables parallel step execution within dependency batches
|
||||
- **JSONPath integration** for dynamic data interpolation (`$.trigger.doc.id`, `$.steps.stepName.output`)
|
||||
- **Condition evaluation system** supporting comparison operators and boolean expressions
|
||||
- **Context management** with proper serialization handling circular references
|
||||
|
||||
**3. Comprehensive Trigger System**
|
||||
- Collection hooks (create, update, delete, read)
|
||||
- Webhook triggers with configurable paths
|
||||
- Global document triggers
|
||||
- Cron scheduling with timezone support
|
||||
- Manual trigger capability via UI components
|
||||
|
||||
## Detailed Component Analysis
|
||||
|
||||
### **Workflow Executor** (`src/core/workflow-executor.ts`)
|
||||
**Rating: 9/10** - Excellent implementation
|
||||
|
||||
**Strengths:**
|
||||
- Sophisticated dependency resolution using topological sorting (lines 286-338)
|
||||
- Parallel execution within dependency batches
|
||||
- Comprehensive error handling and logging throughout execution pipeline
|
||||
- JSONPath-based data resolution with fallback mechanisms (lines 343-407)
|
||||
- Safe serialization preventing circular references (lines 412-448)
|
||||
- Proper workflow run tracking and context updates
|
||||
|
||||
**Areas for improvement:**
|
||||
- Line 790: Console logging should use the logger instance consistently
|
||||
- Error handling could be more granular for different failure types
|
||||
- Consider adding execution timeout mechanisms for long-running workflows
|
||||
|
||||
**Code Quality Highlights:**
|
||||
```typescript
|
||||
// Excellent dependency resolution implementation
|
||||
private resolveExecutionOrder(steps: WorkflowStep[]): WorkflowStep[][] {
|
||||
// Topological sort implementation for parallel execution
|
||||
// Lines 286-338 demonstrate sophisticated algorithm usage
|
||||
}
|
||||
|
||||
// Robust JSONPath resolution with error handling
|
||||
private resolveStepInput(config: Record<string, unknown>, context: ExecutionContext) {
|
||||
// Comprehensive data resolution with fallback mechanisms
|
||||
// Lines 343-407 show excellent defensive programming
|
||||
}
|
||||
```
|
||||
|
||||
### **Plugin Integration** (`src/plugin/index.ts`)
|
||||
**Rating: 8/10** - Very good with some complexity
|
||||
|
||||
**Strengths:**
|
||||
- Proper config-time hook registration avoiding PayloadCMS initialization timing issues (lines 66-145)
|
||||
- Global executor registry pattern for hook access
|
||||
- Comprehensive onInit lifecycle management (lines 170-213)
|
||||
- Proper plugin disabling mechanism (lines 54-57)
|
||||
|
||||
**Concerns:**
|
||||
- Complex global variable fallback mechanism (lines 26-29, 108-111) suggests architectural constraints
|
||||
- Heavy reliance on console.log for debugging in production hooks (lines 94, 114, 123)
|
||||
|
||||
**Architectural Pattern:**
|
||||
```typescript
|
||||
// Config-phase hook registration - critical for PayloadCMS timing
|
||||
const automationHook = Object.assign(
|
||||
async function payloadAutomationHook(args: any) {
|
||||
// Hook implementation with multiple executor access methods
|
||||
},
|
||||
{
|
||||
__isAutomationHook: true,
|
||||
__version: '0.0.21'
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### **Collections Design** (`src/collections/`)
|
||||
**Rating: 9/10** - Excellent schema design
|
||||
|
||||
**Workflow Collection** (`src/collections/Workflow.ts`):
|
||||
- Dynamic field generation based on plugin configuration
|
||||
- Conditional field visibility based on trigger/step types
|
||||
- Comprehensive validation for cron expressions (lines 119-138) and webhook paths (lines 84-90)
|
||||
- Proper integration with custom trigger and step types
|
||||
|
||||
**WorkflowRuns Collection** (`src/collections/WorkflowRuns.ts`):
|
||||
- Rich execution tracking with status management
|
||||
- Comprehensive context preservation using JSON fields
|
||||
- Proper relationship modeling to workflows
|
||||
- Detailed logging and error capture capabilities
|
||||
|
||||
**Schema Highlights:**
|
||||
```typescript
|
||||
// Dynamic field generation based on plugin configuration
|
||||
...(triggers || []).flatMap(t => (t.inputs || []).map(f => ({
|
||||
...f,
|
||||
admin: {
|
||||
...(f.admin || {}),
|
||||
condition: (...args) => args[1]?.type === t.slug && (
|
||||
f.admin?.condition ?
|
||||
f.admin.condition.call(this, ...args) :
|
||||
true
|
||||
),
|
||||
},
|
||||
} as Field)))
|
||||
```
|
||||
|
||||
## Step Implementation Analysis
|
||||
|
||||
### **Step Architecture** (`src/steps/`)
|
||||
**Rating: 8/10** - Well designed and extensible
|
||||
|
||||
**Available Steps:**
|
||||
- HTTP Request (`http-request.ts`, `http-request-handler.ts`)
|
||||
- CRUD Document operations (create, read, update, delete)
|
||||
- Email notifications (`send-email.ts`, `send-email-handler.ts`)
|
||||
|
||||
**Strengths:**
|
||||
- Consistent TaskConfig pattern across all steps
|
||||
- Proper input/output schema definitions
|
||||
- Error handling with state management
|
||||
- Dynamic field generation in workflow UI
|
||||
|
||||
**Example Implementation:**
|
||||
```typescript
|
||||
export const CreateDocumentStepTask = {
|
||||
slug: 'create-document',
|
||||
handler: createDocumentHandler,
|
||||
inputSchema: [
|
||||
{
|
||||
name: 'collectionSlug',
|
||||
type: 'text',
|
||||
required: true
|
||||
},
|
||||
// Comprehensive input schema definition
|
||||
],
|
||||
outputSchema: [
|
||||
// Well-defined output structure
|
||||
]
|
||||
} satisfies TaskConfig<'create-document'>
|
||||
```
|
||||
|
||||
**Improvement opportunities:**
|
||||
- HTTP step could benefit from more configuration options (timeout, authentication, custom headers)
|
||||
- Error messages could be more user-friendly in step handlers (currently quite technical)
|
||||
- Consider adding retry mechanisms for transient failures
|
||||
|
||||
## User Experience & Interface
|
||||
|
||||
### **Admin Interface Integration**
|
||||
**Rating: 8/10** - Good integration with room for enhancement
|
||||
|
||||
**Strengths:**
|
||||
- Workflow and WorkflowRuns collections properly grouped under "Automation"
|
||||
- Manual trigger button component (`TriggerWorkflowButton.tsx`) with proper error handling
|
||||
- Conditional field display based on trigger/step types
|
||||
- Comprehensive workflow run visualization with execution context
|
||||
|
||||
**Current UI Components:**
|
||||
```tsx
|
||||
export const TriggerWorkflowButton: React.FC<TriggerWorkflowButtonProps> = ({
|
||||
workflowId,
|
||||
workflowName,
|
||||
triggerSlug = 'manual-trigger'
|
||||
}) => {
|
||||
// Clean implementation with loading states and error handling
|
||||
// Lines 19-52 show good React patterns
|
||||
}
|
||||
```
|
||||
|
||||
**Missing UI Elements:**
|
||||
- Visual workflow builder/editor (drag-and-drop interface)
|
||||
- Step dependency visualization (graph view)
|
||||
- Real-time execution monitoring dashboard
|
||||
- Workflow debugging tools and step-by-step execution views
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### **Test Coverage**
|
||||
**Rating: 7/10** - Good foundation, needs expansion
|
||||
|
||||
**Current Testing:**
|
||||
```typescript
|
||||
// Integration test example from dev/simple-trigger.spec.ts
|
||||
describe('Workflow Trigger Test', () => {
|
||||
// Proper test setup with MongoDB Memory Server
|
||||
// Comprehensive workflow creation and execution testing
|
||||
// Lines 58-131 demonstrate good testing practices
|
||||
})
|
||||
```
|
||||
|
||||
**Strengths:**
|
||||
- Integration tests using Vitest with MongoDB Memory Server
|
||||
- Basic workflow trigger and execution testing (lines 58-131)
|
||||
- Proper test cleanup and lifecycle management (lines 14-56)
|
||||
- Realistic test scenarios with actual PayloadCMS operations
|
||||
|
||||
**Testing Gaps:**
|
||||
- No E2E tests with Playwright (configured but not implemented)
|
||||
- Limited step handler unit tests
|
||||
- No error scenario testing (malformed inputs, network failures)
|
||||
- Missing performance/load testing for complex workflows
|
||||
- No webhook trigger testing
|
||||
|
||||
### **Test Configuration**
|
||||
**Vitest Config:**
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Development Config:**
|
||||
- Proper test database isolation using MongoDB Memory Server
|
||||
- Clean test environment setup in `dev/payload.config.ts`
|
||||
- Email adapter mocking for testing
|
||||
|
||||
## Code Quality Assessment
|
||||
|
||||
### **TypeScript Usage**
|
||||
**Rating: 9/10** - Excellent type safety
|
||||
|
||||
**Strengths:**
|
||||
- Comprehensive type definitions with proper generics
|
||||
- Generated PayloadCMS type integration avoiding duplication
|
||||
- Proper async/await patterns throughout
|
||||
- Type-safe task handler patterns with `TaskHandler<T>` interface
|
||||
|
||||
**Type System Highlights:**
|
||||
```typescript
|
||||
// Excellent generic type usage
|
||||
export const workflowsPlugin =
|
||||
<TSlug extends string>(pluginOptions: WorkflowsPluginConfig<TSlug>) =>
|
||||
(config: Config): Config => {
|
||||
// Type-safe plugin configuration
|
||||
}
|
||||
|
||||
// Proper task handler typing
|
||||
export const httpStepHandler: TaskHandler<'http-request-step'> = async ({input}) => {
|
||||
// Type-safe step implementation
|
||||
}
|
||||
```
|
||||
|
||||
**TypeScript Configuration:**
|
||||
- Strict mode enabled with comprehensive compiler options
|
||||
- Proper module resolution (NodeNext)
|
||||
- Isolated modules for better build performance
|
||||
- Declaration generation for proper library distribution
|
||||
|
||||
### **Error Handling**
|
||||
**Rating: 7/10** - Good with improvement potential
|
||||
|
||||
**Strengths:**
|
||||
- Try-catch blocks in critical execution paths
|
||||
- Structured error logging with contextual information
|
||||
- Graceful degradation in condition evaluation (lines 583-593 in workflow-executor.ts)
|
||||
|
||||
**Error Handling Patterns:**
|
||||
```typescript
|
||||
// Good error handling with context preservation
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
context.steps[stepName].state = 'failed'
|
||||
context.steps[stepName].error = errorMessage
|
||||
|
||||
this.logger.error({
|
||||
error: errorMessage,
|
||||
input: context.steps[stepName].input,
|
||||
stepName,
|
||||
taskSlug
|
||||
}, 'Step execution failed')
|
||||
|
||||
throw error // Proper re-throwing for upstream handling
|
||||
}
|
||||
```
|
||||
|
||||
**Concerns:**
|
||||
- Some error swallowing in hook execution (line 128 in plugin/index.ts)
|
||||
- Inconsistent error message formats across components
|
||||
- Limited error categorization (network vs. validation vs. system errors)
|
||||
|
||||
### **Performance Considerations**
|
||||
**Rating: 8/10** - Well optimized
|
||||
|
||||
**Strengths:**
|
||||
- Parallel step execution within dependency batches
|
||||
- Efficient topological sorting implementation (O(V+E) complexity)
|
||||
- Proper async/await usage avoiding callback hell
|
||||
- Safe serialization preventing memory issues with circular references
|
||||
|
||||
**Performance Optimizations:**
|
||||
```typescript
|
||||
// Parallel execution implementation
|
||||
const batchPromises = batch.map((step, stepIndex) =>
|
||||
this.executeStep(step, stepIndex, context, req, workflowRun.id)
|
||||
)
|
||||
await Promise.all(batchPromises) // Efficient parallel processing
|
||||
```
|
||||
|
||||
## Security Analysis
|
||||
|
||||
### **Security Posture**
|
||||
**Rating: 8/10** - Good security practices
|
||||
|
||||
**Strengths:**
|
||||
- No code injection vulnerabilities in JSONPath usage (proper JSONPath.js usage)
|
||||
- Proper request context passing maintaining user permissions
|
||||
- Secure webhook endpoint implementation with path validation
|
||||
- Appropriate access controls on collections (configurable via access functions)
|
||||
|
||||
**Security Implementations:**
|
||||
```typescript
|
||||
// Webhook path validation
|
||||
validate: (value: any, {siblingData}: any) => {
|
||||
if (siblingData?.type === 'webhook-trigger' && !value) {
|
||||
return 'Webhook path is required for webhook triggers'
|
||||
}
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
**Security Considerations:**
|
||||
- JSONPath expressions in workflows could be validated more strictly (consider allowlist approach)
|
||||
- Webhook endpoints should consider rate limiting implementation
|
||||
- Consider input sanitization for step parameters (especially JSON inputs)
|
||||
- Audit trail for workflow modifications could be enhanced
|
||||
|
||||
## Identified Issues & Improvements
|
||||
|
||||
### **Critical Issues**
|
||||
None identified - the codebase is production-ready.
|
||||
|
||||
### **High Priority Improvements**
|
||||
|
||||
1. **Visual Workflow Builder**
|
||||
- Implement drag-and-drop workflow designer
|
||||
- Step dependency visualization with graph layout
|
||||
- Real-time validation feedback during workflow creation
|
||||
- Template workflow library for common patterns
|
||||
|
||||
2. **Enhanced Error Handling**
|
||||
- Structured error types for different failure modes
|
||||
- User-friendly error messages in the admin interface
|
||||
- Error recovery mechanisms (retry policies, fallback steps)
|
||||
- Better error propagation from nested step execution
|
||||
|
||||
3. **Monitoring & Observability**
|
||||
- Workflow execution metrics and performance dashboards
|
||||
- Real-time execution monitoring with WebSocket updates
|
||||
- Execution history analytics and reporting
|
||||
- Alerting system for failed workflows
|
||||
|
||||
### **Medium Priority Enhancements**
|
||||
|
||||
1. **Step Library Expansion**
|
||||
- Database query steps (aggregations, complex queries)
|
||||
- File processing steps (CSV parsing, image processing)
|
||||
- Integration with popular services (Slack, Discord, Teams)
|
||||
- Conditional branching and loop steps
|
||||
- Data transformation and mapping steps
|
||||
|
||||
2. **Advanced Trigger Types**
|
||||
- File system watchers for document uploads
|
||||
- API polling triggers for external data changes
|
||||
- Event-driven triggers from external systems
|
||||
- Time-based triggers with more sophisticated scheduling
|
||||
|
||||
3. **Testing Improvements**
|
||||
- Comprehensive E2E test suite with Playwright
|
||||
- Step handler unit tests with mocking
|
||||
- Load testing for complex workflows with many parallel steps
|
||||
- Integration testing with actual external services
|
||||
|
||||
### **Low Priority Items**
|
||||
|
||||
1. **Developer Experience**
|
||||
- CLI tools for workflow management and deployment
|
||||
- Workflow import/export functionality (JSON/YAML formats)
|
||||
- Documentation generator for custom steps
|
||||
- Development mode with enhanced debugging
|
||||
|
||||
2. **Performance Optimizations**
|
||||
- Workflow execution caching for repeated executions
|
||||
- Background job queuing improvements
|
||||
- Database query optimization for large workflow sets
|
||||
- Memory usage optimization for long-running workflows
|
||||
|
||||
## Dependencies & Maintenance
|
||||
|
||||
### **Dependency Health**
|
||||
**Rating: 9/10** - Well maintained dependencies
|
||||
|
||||
**Core Dependencies:**
|
||||
- **PayloadCMS 3.45.0**: Latest version with proper peer dependency management
|
||||
- **JSONPath Plus 10.3.0**: Stable, well-maintained library for data resolution
|
||||
- **Node-cron 4.2.1**: Reliable cron implementation with timezone support
|
||||
- **Pino 9.9.0**: Enterprise-grade logging solution
|
||||
|
||||
**Development Dependencies:**
|
||||
- Modern toolchain with SWC for fast compilation
|
||||
- Comprehensive testing setup (Vitest, Playwright, MongoDB Memory Server)
|
||||
- PayloadCMS ecosystem packages for consistent development experience
|
||||
|
||||
### **Maintenance Considerations**
|
||||
- Regular PayloadCMS compatibility updates needed (major version changes)
|
||||
- Monitor JSONPath Plus for security updates
|
||||
- Node.js version requirements clearly specified (^18.20.2 || >=20.9.0)
|
||||
- PNPM package manager requirement for consistent builds
|
||||
|
||||
### **Build System**
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
||||
"build:types": "tsc --outDir dist --rootDir ./src"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Strengths:**
|
||||
- Fast SWC compilation for production builds
|
||||
- Separate TypeScript declaration generation
|
||||
- Asset copying for complete distribution
|
||||
- Comprehensive export configuration for different usage patterns
|
||||
|
||||
## Recommendations
|
||||
|
||||
### **Immediate Actions**
|
||||
1. **Documentation**: Create comprehensive user documentation with examples
|
||||
2. **Testing**: Implement missing E2E tests and expand unit test coverage
|
||||
3. **Error Messages**: Improve user-facing error messages throughout the system
|
||||
|
||||
### **Short Term (1-3 months)**
|
||||
1. **Visual Builder**: Begin development of drag-and-drop workflow interface
|
||||
2. **Step Library**: Add most commonly requested step types based on user feedback
|
||||
3. **Monitoring**: Implement basic execution monitoring dashboard
|
||||
|
||||
### **Long Term (3-6 months)**
|
||||
1. **Enterprise Features**: Add advanced features like workflow templates, bulk operations
|
||||
2. **Performance**: Implement caching and optimization features for high-volume usage
|
||||
3. **Integrations**: Build ecosystem of pre-built integrations with popular services
|
||||
|
||||
## Conclusion
|
||||
|
||||
The PayloadCMS Automation Plugin represents a **mature, production-ready solution** for workflow automation in PayloadCMS applications. The codebase demonstrates:
|
||||
|
||||
- **Excellent architectural decisions** with proper separation of concerns and extensible design
|
||||
- **Robust execution engine** with sophisticated dependency management and parallel processing
|
||||
- **Comprehensive trigger system** supporting diverse automation scenarios
|
||||
- **Type-safe implementation** following TypeScript best practices
|
||||
- **Production-ready code quality** with proper error handling, logging, and testing foundation
|
||||
|
||||
### **Deployment Readiness: ✅ Ready**
|
||||
|
||||
The plugin can be confidently deployed in production environments with the current feature set. The suggested improvements would enhance user experience and expand capabilities but are not blockers for production use.
|
||||
|
||||
### **Maintenance Score: 8/10**
|
||||
|
||||
The codebase is well-structured for long-term maintenance with clear patterns, comprehensive documentation in code, and good test coverage foundation. The modular architecture supports feature additions without major refactoring.
|
||||
|
||||
---
|
||||
|
||||
**Review completed on January 4, 2025**
|
||||
**Next review recommended: July 2025 (6-month cycle)**
|
||||
187
MIGRATION-v0.0.24.md
Normal file
187
MIGRATION-v0.0.24.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Migration Guide: v0.0.23 → v0.0.24
|
||||
|
||||
## What's New
|
||||
|
||||
Version 0.0.24 introduces **trigger builder helpers** that dramatically reduce boilerplate when creating custom triggers, plus fixes field name clashing between built-in and external trigger parameters.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
**None** - This is a fully backward-compatible release. All existing triggers continue to work exactly as before.
|
||||
|
||||
## New Features
|
||||
|
||||
### 1. Trigger Builder Helpers
|
||||
|
||||
New helper functions eliminate 90% of boilerplate when creating custom triggers:
|
||||
|
||||
```bash
|
||||
npm update @xtr-dev/payload-automation
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Import the new helpers
|
||||
import {
|
||||
createTrigger,
|
||||
webhookTrigger,
|
||||
cronTrigger
|
||||
} from '@xtr-dev/payload-automation/helpers'
|
||||
```
|
||||
|
||||
### 2. Fixed Field Name Clashing
|
||||
|
||||
Built-in trigger parameters now use a JSON backing store to prevent conflicts with custom trigger fields.
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### Step 1: Update Package
|
||||
|
||||
```bash
|
||||
npm install @xtr-dev/payload-automation@latest
|
||||
# or
|
||||
pnpm update @xtr-dev/payload-automation
|
||||
```
|
||||
|
||||
### Step 2: (Optional) Modernize Custom Triggers
|
||||
|
||||
**Your existing triggers will continue to work**, but you can optionally migrate to the cleaner syntax:
|
||||
|
||||
#### Before (Still Works)
|
||||
```typescript
|
||||
const customTrigger = {
|
||||
slug: 'order-webhook',
|
||||
inputs: [
|
||||
{
|
||||
name: 'webhookSecret',
|
||||
type: 'text',
|
||||
required: true,
|
||||
virtual: true,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'order-webhook',
|
||||
description: 'Secret for webhook validation'
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [({ siblingData }) => siblingData?.parameters?.webhookSecret],
|
||||
beforeChange: [({ value, siblingData }) => {
|
||||
if (!siblingData.parameters) siblingData.parameters = {}
|
||||
siblingData.parameters.webhookSecret = value
|
||||
return undefined
|
||||
}]
|
||||
}
|
||||
}
|
||||
// ... more boilerplate
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### After (Recommended)
|
||||
```typescript
|
||||
import { createTrigger } from '@xtr-dev/payload-automation/helpers'
|
||||
|
||||
const orderWebhook = createTrigger('order-webhook').parameters({
|
||||
webhookSecret: {
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Secret for webhook validation'
|
||||
}
|
||||
}
|
||||
// Add more parameters easily
|
||||
})
|
||||
```
|
||||
|
||||
### Step 3: (Optional) Use Preset Builders
|
||||
|
||||
For common trigger patterns:
|
||||
|
||||
```typescript
|
||||
import { webhookTrigger, cronTrigger } from '@xtr-dev/payload-automation/helpers'
|
||||
|
||||
// Webhook trigger with built-in path, secret, headers parameters
|
||||
const paymentWebhook = webhookTrigger('payment-webhook')
|
||||
.parameter('currency', {
|
||||
type: 'select',
|
||||
options: ['USD', 'EUR', 'GBP']
|
||||
})
|
||||
.build()
|
||||
|
||||
// Cron trigger with built-in expression, timezone parameters
|
||||
const dailyReport = cronTrigger('daily-report')
|
||||
.parameter('format', {
|
||||
type: 'select',
|
||||
options: ['pdf', 'csv']
|
||||
})
|
||||
.build()
|
||||
```
|
||||
|
||||
## Quick Migration Examples
|
||||
|
||||
### Simple Trigger Migration
|
||||
|
||||
```typescript
|
||||
// OLD WAY (still works)
|
||||
{
|
||||
slug: 'user-signup',
|
||||
inputs: [/* 20+ lines of boilerplate per field */]
|
||||
}
|
||||
|
||||
// NEW WAY (recommended)
|
||||
import { createTrigger } from '@xtr-dev/payload-automation/helpers'
|
||||
|
||||
const userSignup = createTrigger('user-signup').parameters({
|
||||
source: {
|
||||
type: 'select',
|
||||
options: ['web', 'mobile', 'api'],
|
||||
required: true
|
||||
},
|
||||
userType: {
|
||||
type: 'select',
|
||||
options: ['regular', 'premium'],
|
||||
defaultValue: 'regular'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Webhook Trigger Migration
|
||||
|
||||
```typescript
|
||||
// OLD WAY
|
||||
{
|
||||
slug: 'payment-webhook',
|
||||
inputs: [/* Manual webhookPath field + lots of boilerplate */]
|
||||
}
|
||||
|
||||
// NEW WAY
|
||||
import { webhookTrigger } from '@xtr-dev/payload-automation/helpers'
|
||||
|
||||
const paymentWebhook = webhookTrigger('payment-webhook')
|
||||
.parameter('minimumAmount', {
|
||||
type: 'number',
|
||||
min: 0
|
||||
})
|
||||
.build()
|
||||
```
|
||||
|
||||
## Benefits of Migration
|
||||
|
||||
- **90% less code** - Eliminate virtual field boilerplate
|
||||
- **No field name conflicts** - Built-in parameters isolated
|
||||
- **Better TypeScript support** - Full type inference
|
||||
- **Preset patterns** - Common trigger types ready-to-use
|
||||
- **Composable API** - Easy to extend and customize
|
||||
|
||||
## Compatibility
|
||||
|
||||
- ✅ **Existing triggers** continue to work unchanged
|
||||
- ✅ **Mix old and new** trigger styles in same config
|
||||
- ✅ **No database changes** required
|
||||
- ✅ **PayloadCMS field compatibility** maintained
|
||||
|
||||
## Need Help?
|
||||
|
||||
- [View examples](./examples/trigger-builders.ts)
|
||||
- [Read documentation](./examples/README-trigger-builders.md)
|
||||
- [Report issues](https://github.com/xtr-dev/payload-automation/issues)
|
||||
|
||||
---
|
||||
|
||||
**TL;DR**: Update the package, optionally migrate custom triggers to use the new helpers for cleaner code. All existing triggers continue to work without changes.
|
||||
@@ -1,5 +1,9 @@
|
||||
|
||||
import { default as default_4845c503d8eeb95a2cf39d519276b9b7 } from '../../../../../components/WorkflowExecutionStatus'
|
||||
import { default as default_28774f13376b69227276b43eee64e5a1 } from '../../../../../components/StatusCell'
|
||||
import { default as default_623fcff70b12e3e87839f97bf237499a } from '../../../../../components/ErrorDisplay'
|
||||
|
||||
export const importMap = {
|
||||
|
||||
"../components/WorkflowExecutionStatus#default": default_4845c503d8eeb95a2cf39d519276b9b7,
|
||||
"../components/StatusCell#default": default_28774f13376b69227276b43eee64e5a1,
|
||||
"../components/ErrorDisplay#default": default_623fcff70b12e3e87839f97bf237499a
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export interface Config {
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: number;
|
||||
defaultIDType: string;
|
||||
};
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
@@ -136,7 +136,7 @@ export interface UserAuthOperations {
|
||||
* via the `definition` "posts".
|
||||
*/
|
||||
export interface Post {
|
||||
id: number;
|
||||
id: string;
|
||||
content?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
@@ -146,7 +146,7 @@ export interface Post {
|
||||
* via the `definition` "media".
|
||||
*/
|
||||
export interface Media {
|
||||
id: number;
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
@@ -164,9 +164,9 @@ export interface Media {
|
||||
* via the `definition` "auditLog".
|
||||
*/
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
post?: (number | null) | Post;
|
||||
user?: (number | null) | User;
|
||||
id: string;
|
||||
post?: (string | null) | Post;
|
||||
user?: (string | null) | User;
|
||||
message?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
@@ -176,7 +176,7 @@ export interface AuditLog {
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
@@ -202,7 +202,7 @@ export interface User {
|
||||
* via the `definition` "workflows".
|
||||
*/
|
||||
export interface Workflow {
|
||||
id: number;
|
||||
id: string;
|
||||
/**
|
||||
* Human-readable name for the workflow
|
||||
*/
|
||||
@@ -214,36 +214,45 @@ export interface Workflow {
|
||||
triggers?:
|
||||
| {
|
||||
type?: ('collection-trigger' | 'webhook-trigger' | 'global-trigger' | 'cron-trigger') | null;
|
||||
parameters?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Collection that triggers the workflow
|
||||
*/
|
||||
collectionSlug?: ('posts' | 'media') | null;
|
||||
__builtin_collectionSlug?: ('posts' | 'media') | null;
|
||||
/**
|
||||
* Collection operation that triggers the workflow
|
||||
*/
|
||||
operation?: ('create' | 'delete' | 'read' | 'update') | null;
|
||||
__builtin_operation?: ('create' | 'delete' | 'read' | 'update') | null;
|
||||
/**
|
||||
* URL path for the webhook (e.g., "my-webhook"). Full URL will be /api/workflows/webhook/my-webhook
|
||||
* URL path for the webhook (e.g., "my-webhook"). Full URL will be /api/workflows-webhook/my-webhook
|
||||
*/
|
||||
webhookPath?: string | null;
|
||||
__builtin_webhookPath?: string | null;
|
||||
/**
|
||||
* Global that triggers the workflow
|
||||
*/
|
||||
global?: string | null;
|
||||
__builtin_global?: string | null;
|
||||
/**
|
||||
* Global operation that triggers the workflow
|
||||
*/
|
||||
globalOperation?: 'update' | null;
|
||||
__builtin_globalOperation?: 'update' | null;
|
||||
/**
|
||||
* Cron expression for scheduled execution (e.g., "0 0 * * *" for daily at midnight)
|
||||
*/
|
||||
cronExpression?: string | null;
|
||||
__builtin_cronExpression?: string | null;
|
||||
/**
|
||||
* Timezone for cron execution (e.g., "America/New_York", "Europe/London"). Defaults to UTC.
|
||||
*/
|
||||
timezone?: string | null;
|
||||
__builtin_timezone?: string | null;
|
||||
/**
|
||||
* JSONPath expression that must evaluate to true for this trigger to execute the workflow (e.g., "$.doc.status == 'published'")
|
||||
* JSONPath expression that must evaluate to true for this trigger to execute the workflow (e.g., "$.trigger.doc.status == 'published'")
|
||||
*/
|
||||
condition?: string | null;
|
||||
id?: string | null;
|
||||
@@ -253,7 +262,18 @@ export interface Workflow {
|
||||
| {
|
||||
step?: ('http-request-step' | 'create-document') | null;
|
||||
name?: string | null;
|
||||
input?:
|
||||
/**
|
||||
* The URL to make the HTTP request to
|
||||
*/
|
||||
url?: string | null;
|
||||
/**
|
||||
* HTTP method to use
|
||||
*/
|
||||
method?: ('GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH') | null;
|
||||
/**
|
||||
* HTTP headers as JSON object (e.g., {"Content-Type": "application/json"})
|
||||
*/
|
||||
headers?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
@@ -262,6 +282,80 @@ export interface Workflow {
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Request body data. Use JSONPath to reference values (e.g., {"postId": "$.trigger.doc.id", "title": "$.trigger.doc.title"})
|
||||
*/
|
||||
body?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Request timeout in milliseconds (default: 30000)
|
||||
*/
|
||||
timeout?: number | null;
|
||||
authentication?: {
|
||||
/**
|
||||
* Authentication method
|
||||
*/
|
||||
type?: ('none' | 'bearer' | 'basic' | 'apikey') | null;
|
||||
/**
|
||||
* Bearer token value
|
||||
*/
|
||||
token?: string | null;
|
||||
/**
|
||||
* Basic auth username
|
||||
*/
|
||||
username?: string | null;
|
||||
/**
|
||||
* Basic auth password
|
||||
*/
|
||||
password?: string | null;
|
||||
/**
|
||||
* API key header name (e.g., "X-API-Key")
|
||||
*/
|
||||
headerName?: string | null;
|
||||
/**
|
||||
* API key value
|
||||
*/
|
||||
headerValue?: string | null;
|
||||
};
|
||||
/**
|
||||
* Number of retry attempts on failure (max: 5)
|
||||
*/
|
||||
retries?: number | null;
|
||||
/**
|
||||
* Delay between retries in milliseconds
|
||||
*/
|
||||
retryDelay?: number | null;
|
||||
/**
|
||||
* The collection slug to create a document in
|
||||
*/
|
||||
collectionSlug?: string | null;
|
||||
/**
|
||||
* The document data to create. Use JSONPath to reference trigger data (e.g., {"title": "$.trigger.doc.title", "author": "$.trigger.doc.author"})
|
||||
*/
|
||||
data?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Create as draft (if collection has drafts enabled)
|
||||
*/
|
||||
draft?: boolean | null;
|
||||
/**
|
||||
* Locale for the document (if localization is enabled)
|
||||
*/
|
||||
locale?: string | null;
|
||||
/**
|
||||
* Step names that must complete before this step can run
|
||||
*/
|
||||
@@ -282,11 +376,11 @@ export interface Workflow {
|
||||
* via the `definition` "workflow-runs".
|
||||
*/
|
||||
export interface WorkflowRun {
|
||||
id: number;
|
||||
id: string;
|
||||
/**
|
||||
* Reference to the workflow that was executed
|
||||
*/
|
||||
workflow: number | Workflow;
|
||||
workflow: string | Workflow;
|
||||
/**
|
||||
* Version of the workflow that was executed
|
||||
*/
|
||||
@@ -380,7 +474,7 @@ export interface WorkflowRun {
|
||||
* via the `definition` "payload-jobs".
|
||||
*/
|
||||
export interface PayloadJob {
|
||||
id: number;
|
||||
id: string;
|
||||
/**
|
||||
* Input data provided to the job
|
||||
*/
|
||||
@@ -472,40 +566,40 @@ export interface PayloadJob {
|
||||
* via the `definition` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: number;
|
||||
id: string;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'posts';
|
||||
value: number | Post;
|
||||
value: string | Post;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
value: number | Media;
|
||||
value: string | Media;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'auditLog';
|
||||
value: number | AuditLog;
|
||||
value: string | AuditLog;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'workflows';
|
||||
value: number | Workflow;
|
||||
value: string | Workflow;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'workflow-runs';
|
||||
value: number | WorkflowRun;
|
||||
value: string | WorkflowRun;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
value: string | User;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'payload-jobs';
|
||||
value: number | PayloadJob;
|
||||
value: string | PayloadJob;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
value: string | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
@@ -515,10 +609,10 @@ export interface PayloadLockedDocument {
|
||||
* via the `definition` "payload-preferences".
|
||||
*/
|
||||
export interface PayloadPreference {
|
||||
id: number;
|
||||
id: string;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
value: string | User;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
@@ -538,7 +632,7 @@ export interface PayloadPreference {
|
||||
* via the `definition` "payload-migrations".
|
||||
*/
|
||||
export interface PayloadMigration {
|
||||
id: number;
|
||||
id: string;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
@@ -592,13 +686,14 @@ export interface WorkflowsSelect<T extends boolean = true> {
|
||||
| T
|
||||
| {
|
||||
type?: T;
|
||||
collectionSlug?: T;
|
||||
operation?: T;
|
||||
webhookPath?: T;
|
||||
global?: T;
|
||||
globalOperation?: T;
|
||||
cronExpression?: T;
|
||||
timezone?: T;
|
||||
parameters?: T;
|
||||
__builtin_collectionSlug?: T;
|
||||
__builtin_operation?: T;
|
||||
__builtin_webhookPath?: T;
|
||||
__builtin_global?: T;
|
||||
__builtin_globalOperation?: T;
|
||||
__builtin_cronExpression?: T;
|
||||
__builtin_timezone?: T;
|
||||
condition?: T;
|
||||
id?: T;
|
||||
};
|
||||
@@ -607,7 +702,27 @@ export interface WorkflowsSelect<T extends boolean = true> {
|
||||
| {
|
||||
step?: T;
|
||||
name?: T;
|
||||
input?: T;
|
||||
url?: T;
|
||||
method?: T;
|
||||
headers?: T;
|
||||
body?: T;
|
||||
timeout?: T;
|
||||
authentication?:
|
||||
| T
|
||||
| {
|
||||
type?: T;
|
||||
token?: T;
|
||||
username?: T;
|
||||
password?: T;
|
||||
headerName?: T;
|
||||
headerValue?: T;
|
||||
};
|
||||
retries?: T;
|
||||
retryDelay?: T;
|
||||
collectionSlug?: T;
|
||||
data?: T;
|
||||
draft?: T;
|
||||
locale?: T;
|
||||
dependencies?: T;
|
||||
condition?: T;
|
||||
id?: T;
|
||||
@@ -736,10 +851,118 @@ export interface TaskWorkflowCronExecutor {
|
||||
*/
|
||||
export interface TaskHttpRequestStep {
|
||||
input: {
|
||||
url?: string | null;
|
||||
/**
|
||||
* The URL to make the HTTP request to
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* HTTP method to use
|
||||
*/
|
||||
method?: ('GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH') | null;
|
||||
/**
|
||||
* HTTP headers as JSON object (e.g., {"Content-Type": "application/json"})
|
||||
*/
|
||||
headers?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Request body data. Use JSONPath to reference values (e.g., {"postId": "$.trigger.doc.id", "title": "$.trigger.doc.title"})
|
||||
*/
|
||||
body?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Request timeout in milliseconds (default: 30000)
|
||||
*/
|
||||
timeout?: number | null;
|
||||
authentication?: {
|
||||
/**
|
||||
* Authentication method
|
||||
*/
|
||||
type?: ('none' | 'bearer' | 'basic' | 'apikey') | null;
|
||||
/**
|
||||
* Bearer token value
|
||||
*/
|
||||
token?: string | null;
|
||||
/**
|
||||
* Basic auth username
|
||||
*/
|
||||
username?: string | null;
|
||||
/**
|
||||
* Basic auth password
|
||||
*/
|
||||
password?: string | null;
|
||||
/**
|
||||
* API key header name (e.g., "X-API-Key")
|
||||
*/
|
||||
headerName?: string | null;
|
||||
/**
|
||||
* API key value
|
||||
*/
|
||||
headerValue?: string | null;
|
||||
};
|
||||
/**
|
||||
* Number of retry attempts on failure (max: 5)
|
||||
*/
|
||||
retries?: number | null;
|
||||
/**
|
||||
* Delay between retries in milliseconds
|
||||
*/
|
||||
retryDelay?: number | null;
|
||||
};
|
||||
output: {
|
||||
response?: string | null;
|
||||
/**
|
||||
* HTTP status code
|
||||
*/
|
||||
status?: number | null;
|
||||
/**
|
||||
* HTTP status text
|
||||
*/
|
||||
statusText?: string | null;
|
||||
/**
|
||||
* Response headers
|
||||
*/
|
||||
headers?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Response body
|
||||
*/
|
||||
body?: string | null;
|
||||
/**
|
||||
* Parsed response data (if JSON)
|
||||
*/
|
||||
data?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Request duration in milliseconds
|
||||
*/
|
||||
duration?: number | null;
|
||||
};
|
||||
}
|
||||
/**
|
||||
@@ -753,7 +976,7 @@ export interface TaskCreateDocument {
|
||||
*/
|
||||
collectionSlug: string;
|
||||
/**
|
||||
* The document data to create
|
||||
* The document data to create. Use JSONPath to reference trigger data (e.g., {"title": "$.trigger.doc.title", "author": "$.trigger.doc.author"})
|
||||
*/
|
||||
data:
|
||||
| {
|
||||
|
||||
218
examples/README-trigger-builders.md
Normal file
218
examples/README-trigger-builders.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Trigger Builder Examples
|
||||
|
||||
The new trigger builder API dramatically reduces boilerplate when creating custom triggers.
|
||||
|
||||
## Before vs After
|
||||
|
||||
### Before (Manual Approach)
|
||||
```typescript
|
||||
const customTrigger = {
|
||||
slug: 'order-webhook',
|
||||
inputs: [
|
||||
{
|
||||
name: 'webhookSecret',
|
||||
type: 'text',
|
||||
required: true,
|
||||
virtual: true,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'order-webhook',
|
||||
description: 'Secret for webhook validation'
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [({ siblingData }) => siblingData?.parameters?.webhookSecret],
|
||||
beforeChange: [({ value, siblingData }) => {
|
||||
if (!siblingData.parameters) siblingData.parameters = {}
|
||||
siblingData.parameters.webhookSecret = value
|
||||
return undefined
|
||||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'orderStatuses',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['pending', 'processing', 'completed'],
|
||||
defaultValue: ['completed'],
|
||||
virtual: true,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'order-webhook',
|
||||
description: 'Order statuses that trigger the workflow'
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [({ siblingData }) => siblingData?.parameters?.orderStatuses || ['completed']],
|
||||
beforeChange: [({ value, siblingData }) => {
|
||||
if (!siblingData.parameters) siblingData.parameters = {}
|
||||
siblingData.parameters.orderStatuses = value
|
||||
return undefined
|
||||
}]
|
||||
}
|
||||
}
|
||||
// ... imagine more fields with similar boilerplate
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### After (Builder Approach)
|
||||
```typescript
|
||||
import { createTrigger } from '@xtr-dev/payload-automation/helpers'
|
||||
|
||||
const orderWebhook = createTrigger('order-webhook').parameters({
|
||||
webhookSecret: {
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Secret for webhook validation'
|
||||
}
|
||||
},
|
||||
orderStatuses: {
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['pending', 'processing', 'completed'],
|
||||
defaultValue: ['completed'],
|
||||
admin: {
|
||||
description: 'Order statuses that trigger the workflow'
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Built-in Trigger Presets
|
||||
|
||||
### Webhook Trigger
|
||||
```typescript
|
||||
import { webhookTrigger } from '@xtr-dev/payload-automation/helpers'
|
||||
|
||||
const paymentWebhook = webhookTrigger('payment-webhook')
|
||||
.parameter('currency', {
|
||||
type: 'select',
|
||||
options: ['USD', 'EUR', 'GBP'],
|
||||
defaultValue: 'USD'
|
||||
})
|
||||
.build()
|
||||
```
|
||||
|
||||
### Scheduled/Cron Trigger
|
||||
```typescript
|
||||
import { cronTrigger } from '@xtr-dev/payload-automation/helpers'
|
||||
|
||||
const dailyReport = cronTrigger('daily-report')
|
||||
.parameter('reportFormat', {
|
||||
type: 'select',
|
||||
options: ['pdf', 'csv', 'json'],
|
||||
defaultValue: 'pdf'
|
||||
})
|
||||
.build()
|
||||
```
|
||||
|
||||
### Manual Trigger (No Parameters)
|
||||
```typescript
|
||||
import { manualTrigger } from '@xtr-dev/payload-automation/helpers'
|
||||
|
||||
const backupTrigger = manualTrigger('manual-backup')
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Extending Common Parameters
|
||||
```typescript
|
||||
import { createAdvancedTrigger, webhookParameters } from '@xtr-dev/payload-automation/helpers'
|
||||
|
||||
const advancedWebhook = createAdvancedTrigger('advanced-webhook')
|
||||
.extend(webhookParameters) // Includes path, secret, headers
|
||||
.parameter('retryAttempts', {
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 5,
|
||||
defaultValue: 3
|
||||
})
|
||||
.parameter('timeout', {
|
||||
type: 'number',
|
||||
min: 1000,
|
||||
max: 30000,
|
||||
defaultValue: 5000,
|
||||
admin: {
|
||||
description: 'Timeout in milliseconds'
|
||||
}
|
||||
})
|
||||
.build()
|
||||
```
|
||||
|
||||
### Custom Validation
|
||||
```typescript
|
||||
const validatedTrigger = createTrigger('validated-trigger').parameters({
|
||||
email: {
|
||||
type: 'email',
|
||||
required: true,
|
||||
validate: (value) => {
|
||||
if (value?.endsWith('@spam.com')) {
|
||||
return 'Spam domains not allowed'
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
webhookUrl: {
|
||||
type: 'text',
|
||||
required: true,
|
||||
validate: (value) => {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return 'Only HTTP/HTTPS URLs allowed'
|
||||
}
|
||||
} catch {
|
||||
return 'Please enter a valid URL'
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Usage in Plugin Configuration
|
||||
|
||||
```typescript
|
||||
import { workflowsPlugin } from '@xtr-dev/payload-automation'
|
||||
import {
|
||||
createTrigger,
|
||||
webhookTrigger,
|
||||
cronTrigger
|
||||
} from '@xtr-dev/payload-automation/helpers'
|
||||
|
||||
export default buildConfig({
|
||||
plugins: [
|
||||
workflowsPlugin({
|
||||
triggers: [
|
||||
// Mix different trigger types
|
||||
createTrigger('user-signup').parameters({
|
||||
source: {
|
||||
type: 'select',
|
||||
options: ['web', 'mobile', 'api'],
|
||||
required: true
|
||||
}
|
||||
}),
|
||||
|
||||
webhookTrigger('payment-received')
|
||||
.parameter('minimumAmount', { type: 'number', min: 0 })
|
||||
.build(),
|
||||
|
||||
cronTrigger('weekly-cleanup')
|
||||
.parameter('deleteOlderThan', {
|
||||
type: 'number',
|
||||
defaultValue: 30,
|
||||
admin: { description: 'Delete records older than N days' }
|
||||
})
|
||||
.build()
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
- **90% less boilerplate** - No manual hooks, conditions, or virtual field setup
|
||||
- **Type safety** - Full TypeScript support
|
||||
- **Reusable patterns** - Common trigger types as presets
|
||||
- **Composable** - Mix builders with manual fields
|
||||
- **Backward compatible** - Existing triggers continue to work
|
||||
- **Validation built-in** - Parameter validation handled automatically
|
||||
300
examples/trigger-builders.ts
Normal file
300
examples/trigger-builders.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Examples demonstrating the new trigger builder API
|
||||
* This shows the before/after comparison and various usage patterns
|
||||
*/
|
||||
|
||||
import {
|
||||
createTrigger,
|
||||
createAdvancedTrigger,
|
||||
webhookTrigger,
|
||||
cronTrigger,
|
||||
eventTrigger,
|
||||
manualTrigger,
|
||||
apiTrigger,
|
||||
webhookParameters,
|
||||
cronParameters
|
||||
} from '../src/exports/helpers.js'
|
||||
|
||||
/**
|
||||
* BEFORE: Manual trigger definition with lots of boilerplate
|
||||
*/
|
||||
const oldWayTrigger = {
|
||||
slug: 'order-webhook',
|
||||
inputs: [
|
||||
{
|
||||
name: 'webhookSecret',
|
||||
type: 'text',
|
||||
required: true,
|
||||
virtual: true,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'order-webhook',
|
||||
description: 'Secret for webhook validation'
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [({ siblingData }) => siblingData?.parameters?.webhookSecret],
|
||||
beforeChange: [({ value, siblingData }) => {
|
||||
if (!siblingData.parameters) siblingData.parameters = {}
|
||||
siblingData.parameters.webhookSecret = value
|
||||
return undefined
|
||||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'orderStatuses',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['pending', 'processing', 'completed'],
|
||||
defaultValue: ['completed'],
|
||||
virtual: true,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'order-webhook',
|
||||
description: 'Order statuses that trigger the workflow'
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [({ siblingData }) => siblingData?.parameters?.orderStatuses || ['completed']],
|
||||
beforeChange: [({ value, siblingData }) => {
|
||||
if (!siblingData.parameters) siblingData.parameters = {}
|
||||
siblingData.parameters.orderStatuses = value
|
||||
return undefined
|
||||
}]
|
||||
}
|
||||
}
|
||||
// ... imagine more fields with similar boilerplate
|
||||
]
|
||||
} as const
|
||||
|
||||
/**
|
||||
* AFTER: Clean trigger definition using builders
|
||||
*/
|
||||
|
||||
// 1. Simple trigger with parameters
|
||||
const orderWebhook = createTrigger('order-webhook').parameters({
|
||||
webhookSecret: {
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Secret for webhook validation'
|
||||
}
|
||||
},
|
||||
orderStatuses: {
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['pending', 'processing', 'completed'],
|
||||
defaultValue: ['completed'],
|
||||
admin: {
|
||||
description: 'Order statuses that trigger the workflow'
|
||||
}
|
||||
},
|
||||
minimumAmount: {
|
||||
type: 'number',
|
||||
min: 0,
|
||||
admin: {
|
||||
description: 'Minimum order amount to trigger workflow'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Using preset webhook builder
|
||||
const paymentWebhook = webhookTrigger('payment-webhook')
|
||||
.parameter('currency', {
|
||||
type: 'select',
|
||||
options: ['USD', 'EUR', 'GBP'],
|
||||
defaultValue: 'USD'
|
||||
})
|
||||
.parameter('paymentMethods', {
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['credit_card', 'paypal', 'bank_transfer']
|
||||
})
|
||||
.build()
|
||||
|
||||
// 3. Scheduled trigger using cron builder
|
||||
const dailyReport = cronTrigger('daily-report')
|
||||
.parameter('reportFormat', {
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'PDF Report', value: 'pdf' },
|
||||
{ label: 'CSV Export', value: 'csv' },
|
||||
{ label: 'JSON Data', value: 'json' }
|
||||
],
|
||||
defaultValue: 'pdf'
|
||||
})
|
||||
.parameter('includeCharts', {
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
description: 'Include visual charts in the report'
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
||||
// 4. Event-driven trigger
|
||||
const userActivity = eventTrigger('user-activity')
|
||||
.parameter('actionTypes', {
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['login', 'logout', 'profile_update', 'password_change'],
|
||||
admin: {
|
||||
description: 'User actions that should trigger this workflow'
|
||||
}
|
||||
})
|
||||
.parameter('userRoles', {
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['admin', 'editor', 'user'],
|
||||
admin: {
|
||||
description: 'Only trigger for users with these roles'
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
||||
// 5. Simple manual trigger (no parameters)
|
||||
const manualBackup = manualTrigger('manual-backup')
|
||||
|
||||
// 6. API trigger with authentication
|
||||
const externalApi = apiTrigger('external-api')
|
||||
.parameter('allowedOrigins', {
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Comma-separated list of allowed origins'
|
||||
},
|
||||
validate: (value) => {
|
||||
if (value && typeof value === 'string') {
|
||||
const origins = value.split(',').map(s => s.trim())
|
||||
const validOrigins = origins.every(origin => {
|
||||
try {
|
||||
new URL(origin)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
if (!validOrigins) {
|
||||
return 'All origins must be valid URLs'
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
||||
// 7. Complex trigger extending common parameters
|
||||
const advancedWebhook = createAdvancedTrigger('advanced-webhook')
|
||||
.extend(webhookParameters) // Start with webhook basics
|
||||
.parameter('retryConfig', {
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'maxRetries',
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 10,
|
||||
defaultValue: 3
|
||||
},
|
||||
{
|
||||
name: 'retryDelay',
|
||||
type: 'number',
|
||||
min: 1000,
|
||||
max: 60000,
|
||||
defaultValue: 5000,
|
||||
admin: {
|
||||
description: 'Delay between retries in milliseconds'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
.parameter('filters', {
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'field',
|
||||
type: 'text',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'operator',
|
||||
type: 'select',
|
||||
options: ['equals', 'not_equals', 'contains', 'greater_than'],
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
required: true
|
||||
}
|
||||
]
|
||||
})
|
||||
.build()
|
||||
|
||||
// 8. Custom parameter validation
|
||||
const validatedTrigger = createTrigger('validated-trigger').parameters({
|
||||
email: {
|
||||
type: 'email',
|
||||
required: true,
|
||||
validate: (value) => {
|
||||
if (value && typeof value === 'string') {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(value)) {
|
||||
return 'Please enter a valid email address'
|
||||
}
|
||||
// Custom business logic validation
|
||||
if (value.endsWith('@example.com')) {
|
||||
return 'Example.com emails are not allowed'
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
webhookUrl: {
|
||||
type: 'text',
|
||||
required: true,
|
||||
validate: (value) => {
|
||||
if (value && typeof value === 'string') {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return 'URL must use HTTP or HTTPS protocol'
|
||||
}
|
||||
if (url.hostname === 'localhost') {
|
||||
return 'Localhost URLs are not allowed in production'
|
||||
}
|
||||
} catch {
|
||||
return 'Please enter a valid URL'
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Export all triggers for use in plugin configuration
|
||||
*/
|
||||
export const exampleTriggers = [
|
||||
orderWebhook,
|
||||
paymentWebhook,
|
||||
dailyReport,
|
||||
userActivity,
|
||||
manualBackup,
|
||||
externalApi,
|
||||
advancedWebhook,
|
||||
validatedTrigger
|
||||
]
|
||||
|
||||
/**
|
||||
* Usage in payload.config.ts:
|
||||
*
|
||||
* ```typescript
|
||||
* import { workflowsPlugin } from '@xtr-dev/payload-automation'
|
||||
* import { exampleTriggers } from './examples/trigger-builders'
|
||||
*
|
||||
* export default buildConfig({
|
||||
* plugins: [
|
||||
* workflowsPlugin({
|
||||
* triggers: exampleTriggers,
|
||||
* // ... other config
|
||||
* })
|
||||
* ]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@xtr-dev/payload-workflows",
|
||||
"version": "0.0.23",
|
||||
"version": "0.0.29",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@xtr-dev/payload-workflows",
|
||||
"version": "0.0.23",
|
||||
"version": "0.0.29",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "^10.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xtr-dev/payload-automation",
|
||||
"version": "0.0.23",
|
||||
"version": "0.0.29",
|
||||
"description": "PayloadCMS Automation Plugin - Comprehensive workflow automation system with visual workflow building, execution tracking, and step types",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -34,6 +34,11 @@
|
||||
"import": "./dist/exports/server.js",
|
||||
"types": "./dist/exports/server.d.ts",
|
||||
"default": "./dist/exports/server.js"
|
||||
},
|
||||
"./helpers": {
|
||||
"import": "./dist/exports/helpers.js",
|
||||
"types": "./dist/exports/helpers.d.ts",
|
||||
"default": "./dist/exports/helpers.js"
|
||||
}
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -41,7 +41,7 @@ export const createWorkflowCollection: <T extends string>(options: WorkflowsPlug
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '@/components/WorkflowExecutionStatus'
|
||||
Field: '../components/WorkflowExecutionStatus'
|
||||
},
|
||||
condition: (data) => !!data?.id // Only show for existing workflows
|
||||
}
|
||||
@@ -62,79 +62,181 @@ export const createWorkflowCollection: <T extends string>(options: WorkflowsPlug
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'collectionSlug',
|
||||
name: 'parameters',
|
||||
type: 'json',
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
defaultValue: {}
|
||||
},
|
||||
// Virtual fields for collection trigger
|
||||
{
|
||||
name: '__builtin_collectionSlug',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'collection-trigger',
|
||||
description: 'Collection that triggers the workflow',
|
||||
},
|
||||
options: Object.keys(collectionTriggers || {})
|
||||
hooks: {
|
||||
afterRead: [
|
||||
({ siblingData }) => {
|
||||
return siblingData?.parameters?.collectionSlug || undefined
|
||||
}
|
||||
],
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (!siblingData.parameters) {siblingData.parameters = {}}
|
||||
siblingData.parameters.collectionSlug = value
|
||||
return undefined // Virtual field, don't store directly
|
||||
}
|
||||
]
|
||||
},
|
||||
options: Object.keys(collectionTriggers || {}),
|
||||
virtual: true,
|
||||
},
|
||||
{
|
||||
name: 'operation',
|
||||
name: '__builtin_operation',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'collection-trigger',
|
||||
description: 'Collection operation that triggers the workflow',
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [
|
||||
({ siblingData }) => {
|
||||
return siblingData?.parameters?.operation || undefined
|
||||
}
|
||||
],
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (!siblingData.parameters) {siblingData.parameters = {}}
|
||||
siblingData.parameters.operation = value
|
||||
return undefined // Virtual field, don't store directly
|
||||
}
|
||||
]
|
||||
},
|
||||
options: [
|
||||
'create',
|
||||
'delete',
|
||||
'read',
|
||||
'update',
|
||||
]
|
||||
],
|
||||
virtual: true,
|
||||
},
|
||||
// Virtual fields for webhook trigger
|
||||
{
|
||||
name: 'webhookPath',
|
||||
name: '__builtin_webhookPath',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'webhook-trigger',
|
||||
description: 'URL path for the webhook (e.g., "my-webhook"). Full URL will be /api/workflows-webhook/my-webhook',
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [
|
||||
({ siblingData }) => {
|
||||
return siblingData?.parameters?.webhookPath || undefined
|
||||
}
|
||||
],
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (!siblingData.parameters) {siblingData.parameters = {}}
|
||||
siblingData.parameters.webhookPath = value
|
||||
return undefined // Virtual field, don't store directly
|
||||
}
|
||||
]
|
||||
},
|
||||
validate: (value: any, {siblingData}: any) => {
|
||||
if (siblingData?.type === 'webhook-trigger' && !value) {
|
||||
if (siblingData?.type === 'webhook-trigger' && !value && !siblingData?.parameters?.webhookPath) {
|
||||
return 'Webhook path is required for webhook triggers'
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
virtual: true,
|
||||
},
|
||||
// Virtual fields for global trigger
|
||||
{
|
||||
name: 'global',
|
||||
name: '__builtin_global',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'global-trigger',
|
||||
description: 'Global that triggers the workflow',
|
||||
},
|
||||
options: [] // Will be populated dynamically based on available globals
|
||||
hooks: {
|
||||
afterRead: [
|
||||
({ siblingData }) => {
|
||||
return siblingData?.parameters?.global || undefined
|
||||
}
|
||||
],
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (!siblingData.parameters) {siblingData.parameters = {}}
|
||||
siblingData.parameters.global = value
|
||||
return undefined // Virtual field, don't store directly
|
||||
}
|
||||
]
|
||||
},
|
||||
options: [], // Will be populated dynamically based on available globals
|
||||
virtual: true,
|
||||
},
|
||||
{
|
||||
name: 'globalOperation',
|
||||
name: '__builtin_globalOperation',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'global-trigger',
|
||||
description: 'Global operation that triggers the workflow',
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [
|
||||
({ siblingData }) => {
|
||||
return siblingData?.parameters?.globalOperation || undefined
|
||||
}
|
||||
],
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (!siblingData.parameters) {siblingData.parameters = {}}
|
||||
siblingData.parameters.globalOperation = value
|
||||
return undefined // Virtual field, don't store directly
|
||||
}
|
||||
]
|
||||
},
|
||||
options: [
|
||||
'update'
|
||||
]
|
||||
],
|
||||
virtual: true,
|
||||
},
|
||||
// Virtual fields for cron trigger
|
||||
{
|
||||
name: 'cronExpression',
|
||||
name: '__builtin_cronExpression',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'cron-trigger',
|
||||
description: 'Cron expression for scheduled execution (e.g., "0 0 * * *" for daily at midnight)',
|
||||
placeholder: '0 0 * * *'
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [
|
||||
({ siblingData }) => {
|
||||
return siblingData?.parameters?.cronExpression || undefined
|
||||
}
|
||||
],
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (!siblingData.parameters) {siblingData.parameters = {}}
|
||||
siblingData.parameters.cronExpression = value
|
||||
return undefined // Virtual field, don't store directly
|
||||
}
|
||||
]
|
||||
},
|
||||
validate: (value: any, {siblingData}: any) => {
|
||||
if (siblingData?.type === 'cron-trigger' && !value) {
|
||||
const cronValue = value || siblingData?.parameters?.cronExpression
|
||||
if (siblingData?.type === 'cron-trigger' && !cronValue) {
|
||||
return 'Cron expression is required for cron triggers'
|
||||
}
|
||||
|
||||
// Validate cron expression format if provided
|
||||
if (siblingData?.type === 'cron-trigger' && value) {
|
||||
if (siblingData?.type === 'cron-trigger' && cronValue) {
|
||||
// Basic format validation - should be 5 parts separated by spaces
|
||||
const cronParts = value.trim().split(/\s+/)
|
||||
const cronParts = cronValue.trim().split(/\s+/)
|
||||
if (cronParts.length !== 5) {
|
||||
return 'Invalid cron expression format. Expected 5 parts: "minute hour day month weekday" (e.g., "0 9 * * 1")'
|
||||
}
|
||||
@@ -144,10 +246,11 @@ export const createWorkflowCollection: <T extends string>(options: WorkflowsPlug
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
virtual: true,
|
||||
},
|
||||
{
|
||||
name: 'timezone',
|
||||
name: '__builtin_timezone',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'cron-trigger',
|
||||
@@ -155,18 +258,34 @@ export const createWorkflowCollection: <T extends string>(options: WorkflowsPlug
|
||||
placeholder: 'UTC'
|
||||
},
|
||||
defaultValue: 'UTC',
|
||||
hooks: {
|
||||
afterRead: [
|
||||
({ siblingData }) => {
|
||||
return siblingData?.parameters?.timezone || 'UTC'
|
||||
}
|
||||
],
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (!siblingData.parameters) {siblingData.parameters = {}}
|
||||
siblingData.parameters.timezone = value || 'UTC'
|
||||
return undefined // Virtual field, don't store directly
|
||||
}
|
||||
]
|
||||
},
|
||||
validate: (value: any, {siblingData}: any) => {
|
||||
if (siblingData?.type === 'cron-trigger' && value) {
|
||||
const tzValue = value || siblingData?.parameters?.timezone
|
||||
if (siblingData?.type === 'cron-trigger' && tzValue) {
|
||||
try {
|
||||
// Test if timezone is valid by trying to create a date with it
|
||||
new Intl.DateTimeFormat('en', {timeZone: value})
|
||||
new Intl.DateTimeFormat('en', {timeZone: tzValue})
|
||||
return true
|
||||
} catch {
|
||||
return `Invalid timezone: ${value}. Please use a valid IANA timezone identifier (e.g., "America/New_York", "Europe/London")`
|
||||
return `Invalid timezone: ${tzValue}. Please use a valid IANA timezone identifier (e.g., "America/New_York", "Europe/London")`
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
virtual: true,
|
||||
},
|
||||
{
|
||||
name: 'condition',
|
||||
@@ -176,17 +295,10 @@ export const createWorkflowCollection: <T extends string>(options: WorkflowsPlug
|
||||
},
|
||||
required: false
|
||||
},
|
||||
...(triggers || []).flatMap(t => (t.inputs || []).map(f => ({
|
||||
...f,
|
||||
admin: {
|
||||
...(f.admin || {}),
|
||||
condition: (...args) => args[1]?.type === t.slug && (
|
||||
f.admin?.condition ?
|
||||
f.admin.condition.call(this, ...args) :
|
||||
true
|
||||
),
|
||||
},
|
||||
} as Field)))
|
||||
// Virtual fields for custom triggers
|
||||
// Note: Custom trigger fields from trigger-helpers already have unique names
|
||||
// We just need to pass them through without modification
|
||||
...(triggers || []).flatMap(t => (t.inputs || []))
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@ export const WorkflowRunsCollection: CollectionConfig = {
|
||||
admin: {
|
||||
description: 'Current execution status',
|
||||
components: {
|
||||
Cell: '@/components/StatusCell'
|
||||
Cell: '../components/StatusCell'
|
||||
}
|
||||
},
|
||||
defaultValue: 'pending',
|
||||
@@ -141,7 +141,7 @@ export const WorkflowRunsCollection: CollectionConfig = {
|
||||
description: 'Error message if workflow execution failed',
|
||||
condition: (_, siblingData) => siblingData?.status === 'failed',
|
||||
components: {
|
||||
Field: '@/components/ErrorDisplay'
|
||||
Field: '../components/ErrorDisplay'
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -166,14 +166,15 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
|
||||
|
||||
{/* Technical Details Toggle */}
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
size="small"
|
||||
buttonStyle="secondary"
|
||||
style={{ marginBottom: expanded ? '12px' : '0' }}
|
||||
>
|
||||
{expanded ? 'Hide' : 'Show'} Technical Details
|
||||
</Button>
|
||||
<div style={{ marginBottom: expanded ? '12px' : '0' }}>
|
||||
<Button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
size="small"
|
||||
buttonStyle="secondary"
|
||||
>
|
||||
{expanded ? 'Hide' : 'Show'} Technical Details
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={{
|
||||
|
||||
@@ -8,9 +8,17 @@ export type PayloadWorkflow = {
|
||||
description?: string | null
|
||||
triggers?: Array<{
|
||||
type?: string | null
|
||||
collectionSlug?: string | null
|
||||
operation?: string | null
|
||||
condition?: string | null
|
||||
parameters?: {
|
||||
collectionSlug?: string | null
|
||||
operation?: string | null
|
||||
webhookPath?: string | null
|
||||
cronExpression?: string | null
|
||||
timezone?: string | null
|
||||
global?: string | null
|
||||
globalOperation?: string | null
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
[key: string]: unknown
|
||||
}> | null
|
||||
steps?: Array<{
|
||||
@@ -42,6 +50,30 @@ export interface ExecutionContext {
|
||||
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
|
||||
@@ -987,11 +1019,14 @@ export class WorkflowExecutor {
|
||||
for (const workflow of workflows.docs) {
|
||||
// Check if this workflow has a matching trigger
|
||||
const triggers = workflow.triggers as Array<{
|
||||
collection?: string
|
||||
collectionSlug?: string
|
||||
condition?: string
|
||||
operation: string
|
||||
type: string
|
||||
parameters?: {
|
||||
collection?: string
|
||||
collectionSlug?: string
|
||||
operation?: string
|
||||
[key: string]: any
|
||||
}
|
||||
}>
|
||||
|
||||
this.logger.debug({
|
||||
@@ -1000,16 +1035,16 @@ export class WorkflowExecutor {
|
||||
triggerCount: triggers?.length || 0,
|
||||
triggers: triggers?.map(t => ({
|
||||
type: t.type,
|
||||
collection: t.collection,
|
||||
collectionSlug: t.collectionSlug,
|
||||
operation: t.operation
|
||||
collection: t.parameters?.collection,
|
||||
collectionSlug: t.parameters?.collectionSlug,
|
||||
operation: t.parameters?.operation
|
||||
}))
|
||||
}, 'Checking workflow triggers')
|
||||
|
||||
const matchingTriggers = triggers?.filter(trigger =>
|
||||
trigger.type === 'collection-trigger' &&
|
||||
(trigger.collection === collection || trigger.collectionSlug === collection) &&
|
||||
trigger.operation === operation
|
||||
(trigger.parameters?.collection === collection || trigger.parameters?.collectionSlug === collection) &&
|
||||
trigger.parameters?.operation === operation
|
||||
) || []
|
||||
|
||||
this.logger.info({
|
||||
@@ -1026,9 +1061,9 @@ export class WorkflowExecutor {
|
||||
workflowName: workflow.name,
|
||||
triggerDetails: {
|
||||
type: trigger.type,
|
||||
collection: trigger.collection,
|
||||
collectionSlug: trigger.collectionSlug,
|
||||
operation: trigger.operation,
|
||||
collection: trigger.parameters?.collection,
|
||||
collectionSlug: trigger.parameters?.collectionSlug,
|
||||
operation: trigger.parameters?.operation,
|
||||
hasCondition: !!trigger.condition
|
||||
}
|
||||
}, 'Processing matching trigger - about to execute workflow')
|
||||
|
||||
38
src/exports/helpers.ts
Normal file
38
src/exports/helpers.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Trigger builder helpers for creating custom triggers with less boilerplate
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createTrigger, createTriggerField, webhookTrigger } from '@xtr-dev/payload-automation/helpers'
|
||||
*
|
||||
* // Simple trigger with array of fields
|
||||
* const myTrigger = createTrigger('my-trigger', [
|
||||
* { name: 'apiKey', type: 'text', required: true },
|
||||
* { name: 'timeout', type: 'number', defaultValue: 30 }
|
||||
* ])
|
||||
*
|
||||
* // Single field with virtual storage
|
||||
* const field = createTriggerField(
|
||||
* { name: 'webhookUrl', type: 'text', required: true },
|
||||
* 'my-trigger'
|
||||
* )
|
||||
*
|
||||
* // Webhook trigger preset
|
||||
* const orderWebhook = webhookTrigger('order-webhook')
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Core helpers
|
||||
export {
|
||||
createTriggerField,
|
||||
createTrigger
|
||||
} from '../utils/trigger-helpers.js'
|
||||
|
||||
// Preset builders
|
||||
export {
|
||||
webhookTrigger,
|
||||
cronTrigger,
|
||||
eventTrigger,
|
||||
manualTrigger,
|
||||
apiTrigger
|
||||
} from '../utils/trigger-presets.js'
|
||||
16
src/index.ts
16
src/index.ts
@@ -1,20 +1,20 @@
|
||||
// Main export contains only types and client-safe utilities
|
||||
// Server-side functions are exported via '@xtr-dev/payload-automation/server'
|
||||
|
||||
// Pure types only - completely safe for client bundling
|
||||
export type {
|
||||
CustomTriggerOptions,
|
||||
TriggerResult,
|
||||
ExecutionContext,
|
||||
WorkflowsPluginConfig
|
||||
} from './types/index.js'
|
||||
|
||||
export type {
|
||||
PayloadWorkflow as Workflow,
|
||||
WorkflowStep,
|
||||
WorkflowTrigger
|
||||
} from './core/workflow-executor.js'
|
||||
|
||||
// Pure types only - completely safe for client bundling
|
||||
export type {
|
||||
CustomTriggerOptions,
|
||||
ExecutionContext,
|
||||
TriggerResult,
|
||||
WorkflowsPluginConfig
|
||||
} from './types/index.js'
|
||||
|
||||
// Server-side functions are NOT re-exported here to avoid bundling issues
|
||||
// Import server-side functions from the /server export instead
|
||||
|
||||
|
||||
@@ -54,14 +54,17 @@ export function generateCronTasks(config: Config): void {
|
||||
// Find the matching cron trigger and check its condition if present
|
||||
const triggers = workflow.triggers as Array<{
|
||||
condition?: string
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
parameters?: {
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
[key: string]: any
|
||||
}
|
||||
type: string
|
||||
}>
|
||||
|
||||
const matchingTrigger = triggers?.find(trigger =>
|
||||
trigger.type === 'cron-trigger' &&
|
||||
trigger.cronExpression === cronExpression
|
||||
trigger.parameters?.cronExpression === cronExpression
|
||||
)
|
||||
|
||||
// Check trigger condition if present
|
||||
@@ -183,8 +186,11 @@ export async function registerCronJobs(payload: Payload, logger: Payload['logger
|
||||
|
||||
for (const workflow of workflows.docs) {
|
||||
const triggers = workflow.triggers as Array<{
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
parameters?: {
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
[key: string]: any
|
||||
}
|
||||
type: string
|
||||
}>
|
||||
|
||||
@@ -192,12 +198,12 @@ export async function registerCronJobs(payload: Payload, logger: Payload['logger
|
||||
const cronTriggers = triggers?.filter(t => t.type === 'cron-trigger') || []
|
||||
|
||||
for (const trigger of cronTriggers) {
|
||||
if (trigger.cronExpression) {
|
||||
if (trigger.parameters?.cronExpression) {
|
||||
try {
|
||||
// Validate cron expression before queueing
|
||||
if (!validateCronExpression(trigger.cronExpression)) {
|
||||
if (!validateCronExpression(trigger.parameters.cronExpression)) {
|
||||
logger.error({
|
||||
cronExpression: trigger.cronExpression,
|
||||
cronExpression: trigger.parameters.cronExpression,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Invalid cron expression format')
|
||||
@@ -205,13 +211,13 @@ export async function registerCronJobs(payload: Payload, logger: Payload['logger
|
||||
}
|
||||
|
||||
// Validate timezone if provided
|
||||
if (trigger.timezone) {
|
||||
if (trigger.parameters?.timezone) {
|
||||
try {
|
||||
// Test if timezone is valid by trying to create a date with it
|
||||
new Intl.DateTimeFormat('en', { timeZone: trigger.timezone })
|
||||
new Intl.DateTimeFormat('en', { timeZone: trigger.parameters.timezone })
|
||||
} catch {
|
||||
logger.error({
|
||||
timezone: trigger.timezone,
|
||||
timezone: trigger.parameters.timezone,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Invalid timezone specified')
|
||||
@@ -220,27 +226,27 @@ export async function registerCronJobs(payload: Payload, logger: Payload['logger
|
||||
}
|
||||
|
||||
// Calculate next execution time
|
||||
const nextExecution = getNextCronTime(trigger.cronExpression, trigger.timezone)
|
||||
const nextExecution = getNextCronTime(trigger.parameters.cronExpression, trigger.parameters?.timezone)
|
||||
|
||||
// Queue the job
|
||||
await payload.jobs.queue({
|
||||
input: { cronExpression: trigger.cronExpression, timezone: trigger.timezone, workflowId: workflow.id },
|
||||
input: { cronExpression: trigger.parameters.cronExpression, timezone: trigger.parameters?.timezone, workflowId: workflow.id },
|
||||
task: 'workflow-cron-executor',
|
||||
waitUntil: nextExecution
|
||||
})
|
||||
|
||||
logger.info({
|
||||
cronExpression: trigger.cronExpression,
|
||||
cronExpression: trigger.parameters.cronExpression,
|
||||
nextExecution: nextExecution.toISOString(),
|
||||
timezone: trigger.timezone || 'UTC',
|
||||
timezone: trigger.parameters?.timezone || 'UTC',
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Queued initial cron job for workflow')
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
cronExpression: trigger.cronExpression,
|
||||
cronExpression: trigger.parameters.cronExpression,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timezone: trigger.timezone,
|
||||
timezone: trigger.parameters?.timezone,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Failed to queue cron job')
|
||||
@@ -508,8 +514,11 @@ export async function updateWorkflowCronJobs(
|
||||
}
|
||||
|
||||
const triggers = workflow.triggers as Array<{
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
parameters?: {
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
[key: string]: any
|
||||
}
|
||||
type: string
|
||||
}>
|
||||
|
||||
@@ -524,12 +533,12 @@ export async function updateWorkflowCronJobs(
|
||||
let scheduledJobs = 0
|
||||
|
||||
for (const trigger of cronTriggers) {
|
||||
if (trigger.cronExpression) {
|
||||
if (trigger.parameters?.cronExpression) {
|
||||
try {
|
||||
// Validate cron expression before queueing
|
||||
if (!validateCronExpression(trigger.cronExpression)) {
|
||||
if (!validateCronExpression(trigger.parameters.cronExpression)) {
|
||||
logger.error({
|
||||
cronExpression: trigger.cronExpression,
|
||||
cronExpression: trigger.parameters.cronExpression,
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Invalid cron expression format')
|
||||
@@ -537,12 +546,12 @@ export async function updateWorkflowCronJobs(
|
||||
}
|
||||
|
||||
// Validate timezone if provided
|
||||
if (trigger.timezone) {
|
||||
if (trigger.parameters?.timezone) {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en', { timeZone: trigger.timezone })
|
||||
new Intl.DateTimeFormat('en', { timeZone: trigger.parameters.timezone })
|
||||
} catch {
|
||||
logger.error({
|
||||
timezone: trigger.timezone,
|
||||
timezone: trigger.parameters.timezone,
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Invalid timezone specified')
|
||||
@@ -551,11 +560,11 @@ export async function updateWorkflowCronJobs(
|
||||
}
|
||||
|
||||
// Calculate next execution time
|
||||
const nextExecution = getNextCronTime(trigger.cronExpression, trigger.timezone)
|
||||
const nextExecution = getNextCronTime(trigger.parameters.cronExpression, trigger.parameters?.timezone)
|
||||
|
||||
// Queue the job
|
||||
await payload.jobs.queue({
|
||||
input: { cronExpression: trigger.cronExpression, timezone: trigger.timezone, workflowId },
|
||||
input: { cronExpression: trigger.parameters.cronExpression, timezone: trigger.parameters?.timezone, workflowId },
|
||||
task: 'workflow-cron-executor',
|
||||
waitUntil: nextExecution
|
||||
})
|
||||
@@ -563,17 +572,17 @@ export async function updateWorkflowCronJobs(
|
||||
scheduledJobs++
|
||||
|
||||
logger.info({
|
||||
cronExpression: trigger.cronExpression,
|
||||
cronExpression: trigger.parameters.cronExpression,
|
||||
nextExecution: nextExecution.toISOString(),
|
||||
timezone: trigger.timezone || 'UTC',
|
||||
timezone: trigger.parameters?.timezone || 'UTC',
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Scheduled cron job for workflow')
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
cronExpression: trigger.cronExpression,
|
||||
cronExpression: trigger.parameters?.cronExpression,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timezone: trigger.timezone,
|
||||
timezone: trigger.parameters?.timezone,
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Failed to schedule cron job')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {Config} from 'payload'
|
||||
|
||||
import type {WorkflowsPluginConfig, CollectionTriggerConfigCrud} from "./config-types.js"
|
||||
import type {CollectionTriggerConfigCrud, WorkflowsPluginConfig} from "./config-types.js"
|
||||
|
||||
import {createWorkflowCollection} from '../collections/Workflow.js'
|
||||
import {WorkflowRunsCollection} from '../collections/WorkflowRuns.js'
|
||||
@@ -17,22 +17,22 @@ export {getLogger} from './logger.js'
|
||||
|
||||
// Improved executor registry with proper error handling and logging
|
||||
interface ExecutorRegistry {
|
||||
executor: WorkflowExecutor | null
|
||||
logger: any | null
|
||||
executor: null | WorkflowExecutor
|
||||
isInitialized: boolean
|
||||
logger: any | null
|
||||
}
|
||||
|
||||
const executorRegistry: ExecutorRegistry = {
|
||||
executor: null,
|
||||
logger: null,
|
||||
isInitialized: false
|
||||
isInitialized: false,
|
||||
logger: null
|
||||
}
|
||||
|
||||
const setWorkflowExecutor = (executor: WorkflowExecutor, logger: any) => {
|
||||
executorRegistry.executor = executor
|
||||
executorRegistry.logger = logger
|
||||
executorRegistry.isInitialized = true
|
||||
|
||||
|
||||
logger.info('Workflow executor initialized and registered successfully')
|
||||
}
|
||||
|
||||
@@ -47,68 +47,68 @@ const createFailedWorkflowRun = async (args: any, errorMessage: string, logger:
|
||||
if (!args?.req?.payload || !args?.collection?.slug) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Find workflows that should have been triggered
|
||||
const workflows = await args.req.payload.find({
|
||||
collection: 'workflows',
|
||||
limit: 10,
|
||||
req: args.req,
|
||||
where: {
|
||||
'triggers.type': {
|
||||
equals: 'collection-trigger'
|
||||
},
|
||||
'triggers.collectionSlug': {
|
||||
equals: args.collection.slug
|
||||
},
|
||||
'triggers.operation': {
|
||||
equals: args.operation
|
||||
},
|
||||
'triggers.type': {
|
||||
equals: 'collection-trigger'
|
||||
}
|
||||
},
|
||||
limit: 10,
|
||||
req: args.req
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// Create failed workflow runs for each matching workflow
|
||||
for (const workflow of workflows.docs) {
|
||||
await args.req.payload.create({
|
||||
collection: 'workflow-runs',
|
||||
data: {
|
||||
workflow: workflow.id,
|
||||
workflowVersion: 1,
|
||||
status: 'failed',
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
error: `Hook execution failed: ${errorMessage}`,
|
||||
triggeredBy: args?.req?.user?.email || 'system',
|
||||
context: {
|
||||
steps: {},
|
||||
trigger: {
|
||||
type: 'collection',
|
||||
collection: args.collection.slug,
|
||||
operation: args.operation,
|
||||
doc: args.doc,
|
||||
operation: args.operation,
|
||||
previousDoc: args.previousDoc,
|
||||
triggeredAt: new Date().toISOString()
|
||||
},
|
||||
steps: {}
|
||||
}
|
||||
},
|
||||
error: `Hook execution failed: ${errorMessage}`,
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
steps: [],
|
||||
logs: [{
|
||||
level: 'error',
|
||||
message: `Hook execution failed: ${errorMessage}`,
|
||||
timestamp: new Date().toISOString()
|
||||
}]
|
||||
}],
|
||||
outputs: {},
|
||||
startedAt: new Date().toISOString(),
|
||||
status: 'failed',
|
||||
steps: [],
|
||||
triggeredBy: args?.req?.user?.email || 'system',
|
||||
workflow: workflow.id,
|
||||
workflowVersion: 1
|
||||
},
|
||||
req: args.req
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
if (workflows.docs.length > 0) {
|
||||
logger.info({
|
||||
workflowCount: workflows.docs.length,
|
||||
errorMessage
|
||||
errorMessage,
|
||||
workflowCount: workflows.docs.length
|
||||
}, 'Created failed workflow runs for hook execution error')
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
// Don't let workflow run creation failures break the original operation
|
||||
logger.warn({
|
||||
@@ -141,26 +141,26 @@ export const workflowsPlugin =
|
||||
}
|
||||
|
||||
applyCollectionsConfig<TSlug>(pluginOptions, config)
|
||||
|
||||
|
||||
// CRITICAL: Modify existing collection configs BEFORE PayloadCMS processes them
|
||||
// This is the ONLY time we can add hooks that will actually work
|
||||
const logger = getConfigLogger()
|
||||
logger.info('Attempting to modify collection configs before PayloadCMS initialization...')
|
||||
|
||||
|
||||
if (config.collections && pluginOptions.collectionTriggers) {
|
||||
for (const [triggerSlug, triggerConfig] of Object.entries(pluginOptions.collectionTriggers)) {
|
||||
if (!triggerConfig) continue
|
||||
|
||||
if (!triggerConfig) {continue}
|
||||
|
||||
// Find the collection config that matches
|
||||
const collectionIndex = config.collections.findIndex(c => c.slug === triggerSlug)
|
||||
if (collectionIndex === -1) {
|
||||
logger.warn(`Collection '${triggerSlug}' not found in config.collections`)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
const collection = config.collections[collectionIndex]
|
||||
logger.info(`Found collection '${triggerSlug}' - modifying its hooks...`)
|
||||
|
||||
|
||||
// Initialize hooks if needed
|
||||
if (!collection.hooks) {
|
||||
collection.hooks = {}
|
||||
@@ -168,35 +168,35 @@ export const workflowsPlugin =
|
||||
if (!collection.hooks.afterChange) {
|
||||
collection.hooks.afterChange = []
|
||||
}
|
||||
|
||||
|
||||
// Create a reliable hook function with proper dependency injection
|
||||
const automationHook = Object.assign(
|
||||
async function payloadAutomationHook(args: any) {
|
||||
const registry = getExecutorRegistry()
|
||||
|
||||
|
||||
// Use proper logger if available, fallback to args.req.payload.logger
|
||||
const logger = registry.logger || args?.req?.payload?.logger || console
|
||||
|
||||
|
||||
try {
|
||||
logger.info({
|
||||
collection: args?.collection?.slug,
|
||||
operation: args?.operation,
|
||||
docId: args?.doc?.id,
|
||||
hookType: 'automation'
|
||||
hookType: 'automation',
|
||||
operation: args?.operation
|
||||
}, 'Collection automation hook triggered')
|
||||
|
||||
|
||||
if (!registry.isInitialized) {
|
||||
logger.warn('Workflow executor not yet initialized, skipping execution')
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
if (!registry.executor) {
|
||||
logger.error('Workflow executor is null despite being marked as initialized')
|
||||
// Create a failed workflow run to track this issue
|
||||
await createFailedWorkflowRun(args, 'Executor not available', logger)
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
logger.debug('Executing triggered workflows...')
|
||||
await registry.executor.executeTriggeredWorkflows(
|
||||
args.collection.slug,
|
||||
@@ -205,24 +205,24 @@ export const workflowsPlugin =
|
||||
args.previousDoc,
|
||||
args.req
|
||||
)
|
||||
|
||||
|
||||
logger.info({
|
||||
collection: args?.collection?.slug,
|
||||
operation: args?.operation,
|
||||
docId: args?.doc?.id
|
||||
docId: args?.doc?.id,
|
||||
operation: args?.operation
|
||||
}, 'Workflow execution completed successfully')
|
||||
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
|
||||
logger.error({
|
||||
collection: args?.collection?.slug,
|
||||
docId: args?.doc?.id,
|
||||
error: errorMessage,
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
collection: args?.collection?.slug,
|
||||
operation: args?.operation,
|
||||
docId: args?.doc?.id
|
||||
operation: args?.operation
|
||||
}, 'Hook execution failed')
|
||||
|
||||
|
||||
// Create a failed workflow run to track this error
|
||||
try {
|
||||
await createFailedWorkflowRun(args, errorMessage, logger)
|
||||
@@ -231,10 +231,10 @@ export const workflowsPlugin =
|
||||
error: createError instanceof Error ? createError.message : 'Unknown error'
|
||||
}, 'Failed to create workflow run for hook error')
|
||||
}
|
||||
|
||||
|
||||
// Don't throw to prevent breaking the original operation
|
||||
}
|
||||
|
||||
|
||||
return undefined
|
||||
},
|
||||
{
|
||||
@@ -242,7 +242,7 @@ export const workflowsPlugin =
|
||||
__version: '0.0.22'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
// Add the hook to the collection config
|
||||
collection.hooks.afterChange.push(automationHook)
|
||||
logger.info(`Added automation hook to '${triggerSlug}' - hook count: ${collection.hooks.afterChange.length}`)
|
||||
@@ -275,7 +275,7 @@ export const workflowsPlugin =
|
||||
const incomingOnInit = config.onInit
|
||||
config.onInit = async (payload) => {
|
||||
configLogger.info(`onInit called - collections: ${Object.keys(payload.collections).length}`)
|
||||
|
||||
|
||||
// Execute any existing onInit functions first
|
||||
if (incomingOnInit) {
|
||||
configLogger.debug('Executing existing onInit function')
|
||||
@@ -294,19 +294,19 @@ export const workflowsPlugin =
|
||||
const executor = new WorkflowExecutor(payload, logger)
|
||||
console.log('🚨 EXECUTOR CREATED:', typeof executor)
|
||||
console.log('🚨 EXECUTOR METHODS:', Object.getOwnPropertyNames(Object.getPrototypeOf(executor)))
|
||||
|
||||
|
||||
// Register executor with proper dependency injection
|
||||
setWorkflowExecutor(executor, logger)
|
||||
|
||||
// Hooks are now registered during config phase - just log status
|
||||
logger.info('Hooks were registered during config phase - executor now available')
|
||||
|
||||
|
||||
logger.info('Initializing global hooks...')
|
||||
initGlobalHooks(payload, logger, executor)
|
||||
|
||||
|
||||
logger.info('Initializing workflow hooks...')
|
||||
initWorkflowHooks(payload, logger)
|
||||
|
||||
|
||||
logger.info('Initializing step tasks...')
|
||||
initStepTasks(pluginOptions, payload, logger)
|
||||
|
||||
|
||||
@@ -67,12 +67,15 @@ export function initWebhookEndpoint(config: Config, webhookPrefix = 'webhook'):
|
||||
const triggers = workflow.triggers as Array<{
|
||||
condition?: string
|
||||
type: string
|
||||
webhookPath?: string
|
||||
parameters?: {
|
||||
webhookPath?: string
|
||||
[key: string]: any
|
||||
}
|
||||
}>
|
||||
|
||||
const matchingTrigger = triggers?.find(trigger =>
|
||||
trigger.type === 'webhook-trigger' &&
|
||||
trigger.webhookPath === path
|
||||
trigger.parameters?.webhookPath === path
|
||||
)
|
||||
|
||||
// Check trigger condition if present
|
||||
|
||||
@@ -19,6 +19,8 @@ interface HttpRequestInput {
|
||||
}
|
||||
|
||||
export const httpStepHandler: TaskHandler<'http-request-step'> = async ({input, req}) => {
|
||||
const startTime = Date.now() // Move startTime to outer scope
|
||||
|
||||
try {
|
||||
if (!input || !input.url) {
|
||||
return {
|
||||
@@ -36,7 +38,6 @@ export const httpStepHandler: TaskHandler<'http-request-step'> = async ({input,
|
||||
}
|
||||
|
||||
const typedInput = input as HttpRequestInput
|
||||
const startTime = Date.now()
|
||||
|
||||
// Validate URL
|
||||
try {
|
||||
@@ -260,7 +261,7 @@ export const httpStepHandler: TaskHandler<'http-request-step'> = async ({input,
|
||||
req?.payload?.logger?.error({
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
input: typedInput?.url || 'unknown'
|
||||
input: (input as any)?.url || 'unknown'
|
||||
}, 'Unexpected error in HTTP request handler')
|
||||
|
||||
return {
|
||||
@@ -270,7 +271,7 @@ export const httpStepHandler: TaskHandler<'http-request-step'> = async ({input,
|
||||
headers: {},
|
||||
body: '',
|
||||
data: null,
|
||||
duration: Date.now() - (startTime || Date.now()),
|
||||
duration: Date.now() - startTime,
|
||||
error: `HTTP request handler error: ${error.message}`
|
||||
},
|
||||
state: 'failed'
|
||||
|
||||
158
src/utils/trigger-helpers.ts
Normal file
158
src/utils/trigger-helpers.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { Field } from 'payload'
|
||||
|
||||
import type { CustomTriggerConfig } from '../plugin/config-types.js'
|
||||
|
||||
// Types for better type safety
|
||||
interface FieldWithName {
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface HookContext {
|
||||
siblingData: Record<string, unknown>
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
interface ValidationContext {
|
||||
siblingData: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a virtual field for a trigger parameter that stores its value in the parameters JSON field
|
||||
*
|
||||
* @param field - Standard PayloadCMS field configuration (must be a data field with a name)
|
||||
* @param triggerSlug - The slug of the trigger this field belongs to
|
||||
* @returns Modified field with virtual storage hooks and proper naming
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const myTrigger: CustomTriggerConfig = {
|
||||
* slug: 'my-trigger',
|
||||
* inputs: [
|
||||
* createTriggerField({
|
||||
* name: 'webhookUrl',
|
||||
* type: 'text',
|
||||
* required: true,
|
||||
* admin: {
|
||||
* description: 'URL to call when triggered'
|
||||
* }
|
||||
* }, 'my-trigger')
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function createTriggerField(field: FieldWithName, triggerSlug: string): Field {
|
||||
const originalName = field.name
|
||||
if (!originalName) {
|
||||
throw new Error('Field must have a name property')
|
||||
}
|
||||
|
||||
// Create a unique field name by prefixing with trigger slug
|
||||
const uniqueFieldName = `__trigger_${triggerSlug}_${originalName}`
|
||||
|
||||
const resultField: Record<string, unknown> = {
|
||||
...field,
|
||||
admin: {
|
||||
...(field.admin as Record<string, unknown> || {}),
|
||||
condition: (data: unknown, siblingData: Record<string, unknown>) => {
|
||||
// Only show this field when the trigger type matches
|
||||
const triggerMatches = siblingData?.type === triggerSlug
|
||||
|
||||
// If the original field had a condition, combine it with our trigger condition
|
||||
const originalCondition = (field.admin as Record<string, unknown>)?.condition
|
||||
if (originalCondition && typeof originalCondition === 'function') {
|
||||
return triggerMatches && (originalCondition as (data: unknown, siblingData: Record<string, unknown>) => boolean)(data, siblingData)
|
||||
}
|
||||
|
||||
return triggerMatches
|
||||
}
|
||||
},
|
||||
hooks: {
|
||||
...(field.hooks as Record<string, unknown[]> || {}),
|
||||
afterRead: [
|
||||
...((field.hooks as Record<string, unknown[]>)?.afterRead || []),
|
||||
({ siblingData }: HookContext) => {
|
||||
// Read the value from the parameters JSON field
|
||||
const parameters = siblingData?.parameters as Record<string, unknown>
|
||||
return parameters?.[originalName] ?? (field as Record<string, unknown>).defaultValue
|
||||
}
|
||||
],
|
||||
beforeChange: [
|
||||
...((field.hooks as Record<string, unknown[]>)?.beforeChange || []),
|
||||
({ siblingData, value }: HookContext) => {
|
||||
// Store the value in the parameters JSON field
|
||||
if (!siblingData.parameters) {
|
||||
siblingData.parameters = {}
|
||||
}
|
||||
const parameters = siblingData.parameters as Record<string, unknown>
|
||||
parameters[originalName] = value
|
||||
return undefined // Virtual field, don't store directly
|
||||
}
|
||||
]
|
||||
},
|
||||
name: uniqueFieldName,
|
||||
virtual: true,
|
||||
}
|
||||
|
||||
// Only add validate if the field supports it (data fields)
|
||||
const hasValidation = (field as Record<string, unknown>).validate || (field as Record<string, unknown>).required
|
||||
if (hasValidation) {
|
||||
resultField.validate = (value: unknown, args: ValidationContext) => {
|
||||
const parameters = args.siblingData?.parameters as Record<string, unknown>
|
||||
const paramValue = value ?? parameters?.[originalName]
|
||||
|
||||
// Check required validation
|
||||
const isRequired = (field as Record<string, unknown>).required
|
||||
if (isRequired && args.siblingData?.type === triggerSlug && !paramValue) {
|
||||
const fieldLabel = (field as Record<string, unknown>).label as string
|
||||
const adminDesc = ((field as Record<string, unknown>).admin as Record<string, unknown>)?.description as string
|
||||
const label = fieldLabel || adminDesc || originalName
|
||||
return `${label} is required for ${triggerSlug}`
|
||||
}
|
||||
|
||||
// Run original validation if present
|
||||
const originalValidate = (field as Record<string, unknown>).validate
|
||||
if (originalValidate && typeof originalValidate === 'function') {
|
||||
return (originalValidate as (value: unknown, args: ValidationContext) => boolean | string)(paramValue, args)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return resultField as Field
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a custom trigger configuration with the provided fields
|
||||
*
|
||||
* @param slug - Unique identifier for the trigger
|
||||
* @param fields - Array of PayloadCMS fields that will be shown as trigger parameters
|
||||
* @returns Complete trigger configuration
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const webhookTrigger = createTrigger('webhook', [
|
||||
* {
|
||||
* name: 'url',
|
||||
* type: 'text',
|
||||
* required: true,
|
||||
* admin: {
|
||||
* description: 'Webhook URL'
|
||||
* }
|
||||
* },
|
||||
* {
|
||||
* name: 'method',
|
||||
* type: 'select',
|
||||
* options: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
* defaultValue: 'POST'
|
||||
* }
|
||||
* ])
|
||||
* ```
|
||||
*/
|
||||
export function createTrigger(slug: string, fields: FieldWithName[]): CustomTriggerConfig {
|
||||
return {
|
||||
slug,
|
||||
inputs: fields.map(field => createTriggerField(field, slug))
|
||||
}
|
||||
}
|
||||
157
src/utils/trigger-presets.ts
Normal file
157
src/utils/trigger-presets.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { createTrigger } from './trigger-helpers.js'
|
||||
import type { CustomTriggerConfig } from '../plugin/config-types.js'
|
||||
|
||||
/**
|
||||
* Preset trigger builders for common patterns
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a webhook trigger with common webhook parameters pre-configured
|
||||
*/
|
||||
export function webhookTrigger(slug: string): CustomTriggerConfig {
|
||||
return createTrigger(slug, [
|
||||
{
|
||||
name: 'path',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'URL path for the webhook endpoint (e.g., "my-webhook")'
|
||||
},
|
||||
validate: (value: any) => {
|
||||
if (typeof value === 'string' && value.includes(' ')) {
|
||||
return 'Webhook path cannot contain spaces'
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'secret',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Secret key for webhook signature validation (optional but recommended)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'headers',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Expected HTTP headers for validation (JSON object)'
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scheduled/cron trigger with timing parameters pre-configured
|
||||
*/
|
||||
export function cronTrigger(slug: string): CustomTriggerConfig {
|
||||
return createTrigger(slug, [
|
||||
{
|
||||
name: 'expression',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Cron expression for scheduling (e.g., "0 9 * * 1" for every Monday at 9 AM)',
|
||||
placeholder: '0 9 * * 1'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'timezone',
|
||||
type: 'text',
|
||||
defaultValue: 'UTC',
|
||||
admin: {
|
||||
description: 'Timezone for cron execution (e.g., "America/New_York", "Europe/London")',
|
||||
placeholder: 'UTC'
|
||||
},
|
||||
validate: (value: any) => {
|
||||
if (value) {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en', { timeZone: value as string })
|
||||
return true
|
||||
} catch {
|
||||
return `Invalid timezone: ${value}. Please use a valid IANA timezone identifier`
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event-driven trigger with event filtering parameters
|
||||
*/
|
||||
export function eventTrigger(slug: string): CustomTriggerConfig {
|
||||
return createTrigger(slug, [
|
||||
{
|
||||
name: 'eventTypes',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: [
|
||||
{ label: 'User Created', value: 'user.created' },
|
||||
{ label: 'User Updated', value: 'user.updated' },
|
||||
{ label: 'Document Published', value: 'document.published' },
|
||||
{ label: 'Payment Completed', value: 'payment.completed' }
|
||||
],
|
||||
admin: {
|
||||
description: 'Event types that should trigger this workflow'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'filters',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'JSON filters to apply to event data (e.g., {"status": "active"})'
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple manual trigger (no parameters needed)
|
||||
*/
|
||||
export function manualTrigger(slug: string): CustomTriggerConfig {
|
||||
return {
|
||||
slug,
|
||||
inputs: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an API trigger for external systems to call
|
||||
*/
|
||||
export function apiTrigger(slug: string): CustomTriggerConfig {
|
||||
return createTrigger(slug, [
|
||||
{
|
||||
name: 'endpoint',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'API endpoint path (e.g., "/api/triggers/my-trigger")'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'method',
|
||||
type: 'select',
|
||||
options: ['GET', 'POST', 'PUT', 'PATCH'],
|
||||
defaultValue: 'POST',
|
||||
admin: {
|
||||
description: 'HTTP method for the API endpoint'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'authentication',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'API Key', value: 'api-key' },
|
||||
{ label: 'Bearer Token', value: 'bearer' },
|
||||
{ label: 'Basic Auth', value: 'basic' }
|
||||
],
|
||||
defaultValue: 'api-key',
|
||||
admin: {
|
||||
description: 'Authentication method for the API endpoint'
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
@@ -31,4 +31,8 @@
|
||||
"./src/**/*.tsx",
|
||||
"./dev/next-env.d.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"./src/test",
|
||||
"./test-results"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user