mirror of
https://github.com/xtr-dev/payload-automation.git
synced 2025-12-10 17:03:22 +00:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d0b83cfa3 | ||
| 067b96a5a7 | |||
| cfc716fc78 | |||
|
|
de7589d431 | ||
|
|
5be4f82ebb | ||
|
|
13968904c0 | ||
|
|
e3b79710ba | ||
| 718f5fe16b | |||
| 10a4ca1b35 | |||
| 4c513aa730 | |||
| f29f08972b | |||
| 9c75b28cd7 | |||
| 243bff2de3 | |||
| 705ed331fa | |||
| e0b13d3515 | |||
| 0da87dbda7 | |||
| 508f4c418a | |||
| 069de012ea | |||
| acdfa411e4 | |||
| 0f741acf73 | |||
| 435f9b0c69 | |||
| cda349846a | |||
| b18e2eaf49 | |||
| 9a3b94ef60 | |||
| 8f0ee4bcef | |||
| 449b80e162 | |||
| 25d42b4653 | |||
| 73c8c20c4b | |||
| e138176878 | |||
| 6245a71516 | |||
| 59a97e519e | |||
| b3d2877f0a | |||
| c050ee835a | |||
| 1f80028042 | |||
| 14d1ecf036 | |||
| 3749881d5f | |||
| c46b58f43e | |||
| 398a2d160e | |||
| 96b36a3caa | |||
| 71ecca8253 | |||
| 8eedaba9ed | |||
| 2bc01f30f8 | |||
| 3e9ff10076 | |||
| e204d1241a | |||
| 0fb23cb425 | |||
| 45c5847f5a | |||
| a8ae877039 | |||
| b7b40c400b | |||
| ab5b26c42c | |||
| c47197223c | |||
| 0a036752ea | |||
| 74217d532d | |||
| 04100787d7 | |||
| 253de9b8b0 | |||
| 397559079f | |||
| c352da91fa | |||
| d6aedbc59d | |||
| cd85f90ef1 | |||
| 38fbb1922a | |||
| dfcc5c0fce | |||
| 089e12ac7a |
58
.claude/agents/source-docs-generator.md
Normal file
58
.claude/agents/source-docs-generator.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: source-docs-generator
|
||||
description: Use this agent when you need to generate or update documentation files for source code. Examples: <example>Context: User wants to document their codebase by creating .md files for each source file. user: 'I need documentation for all my source files in the src directory' assistant: 'I'll use the source-docs-generator agent to create documentation files for your source code' <commentary>The user is requesting documentation generation for source files, which is exactly what the source-docs-generator agent is designed for.</commentary></example> <example>Context: User has added new source files and wants documentation updated. user: 'Can you update the docs for the new files I added to src/components?' assistant: 'I'll use the source-docs-generator agent to check for new or updated source files and generate corresponding documentation' <commentary>The agent will check existing docs and only update what's needed.</commentary></example>
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
You are a Source Code Documentation Generator, an expert technical writer specializing in creating clear, comprehensive documentation for source code files. Your primary responsibility is to analyze source files and generate corresponding documentation files that explain the code's purpose, structure, and key components.
|
||||
|
||||
Your process for each task:
|
||||
|
||||
1. **Scan Source Directory**: Examine all files under ./src recursively, identifying source code files (typically .ts, .tsx, .js, .jsx, .py, etc.)
|
||||
|
||||
2. **Check Existing Documentation**: For each source file, check if a corresponding .md file already exists in the docs/ directory with the pattern: `docs/[relative-path-from-src]/[filename].[extension].md`
|
||||
|
||||
3. **Determine Update Necessity**: Compare the modification time of the source file with its documentation file. Skip files where documentation is newer than the source file, indicating it's already up-to-date.
|
||||
|
||||
4. **Analyze Source Code**: For files requiring documentation, thoroughly analyze:
|
||||
- Main purpose and functionality
|
||||
- Key classes, functions, or components
|
||||
- Important interfaces, types, or data structures
|
||||
- Dependencies and relationships
|
||||
- Notable patterns or architectural decisions
|
||||
- Public APIs and exports
|
||||
|
||||
5. **Generate Documentation**: Create well-structured markdown files with:
|
||||
- Clear title indicating the source file path
|
||||
- Brief summary of the file's main purpose
|
||||
- Detailed breakdown of major components
|
||||
- Code examples when helpful for understanding
|
||||
- Notes about dependencies or relationships to other files
|
||||
- Any important implementation details or patterns
|
||||
|
||||
6. **Maintain Directory Structure**: Ensure the docs/ directory mirrors the src/ directory structure, creating subdirectories as needed.
|
||||
|
||||
7. **Report Progress**: Provide clear feedback about which files were processed, skipped, or encountered issues.
|
||||
|
||||
Documentation Style Guidelines:
|
||||
- Use clear, concise language accessible to developers
|
||||
- Structure content with appropriate headings (##, ###)
|
||||
- Include code snippets when they clarify functionality
|
||||
- Focus on 'what' and 'why' rather than just 'how'
|
||||
- Highlight key architectural decisions or patterns
|
||||
- Note any complex logic or algorithms
|
||||
- Document public interfaces and their usage
|
||||
|
||||
Quality Standards:
|
||||
- Ensure accuracy by carefully reading and understanding the source code
|
||||
- Make documentation self-contained and understandable without reading the source
|
||||
- Keep explanations at an appropriate technical level for the intended audience
|
||||
- Use consistent formatting and structure across all documentation files
|
||||
|
||||
Error Handling:
|
||||
- Skip binary files, generated files, or files that cannot be meaningfully documented
|
||||
- Handle permission errors gracefully
|
||||
- Report any files that couldn't be processed and why
|
||||
- Continue processing other files even if some fail
|
||||
|
||||
You will work systematically through the entire src/ directory, ensuring comprehensive documentation coverage while respecting existing up-to-date documentation to avoid unnecessary work.
|
||||
78
.github/workflows/claude-code-review.yml
vendored
Normal file
78
.github/workflows/claude-code-review.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
||||
# model: "claude-opus-4-1-20250805"
|
||||
|
||||
# Direct prompt for automated review (no @claude mention needed)
|
||||
direct_prompt: |
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Be constructive and helpful in your feedback.
|
||||
|
||||
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
|
||||
# use_sticky_comment: true
|
||||
|
||||
# Optional: Customize review based on file types
|
||||
# direct_prompt: |
|
||||
# Review this PR focusing on:
|
||||
# - For TypeScript files: Type safety and proper interface usage
|
||||
# - For API endpoints: Security, input validation, and error handling
|
||||
# - For React components: Performance, accessibility, and best practices
|
||||
# - For tests: Coverage, edge cases, and test quality
|
||||
|
||||
# Optional: Different prompts for different authors
|
||||
# direct_prompt: |
|
||||
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
|
||||
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
|
||||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
|
||||
|
||||
# Optional: Add specific tools for running tests or linting
|
||||
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
|
||||
|
||||
# Optional: Skip review for certain conditions
|
||||
# if: |
|
||||
# !contains(github.event.pull_request.title, '[skip-review]') &&
|
||||
# !contains(github.event.pull_request.title, '[WIP]')
|
||||
|
||||
64
.github/workflows/claude.yml
vendored
Normal file
64
.github/workflows/claude.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
||||
# model: "claude-opus-4-1-20250805"
|
||||
|
||||
# Optional: Customize the trigger phrase (default: @claude)
|
||||
# trigger_phrase: "/claude"
|
||||
|
||||
# Optional: Trigger when specific user is assigned to an issue
|
||||
# assignee_trigger: "claude-bot"
|
||||
|
||||
# Optional: Allow Claude to run specific commands
|
||||
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
|
||||
|
||||
# Optional: Add custom instructions for Claude to customize its behavior for your project
|
||||
# custom_instructions: |
|
||||
# Follow our coding standards
|
||||
# Ensure all new code has tests
|
||||
# Use TypeScript for new files
|
||||
|
||||
# Optional: Custom environment variables for Claude
|
||||
# claude_env: |
|
||||
# NODE_ENV: test
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -5,6 +5,58 @@ All notable changes to the PayloadCMS Automation Plugin will be documented in th
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.0.39] - 2025-09-11
|
||||
|
||||
### Changed
|
||||
- **Breaking Change**: Replaced JSONPath with Handlebars template system for better string interpolation
|
||||
- Automatic type conversion for numeric and boolean fields based on field names
|
||||
- Enhanced condition evaluation with Handlebars template support
|
||||
- Simplified data resolution syntax: `{{steps.stepName.output.field}}` instead of `$.steps.stepName.output.field`
|
||||
|
||||
### Removed
|
||||
- **Breaking Change**: Removed JSONPath dependency (`jsonpath-plus`) and all backward compatibility
|
||||
- Removed `resolveJSONPathValue` and `parseConditionValue` methods
|
||||
|
||||
### Added
|
||||
- Handlebars template engine for dynamic data interpolation
|
||||
- Smart type conversion: strings to numbers/booleans based on field patterns
|
||||
- Enhanced template examples and documentation
|
||||
- Support for complex string building: `"Post {{trigger.doc.title}} was updated"`
|
||||
|
||||
### Migration Notes
|
||||
- Update all workflow configurations to use Handlebars syntax:
|
||||
- `$.steps.stepName.output.id` → `{{steps.stepName.output.id}}`
|
||||
- `$.trigger.doc.status == 'published'` → `{{trigger.doc.status}} == 'published'`
|
||||
- String interpolation now works naturally: `"Message: {{steps.step1.output.result}}"`
|
||||
- Numeric fields (`timeout`, `retries`, etc.) are automatically converted from strings to numbers
|
||||
|
||||
## [0.0.38] - 2025-09-10
|
||||
|
||||
### Changed
|
||||
- Updated dependencies to PayloadCMS 3.45.0
|
||||
- Enhanced plugin configuration and stability
|
||||
|
||||
## [0.0.37] - 2025-09-XX
|
||||
|
||||
### Removed
|
||||
- **Breaking Change**: Removed built-in cron trigger implementation in favor of webhook-based scheduling
|
||||
- Removed unused plugin modules and associated tests
|
||||
- Removed `initCollectionHooks` and associated migration guides
|
||||
|
||||
### Changed
|
||||
- Refactored triggers to TriggerConfig pattern
|
||||
- Simplified executor architecture by removing executorRegistry pattern
|
||||
- Updated to on-demand workflow execution creation
|
||||
|
||||
### Added
|
||||
- Migration guide for v0.0.37 (MIGRATION-v0.0.37.md)
|
||||
- Enhanced parameter field configuration
|
||||
|
||||
### Migration Notes
|
||||
- Built-in cron triggers are no longer supported. Use webhook triggers with external cron services (GitHub Actions, Vercel Cron, etc.)
|
||||
- Update trigger configurations to use the new TriggerConfig pattern
|
||||
- See MIGRATION-v0.0.37.md for detailed migration steps
|
||||
|
||||
## [0.0.16] - 2025-09-01
|
||||
|
||||
### Fixed
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -15,9 +15,9 @@ A local copy of the PayloadCMS documentation is available at `./payload-docs/` f
|
||||
### Essential Commands
|
||||
- `pnpm dev` - Start development server with Next.js (runs on http://localhost:3000, fallback ports used if occupied)
|
||||
- `pnpm build` - Build the plugin for production (runs copyfiles, build:types, build:swc)
|
||||
- `pnpm test` - Run all tests (integration + e2e)
|
||||
- `pnpm test:int` - Run integration tests with Vitest
|
||||
- `pnpm test:e2e` - Run end-to-end tests with Playwright
|
||||
- `pnpm test` - Run tests (currently configured for integration and e2e)
|
||||
- `pnpm test:int` - Run integration tests
|
||||
- `pnpm test:e2e` - Run end-to-end tests
|
||||
- `pnpm lint` - Run ESLint
|
||||
- `pnpm lint:fix` - Auto-fix ESLint issues
|
||||
|
||||
@@ -111,14 +111,8 @@ Steps support a `dependencies` field (array of step names) that:
|
||||
|
||||
### Database Configuration
|
||||
- **Development**: SQLite adapter for simplicity
|
||||
- **Testing**: MongoDB Memory Server for isolation
|
||||
- Database selection in `dev/payload.config.ts`
|
||||
|
||||
### Testing Strategy
|
||||
- **Integration Tests** (`dev/int.spec.ts`): Vitest with 30-second timeouts
|
||||
- **E2E Tests** (`dev/e2e.spec.ts`): Playwright testing against development server
|
||||
- **Test Database**: MongoDB Memory Server for isolated testing
|
||||
|
||||
### Plugin Development Pattern
|
||||
- Uses spread syntax to extend existing PayloadCMS config
|
||||
- Maintains database schema consistency when plugin is disabled
|
||||
@@ -169,7 +163,6 @@ The plugin registers hooks for collections and globals specified in the plugin c
|
||||
|
||||
### Key Development Dependencies
|
||||
- Next.js 15.4.4 for development server
|
||||
- Vitest + Playwright for testing
|
||||
- SWC for fast transpilation
|
||||
- Various PayloadCMS adapters (SQLite, MongoDB, PostgreSQL)
|
||||
|
||||
@@ -178,5 +171,4 @@ The plugin registers hooks for collections and globals specified in the plugin c
|
||||
- `src/plugin/index.ts` - Main plugin configuration and extension logic
|
||||
- `src/core/workflow-executor.ts` - Core execution engine with dependency resolution
|
||||
- `src/collections/Workflow.ts` - Workflow collection schema and configuration
|
||||
- `dev/payload.config.ts` - Development configuration showing plugin integration
|
||||
- `dev/int.spec.ts` and `dev/e2e.spec.ts` - Testing patterns and setup
|
||||
- `dev/payload.config.ts` - Development configuration showing plugin integration
|
||||
@@ -1,116 +0,0 @@
|
||||
# 🚨 CRITICAL: Configuration Field Name Issue Found
|
||||
|
||||
## ❌ The Root Problem
|
||||
|
||||
Your plugin configuration is using the **wrong field name**!
|
||||
|
||||
### What You're Probably Using (WRONG):
|
||||
```javascript
|
||||
automationPlugin({
|
||||
collections: ['orders', 'users', 'products'], // ❌ Wrong field name
|
||||
steps: [...],
|
||||
})
|
||||
```
|
||||
|
||||
### What You MUST Use (CORRECT):
|
||||
```javascript
|
||||
automationPlugin({
|
||||
collectionTriggers: { // ✅ Correct field name
|
||||
'orders': true,
|
||||
'users': true,
|
||||
'products': true
|
||||
},
|
||||
steps: [...],
|
||||
})
|
||||
```
|
||||
|
||||
## 🔧 Immediate Fix Required
|
||||
|
||||
**Update your `payload.config.ts` file:**
|
||||
|
||||
```typescript
|
||||
import { automationPlugin } from '@xtr-dev/payload-automation'
|
||||
|
||||
export default buildConfig({
|
||||
// ... your other config
|
||||
|
||||
plugins: [
|
||||
automationPlugin({
|
||||
collectionTriggers: { // ← CHANGE THIS FIELD NAME
|
||||
orders: true, // Enable all hooks (create, read, update, delete)
|
||||
users: true,
|
||||
products: true
|
||||
},
|
||||
steps: [
|
||||
// ... your step configurations
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## 🎯 Why This Fixes Everything
|
||||
|
||||
1. **Hook Registration**: The plugin only registers hooks for collections listed in `collectionTriggers`
|
||||
2. **No Hooks = No Execution**: If `collectionTriggers` is empty/missing, no hooks get registered
|
||||
3. **Silent Failure**: The plugin logs "No collection triggers configured" and returns early
|
||||
|
||||
## 🔍 Advanced Configuration Options
|
||||
|
||||
You can also be more specific about which operations trigger workflows:
|
||||
|
||||
```javascript
|
||||
automationPlugin({
|
||||
collectionTriggers: {
|
||||
orders: {
|
||||
update: true, // Only trigger on updates
|
||||
create: true // Only trigger on creates
|
||||
// read and delete are false by default
|
||||
},
|
||||
users: true // Enable all operations
|
||||
},
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
## ✅ Expected Results After Fix
|
||||
|
||||
Once you update your configuration and restart:
|
||||
|
||||
1. **Plugin logs will show**:
|
||||
```
|
||||
Starting collection hook registration
|
||||
Collection hooks registered successfully - collectionSlug: "orders"
|
||||
```
|
||||
|
||||
2. **Hook counts will be > 0**:
|
||||
```javascript
|
||||
payload.collections.orders.config.hooks.afterChange.length
|
||||
// Should return a number > 0
|
||||
```
|
||||
|
||||
3. **Workflow execution will work**:
|
||||
- "AUTOMATION PLUGIN: Collection hook triggered" messages
|
||||
- Workflow runs created in database
|
||||
- Jobs processing successfully
|
||||
|
||||
## 🆘 If Still Not Working
|
||||
|
||||
If you fix the configuration and it still doesn't work:
|
||||
|
||||
1. **Check your exact collection slugs**:
|
||||
```javascript
|
||||
console.log('Available collections:', Object.keys(payload.collections))
|
||||
```
|
||||
|
||||
2. **Verify case sensitivity**: Collection slugs are case-sensitive
|
||||
- Use exactly what appears in `Object.keys(payload.collections)`
|
||||
|
||||
3. **Restart completely**:
|
||||
- Stop dev server
|
||||
- Clear any caches
|
||||
- Restart with new configuration
|
||||
|
||||
---
|
||||
|
||||
**This configuration field name issue explains why no hooks were being registered, despite the plugin loading successfully. The v0.0.16 bug fix was valid, but this configuration issue was preventing hooks from being registered in the first place.**
|
||||
@@ -1,68 +0,0 @@
|
||||
# Steps and Triggers Not Implementing
|
||||
|
||||
This document lists workflow steps and triggers that are intentionally **not** being implemented in the core plugin. These are either better suited as custom user implementations or fall outside the plugin's scope.
|
||||
|
||||
## Steps Not Implementing
|
||||
|
||||
### Workflow Orchestration
|
||||
- **Stop Workflow** - Can be achieved through conditional logic
|
||||
- **Run Workflow** - Adds complexity to execution tracking and circular dependency management
|
||||
- **Parallel Fork/Join** - Current dependency system already enables parallel execution
|
||||
|
||||
### External Service Integrations
|
||||
- **GraphQL Query** - Better as custom HTTP request step
|
||||
- **S3/Cloud Storage** - Too provider-specific
|
||||
- **Message Queue** (Kafka, RabbitMQ, SQS) - Infrastructure-specific
|
||||
- **SMS** (Twilio, etc.) - Requires external accounts
|
||||
- **Push Notifications** - Platform-specific implementation
|
||||
- **Slack/Discord/Teams** - Better as custom HTTP webhooks
|
||||
- **Calendar Integration** - Too many providers to support
|
||||
|
||||
### AI/ML Operations
|
||||
- **AI Prompt** (OpenAI, Claude, etc.) - Requires API keys, better as custom implementation
|
||||
- **Text Analysis** - Too many variations and providers
|
||||
- **Image Processing** - Better handled by dedicated services
|
||||
|
||||
### Specialized Data Operations
|
||||
- **Database Query** (Direct SQL/NoSQL) - Security concerns, bypasses Payload
|
||||
- **File Operations** - Complex permission and security implications
|
||||
- **Hash/Encrypt** - Security-sensitive, needs careful implementation
|
||||
- **RSS/Feed Processing** - Too specific for core plugin
|
||||
|
||||
## Triggers Not Implementing
|
||||
|
||||
### Workflow Events
|
||||
- **Workflow Complete/Failed** - Adds circular dependency complexity
|
||||
- **Step Failed** - Complicates error handling flow
|
||||
|
||||
### System Events
|
||||
- **File Upload** - Can use collection hooks on media collections
|
||||
- **User Authentication** (Login/Logout) - Security implications
|
||||
- **Server Start/Stop** - Lifecycle management complexity
|
||||
- **Cache Clear** - Too implementation-specific
|
||||
- **Migration/Backup Events** - Infrastructure-specific
|
||||
|
||||
### External Monitoring
|
||||
- **Email Received** (IMAP/POP3) - Requires mail server setup
|
||||
- **Git Webhooks** - Better as standard webhook triggers
|
||||
- **Performance Alerts** - Requires monitoring infrastructure
|
||||
- **Error Events** - Better handled by dedicated error tracking
|
||||
|
||||
### Advanced Time-Based
|
||||
- **Recurring Patterns** (e.g., "every 2nd Tuesday") - Complex parsing and timezone handling
|
||||
- **Date Range Triggers** - Can be achieved with conditional logic in workflows
|
||||
|
||||
## Why These Aren't Core Features
|
||||
|
||||
1. **Maintainability**: Each external integration requires ongoing maintenance as APIs change
|
||||
2. **Security**: Many features have security implications that are better handled by users who understand their specific requirements
|
||||
3. **Flexibility**: Users can implement these as custom steps/triggers tailored to their needs
|
||||
4. **Scope**: The plugin focuses on being a solid workflow engine, not an everything-integration platform
|
||||
5. **Dependencies**: Avoiding external service dependencies keeps the plugin lightweight
|
||||
|
||||
## What Users Can Do Instead
|
||||
|
||||
- Implement custom steps using the plugin's TaskConfig interface
|
||||
- Use HTTP Request step for most external integrations
|
||||
- Create custom triggers through Payload hooks
|
||||
- Build specialized workflow packages on top of this plugin
|
||||
76
README.md
76
README.md
@@ -1,17 +1,17 @@
|
||||
# @xtr-dev/payload-automation
|
||||
|
||||
A comprehensive workflow automation plugin for PayloadCMS 3.x that enables visual workflow building, execution tracking, and parallel processing.
|
||||
A workflow automation plugin for PayloadCMS 3.x. Run steps in workflows triggered by document changes or webhooks.
|
||||
|
||||
⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔄 **Visual Workflow Builder** - Create complex workflows with drag-and-drop interface
|
||||
- ⚡ **Parallel Execution** - Smart dependency resolution for optimal performance
|
||||
- 🎯 **Multiple Triggers** - Collection hooks, webhooks, manual execution
|
||||
- 📊 **Execution Tracking** - Complete history and monitoring of workflow runs
|
||||
- 🔧 **Extensible Steps** - HTTP requests, document CRUD, email notifications
|
||||
- 🔍 **JSONPath Integration** - Dynamic data interpolation and transformation
|
||||
- 🔄 Visual workflow builder in PayloadCMS admin
|
||||
- ⚡ Run workflows when documents are created/updated/deleted
|
||||
- 🎯 Trigger workflows via webhooks
|
||||
- 📊 Track workflow execution history
|
||||
- 🔧 HTTP requests, document operations, email sending
|
||||
- 🔗 Use data from previous steps in templates
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -46,37 +46,49 @@ export default buildConfig({
|
||||
})
|
||||
```
|
||||
|
||||
## Import Structure
|
||||
|
||||
The plugin uses separate exports to avoid bundling server-side code in client bundles:
|
||||
## Imports
|
||||
|
||||
```typescript
|
||||
// Server-side plugin and functions
|
||||
// Server plugin
|
||||
import { workflowsPlugin } from '@xtr-dev/payload-automation/server'
|
||||
|
||||
// Client-side components
|
||||
import { TriggerWorkflowButton } from '@xtr-dev/payload-automation/client'
|
||||
// Client components
|
||||
import { StatusCell, ErrorDisplay } from '@xtr-dev/payload-automation/client'
|
||||
|
||||
// Types only (safe for both server and client)
|
||||
// Types
|
||||
import type { WorkflowsPluginConfig } from '@xtr-dev/payload-automation'
|
||||
```
|
||||
|
||||
## Step Types
|
||||
|
||||
- **HTTP Request** - Make external API calls
|
||||
### HTTP Request
|
||||
Call external APIs. Supports GET, POST, PUT, DELETE, PATCH. Uses Bearer tokens, API keys, or basic auth.
|
||||
|
||||
HTTP steps succeed even with 4xx/5xx status codes. Only network errors (timeouts, DNS failures) cause step failure. Check `{{steps.stepName.output.status}}` for error handling.
|
||||
|
||||
### Document Operations
|
||||
- **Create Document** - Create PayloadCMS documents
|
||||
- **Read Document** - Query documents with filters
|
||||
- **Update Document** - Modify existing documents
|
||||
- **Update Document** - Modify existing documents
|
||||
- **Delete Document** - Remove documents
|
||||
|
||||
### Communication
|
||||
- **Send Email** - Send notifications via PayloadCMS email
|
||||
|
||||
## Data Resolution
|
||||
## Templates
|
||||
|
||||
Use JSONPath to access workflow data:
|
||||
Use `{{}}` to insert data:
|
||||
|
||||
- `$.trigger.doc.id` - Access trigger document
|
||||
- `$.steps.stepName.output` - Use previous step outputs
|
||||
- `$.context` - Access workflow context
|
||||
- `{{trigger.doc.id}}` - Data from the document that triggered the workflow
|
||||
- `{{steps.stepName.output}}` - Data from previous steps
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"url": "https://api.example.com/posts/{{steps.createPost.output.id}}",
|
||||
"condition": "{{trigger.doc.status}} == 'published'"
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -84,10 +96,26 @@ Use JSONPath to access workflow data:
|
||||
- Node.js ^18.20.2 || >=20.9.0
|
||||
- pnpm ^9 || ^10
|
||||
|
||||
## Documentation
|
||||
## Logging
|
||||
|
||||
Full documentation coming soon. For now, explore the development environment in the repository for examples and patterns.
|
||||
Set `PAYLOAD_AUTOMATION_LOG_LEVEL` to control logs:
|
||||
- `silent`, `error`, `warn` (default), `info`, `debug`, `trace`
|
||||
|
||||
```bash
|
||||
PAYLOAD_AUTOMATION_LOG_LEVEL=debug npm run dev
|
||||
```
|
||||
|
||||
## Scheduled Workflows
|
||||
|
||||
Use webhook triggers with external cron services:
|
||||
|
||||
```bash
|
||||
# Call workflow webhook from cron
|
||||
curl -X POST https://your-app.com/api/workflows-webhook/daily-report
|
||||
```
|
||||
|
||||
Built-in cron triggers were removed in v0.0.37+.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
# 🚨 Critical Update: PayloadCMS Automation Plugin v0.0.16
|
||||
|
||||
## ⚡ Immediate Action Required
|
||||
|
||||
A **critical bug** has been identified and fixed in v0.0.15 that prevented workflows from executing in development environments. **Please update immediately** to resolve workflow execution issues.
|
||||
|
||||
## 🔧 Quick Update Steps
|
||||
|
||||
### 1. Update the Package
|
||||
```bash
|
||||
npm update @xtr-dev/payload-automation
|
||||
# OR
|
||||
yarn upgrade @xtr-dev/payload-automation
|
||||
# OR
|
||||
pnpm update @xtr-dev/payload-automation
|
||||
```
|
||||
|
||||
### 2. Verify Version
|
||||
Check that you're now on v0.0.16:
|
||||
```bash
|
||||
npm list @xtr-dev/payload-automation
|
||||
```
|
||||
|
||||
### 3. Restart Your Development Server
|
||||
```bash
|
||||
# Stop your current dev server (Ctrl+C)
|
||||
# Then restart
|
||||
npm run dev
|
||||
# OR
|
||||
yarn dev
|
||||
# OR
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 4. Test Your Workflows
|
||||
Your workflows should now execute properly! Look for these log messages:
|
||||
|
||||
```
|
||||
[payload-automation] Plugin initialized successfully - all hooks registered
|
||||
AUTOMATION PLUGIN: Collection hook triggered
|
||||
executeTriggeredWorkflows called
|
||||
Found workflows to check
|
||||
```
|
||||
|
||||
## 🐛 What Was Fixed
|
||||
|
||||
### Critical Bug: Hook Registration Failure
|
||||
- **Problem**: The `hooksInitialized` flag prevented proper hook registration in development mode
|
||||
- **Symptom**: Workflows would not execute even when correctly configured
|
||||
- **Fix**: Removed the problematic flag, ensuring hooks register on every initialization
|
||||
|
||||
### Enhanced Debugging
|
||||
- **Added**: Comprehensive logging with "AUTOMATION PLUGIN:" prefix
|
||||
- **Added**: Try/catch blocks to prevent silent failures
|
||||
- **Added**: Better error messages and stack traces
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### If workflows still don't execute after updating:
|
||||
|
||||
1. **Check your workflow configuration** (should now use v0.0.15+ schema):
|
||||
```javascript
|
||||
// ✅ Correct v0.0.15+ schema
|
||||
{
|
||||
triggers: [{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'orders',
|
||||
operation: 'update',
|
||||
condition: '$.trigger.doc.status == "Paid"' // JSONPath format
|
||||
}],
|
||||
steps: [{
|
||||
step: 'uppercaseText', // 'step' not 'type'
|
||||
name: 'Process Order',
|
||||
input: { // 'input' not 'inputs'
|
||||
inputText: '$.trigger.doc.orderName has been paid!'
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
2. **Verify plugin configuration** includes your collections:
|
||||
```javascript
|
||||
automationPlugin({
|
||||
collections: ['orders', 'users', 'products'], // Must include target collections
|
||||
// ... other config
|
||||
})
|
||||
```
|
||||
|
||||
3. **Check the logs** for "AUTOMATION PLUGIN:" messages during hook execution
|
||||
|
||||
4. **Ensure workflow status**: If using versioning, make sure workflows are "Published" not "Draft"
|
||||
|
||||
## 📋 Schema Changes Recap (from v0.0.14 → v0.0.15+)
|
||||
|
||||
If you haven't updated your workflows since v0.0.14, you'll also need to update the schema:
|
||||
|
||||
### Triggers
|
||||
```javascript
|
||||
// ❌ OLD v0.0.14
|
||||
conditions: [
|
||||
{ field: 'status', operator: 'equals', value: 'Paid' }
|
||||
]
|
||||
|
||||
// ✅ NEW v0.0.15+
|
||||
condition: '$.trigger.doc.status == "Paid"'
|
||||
```
|
||||
|
||||
### Steps
|
||||
```javascript
|
||||
// ❌ OLD v0.0.14
|
||||
{
|
||||
type: 'uppercaseText',
|
||||
inputs: { inputText: 'Hello' }
|
||||
}
|
||||
|
||||
// ✅ NEW v0.0.15+
|
||||
{
|
||||
step: 'uppercaseText',
|
||||
input: { inputText: 'Hello' }
|
||||
}
|
||||
```
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
If you're still experiencing issues after updating to v0.0.16:
|
||||
|
||||
1. **Check console logs** for "AUTOMATION PLUGIN:" messages
|
||||
2. **Verify your workflow schema** matches v0.0.15+ format
|
||||
3. **Confirm plugin configuration** includes target collections
|
||||
4. **File an issue** at https://github.com/anthropics/claude-code/issues with:
|
||||
- PayloadCMS version
|
||||
- Complete console logs during workflow execution
|
||||
- Your workflow configuration (sanitized)
|
||||
|
||||
## ✅ Success Indicators
|
||||
|
||||
After updating, you should see:
|
||||
- ✅ Workflow runs created in `workflow-runs` collection
|
||||
- ✅ "AUTOMATION PLUGIN:" log messages during hook execution
|
||||
- ✅ Jobs appearing in `payload-jobs` collection
|
||||
- ✅ Workflow steps executing successfully
|
||||
|
||||
---
|
||||
|
||||
**This is a critical bug fix release - no breaking changes, just fixes the core functionality that wasn't working in v0.0.15.**
|
||||
@@ -1,67 +0,0 @@
|
||||
// Debug script to identify customer-side configuration issues
|
||||
// Run this in your environment to pinpoint the problem
|
||||
|
||||
console.log('🔍 === CUSTOMER ENVIRONMENT DEBUGGING ===')
|
||||
|
||||
// This script needs to be run in your actual environment
|
||||
// Copy this logic into your own debugging script
|
||||
|
||||
const debugChecklist = {
|
||||
"Plugin Version": "Check package.json for @xtr-dev/payload-automation version",
|
||||
"Plugin Configuration": "Verify automationPlugin() is called with correct collections array",
|
||||
"Database Collections": "Confirm 'workflows' and 'workflow-runs' collections exist",
|
||||
"Hook Registration": "Check if afterChange hooks are actually registered on orders collection",
|
||||
"Workflow Status": "Verify workflow document has _status: 'published'",
|
||||
"Workflow Structure": "Confirm triggers array and steps array are populated",
|
||||
"Order Collection": "Verify orders collection exists and is configured in plugin",
|
||||
"PayloadCMS Version": "Check if you're using compatible Payload version",
|
||||
"Environment": "Development vs Production database differences"
|
||||
}
|
||||
|
||||
console.log('\n📋 Debugging Checklist for Your Environment:')
|
||||
Object.entries(debugChecklist).forEach(([check, description], i) => {
|
||||
console.log(`${i + 1}. ${check}: ${description}`)
|
||||
})
|
||||
|
||||
console.log('\n🔍 Specific Things to Check in YOUR Environment:')
|
||||
|
||||
console.log('\n1. Plugin Configuration (payload.config.ts):')
|
||||
console.log(` automationPlugin({
|
||||
collections: ['orders', 'users', 'products'], // <- Must include 'orders'
|
||||
// ... other config
|
||||
})`)
|
||||
|
||||
console.log('\n2. Database Query (run this in your environment):')
|
||||
console.log(` const workflows = await payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2
|
||||
})
|
||||
console.log('Found workflows:', workflows.docs.length)
|
||||
console.log('Workflow details:', JSON.stringify(workflows.docs, null, 2))`)
|
||||
|
||||
console.log('\n3. Hook Registration Check:')
|
||||
console.log(` const orderCollection = payload.collections.orders
|
||||
console.log('afterChange hooks:', orderCollection.config.hooks?.afterChange?.length)`)
|
||||
|
||||
console.log('\n4. Manual Hook Trigger Test:')
|
||||
console.log(` // Manually call the executor method
|
||||
const executor = // get executor instance somehow
|
||||
await executor.executeTriggeredWorkflows('orders', 'update', updatedDoc, previousDoc, req)`)
|
||||
|
||||
console.log('\n5. Most Likely Issues:')
|
||||
console.log(' - Plugin not configured with "orders" in collections array')
|
||||
console.log(' - Workflow is in draft status (not published)')
|
||||
console.log(' - Database connection issue (different DB in dev vs prod)')
|
||||
console.log(' - PayloadCMS version compatibility issue')
|
||||
console.log(' - Hook execution order (automation hook not running last)')
|
||||
|
||||
console.log('\n💡 Quick Test - Add this to your order update code:')
|
||||
console.log(` console.log('🔍 DEBUG: About to update order')
|
||||
const result = await payload.update({ ... })
|
||||
console.log('🔍 DEBUG: Order updated, hooks should have fired')
|
||||
|
||||
// Check immediately after
|
||||
const runs = await payload.find({ collection: 'workflow-runs' })
|
||||
console.log('🔍 DEBUG: Workflow runs after update:', runs.docs.length)`)
|
||||
|
||||
process.exit(0)
|
||||
@@ -1,210 +0,0 @@
|
||||
// Enhanced debugging script for workflow execution issues
|
||||
const { getPayload } = require('payload')
|
||||
const { JSONPath } = require('jsonpath-plus')
|
||||
|
||||
async function debugWorkflowExecution() {
|
||||
const payload = await getPayload({
|
||||
config: require('./dev/payload.config.ts').default
|
||||
})
|
||||
|
||||
console.log('🔍 === WORKFLOW EXECUTION DEBUGGING ===')
|
||||
|
||||
// Step 1: Verify workflow exists and has correct structure
|
||||
console.log('\n📋 Step 1: Finding workflows...')
|
||||
const workflows = await payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2,
|
||||
limit: 100
|
||||
})
|
||||
|
||||
console.log(`Found ${workflows.docs.length} workflows:`)
|
||||
|
||||
for (const workflow of workflows.docs) {
|
||||
console.log(`\n Workflow: "${workflow.name}" (ID: ${workflow.id})`)
|
||||
console.log(` Enabled: ${workflow.enabled !== false}`)
|
||||
console.log(` Triggers: ${JSON.stringify(workflow.triggers, null, 4)}`)
|
||||
console.log(` Steps: ${JSON.stringify(workflow.steps, null, 4)}`)
|
||||
}
|
||||
|
||||
// Step 2: Create test order and simulate the trigger context
|
||||
console.log('\n📦 Step 2: Creating test order...')
|
||||
|
||||
const testOrder = await payload.create({
|
||||
collection: 'orders',
|
||||
data: {
|
||||
orderName: 'Debug Test Order - ' + Date.now(),
|
||||
status: 'Unpaid',
|
||||
customerEmail: 'debug@example.com',
|
||||
totalPrice: 1500,
|
||||
items: [
|
||||
{
|
||||
name: 'Debug Item',
|
||||
quantity: 1,
|
||||
price: 1500
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Created test order: ${testOrder.id} with status: "${testOrder.status}"`)
|
||||
|
||||
// Step 3: Test JSONPath condition evaluation directly
|
||||
console.log('\n🧪 Step 3: Testing JSONPath condition evaluation...')
|
||||
|
||||
// Simulate the execution context that would be created during hook execution
|
||||
const simulatedContext = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
type: 'collection',
|
||||
collection: 'orders',
|
||||
doc: { ...testOrder, status: 'Paid' }, // Simulating the updated status
|
||||
operation: 'update',
|
||||
previousDoc: testOrder, // Original order with 'Unpaid' status
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Simulated context:')
|
||||
console.log(' - Trigger type:', simulatedContext.trigger.type)
|
||||
console.log(' - Collection:', simulatedContext.trigger.collection)
|
||||
console.log(' - Doc status:', simulatedContext.trigger.doc.status)
|
||||
console.log(' - Previous doc status:', simulatedContext.trigger.previousDoc.status)
|
||||
|
||||
// Test the condition used in workflow
|
||||
const condition = '$.doc.status == "Paid"'
|
||||
console.log(`\nTesting condition: ${condition}`)
|
||||
|
||||
try {
|
||||
// Test left side JSONPath resolution
|
||||
const leftResult = JSONPath({
|
||||
json: simulatedContext,
|
||||
path: '$.trigger.doc.status',
|
||||
wrap: false
|
||||
})
|
||||
console.log(` - Left side ($.trigger.doc.status): ${JSON.stringify(leftResult)} (type: ${typeof leftResult})`)
|
||||
|
||||
// Test the comparison manually
|
||||
const comparisonMatch = condition.match(/^(.+?)\s*(==|!=|>|<|>=|<=)\s*(.+)$/)
|
||||
if (comparisonMatch) {
|
||||
const [, leftExpr, operator, rightExpr] = comparisonMatch
|
||||
console.log(` - Left expression: "${leftExpr.trim()}"`)
|
||||
console.log(` - Operator: "${operator}"`)
|
||||
console.log(` - Right expression: "${rightExpr.trim()}"`)
|
||||
|
||||
// Parse right side (remove quotes if it's a string literal)
|
||||
let rightValue = rightExpr.trim()
|
||||
if (rightValue.startsWith('"') && rightValue.endsWith('"')) {
|
||||
rightValue = rightValue.slice(1, -1)
|
||||
}
|
||||
console.log(` - Right value: "${rightValue}" (type: ${typeof rightValue})`)
|
||||
|
||||
const conditionResult = leftResult === rightValue
|
||||
console.log(` - Condition result: ${conditionResult} (${leftResult} === ${rightValue})`)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ JSONPath evaluation failed:', error.message)
|
||||
}
|
||||
|
||||
// Step 4: Test workflow trigger matching logic
|
||||
console.log('\n🎯 Step 4: Testing trigger matching logic...')
|
||||
|
||||
for (const workflow of workflows.docs) {
|
||||
console.log(`\nChecking workflow: "${workflow.name}"`)
|
||||
|
||||
const triggers = workflow.triggers
|
||||
if (!triggers || !Array.isArray(triggers)) {
|
||||
console.log(' ❌ No triggers found')
|
||||
continue
|
||||
}
|
||||
|
||||
for (const trigger of triggers) {
|
||||
console.log(` Trigger details:`)
|
||||
console.log(` - Type: ${trigger.type}`)
|
||||
console.log(` - Collection: ${trigger.collection}`)
|
||||
console.log(` - CollectionSlug: ${trigger.collectionSlug}`)
|
||||
console.log(` - Operation: ${trigger.operation}`)
|
||||
console.log(` - Condition: ${trigger.condition}`)
|
||||
|
||||
// Check basic matching criteria
|
||||
const typeMatch = trigger.type === 'collection-trigger'
|
||||
const collectionMatch = trigger.collection === 'orders' || trigger.collectionSlug === 'orders'
|
||||
const operationMatch = trigger.operation === 'update'
|
||||
|
||||
console.log(` - Type match: ${typeMatch}`)
|
||||
console.log(` - Collection match: ${collectionMatch}`)
|
||||
console.log(` - Operation match: ${operationMatch}`)
|
||||
|
||||
if (typeMatch && collectionMatch && operationMatch) {
|
||||
console.log(` ✅ Basic trigger criteria match!`)
|
||||
|
||||
if (trigger.condition) {
|
||||
console.log(` Testing condition: ${trigger.condition}`)
|
||||
// Note: We'd need to call the actual evaluateCondition method here
|
||||
// but we're simulating the logic
|
||||
} else {
|
||||
console.log(` ✅ No condition required - this trigger should fire!`)
|
||||
}
|
||||
} else {
|
||||
console.log(` ❌ Basic trigger criteria don't match`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Update order and trace hook execution
|
||||
console.log('\n🔄 Step 5: Updating order status to trigger workflow...')
|
||||
|
||||
console.log('Before update - checking existing workflow runs:')
|
||||
const beforeRuns = await payload.find({
|
||||
collection: 'workflow-runs'
|
||||
})
|
||||
console.log(` Existing workflow runs: ${beforeRuns.docs.length}`)
|
||||
|
||||
console.log('\nUpdating order status to "Paid"...')
|
||||
const updatedOrder = await payload.update({
|
||||
collection: 'orders',
|
||||
id: testOrder.id,
|
||||
data: {
|
||||
status: 'Paid'
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Order updated successfully. New status: "${updatedOrder.status}"`)
|
||||
|
||||
// Wait a moment for async processing
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
console.log('\nAfter update - checking for new workflow runs:')
|
||||
const afterRuns = await payload.find({
|
||||
collection: 'workflow-runs'
|
||||
})
|
||||
console.log(` Total workflow runs: ${afterRuns.docs.length}`)
|
||||
console.log(` New runs created: ${afterRuns.docs.length - beforeRuns.docs.length}`)
|
||||
|
||||
if (afterRuns.docs.length > beforeRuns.docs.length) {
|
||||
const newRuns = afterRuns.docs.slice(0, afterRuns.docs.length - beforeRuns.docs.length)
|
||||
for (const run of newRuns) {
|
||||
console.log(` - Run ID: ${run.id}`)
|
||||
console.log(` - Workflow: ${run.workflow}`)
|
||||
console.log(` - Status: ${run.status}`)
|
||||
console.log(` - Context: ${JSON.stringify(run.context, null, 2)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Check job queue
|
||||
console.log('\n⚙️ Step 6: Checking job queue...')
|
||||
const jobs = await payload.find({
|
||||
collection: 'payload-jobs',
|
||||
sort: '-createdAt',
|
||||
limit: 10
|
||||
})
|
||||
|
||||
console.log(`Recent jobs in queue: ${jobs.docs.length}`)
|
||||
for (const job of jobs.docs.slice(0, 5)) {
|
||||
console.log(` - Job ${job.id}: ${job.taskSlug} (${job.processingError ? 'ERROR' : 'OK'})`)
|
||||
}
|
||||
|
||||
console.log('\n✨ Debugging complete!')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
debugWorkflowExecution().catch(console.error)
|
||||
@@ -1,177 +0,0 @@
|
||||
// Enhanced debugging patch for workflow executor
|
||||
// This temporarily patches the workflow executor to add comprehensive logging
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
async function patchAndTestWorkflow() {
|
||||
const payload = await getPayload({
|
||||
config: (await import('./dev/payload.config.ts')).default
|
||||
})
|
||||
|
||||
console.log('🔧 === COMPREHENSIVE WORKFLOW DEBUGGING ===')
|
||||
|
||||
// Step 1: Check workflow collection structure and versioning
|
||||
console.log('\n📋 Step 1: Analyzing workflow collection configuration...')
|
||||
|
||||
const workflowCollection = payload.collections.workflows
|
||||
console.log('Workflow collection config:')
|
||||
console.log(' - Slug:', workflowCollection.config.slug)
|
||||
console.log(' - Versions enabled:', !!workflowCollection.config.versions)
|
||||
console.log(' - Drafts enabled:', !!workflowCollection.config.versions?.drafts)
|
||||
|
||||
// Step 2: Test different query approaches for workflows
|
||||
console.log('\n🔍 Step 2: Testing workflow queries...')
|
||||
|
||||
// Query 1: Default query (what the plugin currently uses)
|
||||
console.log('Query 1: Default query (no status filter)')
|
||||
try {
|
||||
const workflows1 = await payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2,
|
||||
limit: 100
|
||||
})
|
||||
console.log(` - Found: ${workflows1.docs.length} workflows`)
|
||||
for (const wf of workflows1.docs) {
|
||||
console.log(` - "${wf.name}" (ID: ${wf.id}) Status: ${wf._status || 'no-status'}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` - Error: ${error.message}`)
|
||||
}
|
||||
|
||||
// Query 2: Only published workflows
|
||||
console.log('\nQuery 2: Only published workflows')
|
||||
try {
|
||||
const workflows2 = await payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2,
|
||||
limit: 100,
|
||||
where: {
|
||||
_status: {
|
||||
equals: 'published'
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log(` - Found: ${workflows2.docs.length} published workflows`)
|
||||
for (const wf of workflows2.docs) {
|
||||
console.log(` - "${wf.name}" (ID: ${wf.id}) Status: ${wf._status}`)
|
||||
console.log(` Triggers: ${JSON.stringify(wf.triggers, null, 2)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` - Error: ${error.message}`)
|
||||
}
|
||||
|
||||
// Query 3: All workflows with explicit status
|
||||
console.log('\nQuery 3: All workflows with status field')
|
||||
try {
|
||||
const workflows3 = await payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2,
|
||||
limit: 100,
|
||||
where: {
|
||||
_status: {
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log(` - Found: ${workflows3.docs.length} workflows with status`)
|
||||
for (const wf of workflows3.docs) {
|
||||
console.log(` - "${wf.name}" Status: ${wf._status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` - Error: ${error.message}`)
|
||||
}
|
||||
|
||||
// Step 3: Create a test order and manually trigger the evaluation
|
||||
console.log('\n📦 Step 3: Creating test order...')
|
||||
|
||||
const testOrder = await payload.create({
|
||||
collection: 'orders',
|
||||
data: {
|
||||
orderName: 'Debug Comprehensive Test - ' + Date.now(),
|
||||
status: 'Unpaid',
|
||||
customerEmail: 'debug@example.com',
|
||||
totalPrice: 1000,
|
||||
items: [{
|
||||
name: 'Debug Item',
|
||||
quantity: 1,
|
||||
price: 1000
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Created order: ${testOrder.id} with status: ${testOrder.status}`)
|
||||
|
||||
// Step 4: Test the WorkflowExecutor.executeTriggeredWorkflows method directly
|
||||
console.log('\n🎯 Step 4: Testing executeTriggeredWorkflows directly...')
|
||||
|
||||
// Access the workflow executor (this might require accessing internal plugin state)
|
||||
// For now, let's simulate what should happen
|
||||
|
||||
console.log('Simulating executeTriggeredWorkflows call...')
|
||||
console.log(' - Collection: orders')
|
||||
console.log(' - Operation: update')
|
||||
console.log(' - Doc: { ...order, status: "Paid" }')
|
||||
console.log(' - PreviousDoc:', JSON.stringify(testOrder, null, 2))
|
||||
|
||||
// Step 5: Update the order and capture all logs
|
||||
console.log('\n🔄 Step 5: Updating order with comprehensive logging...')
|
||||
|
||||
// First, let's check what hooks are actually registered
|
||||
const orderCollection = payload.collections.orders
|
||||
console.log('Order collection hooks:')
|
||||
console.log(' - afterChange hooks:', orderCollection.config.hooks?.afterChange?.length || 0)
|
||||
|
||||
// Count current workflow runs before
|
||||
const beforeRuns = await payload.find({ collection: 'workflow-runs' })
|
||||
console.log(`Current workflow runs: ${beforeRuns.docs.length}`)
|
||||
|
||||
// Update the order
|
||||
console.log('\nUpdating order status to "Paid"...')
|
||||
const updatedOrder = await payload.update({
|
||||
collection: 'orders',
|
||||
id: testOrder.id,
|
||||
data: { status: 'Paid' }
|
||||
})
|
||||
|
||||
console.log(`Order updated: ${updatedOrder.status}`)
|
||||
|
||||
// Wait and check for workflow runs
|
||||
console.log('Waiting 5 seconds for async processing...')
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
|
||||
const afterRuns = await payload.find({ collection: 'workflow-runs' })
|
||||
console.log(`Workflow runs after: ${afterRuns.docs.length}`)
|
||||
console.log(`New runs created: ${afterRuns.docs.length - beforeRuns.docs.length}`)
|
||||
|
||||
if (afterRuns.docs.length > beforeRuns.docs.length) {
|
||||
console.log('✅ New workflow runs found!')
|
||||
const newRuns = afterRuns.docs.slice(0, afterRuns.docs.length - beforeRuns.docs.length)
|
||||
for (const run of newRuns) {
|
||||
console.log(` - Run ${run.id}: ${run.status}`)
|
||||
}
|
||||
} else {
|
||||
console.log('❌ No new workflow runs created')
|
||||
|
||||
// Additional debugging
|
||||
console.log('\n🕵️ Deep debugging - checking plugin state...')
|
||||
|
||||
// Check if the plugin is actually loaded
|
||||
console.log('Available collections:', Object.keys(payload.collections))
|
||||
|
||||
// Check for recent jobs
|
||||
const recentJobs = await payload.find({
|
||||
collection: 'payload-jobs',
|
||||
sort: '-createdAt',
|
||||
limit: 5
|
||||
})
|
||||
console.log(`Recent jobs: ${recentJobs.docs.length}`)
|
||||
for (const job of recentJobs.docs) {
|
||||
console.log(` - ${job.taskSlug} (${job.processingError ? 'ERROR' : 'OK'})`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✨ Comprehensive debugging complete!')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
patchAndTestWorkflow().catch(console.error)
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
import { StatusCell as StatusCell_6f365a93b6cb4b34ad564b391e21db6f } from '@xtr-dev/payload-automation/client'
|
||||
import { ErrorDisplay as ErrorDisplay_6f365a93b6cb4b34ad564b391e21db6f } from '@xtr-dev/payload-automation/client'
|
||||
|
||||
export const importMap = {
|
||||
|
||||
"@xtr-dev/payload-automation/client#StatusCell": StatusCell_6f365a93b6cb4b34ad564b391e21db6f,
|
||||
"@xtr-dev/payload-automation/client#ErrorDisplay": ErrorDisplay_6f365a93b6cb4b34ad564b391e21db6f
|
||||
}
|
||||
|
||||
@@ -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' | 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:
|
||||
| {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import type {CollectionSlug, TypedJobs} from 'payload';
|
||||
import type {CollectionSlug} from 'payload';
|
||||
|
||||
import {sqliteAdapter} from "@payloadcms/db-sqlite"
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { MongoMemoryReplSet } from 'mongodb-memory-server'
|
||||
import path from 'path'
|
||||
import {buildConfig} from 'payload'
|
||||
import sharp from 'sharp'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import {workflowsPlugin} from "../src/plugin/index.js"
|
||||
import {HttpRequestStepTask} from "../src/steps/http-request.js"
|
||||
import {CreateDocumentStepTask} from "../src/steps/index.js"
|
||||
import {CreateDocumentStepTask,HttpRequestStepTask} from "../src/steps/index.js"
|
||||
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
|
||||
import { seed } from './seed.js'
|
||||
|
||||
@@ -22,16 +19,8 @@ if (!process.env.ROOT_DIR) {
|
||||
}
|
||||
|
||||
const buildConfigWithMemoryDB = async () => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
const memoryDB = await MongoMemoryReplSet.create({
|
||||
replSet: {
|
||||
count: 3,
|
||||
dbName: 'payloadmemory',
|
||||
},
|
||||
})
|
||||
|
||||
process.env.DATABASE_URI = `${memoryDB.getUri()}&retryWrites=true`
|
||||
}
|
||||
// Use MongoDB adapter for testing instead of SQLite
|
||||
const { mongooseAdapter } = await import('@payloadcms/db-mongodb')
|
||||
|
||||
return buildConfig({
|
||||
admin: {
|
||||
@@ -39,6 +28,17 @@ const buildConfigWithMemoryDB = async () => {
|
||||
baseDir: path.resolve(dirname, '..'),
|
||||
},
|
||||
},
|
||||
globals: [
|
||||
{
|
||||
slug: 'settings',
|
||||
fields: [
|
||||
{
|
||||
name: 'siteName',
|
||||
type: 'text'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
collections: [
|
||||
{
|
||||
slug: 'posts',
|
||||
@@ -77,10 +77,8 @@ const buildConfigWithMemoryDB = async () => {
|
||||
]
|
||||
}
|
||||
],
|
||||
db: sqliteAdapter({
|
||||
client: {
|
||||
url: `file:${path.resolve(dirname, 'payload.db')}`,
|
||||
},
|
||||
db: mongooseAdapter({
|
||||
url: process.env.DATABASE_URI || 'mongodb://localhost:27017/payload-test',
|
||||
}),
|
||||
editor: lexicalEditor(),
|
||||
email: testEmailAdapter,
|
||||
@@ -106,14 +104,13 @@ const buildConfigWithMemoryDB = async () => {
|
||||
posts: true,
|
||||
media: true
|
||||
},
|
||||
globalTriggers: {
|
||||
settings: true
|
||||
},
|
||||
steps: [
|
||||
HttpRequestStepTask,
|
||||
CreateDocumentStepTask
|
||||
],
|
||||
triggers: [
|
||||
|
||||
],
|
||||
webhookPrefix: '/workflows-webhook'
|
||||
}),
|
||||
],
|
||||
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import config from './payload.config'
|
||||
|
||||
describe('Workflow Trigger Test', () => {
|
||||
let payload: Payload
|
||||
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
}, 60000)
|
||||
|
||||
afterAll(async () => {
|
||||
if (!payload) return
|
||||
|
||||
try {
|
||||
// Clear test data
|
||||
const workflows = await payload.find({
|
||||
collection: 'workflows',
|
||||
limit: 100
|
||||
})
|
||||
|
||||
for (const workflow of workflows.docs) {
|
||||
await payload.delete({
|
||||
collection: 'workflows',
|
||||
id: workflow.id
|
||||
})
|
||||
}
|
||||
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
limit: 100
|
||||
})
|
||||
|
||||
for (const run of runs.docs) {
|
||||
await payload.delete({
|
||||
collection: 'workflow-runs',
|
||||
id: run.id
|
||||
})
|
||||
}
|
||||
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
limit: 100
|
||||
})
|
||||
|
||||
for (const post of posts.docs) {
|
||||
await payload.delete({
|
||||
collection: 'posts',
|
||||
id: post.id
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Cleanup failed:', error)
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
it('should create a workflow run when a post is created', async () => {
|
||||
// Create a workflow with collection trigger
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Post Creation Workflow',
|
||||
description: 'Triggers when a post is created',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'log-post',
|
||||
step: 'http-request-step',
|
||||
input: {
|
||||
url: 'https://httpbin.org/post',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: {
|
||||
message: 'Post created',
|
||||
postId: '$.trigger.doc.id',
|
||||
postTitle: '$.trigger.doc.content'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(workflow).toBeDefined()
|
||||
expect(workflow.id).toBeDefined()
|
||||
|
||||
// Create a post to trigger the workflow
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'This should trigger the workflow'
|
||||
}
|
||||
})
|
||||
|
||||
expect(post).toBeDefined()
|
||||
expect(post.id).toBeDefined()
|
||||
|
||||
// Wait a bit for workflow to execute
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
// Check for workflow runs
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
},
|
||||
limit: 10
|
||||
})
|
||||
|
||||
expect(runs.totalDocs).toBeGreaterThan(0)
|
||||
expect(runs.docs[0].workflow).toBe(typeof workflow.id === 'object' ? workflow.id.toString() : workflow.id)
|
||||
|
||||
console.log('✅ Workflow run created successfully!')
|
||||
console.log(`Run status: ${runs.docs[0].status}`)
|
||||
console.log(`Run ID: ${runs.docs[0].id}`)
|
||||
|
||||
if (runs.docs[0].status === 'failed' && runs.docs[0].error) {
|
||||
console.log(`Error: ${runs.docs[0].error}`)
|
||||
}
|
||||
}, 30000)
|
||||
})
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import config from './payload.config'
|
||||
|
||||
async function testWorkflowTrigger() {
|
||||
console.log('Starting workflow trigger test...')
|
||||
|
||||
// Get payload instance
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
try {
|
||||
// Create a test user
|
||||
const user = await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Created test user:', user.id)
|
||||
|
||||
// Create a workflow with collection trigger
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Post Creation Workflow',
|
||||
description: 'Triggers when a post is created',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'log-post',
|
||||
step: 'http-request-step',
|
||||
input: {
|
||||
url: 'https://httpbin.org/post',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: {
|
||||
message: 'Post created',
|
||||
postId: '$.trigger.doc.id',
|
||||
postTitle: '$.trigger.doc.title'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
user: user.id
|
||||
})
|
||||
|
||||
console.log('Created workflow:', workflow.id)
|
||||
|
||||
// Create a post to trigger the workflow
|
||||
console.log('Creating post to trigger workflow...')
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Post',
|
||||
content: 'This should trigger the workflow',
|
||||
_status: 'published'
|
||||
},
|
||||
user: user.id
|
||||
})
|
||||
|
||||
console.log('Created post:', post.id)
|
||||
|
||||
// Wait a bit for workflow to execute
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// Check for workflow runs
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Workflow runs found:', runs.totalDocs)
|
||||
|
||||
if (runs.totalDocs > 0) {
|
||||
console.log('✅ SUCCESS: Workflow was triggered!')
|
||||
console.log('Run status:', runs.docs[0].status)
|
||||
console.log('Run context:', JSON.stringify(runs.docs[0].context, null, 2))
|
||||
} else {
|
||||
console.log('❌ FAILURE: Workflow was not triggered')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Test failed:', error)
|
||||
} finally {
|
||||
await payload.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
testWorkflowTrigger().catch(console.error)
|
||||
@@ -1,153 +0,0 @@
|
||||
// Comprehensive diagnostic script for hook registration issues
|
||||
// This should be run in your actual PayloadCMS environment
|
||||
|
||||
console.log('🔍 === COMPREHENSIVE HOOK DIAGNOSTIC ===')
|
||||
|
||||
// STEP 1: Add this to your payload.config.ts or wherever you initialize PayloadCMS
|
||||
console.log(`
|
||||
📋 STEP 1: Add this diagnostic code to your PayloadCMS initialization:
|
||||
|
||||
// After PayloadCMS is initialized, run this diagnostic
|
||||
const diagnostic = async () => {
|
||||
console.log('🔍 === HOOK REGISTRATION DIAGNOSTIC ===')
|
||||
|
||||
// Check if orders collection exists
|
||||
const ordersCollection = payload.collections.orders
|
||||
if (!ordersCollection) {
|
||||
console.log('❌ CRITICAL: orders collection not found!')
|
||||
console.log('Available collections:', Object.keys(payload.collections))
|
||||
return
|
||||
}
|
||||
|
||||
console.log('✅ orders collection found')
|
||||
|
||||
// Check hooks on orders collection
|
||||
const hooks = ordersCollection.config.hooks
|
||||
console.log('Orders collection hooks:')
|
||||
console.log(' - afterChange:', hooks?.afterChange?.length || 0)
|
||||
console.log(' - afterRead:', hooks?.afterRead?.length || 0)
|
||||
console.log(' - afterDelete:', hooks?.afterDelete?.length || 0)
|
||||
|
||||
// If no hooks, something is wrong
|
||||
if (!hooks?.afterChange || hooks.afterChange.length === 0) {
|
||||
console.log('❌ CRITICAL: No afterChange hooks registered on orders collection!')
|
||||
console.log('This means the automation plugin hook registration failed.')
|
||||
return
|
||||
}
|
||||
|
||||
// Test hook execution by manually calling them
|
||||
console.log('\\n🧪 Testing hook execution manually...')
|
||||
|
||||
const testDoc = {
|
||||
id: 'test-' + Date.now(),
|
||||
orderName: 'Test Order',
|
||||
status: 'Paid',
|
||||
customerEmail: 'test@example.com',
|
||||
totalPrice: 1000
|
||||
}
|
||||
|
||||
const previousDoc = {
|
||||
...testDoc,
|
||||
status: 'Unpaid'
|
||||
}
|
||||
|
||||
// Create a mock change object
|
||||
const mockChange = {
|
||||
collection: { slug: 'orders' },
|
||||
operation: 'update',
|
||||
doc: testDoc,
|
||||
previousDoc: previousDoc,
|
||||
req: {} // minimal request object
|
||||
}
|
||||
|
||||
console.log('Calling hooks manually with test data...')
|
||||
|
||||
for (let i = 0; i < hooks.afterChange.length; i++) {
|
||||
try {
|
||||
console.log(\`Calling hook #\${i + 1}...\`)
|
||||
await hooks.afterChange[i](mockChange)
|
||||
console.log(\`✅ Hook #\${i + 1} completed\`)
|
||||
} catch (error) {
|
||||
console.log(\`❌ Hook #\${i + 1} failed:\`, error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run diagnostic after PayloadCMS is fully initialized
|
||||
setTimeout(diagnostic, 2000)
|
||||
`)
|
||||
|
||||
console.log(`
|
||||
📋 STEP 2: Check your plugin configuration
|
||||
|
||||
Verify your payload.config.ts includes the orders collection:
|
||||
|
||||
automationPlugin({
|
||||
collections: ['orders'], // ← MUST include 'orders'
|
||||
// ... other config
|
||||
})
|
||||
|
||||
NOT:
|
||||
automationPlugin({
|
||||
collections: ['users', 'products'], // ← Missing 'orders'!
|
||||
})
|
||||
`)
|
||||
|
||||
console.log(`
|
||||
📋 STEP 3: Alternative hook registration test
|
||||
|
||||
Add this to your order update code to manually verify hooks:
|
||||
|
||||
// Before updating the order
|
||||
console.log('🔍 Pre-update hook check:')
|
||||
const orderCollection = payload.collections.orders
|
||||
console.log('afterChange hooks count:', orderCollection.config.hooks?.afterChange?.length)
|
||||
|
||||
// Update the order
|
||||
const result = await payload.update({...})
|
||||
|
||||
// Check for workflow runs immediately
|
||||
const runs = await payload.find({ collection: 'workflow-runs' })
|
||||
console.log('Workflow runs after update:', runs.docs.length)
|
||||
`)
|
||||
|
||||
console.log(`
|
||||
📋 STEP 4: Most likely root causes
|
||||
|
||||
1. Plugin Configuration Issue:
|
||||
- 'orders' not included in collections array
|
||||
- Plugin disabled or not properly applied
|
||||
|
||||
2. Collection Name Mismatch:
|
||||
- Your collection might be named differently (e.g., 'order' vs 'orders')
|
||||
- Case sensitivity issue
|
||||
|
||||
3. Hook Registration Timing:
|
||||
- Plugin hooks registered before collection is fully initialized
|
||||
- Race condition in PayloadCMS startup
|
||||
|
||||
4. Development Environment Issue:
|
||||
- Hot reloading interfering with hook registration
|
||||
- Multiple PayloadCMS instances
|
||||
|
||||
5. Database/Collection Issue:
|
||||
- Collection doesn't exist in database
|
||||
- Collection configuration mismatch
|
||||
`)
|
||||
|
||||
console.log(`
|
||||
🆘 QUICK DEBUG COMMANDS
|
||||
|
||||
Run these in your browser console or Node.js environment:
|
||||
|
||||
// Check available collections
|
||||
Object.keys(payload.collections)
|
||||
|
||||
// Check specific collection hooks
|
||||
payload.collections.orders?.config?.hooks?.afterChange?.length
|
||||
|
||||
// Check plugin configuration (if accessible)
|
||||
// This depends on how your config is structured
|
||||
`)
|
||||
|
||||
process.exit(0)
|
||||
@@ -28,6 +28,12 @@ export default [
|
||||
rules: {
|
||||
'no-restricted-exports': 'off',
|
||||
'no-console': 'off',
|
||||
'perfectionist/sort-object-types': 'off',
|
||||
'perfectionist/sort-objects': 'off',
|
||||
'perfectionist/sort-exports': 'off',
|
||||
'perfectionist/sort-imports': 'off',
|
||||
'perfectionist/sort-switch-case': 'off',
|
||||
'perfectionist/sort-interfaces': 'off'
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# PayloadCMS Workflows Plugin Examples
|
||||
|
||||
This directory contains example code demonstrating how to use the PayloadCMS Workflows plugin.
|
||||
|
||||
## Manual Trigger Example
|
||||
|
||||
The `manual-trigger-example.ts` file shows how to:
|
||||
- Create a workflow with a manual trigger button in the admin UI
|
||||
- Trigger workflows programmatically using custom triggers
|
||||
- Access trigger data in workflow steps using JSONPath
|
||||
|
||||
### Setting up a Manual Trigger Workflow
|
||||
|
||||
1. Configure the plugin with a custom trigger:
|
||||
```typescript
|
||||
workflowsPlugin({
|
||||
triggers: [
|
||||
{
|
||||
slug: 'manual-trigger',
|
||||
inputs: [] // No inputs needed for simple manual triggers
|
||||
}
|
||||
],
|
||||
// ... other config
|
||||
})
|
||||
```
|
||||
|
||||
2. Create a workflow with the manual trigger:
|
||||
```typescript
|
||||
await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'My Manual Workflow',
|
||||
triggers: [
|
||||
{
|
||||
type: 'manual-trigger'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
// Your workflow steps here
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
3. The workflow will now have a "Trigger Workflow" button in the admin UI
|
||||
|
||||
### Triggering Workflows Programmatically
|
||||
|
||||
```typescript
|
||||
import { triggerCustomWorkflow } from '@xtr-dev/payload-automation'
|
||||
|
||||
// Trigger all workflows with 'manual-trigger'
|
||||
const results = await triggerCustomWorkflow(payload, {
|
||||
slug: 'manual-trigger',
|
||||
data: {
|
||||
// Custom data to pass to the workflow
|
||||
source: 'api',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Accessing Trigger Data in Steps
|
||||
|
||||
Use JSONPath expressions to access trigger data in your workflow steps:
|
||||
- `$.trigger.data.source` - Access custom data fields
|
||||
- `$.trigger.type` - The trigger type
|
||||
- `$.trigger.triggeredAt` - When the trigger was activated
|
||||
@@ -1,274 +0,0 @@
|
||||
import { buildConfig } from 'payload'
|
||||
import { workflowsPlugin, triggerCustomWorkflow } from '@xtr-dev/payload-automation'
|
||||
import type { Field } from 'payload'
|
||||
|
||||
// Example: Data import trigger with custom fields
|
||||
const dataImportFields: Field[] = [
|
||||
{
|
||||
name: 'sourceUrl',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'URL of the data source to import from'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'format',
|
||||
type: 'select',
|
||||
options: ['json', 'csv', 'xml'],
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Format of the data to import'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'mapping',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Field mapping configuration'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// Example: Manual review trigger with approval fields
|
||||
const manualReviewFields: Field[] = [
|
||||
{
|
||||
name: 'reviewerId',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'ID of the reviewer'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'reviewNotes',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Notes from the review'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'approved',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Whether the item was approved'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export default buildConfig({
|
||||
// ... other config
|
||||
|
||||
plugins: [
|
||||
workflowsPlugin({
|
||||
collectionTriggers: {
|
||||
posts: true, // Enable all CRUD triggers for posts
|
||||
products: { // Selective triggers for products
|
||||
create: true,
|
||||
update: true
|
||||
}
|
||||
},
|
||||
|
||||
// Define custom triggers that will appear in the workflow UI
|
||||
triggers: [
|
||||
{
|
||||
slug: 'data-import',
|
||||
inputs: dataImportFields
|
||||
},
|
||||
{
|
||||
slug: 'manual-review',
|
||||
inputs: manualReviewFields
|
||||
},
|
||||
{
|
||||
slug: 'scheduled-report',
|
||||
inputs: [
|
||||
{
|
||||
name: 'reportType',
|
||||
type: 'select',
|
||||
options: ['daily', 'weekly', 'monthly'],
|
||||
required: true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
steps: [
|
||||
// ... your workflow steps
|
||||
]
|
||||
})
|
||||
],
|
||||
|
||||
onInit: async (payload) => {
|
||||
// Example 1: Trigger workflow from external data source
|
||||
// This could be called from a webhook, scheduled job, or any other event
|
||||
const handleDataImport = async (sourceUrl: string, format: string) => {
|
||||
const results = await triggerCustomWorkflow(payload, {
|
||||
slug: 'data-import',
|
||||
data: {
|
||||
sourceUrl,
|
||||
format,
|
||||
mapping: {
|
||||
title: 'name',
|
||||
description: 'summary'
|
||||
},
|
||||
importedAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Data import workflows triggered:', results)
|
||||
}
|
||||
|
||||
// Example 2: Trigger workflow after custom business logic
|
||||
const handleDocumentReview = async (documentId: string, reviewerId: string, approved: boolean) => {
|
||||
// Perform your custom review logic here
|
||||
const reviewData = {
|
||||
documentId,
|
||||
reviewerId,
|
||||
reviewNotes: approved ? 'Document meets all requirements' : 'Needs revision',
|
||||
approved,
|
||||
reviewedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
// Trigger workflows that listen for manual review
|
||||
const results = await triggerCustomWorkflow(payload, {
|
||||
slug: 'manual-review',
|
||||
data: reviewData,
|
||||
user: {
|
||||
id: reviewerId,
|
||||
email: 'reviewer@example.com'
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Example 3: Integrate with external services
|
||||
// You could set up listeners for external events
|
||||
if (process.env.ENABLE_EXTERNAL_SYNC) {
|
||||
// Listen to external service events (example with a hypothetical event emitter)
|
||||
// externalService.on('data-ready', async (event) => {
|
||||
// await triggerCustomWorkflow(payload, {
|
||||
// slug: 'data-import',
|
||||
// data: event.data
|
||||
// })
|
||||
// })
|
||||
}
|
||||
|
||||
// Example 4: Create scheduled reports using node-cron or similar
|
||||
// This shows how you might trigger a custom workflow on a schedule
|
||||
// without using the built-in cron trigger
|
||||
const scheduleReports = async () => {
|
||||
// This could be called by a cron job or scheduled task
|
||||
await triggerCustomWorkflow(payload, {
|
||||
slug: 'scheduled-report',
|
||||
data: {
|
||||
reportType: 'daily',
|
||||
generatedAt: new Date().toISOString(),
|
||||
metrics: {
|
||||
totalUsers: 1000,
|
||||
activeUsers: 750,
|
||||
newSignups: 25
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Example 5: Hook into collection operations for complex logic
|
||||
const postsCollection = payload.collections.posts
|
||||
if (postsCollection) {
|
||||
postsCollection.config.hooks = postsCollection.config.hooks || {}
|
||||
postsCollection.config.hooks.afterChange = postsCollection.config.hooks.afterChange || []
|
||||
|
||||
postsCollection.config.hooks.afterChange.push(async ({ doc, operation, req }) => {
|
||||
// Custom logic to determine if we should trigger a workflow
|
||||
if (operation === 'create' && doc.status === 'published') {
|
||||
// Trigger a custom workflow for newly published posts
|
||||
await triggerCustomWorkflow(payload, {
|
||||
slug: 'manual-review',
|
||||
data: {
|
||||
documentId: doc.id,
|
||||
documentType: 'post',
|
||||
reviewerId: 'auto-review',
|
||||
reviewNotes: 'Automatically queued for review',
|
||||
approved: false
|
||||
},
|
||||
req // Pass the request context
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Make functions available globally for testing/debugging
|
||||
;(global as any).handleDataImport = handleDataImport
|
||||
;(global as any).handleDocumentReview = handleDocumentReview
|
||||
;(global as any).scheduleReports = scheduleReports
|
||||
}
|
||||
})
|
||||
|
||||
// Example workflow configuration that would use these custom triggers:
|
||||
/*
|
||||
{
|
||||
name: "Process Data Import",
|
||||
triggers: [{
|
||||
type: "data-import",
|
||||
sourceUrl: "https://api.example.com/data",
|
||||
format: "json",
|
||||
mapping: { ... }
|
||||
}],
|
||||
steps: [
|
||||
{
|
||||
step: "http-request",
|
||||
name: "fetch-data",
|
||||
input: {
|
||||
url: "$.trigger.data.sourceUrl",
|
||||
method: "GET"
|
||||
}
|
||||
},
|
||||
{
|
||||
step: "create-document",
|
||||
name: "import-records",
|
||||
input: {
|
||||
collection: "imported-data",
|
||||
data: "$.steps.fetch-data.output.body"
|
||||
},
|
||||
dependencies: ["fetch-data"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
{
|
||||
name: "Review Approval Workflow",
|
||||
triggers: [{
|
||||
type: "manual-review",
|
||||
reviewerId: "",
|
||||
reviewNotes: "",
|
||||
approved: false
|
||||
}],
|
||||
steps: [
|
||||
{
|
||||
step: "update-document",
|
||||
name: "update-status",
|
||||
input: {
|
||||
collection: "documents",
|
||||
id: "$.trigger.data.documentId",
|
||||
data: {
|
||||
status: "$.trigger.data.approved ? 'approved' : 'rejected'",
|
||||
reviewedBy: "$.trigger.data.reviewerId",
|
||||
reviewedAt: "$.trigger.data.reviewedAt"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
step: "send-email",
|
||||
name: "notify-author",
|
||||
input: {
|
||||
to: "author@example.com",
|
||||
subject: "Document Review Complete",
|
||||
text: "Your document has been $.trigger.data.approved ? 'approved' : 'rejected'"
|
||||
},
|
||||
dependencies: ["update-status"]
|
||||
}
|
||||
]
|
||||
}
|
||||
*/
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Example: Manual Trigger Workflow
|
||||
*
|
||||
* This example shows how to create a workflow that can be triggered
|
||||
* manually from the PayloadCMS admin interface using a custom button.
|
||||
*/
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
/**
|
||||
* Create a workflow with manual trigger
|
||||
*/
|
||||
export async function createManualTriggerWorkflow(payload: Payload) {
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Manual Data Processing',
|
||||
description: 'A workflow that can be triggered manually from the admin UI',
|
||||
triggers: [
|
||||
{
|
||||
type: 'manual-trigger' // This enables the trigger button in the admin
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'fetch-data',
|
||||
type: 'http-request-step',
|
||||
input: {
|
||||
url: 'https://api.example.com/data',
|
||||
method: 'GET'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'process-data',
|
||||
type: 'create-document',
|
||||
input: {
|
||||
collection: 'auditLog',
|
||||
data: {
|
||||
message: 'Manual workflow executed',
|
||||
triggeredAt: '$.trigger.data.timestamp'
|
||||
}
|
||||
},
|
||||
dependencies: ['fetch-data'] // This step depends on fetch-data
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Created workflow:', workflow.id)
|
||||
return workflow
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a workflow programmatically using the custom trigger
|
||||
*/
|
||||
export async function triggerWorkflowProgrammatically(payload: Payload) {
|
||||
// Import the trigger functions from the plugin
|
||||
const { triggerCustomWorkflow, triggerWorkflowById } = await import('@xtr-dev/payload-automation')
|
||||
|
||||
// Option 1: Trigger all workflows with a specific trigger slug
|
||||
const results = await triggerCustomWorkflow(payload, {
|
||||
slug: 'manual-trigger',
|
||||
data: {
|
||||
source: 'api',
|
||||
timestamp: new Date().toISOString(),
|
||||
user: 'system'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Triggered workflows:', results)
|
||||
|
||||
// Option 2: Trigger a specific workflow by ID
|
||||
const workflowId = 'your-workflow-id'
|
||||
const result = await triggerWorkflowById(
|
||||
payload,
|
||||
workflowId,
|
||||
'manual-trigger',
|
||||
{
|
||||
source: 'api',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
)
|
||||
|
||||
console.log('Triggered workflow:', result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage in your application
|
||||
*/
|
||||
export async function setupManualTriggerExample(payload: Payload) {
|
||||
// Create the workflow
|
||||
const workflow = await createManualTriggerWorkflow(payload)
|
||||
|
||||
// The workflow is now available in the admin UI with a trigger button
|
||||
console.log('Workflow created! You can now:')
|
||||
console.log('1. Go to the admin UI and navigate to the Workflows collection')
|
||||
console.log('2. Open the workflow:', workflow.name)
|
||||
console.log('3. Click the "Trigger Workflow" button to execute it manually')
|
||||
|
||||
// You can also trigger it programmatically
|
||||
await triggerWorkflowProgrammatically(payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes:
|
||||
*
|
||||
* 1. The manual trigger button appears automatically in the workflow admin UI
|
||||
* when a workflow has a trigger with type 'manual-trigger'
|
||||
*
|
||||
* 2. You can have multiple triggers on the same workflow, including manual triggers
|
||||
*
|
||||
* 3. The trigger passes data to the workflow execution context, accessible via:
|
||||
* - $.trigger.data - The custom data passed when triggering
|
||||
* - $.trigger.type - The trigger type ('manual-trigger')
|
||||
* - $.trigger.triggeredAt - Timestamp of when the trigger was activated
|
||||
*
|
||||
* 4. Manual triggers are useful for:
|
||||
* - Administrative tasks
|
||||
* - Data migration workflows
|
||||
* - Testing and debugging
|
||||
* - On-demand processing
|
||||
*/
|
||||
@@ -1,130 +0,0 @@
|
||||
// Hook verification test - run this in your PayloadCMS environment
|
||||
// This will help identify why registered hooks aren't executing
|
||||
|
||||
console.log('🔍 === HOOK VERIFICATION TEST ===')
|
||||
|
||||
console.log(`
|
||||
Add this code to your PayloadCMS environment after initialization:
|
||||
|
||||
const verifyHooks = async () => {
|
||||
console.log('🔍 === HOOK VERIFICATION DIAGNOSTIC ===')
|
||||
|
||||
// 1. Check if hooks are still registered
|
||||
const ordersCollection = payload.collections.orders
|
||||
const hooks = ordersCollection.config.hooks.afterChange || []
|
||||
|
||||
console.log('Hook count:', hooks.length)
|
||||
console.log('Hook types:', hooks.map((h, i) => \`#\${i}: \${typeof h}\`))
|
||||
|
||||
// 2. Check if hooks are actually functions
|
||||
for (let i = 0; i < hooks.length; i++) {
|
||||
const hook = hooks[i]
|
||||
console.log(\`Hook #\${i}:\`)
|
||||
console.log(\` - Type: \${typeof hook}\`)
|
||||
console.log(\` - Is Function: \${typeof hook === 'function'}\`)
|
||||
console.log(\` - Has Name: \${hook.name || 'anonymous'}\`)
|
||||
console.log(\` - String Preview: \${hook.toString().substring(0, 100)}...\`)
|
||||
}
|
||||
|
||||
// 3. Try to manually execute each hook
|
||||
console.log('\\n🧪 MANUAL HOOK EXECUTION TEST')
|
||||
|
||||
const mockChange = {
|
||||
collection: { slug: 'orders' },
|
||||
operation: 'update',
|
||||
doc: {
|
||||
id: 'test-' + Date.now(),
|
||||
orderName: 'Test Order',
|
||||
status: 'Paid',
|
||||
customerEmail: 'test@example.com'
|
||||
},
|
||||
previousDoc: {
|
||||
id: 'test-' + Date.now(),
|
||||
orderName: 'Test Order',
|
||||
status: 'Unpaid',
|
||||
customerEmail: 'test@example.com'
|
||||
},
|
||||
req: { user: null } // Minimal request object
|
||||
}
|
||||
|
||||
for (let i = 0; i < hooks.length; i++) {
|
||||
try {
|
||||
console.log(\`\\nTesting hook #\${i}...\`)
|
||||
console.log('About to call hook with mock data')
|
||||
|
||||
const result = await hooks[i](mockChange)
|
||||
|
||||
console.log(\`✅ Hook #\${i} executed successfully\`)
|
||||
console.log('Result:', result)
|
||||
|
||||
} catch (error) {
|
||||
console.log(\`❌ Hook #\${i} failed:\`)
|
||||
console.log('Error:', error.message)
|
||||
console.log('Stack:', error.stack)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check if hooks are being called during real operations
|
||||
console.log('\\n🔍 REAL OPERATION TEST')
|
||||
console.log('Creating a test order to see if hooks fire...')
|
||||
|
||||
// Add a simple test hook to verify hook execution
|
||||
const testHook = async (change) => {
|
||||
console.log('🚨 TEST HOOK FIRED! 🚨')
|
||||
console.log('Collection:', change.collection.slug)
|
||||
console.log('Operation:', change.operation)
|
||||
}
|
||||
|
||||
// Add test hook at the beginning
|
||||
ordersCollection.config.hooks.afterChange.unshift(testHook)
|
||||
console.log('Added test hook at position 0')
|
||||
|
||||
try {
|
||||
const testOrder = await payload.create({
|
||||
collection: 'orders',
|
||||
data: {
|
||||
orderName: 'Hook Verification Test',
|
||||
status: 'Unpaid',
|
||||
customerEmail: 'hooktest@example.com',
|
||||
totalPrice: 1000,
|
||||
items: [{ name: 'Test Item', quantity: 1, price: 1000 }]
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Test order created:', testOrder.id)
|
||||
|
||||
// Update the order to trigger hooks
|
||||
const updatedOrder = await payload.update({
|
||||
collection: 'orders',
|
||||
id: testOrder.id,
|
||||
data: { status: 'Paid' }
|
||||
})
|
||||
|
||||
console.log('Test order updated to:', updatedOrder.status)
|
||||
|
||||
} catch (error) {
|
||||
console.log('Error during test operation:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Run after PayloadCMS is initialized
|
||||
setTimeout(verifyHooks, 3000)
|
||||
`)
|
||||
|
||||
console.log(`
|
||||
🎯 Expected Results:
|
||||
|
||||
If you see "🚨 TEST HOOK FIRED! 🚨" but NOT the automation plugin messages:
|
||||
- Hook execution works, but the automation plugin hook has an issue
|
||||
- Likely problem: Hook function malformed or has runtime error
|
||||
|
||||
If you DON'T see "🚨 TEST HOOK FIRED! 🚨":
|
||||
- Hook execution is completely broken
|
||||
- PayloadCMS configuration or timing issue
|
||||
|
||||
If hooks execute manually but not during real operations:
|
||||
- Hook registration timing issue
|
||||
- PayloadCMS lifecycle problem
|
||||
`)
|
||||
|
||||
process.exit(0)
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@xtr-dev/payload-workflows",
|
||||
"version": "0.0.19",
|
||||
"version": "0.0.40",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@xtr-dev/payload-workflows",
|
||||
"version": "0.0.19",
|
||||
"version": "0.0.40",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "^10.3.0",
|
||||
|
||||
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xtr-dev/payload-automation",
|
||||
"version": "0.0.19",
|
||||
"version": "0.0.40",
|
||||
"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",
|
||||
@@ -70,6 +75,8 @@
|
||||
"@payloadcms/ui": "3.45.0",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@swc/cli": "0.6.0",
|
||||
"@types/handlebars": "^4.1.0",
|
||||
"@types/nock": "^11.1.0",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "19.1.8",
|
||||
@@ -80,6 +87,7 @@
|
||||
"graphql": "^16.8.1",
|
||||
"mongodb-memory-server": "10.1.4",
|
||||
"next": "15.4.4",
|
||||
"nock": "^14.0.10",
|
||||
"payload": "3.45.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
@@ -87,6 +95,7 @@
|
||||
"sharp": "0.34.3",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "5.7.3",
|
||||
"undici": "^7.15.0",
|
||||
"vitest": "^3.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -127,7 +136,7 @@
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "^10.3.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"node-cron": "^4.2.1",
|
||||
"pino": "^9.9.0"
|
||||
}
|
||||
|
||||
172
pnpm-lock.yaml
generated
172
pnpm-lock.yaml
generated
@@ -8,9 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
jsonpath-plus:
|
||||
specifier: ^10.3.0
|
||||
version: 10.3.0
|
||||
handlebars:
|
||||
specifier: ^4.7.8
|
||||
version: 4.7.8
|
||||
node-cron:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
@@ -45,6 +45,12 @@ importers:
|
||||
'@swc/cli':
|
||||
specifier: 0.6.0
|
||||
version: 0.6.0(@swc/core@1.13.4)
|
||||
'@types/handlebars':
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
'@types/nock':
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
'@types/node':
|
||||
specifier: ^22.5.4
|
||||
version: 22.17.2
|
||||
@@ -75,6 +81,9 @@ importers:
|
||||
next:
|
||||
specifier: 15.4.4
|
||||
version: 15.4.4(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4)
|
||||
nock:
|
||||
specifier: ^14.0.10
|
||||
version: 14.0.10
|
||||
payload:
|
||||
specifier: 3.45.0
|
||||
version: 3.45.0(graphql@16.11.0)(typescript@5.7.3)
|
||||
@@ -96,6 +105,9 @@ importers:
|
||||
typescript:
|
||||
specifier: 5.7.3
|
||||
version: 5.7.3
|
||||
undici:
|
||||
specifier: ^7.15.0
|
||||
version: 7.15.0
|
||||
vitest:
|
||||
specifier: ^3.1.2
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.5.1)(sass@1.77.4)(tsx@4.20.5)
|
||||
@@ -953,18 +965,6 @@ packages:
|
||||
'@jsdevtools/ono@7.1.3':
|
||||
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
|
||||
|
||||
'@jsep-plugin/assignment@1.3.0':
|
||||
resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
peerDependencies:
|
||||
jsep: ^0.4.0||^1.0.0
|
||||
|
||||
'@jsep-plugin/regex@1.0.4':
|
||||
resolution: {integrity: sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
peerDependencies:
|
||||
jsep: ^0.4.0||^1.0.0
|
||||
|
||||
'@lexical/clipboard@0.28.0':
|
||||
resolution: {integrity: sha512-LYqion+kAwFQJStA37JAEMxTL/m1WlZbotDfM/2WuONmlO0yWxiyRDI18oeCwhBD6LQQd9c3Ccxp9HFwUG1AVw==}
|
||||
|
||||
@@ -1103,6 +1103,10 @@ packages:
|
||||
'@mongodb-js/saslprep@1.3.0':
|
||||
resolution: {integrity: sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==}
|
||||
|
||||
'@mswjs/interceptors@0.39.6':
|
||||
resolution: {integrity: sha512-bndDP83naYYkfayr/qhBHMhk0YGwS1iv6vaEGcr0SQbO0IZtbOPqjKjds/WcG+bJA+1T5vCx6kprKOzn5Bg+Vw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@napi-rs/nice-android-arm-eabi@1.1.1':
|
||||
resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1278,6 +1282,15 @@ packages:
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@open-draft/deferred-promise@2.2.0':
|
||||
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
|
||||
|
||||
'@open-draft/logger@0.3.0':
|
||||
resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
|
||||
|
||||
'@open-draft/until@2.1.0':
|
||||
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
|
||||
|
||||
'@payloadcms/db-mongodb@3.45.0':
|
||||
resolution: {integrity: sha512-Oahk6LJatrQW2+DG0OoSoaWnXSiJ2iBL+2l5WLD2xvRHOlJ3Ls1gUZCrsDItDe8veqwVGSLrMc7gxDwDaMICvg==}
|
||||
peerDependencies:
|
||||
@@ -1578,6 +1591,10 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/handlebars@4.1.0':
|
||||
resolution: {integrity: sha512-gq9YweFKNNB1uFK71eRqsd4niVkXrxHugqWFQkeLRJvGjnxsLr16bYtcsG4tOFwmYi0Bax+wCkbf1reUfdl4kA==}
|
||||
deprecated: This is a stub types definition. handlebars provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
@@ -1596,6 +1613,10 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/nock@11.1.0':
|
||||
resolution: {integrity: sha512-jI/ewavBQ7X5178262JQR0ewicPAcJhXS/iFaNJl0VHLfyosZ/kwSrsa6VNQNSO8i9d8SqdRgOtZSOKJ/+iNMw==}
|
||||
deprecated: This is a stub types definition. nock provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/node-cron@3.0.11':
|
||||
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
|
||||
|
||||
@@ -2860,6 +2881,11 @@ packages:
|
||||
resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==}
|
||||
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
||||
|
||||
handlebars@4.7.8:
|
||||
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
|
||||
engines: {node: '>=0.4.7'}
|
||||
hasBin: true
|
||||
|
||||
has-bigints@1.1.0:
|
||||
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3048,6 +3074,9 @@ packages:
|
||||
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-node-process@1.2.0:
|
||||
resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==}
|
||||
|
||||
is-number-object@1.1.1:
|
||||
resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3143,10 +3172,6 @@ packages:
|
||||
resolution: {integrity: sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
jsep@1.4.0:
|
||||
resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
|
||||
jsesc@3.1.0:
|
||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3172,10 +3197,8 @@ packages:
|
||||
json-stable-stringify-without-jsonify@1.0.1:
|
||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||
|
||||
jsonpath-plus@10.3.0:
|
||||
resolution: {integrity: sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
json-stringify-safe@5.0.1:
|
||||
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
|
||||
|
||||
jsox@1.2.121:
|
||||
resolution: {integrity: sha512-9Ag50tKhpTwS6r5wh3MJSAvpSof0UBr39Pto8OnzFT32Z/pAbxAsKHzyvsyMEHVslELvHyO/4/jaQELHk8wDcw==}
|
||||
@@ -3502,6 +3525,9 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
neo-async@2.6.2:
|
||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||
|
||||
new-find-package-json@2.0.0:
|
||||
resolution: {integrity: sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
@@ -3527,6 +3553,10 @@ packages:
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
nock@14.0.10:
|
||||
resolution: {integrity: sha512-Q7HjkpyPeLa0ZVZC5qpxBt5EyLczFJ91MEewQiIi9taWuA0KB/MDJlUWtON+7dGouVdADTQsf9RA7TZk6D8VMw==}
|
||||
engines: {node: '>=18.20.0 <20 || >=20.12.1'}
|
||||
|
||||
node-cron@4.2.1:
|
||||
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -3600,6 +3630,9 @@ packages:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
outvariant@1.4.3:
|
||||
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
|
||||
|
||||
own-keys@1.0.1:
|
||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3853,6 +3886,10 @@ packages:
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
propagate@2.0.1:
|
||||
resolution: {integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
pump@3.0.3:
|
||||
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
|
||||
|
||||
@@ -4197,6 +4234,9 @@ packages:
|
||||
streamx@2.22.1:
|
||||
resolution: {integrity: sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==}
|
||||
|
||||
strict-event-emitter@0.5.1:
|
||||
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
|
||||
|
||||
string-ts@2.2.1:
|
||||
resolution: {integrity: sha512-Q2u0gko67PLLhbte5HmPfdOjNvUKbKQM+mCNQae6jE91DmoFHY6HH9GcdqCeNx87DZ2KKjiFxmA0R/42OneGWw==}
|
||||
|
||||
@@ -4426,6 +4466,11 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uglify-js@3.19.3:
|
||||
resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
hasBin: true
|
||||
|
||||
uint8array-extras@1.5.0:
|
||||
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4444,6 +4489,10 @@ packages:
|
||||
resolution: {integrity: sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
undici@7.15.0:
|
||||
resolution: {integrity: sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unist-util-is@6.0.0:
|
||||
resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==}
|
||||
|
||||
@@ -4613,6 +4662,9 @@ packages:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
wordwrap@1.0.0:
|
||||
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5397,14 +5449,6 @@ snapshots:
|
||||
|
||||
'@jsdevtools/ono@7.1.3': {}
|
||||
|
||||
'@jsep-plugin/assignment@1.3.0(jsep@1.4.0)':
|
||||
dependencies:
|
||||
jsep: 1.4.0
|
||||
|
||||
'@jsep-plugin/regex@1.0.4(jsep@1.4.0)':
|
||||
dependencies:
|
||||
jsep: 1.4.0
|
||||
|
||||
'@lexical/clipboard@0.28.0':
|
||||
dependencies:
|
||||
'@lexical/html': 0.28.0
|
||||
@@ -5625,6 +5669,15 @@ snapshots:
|
||||
dependencies:
|
||||
sparse-bitfield: 3.0.3
|
||||
|
||||
'@mswjs/interceptors@0.39.6':
|
||||
dependencies:
|
||||
'@open-draft/deferred-promise': 2.2.0
|
||||
'@open-draft/logger': 0.3.0
|
||||
'@open-draft/until': 2.1.0
|
||||
is-node-process: 1.2.0
|
||||
outvariant: 1.4.3
|
||||
strict-event-emitter: 0.5.1
|
||||
|
||||
'@napi-rs/nice-android-arm-eabi@1.1.1':
|
||||
optional: true
|
||||
|
||||
@@ -5739,6 +5792,15 @@ snapshots:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.19.1
|
||||
|
||||
'@open-draft/deferred-promise@2.2.0': {}
|
||||
|
||||
'@open-draft/logger@0.3.0':
|
||||
dependencies:
|
||||
is-node-process: 1.2.0
|
||||
outvariant: 1.4.3
|
||||
|
||||
'@open-draft/until@2.1.0': {}
|
||||
|
||||
'@payloadcms/db-mongodb@3.45.0(payload@3.45.0(graphql@16.11.0)(typescript@5.7.3))':
|
||||
dependencies:
|
||||
mongoose: 8.15.1
|
||||
@@ -6248,6 +6310,10 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/handlebars@4.1.0':
|
||||
dependencies:
|
||||
handlebars: 4.7.8
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -6264,6 +6330,10 @@ snapshots:
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/nock@11.1.0':
|
||||
dependencies:
|
||||
nock: 14.0.10
|
||||
|
||||
'@types/node-cron@3.0.11': {}
|
||||
|
||||
'@types/node@22.17.2':
|
||||
@@ -7894,6 +7964,15 @@ snapshots:
|
||||
|
||||
graphql@16.11.0: {}
|
||||
|
||||
handlebars@4.7.8:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
neo-async: 2.6.2
|
||||
source-map: 0.6.1
|
||||
wordwrap: 1.0.0
|
||||
optionalDependencies:
|
||||
uglify-js: 3.19.3
|
||||
|
||||
has-bigints@1.1.0: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
@@ -8071,6 +8150,8 @@ snapshots:
|
||||
|
||||
is-negative-zero@2.0.3: {}
|
||||
|
||||
is-node-process@1.2.0: {}
|
||||
|
||||
is-number-object@1.1.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -8150,8 +8231,6 @@ snapshots:
|
||||
|
||||
jsdoc-type-pratt-parser@4.8.0: {}
|
||||
|
||||
jsep@1.4.0: {}
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-buffer@3.0.1: {}
|
||||
@@ -8176,11 +8255,7 @@ snapshots:
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
jsonpath-plus@10.3.0:
|
||||
dependencies:
|
||||
'@jsep-plugin/assignment': 1.3.0(jsep@1.4.0)
|
||||
'@jsep-plugin/regex': 1.0.4(jsep@1.4.0)
|
||||
jsep: 1.4.0
|
||||
json-stringify-safe@5.0.1: {}
|
||||
|
||||
jsox@1.2.121: {}
|
||||
|
||||
@@ -8622,6 +8697,8 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
new-find-package-json@2.0.0:
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
@@ -8653,6 +8730,12 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
nock@14.0.10:
|
||||
dependencies:
|
||||
'@mswjs/interceptors': 0.39.6
|
||||
json-stringify-safe: 5.0.1
|
||||
propagate: 2.0.1
|
||||
|
||||
node-cron@4.2.1: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
@@ -8728,6 +8811,8 @@ snapshots:
|
||||
type-check: 0.4.0
|
||||
word-wrap: 1.2.5
|
||||
|
||||
outvariant@1.4.3: {}
|
||||
|
||||
own-keys@1.0.1:
|
||||
dependencies:
|
||||
get-intrinsic: 1.3.0
|
||||
@@ -9016,6 +9101,8 @@ snapshots:
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
propagate@2.0.1: {}
|
||||
|
||||
pump@3.0.3:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
@@ -9422,6 +9509,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
bare-events: 2.6.1
|
||||
|
||||
strict-event-emitter@0.5.1: {}
|
||||
|
||||
string-ts@2.2.1: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
@@ -9673,6 +9762,9 @@ snapshots:
|
||||
|
||||
typescript@5.7.3: {}
|
||||
|
||||
uglify-js@3.19.3:
|
||||
optional: true
|
||||
|
||||
uint8array-extras@1.5.0: {}
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
@@ -9691,6 +9783,8 @@ snapshots:
|
||||
|
||||
undici@7.10.0: {}
|
||||
|
||||
undici@7.15.0: {}
|
||||
|
||||
unist-util-is@6.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -9883,6 +9977,8 @@ snapshots:
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wordwrap@1.0.0: {}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
||||
@@ -1,231 +1,121 @@
|
||||
import type {CollectionConfig, Field} from 'payload'
|
||||
import type {CollectionConfig} from 'payload'
|
||||
|
||||
import type {WorkflowsPluginConfig} from "../plugin/config-types.js"
|
||||
|
||||
export const createWorkflowCollection: <T extends string>(options: WorkflowsPluginConfig<T>) => CollectionConfig = ({
|
||||
collectionTriggers,
|
||||
steps,
|
||||
triggers
|
||||
}) => ({
|
||||
slug: 'workflows',
|
||||
access: {
|
||||
create: () => true,
|
||||
delete: () => true,
|
||||
read: () => true,
|
||||
update: () => true,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['name', 'updatedAt'],
|
||||
description: 'Create and manage automated workflows.',
|
||||
group: 'Automation',
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Human-readable name for the workflow',
|
||||
import {parameter} from "../fields/parameter.js"
|
||||
import {collectionTrigger, globalTrigger} from "../triggers/index.js"
|
||||
|
||||
export const createWorkflowCollection: <T extends string>(options: WorkflowsPluginConfig<T>) => CollectionConfig = (options) => {
|
||||
const steps = options.steps || []
|
||||
const triggers = (options.triggers || []).map(t => t(options)).concat(collectionTrigger(options), globalTrigger(options))
|
||||
return {
|
||||
slug: 'workflows',
|
||||
access: {
|
||||
create: () => true,
|
||||
delete: () => true,
|
||||
read: () => true,
|
||||
update: () => true,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['name', 'updatedAt'],
|
||||
description: 'Create and manage automated workflows.',
|
||||
group: 'Automation',
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Human-readable name for the workflow',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Optional description of what this workflow does',
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Optional description of what this workflow does',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'triggers',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
options: [
|
||||
'collection-trigger',
|
||||
'webhook-trigger',
|
||||
'global-trigger',
|
||||
'cron-trigger',
|
||||
...(triggers || []).map(t => t.slug)
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'collectionSlug',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'collection-trigger',
|
||||
description: 'Collection that triggers the workflow',
|
||||
{
|
||||
name: 'triggers',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
options: [
|
||||
...triggers.map(t => t.slug)
|
||||
]
|
||||
},
|
||||
options: Object.keys(collectionTriggers || {})
|
||||
},
|
||||
{
|
||||
name: 'operation',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'collection-trigger',
|
||||
description: 'Collection operation that triggers the workflow',
|
||||
},
|
||||
options: [
|
||||
'create',
|
||||
'delete',
|
||||
'read',
|
||||
'update',
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'webhookPath',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'webhook-trigger',
|
||||
description: 'URL path for the webhook (e.g., "my-webhook"). Full URL will be /api/workflows/webhook/my-webhook',
|
||||
},
|
||||
validate: (value: any, {siblingData}: any) => {
|
||||
if (siblingData?.type === 'webhook-trigger' && !value) {
|
||||
return 'Webhook path is required for webhook triggers'
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'global',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'global-trigger',
|
||||
description: 'Global that triggers the workflow',
|
||||
},
|
||||
options: [] // Will be populated dynamically based on available globals
|
||||
},
|
||||
{
|
||||
name: 'globalOperation',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'global-trigger',
|
||||
description: 'Global operation that triggers the workflow',
|
||||
},
|
||||
options: [
|
||||
'update'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'cronExpression',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'cron-trigger',
|
||||
description: 'Cron expression for scheduled execution (e.g., "0 0 * * *" for daily at midnight)',
|
||||
placeholder: '0 0 * * *'
|
||||
},
|
||||
validate: (value: any, {siblingData}: any) => {
|
||||
if (siblingData?.type === 'cron-trigger' && !value) {
|
||||
return 'Cron expression is required for cron triggers'
|
||||
}
|
||||
|
||||
// Validate cron expression format if provided
|
||||
if (siblingData?.type === 'cron-trigger' && value) {
|
||||
// Basic format validation - should be 5 parts separated by spaces
|
||||
const cronParts = value.trim().split(/\s+/)
|
||||
if (cronParts.length !== 5) {
|
||||
return 'Invalid cron expression format. Expected 5 parts: "minute hour day month weekday" (e.g., "0 9 * * 1")'
|
||||
}
|
||||
|
||||
// Additional validation could use node-cron but we avoid dynamic imports here
|
||||
// The main validation happens at runtime in the cron scheduler
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'timezone',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'cron-trigger',
|
||||
description: 'Timezone for cron execution (e.g., "America/New_York", "Europe/London"). Defaults to UTC.',
|
||||
placeholder: 'UTC'
|
||||
},
|
||||
defaultValue: 'UTC',
|
||||
validate: (value: any, {siblingData}: any) => {
|
||||
if (siblingData?.type === 'cron-trigger' && value) {
|
||||
try {
|
||||
// Test if timezone is valid by trying to create a date with it
|
||||
new Intl.DateTimeFormat('en', {timeZone: value})
|
||||
return true
|
||||
} catch {
|
||||
return `Invalid timezone: ${value}. Please use a valid IANA timezone identifier (e.g., "America/New_York", "Europe/London")`
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'condition',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'JSONPath expression that must evaluate to true for this trigger to execute the workflow (e.g., "$.doc.status == \'published\'")'
|
||||
},
|
||||
required: false
|
||||
},
|
||||
...(triggers || []).flatMap(t => (t.inputs || []).map(f => ({
|
||||
...f,
|
||||
admin: {
|
||||
...(f.admin || {}),
|
||||
condition: (...args) => args[1]?.type === t.slug && (
|
||||
f.admin?.condition ?
|
||||
f.admin.condition.call(this, ...args) :
|
||||
true
|
||||
),
|
||||
},
|
||||
} as Field)))
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'steps',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'step',
|
||||
type: 'select',
|
||||
options: steps.map(t => t.slug)
|
||||
{
|
||||
name: 'parameters',
|
||||
type: 'json',
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'input',
|
||||
type: 'json',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'dependencies',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Step names that must complete before this step can run'
|
||||
defaultValue: {}
|
||||
},
|
||||
hasMany: true,
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'condition',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'JSONPath expression that must evaluate to true for this step to execute (e.g., "$.trigger.doc.status == \'published\'")'
|
||||
// Virtual fields for custom triggers
|
||||
...triggers.flatMap(t => (t.parameters || []).map(p => parameter(t.slug, p as any))),
|
||||
{
|
||||
name: 'condition',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'JSONPath expression that must evaluate to true for this trigger to execute the workflow (e.g., "$.trigger.doc.status == \'published\'")'
|
||||
},
|
||||
required: false
|
||||
},
|
||||
required: false
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: false,
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'steps',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
defaultValue: 'Unnamed Step'
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
options: steps.map(t => t.slug)
|
||||
},
|
||||
{
|
||||
name: 'input',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Step input configuration. Use JSONPath expressions to reference dynamic data (e.g., {"url": "$.trigger.doc.webhookUrl", "data": "$.steps.previousStep.output.result"})'
|
||||
},
|
||||
defaultValue: {}
|
||||
},
|
||||
{
|
||||
name: 'dependencies',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Step names that must complete before this step can run'
|
||||
},
|
||||
hasMany: true,
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'condition',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'JSONPath expression that must evaluate to true for this step to execute (e.g., "$.trigger.doc.status == \'published\'")'
|
||||
},
|
||||
required: false
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: false,
|
||||
},
|
||||
maxPerDoc: 10,
|
||||
},
|
||||
maxPerDoc: 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,27 +39,30 @@ export const WorkflowRunsCollection: CollectionConfig = {
|
||||
type: 'select',
|
||||
admin: {
|
||||
description: 'Current execution status',
|
||||
components: {
|
||||
Cell: '@xtr-dev/payload-automation/client#StatusCell'
|
||||
}
|
||||
},
|
||||
defaultValue: 'pending',
|
||||
options: [
|
||||
{
|
||||
label: 'Pending',
|
||||
label: '⏳ Pending',
|
||||
value: 'pending',
|
||||
},
|
||||
{
|
||||
label: 'Running',
|
||||
label: '🔄 Running',
|
||||
value: 'running',
|
||||
},
|
||||
{
|
||||
label: 'Completed',
|
||||
label: '✅ Completed',
|
||||
value: 'completed',
|
||||
},
|
||||
{
|
||||
label: 'Failed',
|
||||
label: '❌ Failed',
|
||||
value: 'failed',
|
||||
},
|
||||
{
|
||||
label: 'Cancelled',
|
||||
label: '⏹️ Cancelled',
|
||||
value: 'cancelled',
|
||||
},
|
||||
],
|
||||
@@ -136,6 +139,10 @@ export const WorkflowRunsCollection: CollectionConfig = {
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Error message if workflow execution failed',
|
||||
condition: (_, siblingData) => siblingData?.status === 'failed',
|
||||
components: {
|
||||
Field: '@xtr-dev/payload-automation/client#ErrorDisplay'
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
263
src/components/ErrorDisplay.tsx
Normal file
263
src/components/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Button } from '@payloadcms/ui'
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
readOnly?: boolean
|
||||
path?: string
|
||||
}
|
||||
|
||||
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse common error patterns
|
||||
const parseError = (error: string) => {
|
||||
// Check for different error types and provide user-friendly messages
|
||||
if (error.includes('Request timeout')) {
|
||||
return {
|
||||
type: 'timeout',
|
||||
title: 'Request Timeout',
|
||||
message: 'The HTTP request took too long to complete. Consider increasing the timeout value or checking the target server.',
|
||||
technical: error
|
||||
}
|
||||
}
|
||||
|
||||
if (error.includes('Network error') || error.includes('fetch')) {
|
||||
return {
|
||||
type: 'network',
|
||||
title: 'Network Error',
|
||||
message: 'Unable to connect to the target server. Please check the URL and network connectivity.',
|
||||
technical: error
|
||||
}
|
||||
}
|
||||
|
||||
if (error.includes('Hook execution failed')) {
|
||||
return {
|
||||
type: 'hook',
|
||||
title: 'Workflow Hook Failed',
|
||||
message: 'The workflow trigger hook encountered an error. This may be due to PayloadCMS initialization issues.',
|
||||
technical: error
|
||||
}
|
||||
}
|
||||
|
||||
if (error.includes('Executor not available')) {
|
||||
return {
|
||||
type: 'executor',
|
||||
title: 'Workflow Engine Unavailable',
|
||||
message: 'The workflow execution engine is not properly initialized. Try restarting the server.',
|
||||
technical: error
|
||||
}
|
||||
}
|
||||
|
||||
if (error.includes('Collection slug is required') || error.includes('Document data is required')) {
|
||||
return {
|
||||
type: 'validation',
|
||||
title: 'Invalid Input Data',
|
||||
message: 'Required fields are missing from the workflow step configuration. Please check your step inputs.',
|
||||
technical: error
|
||||
}
|
||||
}
|
||||
|
||||
if (error.includes('status') && error.includes('4')) {
|
||||
return {
|
||||
type: 'client',
|
||||
title: 'Client Error (4xx)',
|
||||
message: 'The request was rejected by the server. Check your API credentials and request format.',
|
||||
technical: error
|
||||
}
|
||||
}
|
||||
|
||||
if (error.includes('status') && error.includes('5')) {
|
||||
return {
|
||||
type: 'server',
|
||||
title: 'Server Error (5xx)',
|
||||
message: 'The target server encountered an error. This is usually temporary - try again later.',
|
||||
technical: error
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error
|
||||
return {
|
||||
type: 'generic',
|
||||
title: 'Workflow Error',
|
||||
message: 'An error occurred during workflow execution. See technical details below.',
|
||||
technical: error
|
||||
}
|
||||
}
|
||||
|
||||
const errorInfo = parseError(value)
|
||||
|
||||
const getErrorIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'timeout': return '⏰'
|
||||
case 'network': return '🌐'
|
||||
case 'hook': return '🔗'
|
||||
case 'executor': return '⚙️'
|
||||
case 'validation': return '📋'
|
||||
case 'client': return '🚫'
|
||||
case 'server': return '🔥'
|
||||
default: return '❗'
|
||||
}
|
||||
}
|
||||
|
||||
const getErrorColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'timeout': return '#F59E0B'
|
||||
case 'network': return '#EF4444'
|
||||
case 'hook': return '#8B5CF6'
|
||||
case 'executor': return '#6B7280'
|
||||
case 'validation': return '#F59E0B'
|
||||
case 'client': return '#EF4444'
|
||||
case 'server': return '#DC2626'
|
||||
default: return '#EF4444'
|
||||
}
|
||||
}
|
||||
|
||||
const errorColor = getErrorColor(errorInfo.type)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
border: `2px solid ${errorColor}30`,
|
||||
borderRadius: '8px',
|
||||
backgroundColor: `${errorColor}08`,
|
||||
padding: '16px',
|
||||
marginTop: '8px'
|
||||
}}>
|
||||
{/* Error Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<span style={{ fontSize: '24px' }}>
|
||||
{getErrorIcon(errorInfo.type)}
|
||||
</span>
|
||||
<div>
|
||||
<h4 style={{
|
||||
margin: 0,
|
||||
color: errorColor,
|
||||
fontSize: '16px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{errorInfo.title}
|
||||
</h4>
|
||||
<p style={{
|
||||
margin: '4px 0 0 0',
|
||||
color: '#6B7280',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{errorInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Details Toggle */}
|
||||
<div>
|
||||
<div style={{ marginBottom: expanded ? '12px' : '0' }}>
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
size="small"
|
||||
>
|
||||
{expanded ? 'Hide' : 'Show'} Technical Details
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={{
|
||||
backgroundColor: '#F8F9FA',
|
||||
border: '1px solid #E5E7EB',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '13px',
|
||||
color: '#374151',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowX: 'auto'
|
||||
}}>
|
||||
{errorInfo.technical}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
backgroundColor: `${errorColor}10`,
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px'
|
||||
}}>
|
||||
<strong>💡 Quick fixes:</strong>
|
||||
<ul style={{ margin: '8px 0 0 0', paddingLeft: '20px' }}>
|
||||
{errorInfo.type === 'timeout' && (
|
||||
<>
|
||||
<li>Increase the timeout value in step configuration</li>
|
||||
<li>Check if the target server is responding slowly</li>
|
||||
</>
|
||||
)}
|
||||
{errorInfo.type === 'network' && (
|
||||
<>
|
||||
<li>Verify the URL is correct and accessible</li>
|
||||
<li>Check firewall and network connectivity</li>
|
||||
</>
|
||||
)}
|
||||
{errorInfo.type === 'hook' && (
|
||||
<>
|
||||
<li>Restart the PayloadCMS server</li>
|
||||
<li>Check server logs for initialization errors</li>
|
||||
</>
|
||||
)}
|
||||
{errorInfo.type === 'executor' && (
|
||||
<>
|
||||
<li>Restart the PayloadCMS application</li>
|
||||
<li>Verify the automation plugin is properly configured</li>
|
||||
</>
|
||||
)}
|
||||
{errorInfo.type === 'validation' && (
|
||||
<>
|
||||
<li>Check all required fields are filled in the workflow step</li>
|
||||
<li>Verify JSONPath expressions in step inputs</li>
|
||||
</>
|
||||
)}
|
||||
{(errorInfo.type === 'client' || errorInfo.type === 'server') && (
|
||||
<>
|
||||
<li>Check API credentials and permissions</li>
|
||||
<li>Verify the request format matches API expectations</li>
|
||||
<li>Try the request manually to test the endpoint</li>
|
||||
</>
|
||||
)}
|
||||
{errorInfo.type === 'generic' && (
|
||||
<>
|
||||
<li>Check the workflow configuration</li>
|
||||
<li>Review server logs for more details</li>
|
||||
<li>Try running the workflow again</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Hidden textarea for editing if needed */}
|
||||
{!readOnly && onChange && (
|
||||
<textarea
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ display: 'none' }}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/components/StatusCell.tsx
Normal file
45
src/components/StatusCell.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface StatusCellProps {
|
||||
cellData: string
|
||||
}
|
||||
|
||||
export const StatusCell: React.FC<StatusCellProps> = ({ cellData }) => {
|
||||
const getStatusDisplay = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { icon: '⏳', color: '#6B7280', label: 'Pending' }
|
||||
case 'running':
|
||||
return { icon: '🔄', color: '#3B82F6', label: 'Running' }
|
||||
case 'completed':
|
||||
return { icon: '✅', color: '#10B981', label: 'Completed' }
|
||||
case 'failed':
|
||||
return { icon: '❌', color: '#EF4444', label: 'Failed' }
|
||||
case 'cancelled':
|
||||
return { icon: '⏹️', color: '#F59E0B', label: 'Cancelled' }
|
||||
default:
|
||||
return { icon: '❓', color: '#6B7280', label: status || 'Unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
const { icon, color, label } = getStatusDisplay(cellData)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: `${color}15`,
|
||||
border: `1px solid ${color}30`,
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
<span style={{ fontSize: '16px' }}>{icon}</span>
|
||||
<span style={{ color }}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button, toast } from '@payloadcms/ui'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface TriggerWorkflowButtonProps {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
triggerSlug?: string
|
||||
}
|
||||
|
||||
export const TriggerWorkflowButton: React.FC<TriggerWorkflowButtonProps> = ({
|
||||
workflowId,
|
||||
workflowName,
|
||||
triggerSlug = 'manual-trigger'
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleTrigger = async () => {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/workflows/trigger-custom', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowId,
|
||||
triggerSlug,
|
||||
data: {
|
||||
triggeredAt: new Date().toISOString(),
|
||||
source: 'admin-button'
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to trigger workflow')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
toast.success(`Workflow "${workflowName}" triggered successfully! Run ID: ${result.runId}`)
|
||||
} catch (error) {
|
||||
console.error('Error triggering workflow:', error)
|
||||
toast.error(`Failed to trigger workflow: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleTrigger}
|
||||
disabled={loading}
|
||||
size="small"
|
||||
buttonStyle="secondary"
|
||||
>
|
||||
{loading ? 'Triggering...' : 'Trigger Workflow'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
297
src/components/WorkflowBuilder/StepConfigurationForm.tsx
Normal file
297
src/components/WorkflowBuilder/StepConfigurationForm.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import { Button } from '@payloadcms/ui'
|
||||
|
||||
interface StepField {
|
||||
name: string
|
||||
type: string
|
||||
label?: string
|
||||
admin?: {
|
||||
description?: string
|
||||
condition?: (data: any, siblingData: any) => boolean
|
||||
}
|
||||
options?: Array<{ label: string; value: string }>
|
||||
defaultValue?: any
|
||||
required?: boolean
|
||||
hasMany?: boolean
|
||||
fields?: StepField[] // For group fields
|
||||
}
|
||||
|
||||
interface StepType {
|
||||
slug: string
|
||||
label?: string
|
||||
inputSchema?: StepField[]
|
||||
outputSchema?: StepField[]
|
||||
}
|
||||
|
||||
interface StepConfigurationFormProps {
|
||||
selectedNode: Node | null
|
||||
availableStepTypes: StepType[]
|
||||
availableSteps: string[] // For dependency selection
|
||||
onNodeUpdate: (nodeId: string, data: Partial<Node['data']>) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const StepConfigurationForm: React.FC<StepConfigurationFormProps> = ({
|
||||
selectedNode,
|
||||
availableStepTypes,
|
||||
availableSteps,
|
||||
onNodeUpdate,
|
||||
onClose
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Record<string, any>>(
|
||||
selectedNode?.data.configuration || {}
|
||||
)
|
||||
const [jsonText, setJsonText] = useState<string>(() =>
|
||||
JSON.stringify(selectedNode?.data.configuration || {}, null, 2)
|
||||
)
|
||||
|
||||
if (!selectedNode) return null
|
||||
|
||||
const stepType = availableStepTypes.find(type => type.slug === selectedNode.data.stepType)
|
||||
const inputSchema = stepType?.inputSchema || []
|
||||
|
||||
// Update form data when selected node changes
|
||||
useEffect(() => {
|
||||
const config = selectedNode?.data.configuration || {}
|
||||
setFormData(config)
|
||||
setJsonText(JSON.stringify(config, null, 2))
|
||||
}, [selectedNode])
|
||||
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
// Update the node with form data
|
||||
onNodeUpdate(selectedNode.id, {
|
||||
...selectedNode.data,
|
||||
configuration: formData
|
||||
})
|
||||
|
||||
onClose()
|
||||
}, [selectedNode, formData, onNodeUpdate, onClose])
|
||||
|
||||
const renderStepConfiguration = () => {
|
||||
if (!inputSchema.length) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--theme-text-400)',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
This step type has no configuration parameters.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
color: 'var(--theme-text)'
|
||||
}}>
|
||||
Step Configuration
|
||||
</label>
|
||||
<div style={{ fontSize: '11px', color: 'var(--theme-text-400)', marginBottom: '8px' }}>
|
||||
Configure this step's parameters in JSON format. Use JSONPath expressions like <code>$.trigger.doc.id</code> to reference dynamic data.
|
||||
</div>
|
||||
|
||||
{/* Schema Reference */}
|
||||
<details style={{ marginBottom: '12px' }}>
|
||||
<summary style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-text-400)',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
📖 Available Fields (click to expand)
|
||||
</summary>
|
||||
<div style={{
|
||||
background: 'var(--theme-elevation-50)',
|
||||
border: '1px solid var(--theme-elevation-100)',
|
||||
borderRadius: '4px',
|
||||
padding: '12px',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{inputSchema.map((field, index) => (
|
||||
<div key={field.name} style={{ marginBottom: index < inputSchema.length - 1 ? '8px' : '0' }}>
|
||||
<strong>{field.name}</strong> ({field.type})
|
||||
{field.required && <span style={{ color: 'var(--theme-error-500)' }}> *required</span>}
|
||||
{field.admin?.description && (
|
||||
<div style={{ color: 'var(--theme-text-400)', marginTop: '2px' }}>
|
||||
{field.admin.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => {
|
||||
const text = e.target.value
|
||||
setJsonText(text)
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
setFormData(parsed)
|
||||
} catch {
|
||||
// Keep invalid JSON, user is still typing
|
||||
// Don't update formData until JSON is valid
|
||||
}
|
||||
}}
|
||||
rows={Math.min(Math.max(inputSchema.length * 2, 6), 15)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid var(--theme-elevation-100)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: '1.4',
|
||||
background: 'var(--theme-input-bg)',
|
||||
color: 'var(--theme-text)',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
placeholder='{\n "field1": "value1",\n "field2": "$.trigger.doc.id"\n}'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid var(--theme-elevation-100)',
|
||||
background: 'var(--theme-elevation-50)'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<h4 style={{ margin: 0, fontSize: '16px', fontWeight: '600', color: 'var(--theme-text)' }}>
|
||||
Configure Step
|
||||
</h4>
|
||||
<Button
|
||||
buttonStyle="none"
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--theme-text-400)', marginTop: '4px' }}>
|
||||
{stepType?.label || (selectedNode.data.stepType as string)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '16px'
|
||||
}}>
|
||||
{/* Basic step info */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
Step Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(selectedNode.data.label as string) || ''}
|
||||
onChange={(e) => onNodeUpdate(selectedNode.id, {
|
||||
...selectedNode.data,
|
||||
label: e.target.value
|
||||
})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid var(--theme-elevation-100)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
Dependencies
|
||||
</label>
|
||||
<div style={{ fontSize: '11px', color: 'var(--theme-text-400)', marginBottom: '8px' }}>
|
||||
Steps that must complete before this step can run
|
||||
</div>
|
||||
{availableSteps
|
||||
.filter(step => step !== selectedNode.id)
|
||||
.map(stepId => (
|
||||
<label key={stepId} style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={((selectedNode.data.dependencies as string[]) || []).includes(stepId)}
|
||||
onChange={(e) => {
|
||||
const currentDeps = (selectedNode.data.dependencies as string[]) || []
|
||||
const newDeps = e.target.checked
|
||||
? [...currentDeps, stepId]
|
||||
: currentDeps.filter((dep: string) => dep !== stepId)
|
||||
|
||||
onNodeUpdate(selectedNode.id, {
|
||||
...selectedNode.data,
|
||||
dependencies: newDeps
|
||||
})
|
||||
}}
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
{stepId}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step-specific configuration */}
|
||||
{renderStepConfiguration()}
|
||||
|
||||
{/* Submit button */}
|
||||
<div style={{
|
||||
borderTop: '1px solid var(--theme-elevation-100)',
|
||||
paddingTop: '16px',
|
||||
marginTop: '16px'
|
||||
}}>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
src/components/WorkflowBuilder/WorkflowBuilder.tsx
Normal file
255
src/components/WorkflowBuilder/WorkflowBuilder.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Node,
|
||||
Edge,
|
||||
addEdge,
|
||||
Connection,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
Controls,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
MiniMap,
|
||||
Panel
|
||||
} from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
|
||||
// Import custom node types
|
||||
import { StepNode } from './nodes/StepNode.js'
|
||||
import { WorkflowToolbar } from './WorkflowToolbar.js'
|
||||
import { StepConfigurationForm } from './StepConfigurationForm.js'
|
||||
|
||||
// Define node types for React Flow
|
||||
const nodeTypes = {
|
||||
stepNode: StepNode,
|
||||
}
|
||||
|
||||
interface WorkflowData {
|
||||
id: string
|
||||
name: string
|
||||
steps?: Array<{
|
||||
name: string
|
||||
type: string
|
||||
position?: { x: number; y: number }
|
||||
visual?: { color?: string; icon?: string }
|
||||
dependencies?: string[]
|
||||
}>
|
||||
layout?: {
|
||||
viewport?: { x: number; y: number; zoom: number }
|
||||
}
|
||||
}
|
||||
|
||||
interface StepType {
|
||||
slug: string
|
||||
label?: string
|
||||
inputSchema?: any[]
|
||||
outputSchema?: any[]
|
||||
}
|
||||
|
||||
interface WorkflowBuilderProps {
|
||||
workflow?: WorkflowData
|
||||
availableStepTypes?: StepType[]
|
||||
onSave?: (workflow: WorkflowData) => void
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export const WorkflowBuilder: React.FC<WorkflowBuilderProps> = ({
|
||||
workflow,
|
||||
availableStepTypes = [],
|
||||
onSave,
|
||||
readonly = false
|
||||
}) => {
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
|
||||
|
||||
// Convert workflow steps to React Flow nodes
|
||||
const initialNodes: Node[] = useMemo(() => {
|
||||
if (!workflow?.steps) return []
|
||||
|
||||
return workflow.steps.map((step, index) => ({
|
||||
id: step.name || `step-${index}`,
|
||||
type: 'stepNode',
|
||||
position: step.position || { x: 100 + index * 200, y: 100 },
|
||||
data: {
|
||||
label: step.name || 'Unnamed Step',
|
||||
stepType: step.type,
|
||||
color: step.visual?.color || '#3b82f6',
|
||||
icon: step.visual?.icon,
|
||||
dependencies: step.dependencies || []
|
||||
}
|
||||
}))
|
||||
}, [workflow?.steps])
|
||||
|
||||
// Convert dependencies to React Flow edges
|
||||
const initialEdges: Edge[] = useMemo(() => {
|
||||
if (!workflow?.steps) return []
|
||||
|
||||
const edges: Edge[] = []
|
||||
|
||||
workflow.steps.forEach((step, index) => {
|
||||
const targetId = step.name || `step-${index}`
|
||||
|
||||
if (step.dependencies) {
|
||||
step.dependencies.forEach((depName) => {
|
||||
// Find the source step
|
||||
const sourceStep = workflow.steps?.find(s => s.name === depName)
|
||||
if (sourceStep) {
|
||||
const sourceId = sourceStep.name || `step-${workflow.steps?.indexOf(sourceStep)}`
|
||||
edges.push({
|
||||
id: `${sourceId}-${targetId}`,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
type: 'smoothstep'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return edges
|
||||
}, [workflow?.steps])
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
|
||||
|
||||
// Handle new connections
|
||||
const onConnect = useCallback((params: Connection) => {
|
||||
if (readonly) return
|
||||
|
||||
setEdges((eds) => addEdge({
|
||||
...params,
|
||||
type: 'smoothstep'
|
||||
}, eds))
|
||||
}, [setEdges, readonly])
|
||||
|
||||
// Handle node selection
|
||||
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
||||
console.log('Node clicked:', node.id, node.data.label)
|
||||
setSelectedNode(node)
|
||||
}, [])
|
||||
|
||||
// Handle adding new step
|
||||
const onAddStep = useCallback((stepType: string) => {
|
||||
if (readonly) return
|
||||
|
||||
const newStep: Node = {
|
||||
id: `step-${Date.now()}`,
|
||||
type: 'stepNode',
|
||||
position: { x: 100, y: 100 },
|
||||
data: {
|
||||
label: 'New Step',
|
||||
stepType,
|
||||
color: '#3b82f6',
|
||||
dependencies: []
|
||||
}
|
||||
}
|
||||
|
||||
setNodes((nds) => [...nds, newStep])
|
||||
}, [setNodes, readonly])
|
||||
|
||||
// Handle updating a node's data
|
||||
const handleNodeUpdate = useCallback((nodeId: string, newData: Partial<Node['data']>) => {
|
||||
setNodes((nds) =>
|
||||
nds.map((node) =>
|
||||
node.id === nodeId
|
||||
? { ...node, data: { ...node.data, ...newData } }
|
||||
: node
|
||||
)
|
||||
)
|
||||
}, [setNodes])
|
||||
|
||||
// Handle saving workflow
|
||||
const handleSave = useCallback(() => {
|
||||
if (!workflow || !onSave) return
|
||||
|
||||
// Convert nodes and edges back to workflow format
|
||||
const updatedSteps = nodes.map((node) => {
|
||||
// Find dependencies from edges
|
||||
const dependencies = edges
|
||||
.filter(edge => edge.target === node.id)
|
||||
.map(edge => edge.source)
|
||||
|
||||
return {
|
||||
name: node.id,
|
||||
type: node.data.stepType as string,
|
||||
position: node.position,
|
||||
visual: {
|
||||
color: node.data.color as string,
|
||||
icon: node.data.icon as string
|
||||
},
|
||||
dependencies: dependencies.length > 0 ? dependencies : undefined
|
||||
}
|
||||
})
|
||||
|
||||
const updatedWorkflow: WorkflowData = {
|
||||
...workflow,
|
||||
steps: updatedSteps
|
||||
}
|
||||
|
||||
onSave(updatedWorkflow)
|
||||
}, [workflow, nodes, edges, onSave])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '600px',
|
||||
display: 'flex',
|
||||
background: 'var(--theme-bg)',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--theme-elevation-100)'
|
||||
}}>
|
||||
{/* Main canvas area */}
|
||||
<div style={{
|
||||
flex: selectedNode ? '1 1 70%' : '1 1 100%',
|
||||
transition: 'flex 0.3s ease'
|
||||
}}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
attributionPosition="top-right"
|
||||
>
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||
|
||||
{!readonly && (
|
||||
<Panel position="top-left">
|
||||
<WorkflowToolbar
|
||||
availableStepTypes={availableStepTypes}
|
||||
onAddStep={onAddStep}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Side panel for step configuration */}
|
||||
{selectedNode && !readonly && (
|
||||
<div style={{
|
||||
flex: '0 0 30%',
|
||||
borderLeft: '1px solid var(--theme-elevation-100)',
|
||||
background: 'var(--theme-elevation-0)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<StepConfigurationForm
|
||||
selectedNode={selectedNode}
|
||||
availableStepTypes={availableStepTypes}
|
||||
availableSteps={nodes.map(node => node.id)}
|
||||
onNodeUpdate={handleNodeUpdate}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
src/components/WorkflowBuilder/WorkflowToolbar.tsx
Normal file
118
src/components/WorkflowBuilder/WorkflowToolbar.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface AvailableStepType {
|
||||
slug: string
|
||||
label?: string
|
||||
inputSchema?: any[]
|
||||
outputSchema?: any[]
|
||||
}
|
||||
|
||||
interface WorkflowToolbarProps {
|
||||
availableStepTypes: AvailableStepType[]
|
||||
onAddStep: (stepType: string) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
export const WorkflowToolbar: React.FC<WorkflowToolbarProps> = ({
|
||||
availableStepTypes,
|
||||
onAddStep,
|
||||
onSave
|
||||
}) => {
|
||||
const getStepTypeLabel = (stepType: AvailableStepType) => {
|
||||
return stepType.label || stepType.slug.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
}
|
||||
|
||||
const getStepTypeIcon = (stepType: AvailableStepType) => {
|
||||
// Simple icon mapping based on step type
|
||||
switch (stepType.slug) {
|
||||
case 'http-request-step':
|
||||
return '🌐'
|
||||
case 'create-document-step':
|
||||
return '📄'
|
||||
case 'read-document-step':
|
||||
return '👁️'
|
||||
case 'update-document-step':
|
||||
return '✏️'
|
||||
case 'delete-document-step':
|
||||
return '🗑️'
|
||||
case 'send-email-step':
|
||||
return '📧'
|
||||
default:
|
||||
return '⚡'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--theme-elevation-0)',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--theme-elevation-150)',
|
||||
minWidth: '200px'
|
||||
}}>
|
||||
<h4 style={{
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--theme-text)'
|
||||
}}>
|
||||
Add Step
|
||||
</h4>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
{availableStepTypes.map((stepType) => (
|
||||
<button
|
||||
key={stepType.slug}
|
||||
onClick={() => onAddStep(stepType.slug)}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
margin: '4px 0',
|
||||
background: 'var(--theme-elevation-50)',
|
||||
border: '1px solid var(--theme-elevation-100)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '12px',
|
||||
color: 'var(--theme-text)',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--theme-elevation-100)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--theme-elevation-50)'
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: '8px' }}>
|
||||
{getStepTypeIcon(stepType)}
|
||||
</span>
|
||||
{getStepTypeLabel(stepType)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--theme-elevation-100)', paddingTop: '12px' }}>
|
||||
<button
|
||||
onClick={onSave}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 16px',
|
||||
background: 'var(--theme-success-500)',
|
||||
color: 'var(--theme-base-0)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
💾 Save Workflow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
src/components/WorkflowBuilder/index.ts
Normal file
4
src/components/WorkflowBuilder/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { WorkflowBuilder } from './WorkflowBuilder.js'
|
||||
export { WorkflowToolbar } from './WorkflowToolbar.js'
|
||||
export { StepConfigurationForm } from './StepConfigurationForm.js'
|
||||
export { StepNode } from './nodes/StepNode.js'
|
||||
157
src/components/WorkflowBuilder/nodes/StepNode.tsx
Normal file
157
src/components/WorkflowBuilder/nodes/StepNode.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import React, { memo } from 'react'
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react'
|
||||
|
||||
interface StepNodeData {
|
||||
label: string
|
||||
stepType: string
|
||||
color?: string
|
||||
icon?: string
|
||||
dependencies?: string[]
|
||||
}
|
||||
|
||||
export const StepNode: React.FC<NodeProps> = memo(({ data, selected }) => {
|
||||
const { label, stepType, color = '#3b82f6', icon, dependencies = [] } = data as unknown as StepNodeData
|
||||
|
||||
const getStepTypeIcon = (type: string) => {
|
||||
// Return icon from data or default based on type
|
||||
if (icon) return icon
|
||||
|
||||
switch (type) {
|
||||
case 'http-request-step':
|
||||
return '🌐'
|
||||
case 'create-document-step':
|
||||
return '📄'
|
||||
case 'read-document-step':
|
||||
return '👁️'
|
||||
case 'update-document-step':
|
||||
return '✏️'
|
||||
case 'delete-document-step':
|
||||
return '🗑️'
|
||||
case 'send-email-step':
|
||||
return '📧'
|
||||
default:
|
||||
return '⚡'
|
||||
}
|
||||
}
|
||||
|
||||
const getStepTypeLabel = (type: string) => {
|
||||
return type.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: color,
|
||||
color: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
minWidth: '150px',
|
||||
border: selected ? '2px solid #1e40af' : '1px solid rgba(255, 255, 255, 0.2)',
|
||||
boxShadow: selected
|
||||
? '0 8px 25px rgba(0, 0, 0, 0.15)'
|
||||
: '0 4px 15px rgba(0, 0, 0, 0.1)',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
position: 'relative'
|
||||
}}
|
||||
title="Click to configure this step"
|
||||
>
|
||||
{/* Input Handle - only show if this step has dependencies */}
|
||||
{dependencies.length > 0 && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
style={{
|
||||
background: '#fff',
|
||||
border: '2px solid #3b82f6',
|
||||
width: '10px',
|
||||
height: '10px'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
<span style={{ fontSize: '16px' }}>
|
||||
{getStepTypeIcon(stepType)}
|
||||
</span>
|
||||
<div>
|
||||
<div style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.2'
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
opacity: 0.9,
|
||||
fontWeight: '400'
|
||||
}}>
|
||||
{getStepTypeLabel(stepType)}
|
||||
</div>
|
||||
|
||||
{/* Status indicator for dependencies */}
|
||||
{dependencies.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '20px',
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '50%',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '8px',
|
||||
fontWeight: 'bold',
|
||||
pointerEvents: 'none' // Allow clicks to pass through to parent
|
||||
}}>
|
||||
{dependencies.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configuration indicator */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
background: 'rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '50%',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
pointerEvents: 'none' // Allow clicks to pass through to parent
|
||||
}}>
|
||||
⚙️
|
||||
</div>
|
||||
|
||||
{/* Output Handle - always show for potential connections */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
style={{
|
||||
background: '#fff',
|
||||
border: '2px solid #3b82f6',
|
||||
width: '10px',
|
||||
height: '10px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
StepNode.displayName = 'StepNode'
|
||||
@@ -5,61 +5,45 @@ import type { Payload, PayloadRequest } from 'payload'
|
||||
export type PayloadWorkflow = {
|
||||
id: number
|
||||
name: string
|
||||
description?: string | null
|
||||
description?: null | string
|
||||
triggers?: Array<{
|
||||
type?: string | null
|
||||
collectionSlug?: string | null
|
||||
operation?: string | null
|
||||
condition?: string | null
|
||||
type?: null | string
|
||||
condition?: null | string
|
||||
parameters?: {
|
||||
collectionSlug?: null | string
|
||||
operation?: null | string
|
||||
global?: null | string
|
||||
globalOperation?: null | string
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
[key: string]: unknown
|
||||
}> | null
|
||||
steps?: Array<{
|
||||
step?: string | null
|
||||
name?: string | null
|
||||
type?: null | string
|
||||
name?: null | string
|
||||
input?: unknown
|
||||
dependencies?: string[] | null
|
||||
condition?: string | null
|
||||
dependencies?: null | string[]
|
||||
condition?: null | string
|
||||
[key: string]: unknown
|
||||
}> | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
import { JSONPath } from 'jsonpath-plus'
|
||||
import Handlebars from 'handlebars'
|
||||
|
||||
// Helper type to extract workflow step data from the generated types
|
||||
export type WorkflowStep = NonNullable<PayloadWorkflow['steps']>[0] & {
|
||||
export type WorkflowStep = {
|
||||
name: string // Ensure name is always present for our execution logic
|
||||
}
|
||||
} & NonNullable<PayloadWorkflow['steps']>[0]
|
||||
|
||||
// Helper type to extract workflow trigger data from the generated types
|
||||
export type WorkflowTrigger = NonNullable<PayloadWorkflow['triggers']>[0] & {
|
||||
// Helper type to extract workflow trigger data from the generated types
|
||||
export type WorkflowTrigger = {
|
||||
type: string // Ensure type is always present for our execution logic
|
||||
}
|
||||
} & NonNullable<PayloadWorkflow['triggers']>[0]
|
||||
|
||||
export interface ExecutionContext {
|
||||
steps: Record<string, {
|
||||
error?: string
|
||||
input: unknown
|
||||
output: unknown
|
||||
state: 'failed' | 'pending' | 'running' | 'succeeded'
|
||||
}>
|
||||
trigger: {
|
||||
collection?: string
|
||||
data?: unknown
|
||||
doc?: unknown
|
||||
headers?: Record<string, string>
|
||||
operation?: string
|
||||
path?: string
|
||||
previousDoc?: unknown
|
||||
req?: PayloadRequest
|
||||
triggeredAt?: string
|
||||
type: string
|
||||
user?: {
|
||||
collection?: string
|
||||
email?: string
|
||||
id?: string
|
||||
}
|
||||
}
|
||||
steps: Record<string, any>
|
||||
trigger: Record<string, any>
|
||||
}
|
||||
|
||||
export class WorkflowExecutor {
|
||||
@@ -68,6 +52,82 @@ export class WorkflowExecutor {
|
||||
private logger: Payload['logger']
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Convert string values to appropriate types based on common patterns
|
||||
*/
|
||||
private convertValueType(value: unknown, key: string): unknown {
|
||||
if (typeof value !== 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
// Type conversion patterns based on field names and values
|
||||
const numericFields = ['timeout', 'retries', 'delay', 'port', 'limit', 'offset', 'count', 'max', 'min']
|
||||
const booleanFields = ['enabled', 'required', 'active', 'success', 'failed', 'complete']
|
||||
|
||||
// Convert numeric fields
|
||||
if (numericFields.some(field => key.toLowerCase().includes(field))) {
|
||||
const numValue = Number(value)
|
||||
if (!isNaN(numValue)) {
|
||||
this.logger.debug({
|
||||
key,
|
||||
originalValue: value,
|
||||
convertedValue: numValue
|
||||
}, 'Auto-converted field to number')
|
||||
return numValue
|
||||
}
|
||||
}
|
||||
|
||||
// Convert boolean fields
|
||||
if (booleanFields.some(field => key.toLowerCase().includes(field))) {
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
}
|
||||
|
||||
// Try to parse as number if it looks numeric
|
||||
if (/^\d+$/.test(value)) {
|
||||
const numValue = parseInt(value, 10)
|
||||
this.logger.debug({
|
||||
key,
|
||||
originalValue: value,
|
||||
convertedValue: numValue
|
||||
}, 'Auto-converted numeric string to number')
|
||||
return numValue
|
||||
}
|
||||
|
||||
// Try to parse as float if it looks like a decimal
|
||||
if (/^\d+\.\d+$/.test(value)) {
|
||||
const floatValue = parseFloat(value)
|
||||
this.logger.debug({
|
||||
key,
|
||||
originalValue: value,
|
||||
convertedValue: floatValue
|
||||
}, 'Auto-converted decimal string to number')
|
||||
return floatValue
|
||||
}
|
||||
|
||||
// Return as string if no conversion applies
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies error types based on error messages
|
||||
*/
|
||||
private classifyErrorType(errorMessage: string): string {
|
||||
if (errorMessage.includes('timeout') || errorMessage.includes('ETIMEDOUT')) {
|
||||
return 'timeout'
|
||||
}
|
||||
if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) {
|
||||
return 'dns'
|
||||
}
|
||||
if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('ECONNRESET')) {
|
||||
return 'connection'
|
||||
}
|
||||
if (errorMessage.includes('network') || errorMessage.includes('fetch')) {
|
||||
return 'network'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a step condition using JSONPath
|
||||
*/
|
||||
@@ -154,15 +214,19 @@ export class WorkflowExecutor {
|
||||
error: undefined,
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
state: 'running'
|
||||
state: 'running',
|
||||
_startTime: Date.now() // Track execution start time for independent duration tracking
|
||||
}
|
||||
|
||||
// Move taskSlug declaration outside try block so it's accessible in catch
|
||||
const taskSlug = step.step // Use the 'step' field for task type
|
||||
const taskSlug = step.type as string
|
||||
|
||||
try {
|
||||
// Resolve input data using JSONPath
|
||||
const resolvedInput = this.resolveStepInput(step.input as Record<string, unknown> || {}, context)
|
||||
// Get input configuration from the step
|
||||
const inputConfig = (step.input as Record<string, unknown>) || {}
|
||||
|
||||
// Resolve input data using Handlebars templates
|
||||
const resolvedInput = this.resolveStepInput(inputConfig, context, taskSlug)
|
||||
context.steps[stepName].input = resolvedInput
|
||||
|
||||
if (!taskSlug) {
|
||||
@@ -182,12 +246,22 @@ export class WorkflowExecutor {
|
||||
task: taskSlug
|
||||
})
|
||||
|
||||
// Run the job immediately
|
||||
await this.payload.jobs.run({
|
||||
limit: 1,
|
||||
// Run the specific job immediately and wait for completion
|
||||
this.logger.info({ jobId: job.id }, 'Running job immediately using runByID')
|
||||
const runResults = await this.payload.jobs.runByID({
|
||||
id: job.id,
|
||||
req
|
||||
})
|
||||
|
||||
this.logger.info({
|
||||
jobId: job.id,
|
||||
runResult: runResults,
|
||||
hasResult: !!runResults
|
||||
}, 'Job run completed')
|
||||
|
||||
// Give a small delay to ensure job is fully processed
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// Get the job result
|
||||
const completedJob = await this.payload.findByID({
|
||||
id: job.id,
|
||||
@@ -195,6 +269,13 @@ export class WorkflowExecutor {
|
||||
req
|
||||
})
|
||||
|
||||
this.logger.info({
|
||||
jobId: job.id,
|
||||
totalTried: completedJob.totalTried,
|
||||
hasError: completedJob.hasError,
|
||||
taskStatus: completedJob.taskStatus ? Object.keys(completedJob.taskStatus) : 'null'
|
||||
}, 'Retrieved job results')
|
||||
|
||||
const taskStatus = completedJob.taskStatus?.[completedJob.taskSlug]?.[completedJob.totalTried]
|
||||
const isComplete = taskStatus?.complete === true
|
||||
const hasError = completedJob.hasError || !isComplete
|
||||
@@ -213,9 +294,37 @@ export class WorkflowExecutor {
|
||||
errorMessage = completedJob.error.message || completedJob.error
|
||||
}
|
||||
|
||||
// Final fallback to generic message
|
||||
// Try to get error from task output if available
|
||||
if (!errorMessage && taskStatus?.output?.error) {
|
||||
errorMessage = taskStatus.output.error
|
||||
}
|
||||
|
||||
// Check if task handler returned with state='failed'
|
||||
if (!errorMessage && taskStatus?.state === 'failed') {
|
||||
errorMessage = 'Task handler returned a failed state'
|
||||
// Try to get more specific error from output
|
||||
if (taskStatus.output?.error) {
|
||||
errorMessage = taskStatus.output.error
|
||||
}
|
||||
}
|
||||
|
||||
// Check for network errors in the job data
|
||||
if (!errorMessage && completedJob.result) {
|
||||
const result = completedJob.result
|
||||
if (result.error) {
|
||||
errorMessage = result.error
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback to generic message with more detail
|
||||
if (!errorMessage) {
|
||||
errorMessage = `Task ${taskSlug} failed without detailed error information`
|
||||
const jobDetails = {
|
||||
taskSlug,
|
||||
hasError: completedJob.hasError,
|
||||
taskStatus: taskStatus?.complete,
|
||||
totalTried: completedJob.totalTried
|
||||
}
|
||||
errorMessage = `Task ${taskSlug} failed without detailed error information. Job details: ${JSON.stringify(jobDetails)}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +345,30 @@ export class WorkflowExecutor {
|
||||
context.steps[stepName].error = result.error
|
||||
}
|
||||
|
||||
// Independent execution tracking (not dependent on PayloadCMS task status)
|
||||
context.steps[stepName].executionInfo = {
|
||||
completed: true, // Step execution completed (regardless of success/failure)
|
||||
success: result.state === 'succeeded',
|
||||
executedAt: new Date().toISOString(),
|
||||
duration: Date.now() - (context.steps[stepName]._startTime || Date.now())
|
||||
}
|
||||
|
||||
// For failed steps, try to extract detailed error information from the job logs
|
||||
// This approach is more reliable than external storage and persists with the workflow
|
||||
if (result.state === 'failed') {
|
||||
const errorDetails = this.extractErrorDetailsFromJob(completedJob, context.steps[stepName], stepName)
|
||||
if (errorDetails) {
|
||||
context.steps[stepName].errorDetails = errorDetails
|
||||
|
||||
this.logger.info({
|
||||
stepName,
|
||||
errorType: errorDetails.errorType,
|
||||
duration: errorDetails.duration,
|
||||
attempts: errorDetails.attempts
|
||||
}, 'Extracted detailed error information for failed step')
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug({context}, 'Step execution context')
|
||||
|
||||
if (result.state !== 'succeeded') {
|
||||
@@ -257,6 +390,15 @@ export class WorkflowExecutor {
|
||||
context.steps[stepName].state = 'failed'
|
||||
context.steps[stepName].error = errorMessage
|
||||
|
||||
// Independent execution tracking for failed steps
|
||||
context.steps[stepName].executionInfo = {
|
||||
completed: true, // Execution attempted and completed (even if it failed)
|
||||
success: false,
|
||||
executedAt: new Date().toISOString(),
|
||||
duration: Date.now() - (context.steps[stepName]._startTime || Date.now()),
|
||||
failureReason: errorMessage
|
||||
}
|
||||
|
||||
this.logger.error({
|
||||
error: errorMessage,
|
||||
input: context.steps[stepName].input,
|
||||
@@ -280,6 +422,69 @@ export class WorkflowExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts detailed error information from job logs and input
|
||||
*/
|
||||
private extractErrorDetailsFromJob(job: any, stepContext: any, stepName: string) {
|
||||
try {
|
||||
// Get error information from multiple sources
|
||||
const input = stepContext.input || {}
|
||||
const logs = job.log || []
|
||||
const latestLog = logs[logs.length - 1]
|
||||
|
||||
// Extract error message from job error or log
|
||||
const errorMessage = job.error?.message || latestLog?.error?.message || 'Unknown error'
|
||||
|
||||
// For timeout scenarios, check if it's a timeout based on duration and timeout setting
|
||||
let errorType = this.classifyErrorType(errorMessage)
|
||||
|
||||
// Special handling for HTTP timeouts - if task failed and duration exceeds timeout, it's likely a timeout
|
||||
if (errorType === 'unknown' && input.timeout && stepContext.executionInfo?.duration) {
|
||||
const timeoutMs = parseInt(input.timeout) || 30000
|
||||
const actualDuration = stepContext.executionInfo.duration
|
||||
|
||||
// If execution duration is close to or exceeds timeout, classify as timeout
|
||||
if (actualDuration >= (timeoutMs * 0.9)) { // 90% of timeout threshold
|
||||
errorType = 'timeout'
|
||||
this.logger.debug({
|
||||
timeoutMs,
|
||||
actualDuration,
|
||||
stepName
|
||||
}, 'Classified error as timeout based on duration analysis')
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate duration from execution info if available
|
||||
const duration = stepContext.executionInfo?.duration || 0
|
||||
|
||||
// Extract attempt count from logs
|
||||
const attempts = job.totalTried || 1
|
||||
|
||||
return {
|
||||
stepId: `${stepName}-${Date.now()}`,
|
||||
errorType,
|
||||
duration,
|
||||
attempts,
|
||||
finalError: errorMessage,
|
||||
context: {
|
||||
url: input.url,
|
||||
method: input.method,
|
||||
timeout: input.timeout,
|
||||
statusCode: latestLog?.output?.status,
|
||||
headers: input.headers
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
stepName
|
||||
}, 'Failed to extract error details from job')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolve step execution order based on dependencies
|
||||
*/
|
||||
@@ -337,52 +542,56 @@ export class WorkflowExecutor {
|
||||
return executionBatches
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolve step input using JSONPath expressions
|
||||
* Resolve step input using Handlebars templates with automatic type conversion
|
||||
*/
|
||||
private resolveStepInput(config: Record<string, unknown>, context: ExecutionContext): Record<string, unknown> {
|
||||
private resolveStepInput(config: Record<string, unknown>, context: ExecutionContext, stepType?: string): Record<string, unknown> {
|
||||
const resolved: Record<string, unknown> = {}
|
||||
|
||||
this.logger.debug({
|
||||
configKeys: Object.keys(config),
|
||||
contextSteps: Object.keys(context.steps),
|
||||
triggerType: context.trigger?.type
|
||||
}, 'Starting step input resolution')
|
||||
triggerType: context.trigger?.type,
|
||||
stepType
|
||||
}, 'Starting step input resolution with Handlebars')
|
||||
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (typeof value === 'string' && value.startsWith('$')) {
|
||||
// This is a JSONPath expression
|
||||
this.logger.debug({
|
||||
key,
|
||||
jsonPath: value,
|
||||
availableSteps: Object.keys(context.steps),
|
||||
hasTriggerData: !!context.trigger?.data,
|
||||
hasTriggerDoc: !!context.trigger?.doc
|
||||
}, 'Resolving JSONPath expression')
|
||||
|
||||
try {
|
||||
const result = JSONPath({
|
||||
json: context,
|
||||
path: value,
|
||||
wrap: false
|
||||
})
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// Check if the string contains Handlebars templates
|
||||
if (value.includes('{{') && value.includes('}}')) {
|
||||
this.logger.debug({
|
||||
key,
|
||||
jsonPath: value,
|
||||
result: JSON.stringify(result).substring(0, 200),
|
||||
resultType: Array.isArray(result) ? 'array' : typeof result
|
||||
}, 'JSONPath resolved successfully')
|
||||
|
||||
resolved[key] = result
|
||||
} catch (error) {
|
||||
this.logger.warn({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
key,
|
||||
path: value,
|
||||
contextSnapshot: JSON.stringify(context).substring(0, 500)
|
||||
}, 'Failed to resolve JSONPath')
|
||||
resolved[key] = value // Keep original value if resolution fails
|
||||
template: value,
|
||||
availableSteps: Object.keys(context.steps),
|
||||
hasTriggerData: !!context.trigger?.data,
|
||||
hasTriggerDoc: !!context.trigger?.doc
|
||||
}, 'Processing Handlebars template')
|
||||
|
||||
try {
|
||||
const template = Handlebars.compile(value)
|
||||
const result = template(context)
|
||||
|
||||
this.logger.debug({
|
||||
key,
|
||||
template: value,
|
||||
result: JSON.stringify(result).substring(0, 200),
|
||||
resultType: typeof result
|
||||
}, 'Handlebars template resolved successfully')
|
||||
|
||||
resolved[key] = this.convertValueType(result, key)
|
||||
} catch (error) {
|
||||
this.logger.warn({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
key,
|
||||
template: value,
|
||||
contextSnapshot: JSON.stringify(context).substring(0, 500)
|
||||
}, 'Failed to resolve Handlebars template')
|
||||
resolved[key] = value // Keep original value if resolution fails
|
||||
}
|
||||
} else {
|
||||
// Regular string, apply type conversion
|
||||
resolved[key] = this.convertValueType(value, key)
|
||||
}
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Recursively resolve nested objects
|
||||
@@ -390,8 +599,8 @@ export class WorkflowExecutor {
|
||||
key,
|
||||
nestedKeys: Object.keys(value as Record<string, unknown>)
|
||||
}, 'Recursively resolving nested object')
|
||||
|
||||
resolved[key] = this.resolveStepInput(value as Record<string, unknown>, context)
|
||||
|
||||
resolved[key] = this.resolveStepInput(value as Record<string, unknown>, context, stepType)
|
||||
} else {
|
||||
// Keep literal values as-is
|
||||
resolved[key] = value
|
||||
@@ -411,22 +620,22 @@ export class WorkflowExecutor {
|
||||
*/
|
||||
private safeSerialize(obj: unknown): unknown {
|
||||
const seen = new WeakSet()
|
||||
|
||||
|
||||
const serialize = (value: unknown): unknown => {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (seen.has(value as object)) {
|
||||
|
||||
if (seen.has(value)) {
|
||||
return '[Circular Reference]'
|
||||
}
|
||||
|
||||
seen.add(value as object)
|
||||
|
||||
|
||||
seen.add(value)
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(serialize)
|
||||
}
|
||||
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||
try {
|
||||
@@ -440,10 +649,10 @@ export class WorkflowExecutor {
|
||||
result[key] = '[Non-serializable]'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
return serialize(obj)
|
||||
}
|
||||
|
||||
@@ -480,7 +689,7 @@ export class WorkflowExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a condition using JSONPath and comparison operators
|
||||
* Evaluate a condition using Handlebars templates and comparison operators
|
||||
*/
|
||||
public evaluateCondition(condition: string, context: ExecutionContext): boolean {
|
||||
this.logger.debug({
|
||||
@@ -494,16 +703,16 @@ export class WorkflowExecutor {
|
||||
try {
|
||||
// Check if this is a comparison expression
|
||||
const comparisonMatch = condition.match(/^(.+?)\s*(==|!=|>|<|>=|<=)\s*(.+)$/)
|
||||
|
||||
|
||||
if (comparisonMatch) {
|
||||
const [, leftExpr, operator, rightExpr] = comparisonMatch
|
||||
|
||||
// Evaluate left side (should be JSONPath)
|
||||
const leftValue = this.resolveJSONPathValue(leftExpr.trim(), context)
|
||||
|
||||
// Parse right side (could be string, number, boolean, or JSONPath)
|
||||
const rightValue = this.parseConditionValue(rightExpr.trim(), context)
|
||||
|
||||
|
||||
// Evaluate left side (could be Handlebars template or JSONPath)
|
||||
const leftValue = this.resolveConditionValue(leftExpr.trim(), context)
|
||||
|
||||
// Evaluate right side (could be Handlebars template, JSONPath, or literal)
|
||||
const rightValue = this.resolveConditionValue(rightExpr.trim(), context)
|
||||
|
||||
this.logger.debug({
|
||||
condition,
|
||||
leftExpr: leftExpr.trim(),
|
||||
@@ -514,32 +723,32 @@ export class WorkflowExecutor {
|
||||
leftType: typeof leftValue,
|
||||
rightType: typeof rightValue
|
||||
}, 'Evaluating comparison condition')
|
||||
|
||||
|
||||
// Perform comparison
|
||||
let result: boolean
|
||||
switch (operator) {
|
||||
case '==':
|
||||
result = leftValue === rightValue
|
||||
break
|
||||
case '!=':
|
||||
result = leftValue !== rightValue
|
||||
break
|
||||
case '>':
|
||||
result = Number(leftValue) > Number(rightValue)
|
||||
break
|
||||
case '<':
|
||||
result = Number(leftValue) < Number(rightValue)
|
||||
break
|
||||
case '>=':
|
||||
result = Number(leftValue) >= Number(rightValue)
|
||||
break
|
||||
case '<=':
|
||||
result = Number(leftValue) <= Number(rightValue)
|
||||
break
|
||||
case '==':
|
||||
result = leftValue === rightValue
|
||||
break
|
||||
case '>':
|
||||
result = Number(leftValue) > Number(rightValue)
|
||||
break
|
||||
case '>=':
|
||||
result = Number(leftValue) >= Number(rightValue)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown comparison operator: ${operator}`)
|
||||
}
|
||||
|
||||
|
||||
this.logger.debug({
|
||||
condition,
|
||||
result,
|
||||
@@ -547,22 +756,18 @@ export class WorkflowExecutor {
|
||||
rightValue,
|
||||
operator
|
||||
}, 'Comparison condition evaluation completed')
|
||||
|
||||
|
||||
return result
|
||||
} else {
|
||||
// Treat as simple JSONPath boolean evaluation
|
||||
const result = JSONPath({
|
||||
json: context,
|
||||
path: condition,
|
||||
wrap: false
|
||||
})
|
||||
// Treat as template or JSONPath boolean evaluation
|
||||
const result = this.resolveConditionValue(condition, context)
|
||||
|
||||
this.logger.debug({
|
||||
condition,
|
||||
result,
|
||||
resultType: Array.isArray(result) ? 'array' : typeof result,
|
||||
resultLength: Array.isArray(result) ? result.length : undefined
|
||||
}, 'JSONPath boolean evaluation result')
|
||||
}, 'Boolean evaluation result')
|
||||
|
||||
// Handle different result types
|
||||
let finalResult: boolean
|
||||
@@ -591,46 +796,40 @@ export class WorkflowExecutor {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolve a JSONPath value from the context
|
||||
* Resolve a condition value using Handlebars templates or JSONPath
|
||||
*/
|
||||
private resolveJSONPathValue(expr: string, context: ExecutionContext): any {
|
||||
if (expr.startsWith('$')) {
|
||||
const result = JSONPath({
|
||||
json: context,
|
||||
path: expr,
|
||||
wrap: false
|
||||
})
|
||||
// Return first result if array, otherwise the result itself
|
||||
return Array.isArray(result) && result.length > 0 ? result[0] : result
|
||||
}
|
||||
return expr
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a condition value (string literal, number, boolean, or JSONPath)
|
||||
*/
|
||||
private parseConditionValue(expr: string, context: ExecutionContext): any {
|
||||
private resolveConditionValue(expr: string, context: ExecutionContext): any {
|
||||
// Handle string literals
|
||||
if ((expr.startsWith('"') && expr.endsWith('"')) || (expr.startsWith("'") && expr.endsWith("'"))) {
|
||||
return expr.slice(1, -1) // Remove quotes
|
||||
}
|
||||
|
||||
|
||||
// Handle boolean literals
|
||||
if (expr === 'true') return true
|
||||
if (expr === 'false') return false
|
||||
|
||||
if (expr === 'true') {return true}
|
||||
if (expr === 'false') {return false}
|
||||
|
||||
// Handle number literals
|
||||
if (/^-?\d+(\.\d+)?$/.test(expr)) {
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(expr)) {
|
||||
return Number(expr)
|
||||
}
|
||||
|
||||
// Handle JSONPath expressions
|
||||
if (expr.startsWith('$')) {
|
||||
return this.resolveJSONPathValue(expr, context)
|
||||
|
||||
// Handle Handlebars templates
|
||||
if (expr.includes('{{') && expr.includes('}}')) {
|
||||
try {
|
||||
const template = Handlebars.compile(expr)
|
||||
return template(context)
|
||||
} catch (error) {
|
||||
this.logger.warn({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
expr
|
||||
}, 'Failed to resolve Handlebars condition')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Return as string if nothing else matches
|
||||
return expr
|
||||
}
|
||||
@@ -776,160 +975,4 @@ export class WorkflowExecutor {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and execute workflows triggered by a collection operation
|
||||
*/
|
||||
async executeTriggeredWorkflows(
|
||||
collection: string,
|
||||
operation: 'create' | 'delete' | 'read' | 'update',
|
||||
doc: unknown,
|
||||
previousDoc: unknown,
|
||||
req: PayloadRequest
|
||||
): Promise<void> {
|
||||
console.log('🚨 EXECUTOR: executeTriggeredWorkflows called!')
|
||||
console.log('🚨 EXECUTOR: Collection =', collection)
|
||||
console.log('🚨 EXECUTOR: Operation =', operation)
|
||||
console.log('🚨 EXECUTOR: Doc ID =', (doc as any)?.id)
|
||||
console.log('🚨 EXECUTOR: Has payload?', !!this.payload)
|
||||
console.log('🚨 EXECUTOR: Has logger?', !!this.logger)
|
||||
|
||||
this.logger.info({
|
||||
collection,
|
||||
operation,
|
||||
docId: (doc as any)?.id
|
||||
}, 'executeTriggeredWorkflows called')
|
||||
|
||||
try {
|
||||
// Find workflows with matching triggers
|
||||
const workflows = await this.payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2, // Include steps and triggers
|
||||
limit: 100,
|
||||
req
|
||||
})
|
||||
|
||||
this.logger.info({
|
||||
workflowCount: workflows.docs.length
|
||||
}, 'Found workflows to check')
|
||||
|
||||
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
|
||||
}>
|
||||
|
||||
this.logger.debug({
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name,
|
||||
triggerCount: triggers?.length || 0,
|
||||
triggers: triggers?.map(t => ({
|
||||
type: t.type,
|
||||
collection: t.collection,
|
||||
collectionSlug: t.collectionSlug,
|
||||
operation: t.operation
|
||||
}))
|
||||
}, 'Checking workflow triggers')
|
||||
|
||||
const matchingTriggers = triggers?.filter(trigger =>
|
||||
trigger.type === 'collection-trigger' &&
|
||||
(trigger.collection === collection || trigger.collectionSlug === collection) &&
|
||||
trigger.operation === operation
|
||||
) || []
|
||||
|
||||
this.logger.info({
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name,
|
||||
matchingTriggerCount: matchingTriggers.length,
|
||||
targetCollection: collection,
|
||||
targetOperation: operation
|
||||
}, 'Matching triggers found')
|
||||
|
||||
for (const trigger of matchingTriggers) {
|
||||
this.logger.info({
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name,
|
||||
triggerDetails: {
|
||||
type: trigger.type,
|
||||
collection: trigger.collection,
|
||||
collectionSlug: trigger.collectionSlug,
|
||||
operation: trigger.operation,
|
||||
hasCondition: !!trigger.condition
|
||||
}
|
||||
}, 'Processing matching trigger - about to execute workflow')
|
||||
|
||||
// Create execution context for condition evaluation
|
||||
const context: ExecutionContext = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
type: 'collection',
|
||||
collection,
|
||||
doc,
|
||||
operation,
|
||||
previousDoc,
|
||||
req
|
||||
}
|
||||
}
|
||||
|
||||
// Check trigger condition if present
|
||||
if (trigger.condition) {
|
||||
this.logger.debug({
|
||||
collection,
|
||||
operation,
|
||||
condition: trigger.condition,
|
||||
docId: (doc as any)?.id,
|
||||
docFields: doc ? Object.keys(doc) : [],
|
||||
previousDocId: (previousDoc as any)?.id,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Evaluating collection trigger condition')
|
||||
|
||||
const conditionMet = this.evaluateCondition(trigger.condition, context)
|
||||
|
||||
if (!conditionMet) {
|
||||
this.logger.info({
|
||||
collection,
|
||||
condition: trigger.condition,
|
||||
operation,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name,
|
||||
docSnapshot: JSON.stringify(doc).substring(0, 200)
|
||||
}, 'Trigger condition not met, skipping workflow')
|
||||
continue
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
collection,
|
||||
condition: trigger.condition,
|
||||
operation,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name,
|
||||
docSnapshot: JSON.stringify(doc).substring(0, 200)
|
||||
}, 'Trigger condition met')
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
collection,
|
||||
operation,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Triggering workflow')
|
||||
|
||||
// Execute the workflow
|
||||
await this.execute(workflow as PayloadWorkflow, context, req)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error({ error: error instanceof Error ? error.message : 'Unknown error' }, 'Workflow execution failed')
|
||||
this.logger.error({
|
||||
collection,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
operation
|
||||
}, 'Failed to execute triggered workflows')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Client-side components that may have CSS imports or PayloadCMS UI dependencies
|
||||
// These are separated to avoid CSS import errors during Node.js type generation
|
||||
|
||||
export { TriggerWorkflowButton } from '../components/TriggerWorkflowButton.js'
|
||||
export { StatusCell } from '../components/StatusCell.js'
|
||||
export { ErrorDisplay } from '../components/ErrorDisplay.js'
|
||||
|
||||
// Future client components can be added here:
|
||||
// export { default as WorkflowDashboard } from '../components/WorkflowDashboard/index.js'
|
||||
|
||||
113
src/fields/WorkflowBuilderField.tsx
Normal file
113
src/fields/WorkflowBuilderField.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useField, useFormFields } from '@payloadcms/ui'
|
||||
import { WorkflowBuilder } from '../components/WorkflowBuilder/index.js'
|
||||
|
||||
// Import the step types from the steps module
|
||||
import * as stepTasks from '../steps/index.js'
|
||||
|
||||
// Extract available step types from imported tasks
|
||||
const getAvailableStepTypes = () => {
|
||||
const stepTypes: Array<{
|
||||
slug: string
|
||||
label?: string
|
||||
inputSchema?: any[]
|
||||
outputSchema?: any[]
|
||||
}> = []
|
||||
|
||||
// Get all exported step tasks
|
||||
const tasks = [
|
||||
stepTasks.HttpRequestStepTask,
|
||||
stepTasks.CreateDocumentStepTask,
|
||||
stepTasks.ReadDocumentStepTask,
|
||||
stepTasks.UpdateDocumentStepTask,
|
||||
stepTasks.DeleteDocumentStepTask,
|
||||
stepTasks.SendEmailStepTask
|
||||
]
|
||||
|
||||
tasks.forEach(task => {
|
||||
if (task && task.slug) {
|
||||
stepTypes.push({
|
||||
slug: task.slug,
|
||||
label: undefined, // Tasks don't have labels, will use slug
|
||||
inputSchema: task.inputSchema,
|
||||
outputSchema: task.outputSchema
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return stepTypes
|
||||
}
|
||||
|
||||
interface WorkflowBuilderFieldProps {
|
||||
name?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
export const WorkflowBuilderField: React.FC<WorkflowBuilderFieldProps> = ({
|
||||
name,
|
||||
path
|
||||
}) => {
|
||||
const availableStepTypes = getAvailableStepTypes()
|
||||
const { value: steps, setValue: setSteps } = useField<any>({ path: 'steps' })
|
||||
const { value: layout, setValue: setLayout } = useField<any>({ path: 'layout' })
|
||||
const { value: workflowName } = useField<string>({ path: 'name' })
|
||||
|
||||
const [workflowData, setWorkflowData] = useState<any>({
|
||||
id: 'temp',
|
||||
name: workflowName || 'Workflow',
|
||||
steps: steps || [],
|
||||
layout: layout || {}
|
||||
})
|
||||
|
||||
// Update local state when form fields change
|
||||
useEffect(() => {
|
||||
setWorkflowData({
|
||||
id: 'temp',
|
||||
name: workflowName || 'Workflow',
|
||||
steps: steps || [],
|
||||
layout: layout || {}
|
||||
})
|
||||
}, [steps, layout, workflowName])
|
||||
|
||||
const handleSave = useCallback((updatedWorkflow: any) => {
|
||||
// Update the form fields
|
||||
if (updatedWorkflow.steps) {
|
||||
setSteps(updatedWorkflow.steps)
|
||||
}
|
||||
if (updatedWorkflow.layout) {
|
||||
setLayout(updatedWorkflow.layout)
|
||||
}
|
||||
}, [setSteps, setLayout])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
marginBottom: '20px',
|
||||
border: '1px solid var(--theme-elevation-100)',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--theme-elevation-50)',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--theme-elevation-100)'
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600', color: 'var(--theme-text)' }}>
|
||||
Visual Workflow Builder
|
||||
</h3>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '12px', color: 'var(--theme-text-400)' }}>
|
||||
Drag and drop steps to build your workflow visually. Click on any step to configure its parameters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<WorkflowBuilder
|
||||
workflow={workflowData}
|
||||
availableStepTypes={availableStepTypes}
|
||||
onSave={handleSave}
|
||||
readonly={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
src/fields/parameter.ts
Normal file
32
src/fields/parameter.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type {Field} from "payload"
|
||||
|
||||
|
||||
export const parameter = (slug: string, field: {name: string} & Field): Field => ({
|
||||
...field,
|
||||
name: 'parameter' + field.name.replace(/^\w/, c => c.toUpperCase()) + Math.random().toString().replace(/\D/g, ''),
|
||||
admin: {
|
||||
...(field.admin as unknown || {}),
|
||||
condition: (_, siblingData, __) => {
|
||||
const previous = field.admin?.condition?.call(null, _, siblingData, __)
|
||||
return (previous === undefined || previous) && (siblingData?.type === slug)
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
afterRead: [
|
||||
({ siblingData }) => {
|
||||
const parameters = siblingData?.parameters || {}
|
||||
return parameters[field.name]
|
||||
}
|
||||
],
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (!siblingData.parameters) {
|
||||
siblingData.parameters = {}
|
||||
}
|
||||
siblingData.parameters[field.name] = value
|
||||
return undefined // Virtual field, don't store directly
|
||||
}
|
||||
]
|
||||
},
|
||||
virtual: true,
|
||||
} as Field)
|
||||
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
|
||||
|
||||
|
||||
99
src/plugin/collection-hook.ts
Normal file
99
src/plugin/collection-hook.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {WorkflowExecutor} from "../core/workflow-executor.js"
|
||||
|
||||
export const createCollectionTriggerHook = (collectionSlug: string, hookType: string) => {
|
||||
return async (args: any) => {
|
||||
const req = 'req' in args ? args.req :
|
||||
'args' in args ? args.args.req :
|
||||
undefined
|
||||
if (!req) {
|
||||
throw new Error('No request object found in hook arguments')
|
||||
}
|
||||
const payload = req.payload
|
||||
const {docs: workflows} = await payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2,
|
||||
limit: 100,
|
||||
where: {
|
||||
'triggers.parameters.collectionSlug': {
|
||||
equals: collectionSlug
|
||||
},
|
||||
'triggers.parameters.hook': {
|
||||
equals: hookType
|
||||
},
|
||||
'triggers.type': {
|
||||
equals: 'collection-hook'
|
||||
}
|
||||
}
|
||||
})
|
||||
const executor = new WorkflowExecutor(payload, payload.logger)
|
||||
// invoke each workflow
|
||||
for (const workflow of workflows) {
|
||||
// Create execution context
|
||||
const context = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
...args,
|
||||
type: 'collection',
|
||||
collection: collectionSlug,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any trigger has a condition and evaluate it
|
||||
let shouldExecute = false
|
||||
for (const trigger of workflow.triggers || []) {
|
||||
if (trigger.type === 'collection-hook' &&
|
||||
trigger.parameters?.collectionSlug === collectionSlug &&
|
||||
trigger.parameters?.hook === hookType) {
|
||||
|
||||
if (trigger.condition) {
|
||||
// Evaluate the condition
|
||||
try {
|
||||
const conditionMet = executor.evaluateCondition(trigger.condition, context)
|
||||
if (conditionMet) {
|
||||
shouldExecute = true
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
payload.logger.error({
|
||||
workflowId: workflow.id,
|
||||
condition: trigger.condition,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, 'Failed to evaluate trigger condition')
|
||||
}
|
||||
} else {
|
||||
// No condition means always execute
|
||||
shouldExecute = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldExecute) {
|
||||
payload.logger.debug({
|
||||
workflowId: workflow.id,
|
||||
collection: collectionSlug,
|
||||
hookType
|
||||
}, 'Workflow skipped due to unmet condition')
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await executor.execute(workflow as any, context, req)
|
||||
payload.logger.info({
|
||||
workflowId: workflow.id,
|
||||
collection: collectionSlug,
|
||||
hookType
|
||||
}, 'Workflow executed successfully')
|
||||
} catch (error) {
|
||||
payload.logger.error({
|
||||
workflowId: workflow.id,
|
||||
collection: collectionSlug,
|
||||
hookType,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, 'Workflow execution failed')
|
||||
// Don't throw to prevent breaking the original operation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,21 @@
|
||||
import type {Field, TaskConfig} from "payload"
|
||||
import type {CollectionConfig, GlobalConfig, TaskConfig} from "payload"
|
||||
|
||||
export type CollectionTriggerConfigCrud = {
|
||||
create?: true
|
||||
delete?: true
|
||||
read?: true
|
||||
update?: true
|
||||
}
|
||||
import type {Trigger} from "../triggers/types.js"
|
||||
|
||||
export type CollectionTriggerConfig = CollectionTriggerConfigCrud | true
|
||||
export type TriggerConfig = (config: WorkflowsPluginConfig) => Trigger
|
||||
|
||||
export type CustomTriggerConfig = {
|
||||
inputs?: Field[]
|
||||
slug: string,
|
||||
}
|
||||
|
||||
export type WorkflowsPluginConfig<TSlug extends string> = {
|
||||
collectionTriggers: {
|
||||
[key in TSlug]?: CollectionTriggerConfig
|
||||
export type WorkflowsPluginConfig<TSlug extends string = string, TGlobal extends string = string> = {
|
||||
collectionTriggers?: {
|
||||
[key in TSlug]?: {
|
||||
[key in keyof CollectionConfig['hooks']]?: true
|
||||
} | true
|
||||
}
|
||||
globalTriggers?: {
|
||||
[key in TGlobal]?: {
|
||||
[key in keyof GlobalConfig['hooks']]?: true
|
||||
} | true
|
||||
}
|
||||
enabled?: boolean
|
||||
steps: TaskConfig<string>[],
|
||||
triggers?: CustomTriggerConfig[]
|
||||
webhookPrefix?: string
|
||||
steps: TaskConfig<string>[]
|
||||
triggers?: TriggerConfig[]
|
||||
}
|
||||
|
||||
@@ -1,633 +0,0 @@
|
||||
import type {Config, Payload, TaskConfig} from 'payload'
|
||||
|
||||
import cron from 'node-cron'
|
||||
|
||||
import {type PayloadWorkflow, WorkflowExecutor} from '../core/workflow-executor.js'
|
||||
import {getConfigLogger} from './logger.js'
|
||||
|
||||
/**
|
||||
* Generate dynamic cron tasks for all workflows with cron triggers
|
||||
* This is called at config time to register all scheduled tasks
|
||||
*/
|
||||
export function generateCronTasks(config: Config): void {
|
||||
const logger = getConfigLogger()
|
||||
|
||||
// Note: We can't query the database at config time, so we'll need a different approach
|
||||
// We'll create a single task that handles all cron-triggered workflows
|
||||
const cronTask: TaskConfig = {
|
||||
slug: 'workflow-cron-executor',
|
||||
handler: async ({ input, req }) => {
|
||||
const { cronExpression, timezone, workflowId } = input as {
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
const logger = req.payload.logger.child({ plugin: '@xtr-dev/payload-automation' })
|
||||
|
||||
try {
|
||||
// Get the workflow
|
||||
const workflow = await req.payload.findByID({
|
||||
id: workflowId,
|
||||
collection: 'workflows',
|
||||
depth: 2,
|
||||
req
|
||||
})
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error(`Workflow ${workflowId} not found`)
|
||||
}
|
||||
|
||||
// Create execution context for cron trigger
|
||||
const context = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
type: 'cron',
|
||||
req,
|
||||
triggeredAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// Create executor
|
||||
const executor = new WorkflowExecutor(req.payload, logger)
|
||||
|
||||
// Find the matching cron trigger and check its condition if present
|
||||
const triggers = workflow.triggers as Array<{
|
||||
condition?: string
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
type: string
|
||||
}>
|
||||
|
||||
const matchingTrigger = triggers?.find(trigger =>
|
||||
trigger.type === 'cron-trigger' &&
|
||||
trigger.cronExpression === cronExpression
|
||||
)
|
||||
|
||||
// Check trigger condition if present
|
||||
if (matchingTrigger?.condition) {
|
||||
const conditionMet = executor.evaluateCondition(matchingTrigger.condition, context)
|
||||
|
||||
if (!conditionMet) {
|
||||
logger.info({
|
||||
condition: matchingTrigger.condition,
|
||||
cronExpression,
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Cron trigger condition not met, skipping workflow execution')
|
||||
|
||||
// Re-queue for next execution but don't run workflow
|
||||
if (cronExpression) {
|
||||
void requeueCronJob(workflowId, cronExpression, timezone, req.payload, logger)
|
||||
}
|
||||
|
||||
return {
|
||||
output: {
|
||||
executedAt: new Date().toISOString(),
|
||||
reason: 'Condition not met',
|
||||
status: 'skipped',
|
||||
workflowId
|
||||
},
|
||||
state: 'succeeded'
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({
|
||||
condition: matchingTrigger.condition,
|
||||
cronExpression,
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Cron trigger condition met')
|
||||
}
|
||||
|
||||
// Execute the workflow
|
||||
await executor.execute(workflow as PayloadWorkflow, context, req)
|
||||
|
||||
// Re-queue the job for the next scheduled execution if cronExpression is provided
|
||||
if (cronExpression) {
|
||||
void requeueCronJob(workflowId, cronExpression, timezone, req.payload, logger)
|
||||
}
|
||||
|
||||
return {
|
||||
output: {
|
||||
executedAt: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
workflowId
|
||||
},
|
||||
state: 'succeeded'
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
workflowId
|
||||
}, 'Cron job execution failed')
|
||||
|
||||
// Re-queue even on failure to ensure continuity (unless it's a validation error)
|
||||
if (cronExpression && !(error instanceof Error && error.message.includes('Invalid cron'))) {
|
||||
void requeueCronJob(workflowId, cronExpression, timezone, req.payload, logger)
|
||||
.catch((requeueError) => {
|
||||
logger.error({
|
||||
error: requeueError instanceof Error ? requeueError.message : 'Unknown error',
|
||||
workflowId
|
||||
}, 'Failed to re-queue cron job after execution failure')
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
output: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
workflowId
|
||||
},
|
||||
state: 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the cron task to config if not already present
|
||||
if (!config.jobs) {
|
||||
config.jobs = { tasks: [] }
|
||||
}
|
||||
|
||||
if (!config.jobs.tasks) {
|
||||
config.jobs.tasks = []
|
||||
}
|
||||
|
||||
if (!config.jobs.tasks.find(task => task.slug === cronTask.slug)) {
|
||||
logger.debug(`Registering cron executor task: ${cronTask.slug}`)
|
||||
config.jobs.tasks.push(cronTask)
|
||||
} else {
|
||||
logger.debug(`Cron executor task ${cronTask.slug} already registered, skipping`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register cron jobs for workflows with cron triggers
|
||||
* This is called at runtime after PayloadCMS is initialized
|
||||
*/
|
||||
export async function registerCronJobs(payload: Payload, logger: Payload['logger']): Promise<void> {
|
||||
try {
|
||||
// Find all workflows with cron triggers
|
||||
const workflows = await payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
where: {
|
||||
'triggers.type': {
|
||||
equals: 'cron-trigger'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`Found ${workflows.docs.length} workflows with cron triggers`)
|
||||
|
||||
for (const workflow of workflows.docs) {
|
||||
const triggers = workflow.triggers as Array<{
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
type: string
|
||||
}>
|
||||
|
||||
// Find all cron triggers for this workflow
|
||||
const cronTriggers = triggers?.filter(t => t.type === 'cron-trigger') || []
|
||||
|
||||
for (const trigger of cronTriggers) {
|
||||
if (trigger.cronExpression) {
|
||||
try {
|
||||
// Validate cron expression before queueing
|
||||
if (!validateCronExpression(trigger.cronExpression)) {
|
||||
logger.error({
|
||||
cronExpression: trigger.cronExpression,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Invalid cron expression format')
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate timezone if provided
|
||||
if (trigger.timezone) {
|
||||
try {
|
||||
// Test if timezone is valid by trying to create a date with it
|
||||
new Intl.DateTimeFormat('en', { timeZone: trigger.timezone })
|
||||
} catch {
|
||||
logger.error({
|
||||
timezone: trigger.timezone,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Invalid timezone specified')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate next execution time
|
||||
const nextExecution = getNextCronTime(trigger.cronExpression, trigger.timezone)
|
||||
|
||||
// Queue the job
|
||||
await payload.jobs.queue({
|
||||
input: { cronExpression: trigger.cronExpression, timezone: trigger.timezone, workflowId: workflow.id },
|
||||
task: 'workflow-cron-executor',
|
||||
waitUntil: nextExecution
|
||||
})
|
||||
|
||||
logger.info({
|
||||
cronExpression: trigger.cronExpression,
|
||||
nextExecution: nextExecution.toISOString(),
|
||||
timezone: trigger.timezone || 'UTC',
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Queued initial cron job for workflow')
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
cronExpression: trigger.cronExpression,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timezone: trigger.timezone,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Failed to queue cron job')
|
||||
}
|
||||
} else {
|
||||
logger.warn({
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Cron trigger found but no cron expression specified')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, 'Failed to register cron jobs')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression
|
||||
*/
|
||||
export function validateCronExpression(cronExpression: string): boolean {
|
||||
return cron.validate(cronExpression)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next time a cron expression should run
|
||||
*/
|
||||
function getNextCronTime(cronExpression: string, timezone?: string): Date {
|
||||
if (!validateCronExpression(cronExpression)) {
|
||||
throw new Error(`Invalid cron expression: ${cronExpression}`)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const options: { timezone?: string } = timezone ? { timezone } : {}
|
||||
|
||||
// Create a task to find the next execution time
|
||||
const task = cron.schedule(cronExpression, () => {}, {
|
||||
...options
|
||||
})
|
||||
|
||||
// Parse cron expression parts
|
||||
const cronParts = cronExpression.trim().split(/\s+/)
|
||||
if (cronParts.length !== 5) {
|
||||
void task.destroy()
|
||||
throw new Error(`Invalid cron format: ${cronExpression}. Expected 5 parts.`)
|
||||
}
|
||||
|
||||
const [minutePart, hourPart, dayPart, monthPart, weekdayPart] = cronParts
|
||||
|
||||
// Calculate next execution with proper lookahead for any schedule frequency
|
||||
// Start from next minute and look ahead systematically
|
||||
let testTime = new Date(now.getTime() + 60 * 1000) // Start 1 minute from now
|
||||
testTime.setSeconds(0, 0) // Reset seconds and milliseconds
|
||||
|
||||
// Maximum iterations to prevent infinite loops (covers ~2 years)
|
||||
const maxIterations = 2 * 365 * 24 * 60 // 2 years worth of minutes
|
||||
let iterations = 0
|
||||
|
||||
while (iterations < maxIterations) {
|
||||
const minute = testTime.getMinutes()
|
||||
const hour = testTime.getHours()
|
||||
const dayOfMonth = testTime.getDate()
|
||||
const month = testTime.getMonth() + 1
|
||||
const dayOfWeek = testTime.getDay()
|
||||
|
||||
if (matchesCronPart(minute, minutePart) &&
|
||||
matchesCronPart(hour, hourPart) &&
|
||||
matchesCronPart(dayOfMonth, dayPart) &&
|
||||
matchesCronPart(month, monthPart) &&
|
||||
matchesCronPart(dayOfWeek, weekdayPart)) {
|
||||
void task.destroy()
|
||||
return testTime
|
||||
}
|
||||
|
||||
// Increment time intelligently based on cron pattern
|
||||
testTime = incrementTimeForCronPattern(testTime, cronParts)
|
||||
iterations++
|
||||
}
|
||||
|
||||
void task.destroy()
|
||||
throw new Error(`Could not calculate next execution time for cron expression: ${cronExpression} within reasonable timeframe`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligently increment time based on cron pattern to avoid unnecessary iterations
|
||||
*/
|
||||
function incrementTimeForCronPattern(currentTime: Date, cronParts: string[]): Date {
|
||||
const [minutePart, hourPart, _dayPart, _monthPart, _weekdayPart] = cronParts
|
||||
const nextTime = new Date(currentTime)
|
||||
|
||||
// If minute is specific (not wildcard), we can jump to next hour
|
||||
if (minutePart !== '*' && !minutePart.includes('/')) {
|
||||
const targetMinute = getNextValidCronValue(currentTime.getMinutes(), minutePart)
|
||||
if (targetMinute <= currentTime.getMinutes()) {
|
||||
// Move to next hour
|
||||
nextTime.setHours(nextTime.getHours() + 1, targetMinute, 0, 0)
|
||||
} else {
|
||||
nextTime.setMinutes(targetMinute, 0, 0)
|
||||
}
|
||||
return nextTime
|
||||
}
|
||||
|
||||
// If hour is specific and we're past it, jump to next day
|
||||
if (hourPart !== '*' && !hourPart.includes('/')) {
|
||||
const targetHour = getNextValidCronValue(currentTime.getHours(), hourPart)
|
||||
if (targetHour <= currentTime.getHours()) {
|
||||
// Move to next day
|
||||
nextTime.setDate(nextTime.getDate() + 1)
|
||||
nextTime.setHours(targetHour, 0, 0, 0)
|
||||
} else {
|
||||
nextTime.setHours(targetHour, 0, 0, 0)
|
||||
}
|
||||
return nextTime
|
||||
}
|
||||
|
||||
// Default: increment by 1 minute
|
||||
nextTime.setTime(nextTime.getTime() + 60 * 1000)
|
||||
return nextTime
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next valid value for a cron part
|
||||
*/
|
||||
function getNextValidCronValue(currentValue: number, cronPart: string): number {
|
||||
if (cronPart === '*') {return currentValue + 1}
|
||||
|
||||
// Handle specific values and ranges
|
||||
const values = parseCronPart(cronPart)
|
||||
return values.find(v => v > currentValue) || values[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a cron part into an array of valid values
|
||||
*/
|
||||
function parseCronPart(cronPart: string): number[] {
|
||||
if (cronPart === '*') {return []}
|
||||
|
||||
const values: number[] = []
|
||||
|
||||
// Handle comma-separated values
|
||||
if (cronPart.includes(',')) {
|
||||
cronPart.split(',').forEach(part => {
|
||||
values.push(...parseCronPart(part.trim()))
|
||||
})
|
||||
return values.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
// Handle ranges
|
||||
if (cronPart.includes('-')) {
|
||||
const [start, end] = cronPart.split('-').map(n => parseInt(n, 10))
|
||||
for (let i = start; i <= end; i++) {
|
||||
values.push(i)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// Handle step values
|
||||
if (cronPart.includes('/')) {
|
||||
const [range, step] = cronPart.split('/')
|
||||
const stepNum = parseInt(step, 10)
|
||||
|
||||
if (range === '*') {
|
||||
// For wildcards with steps, return empty - handled elsewhere
|
||||
return []
|
||||
}
|
||||
|
||||
const baseValues = parseCronPart(range)
|
||||
return baseValues.filter((_, index) => index % stepNum === 0)
|
||||
}
|
||||
|
||||
// Single value
|
||||
values.push(parseInt(cronPart, 10))
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value matches a cron expression part
|
||||
*/
|
||||
function matchesCronPart(value: number, cronPart: string): boolean {
|
||||
if (cronPart === '*') {return true}
|
||||
|
||||
// Handle step values (e.g., */5)
|
||||
if (cronPart.includes('/')) {
|
||||
const [range, step] = cronPart.split('/')
|
||||
const stepNum = parseInt(step, 10)
|
||||
|
||||
if (range === '*') {
|
||||
return value % stepNum === 0
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ranges (e.g., 1-5)
|
||||
if (cronPart.includes('-')) {
|
||||
const [start, end] = cronPart.split('-').map(n => parseInt(n, 10))
|
||||
return value >= start && value <= end
|
||||
}
|
||||
|
||||
// Handle comma-separated values (e.g., 1,3,5)
|
||||
if (cronPart.includes(',')) {
|
||||
const values = cronPart.split(',').map(n => parseInt(n, 10))
|
||||
return values.includes(value)
|
||||
}
|
||||
|
||||
// Handle single value
|
||||
const cronValue = parseInt(cronPart, 10)
|
||||
return value === cronValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle re-queueing of cron jobs after they execute
|
||||
* This ensures the job runs again at the next scheduled time
|
||||
*/
|
||||
export async function requeueCronJob(
|
||||
workflowId: string,
|
||||
cronExpression: string,
|
||||
timezone: string | undefined,
|
||||
payload: Payload,
|
||||
logger: Payload['logger']
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Queue the job to run at the next scheduled time
|
||||
await payload.jobs.queue({
|
||||
input: { cronExpression, timezone, workflowId },
|
||||
task: 'workflow-cron-executor',
|
||||
waitUntil: getNextCronTime(cronExpression, timezone)
|
||||
})
|
||||
|
||||
logger.debug({
|
||||
nextRun: getNextCronTime(cronExpression, timezone),
|
||||
timezone: timezone || 'UTC',
|
||||
workflowId
|
||||
}, 'Re-queued cron job')
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
workflowId
|
||||
}, 'Failed to re-queue cron job')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register or update cron jobs for a specific workflow
|
||||
*/
|
||||
export async function updateWorkflowCronJobs(
|
||||
workflowId: string,
|
||||
payload: Payload,
|
||||
logger: Payload['logger']
|
||||
): Promise<void> {
|
||||
try {
|
||||
// First, cancel any existing cron jobs for this workflow
|
||||
cancelWorkflowCronJobs(workflowId, payload, logger)
|
||||
|
||||
// Get the workflow
|
||||
const workflow = await payload.findByID({
|
||||
id: workflowId,
|
||||
collection: 'workflows',
|
||||
depth: 0
|
||||
})
|
||||
|
||||
if (!workflow) {
|
||||
logger.warn({ workflowId }, 'Workflow not found for cron job update')
|
||||
return
|
||||
}
|
||||
|
||||
const triggers = workflow.triggers as Array<{
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
type: string
|
||||
}>
|
||||
|
||||
// Find all cron triggers for this workflow
|
||||
const cronTriggers = triggers?.filter(t => t.type === 'cron-trigger') || []
|
||||
|
||||
if (cronTriggers.length === 0) {
|
||||
logger.debug({ workflowId }, 'No cron triggers found for workflow')
|
||||
return
|
||||
}
|
||||
|
||||
let scheduledJobs = 0
|
||||
|
||||
for (const trigger of cronTriggers) {
|
||||
if (trigger.cronExpression) {
|
||||
try {
|
||||
// Validate cron expression before queueing
|
||||
if (!validateCronExpression(trigger.cronExpression)) {
|
||||
logger.error({
|
||||
cronExpression: trigger.cronExpression,
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Invalid cron expression format')
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate timezone if provided
|
||||
if (trigger.timezone) {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en', { timeZone: trigger.timezone })
|
||||
} catch {
|
||||
logger.error({
|
||||
timezone: trigger.timezone,
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Invalid timezone specified')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate next execution time
|
||||
const nextExecution = getNextCronTime(trigger.cronExpression, trigger.timezone)
|
||||
|
||||
// Queue the job
|
||||
await payload.jobs.queue({
|
||||
input: { cronExpression: trigger.cronExpression, timezone: trigger.timezone, workflowId },
|
||||
task: 'workflow-cron-executor',
|
||||
waitUntil: nextExecution
|
||||
})
|
||||
|
||||
scheduledJobs++
|
||||
|
||||
logger.info({
|
||||
cronExpression: trigger.cronExpression,
|
||||
nextExecution: nextExecution.toISOString(),
|
||||
timezone: trigger.timezone || 'UTC',
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Scheduled cron job for workflow')
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
cronExpression: trigger.cronExpression,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timezone: trigger.timezone,
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Failed to schedule cron job')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduledJobs > 0) {
|
||||
logger.info({ scheduledJobs, workflowId }, 'Updated cron jobs for workflow')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
workflowId
|
||||
}, 'Failed to update workflow cron jobs')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all cron jobs for a specific workflow
|
||||
*/
|
||||
export function cancelWorkflowCronJobs(
|
||||
workflowId: string,
|
||||
payload: Payload,
|
||||
logger: Payload['logger']
|
||||
): void {
|
||||
try {
|
||||
// Note: PayloadCMS job system doesn't have a built-in way to cancel specific jobs by input
|
||||
// This is a limitation we need to work around
|
||||
// For now, we log that we would cancel jobs for this workflow
|
||||
logger.debug({ workflowId }, 'Would cancel existing cron jobs for workflow (PayloadCMS limitation: cannot selectively cancel jobs)')
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
workflowId
|
||||
}, 'Failed to cancel workflow cron jobs')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove cron jobs for a deleted workflow
|
||||
*/
|
||||
export function removeWorkflowCronJobs(
|
||||
workflowId: string,
|
||||
payload: Payload,
|
||||
logger: Payload['logger']
|
||||
): void {
|
||||
try {
|
||||
cancelWorkflowCronJobs(workflowId, payload, logger)
|
||||
logger.info({ workflowId }, 'Removed cron jobs for deleted workflow')
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
workflowId
|
||||
}, 'Failed to remove workflow cron jobs')
|
||||
}
|
||||
}
|
||||
95
src/plugin/global-hook.ts
Normal file
95
src/plugin/global-hook.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {WorkflowExecutor} from '../core/workflow-executor.js'
|
||||
|
||||
export const createGlobalTriggerHook = (globalSlug: string, hookType: string) => {
|
||||
return async function payloadGlobalAutomationHook(args: any) {
|
||||
const req = 'req' in args ? args.req :
|
||||
'args' in args ? args.args.req :
|
||||
undefined
|
||||
if (!req) {
|
||||
throw new Error('No request object found in global hook arguments')
|
||||
}
|
||||
|
||||
const payload = req.payload
|
||||
const logger = payload.logger
|
||||
|
||||
try {
|
||||
logger.info({
|
||||
global: globalSlug,
|
||||
hookType,
|
||||
operation: hookType
|
||||
}, 'Global automation hook triggered')
|
||||
|
||||
// Create executor on-demand
|
||||
const executor = new WorkflowExecutor(payload, logger)
|
||||
|
||||
logger.debug('Executing triggered global workflows...')
|
||||
|
||||
// Find workflows with matching global triggers
|
||||
const {docs: workflows} = await payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2,
|
||||
limit: 100,
|
||||
where: {
|
||||
'triggers.parameters.global': {
|
||||
equals: globalSlug
|
||||
},
|
||||
'triggers.parameters.operation': {
|
||||
equals: hookType
|
||||
},
|
||||
'triggers.type': {
|
||||
equals: 'global-hook'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Execute each matching workflow
|
||||
for (const workflow of workflows) {
|
||||
// Create execution context
|
||||
const context = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
...args,
|
||||
type: 'global',
|
||||
global: globalSlug,
|
||||
operation: hookType,
|
||||
req
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await executor.execute(workflow, context, req)
|
||||
logger.info({
|
||||
workflowId: workflow.id,
|
||||
global: globalSlug,
|
||||
hookType
|
||||
}, 'Global workflow executed successfully')
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
workflowId: workflow.id,
|
||||
global: globalSlug,
|
||||
hookType,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, 'Global workflow execution failed')
|
||||
// Don't throw to prevent breaking the original operation
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({
|
||||
global: globalSlug,
|
||||
hookType
|
||||
}, 'Global workflow execution completed successfully')
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
logger.error({
|
||||
global: globalSlug,
|
||||
hookType,
|
||||
error: errorMessage,
|
||||
errorStack: error instanceof Error ? error.stack : undefined
|
||||
}, 'Global hook execution failed')
|
||||
|
||||
// Don't throw to prevent breaking the original operation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,16 @@
|
||||
import type {Config} from 'payload'
|
||||
import type {CollectionConfig, Config} from 'payload'
|
||||
|
||||
import type {WorkflowsPluginConfig, CollectionTriggerConfigCrud} from "./config-types.js"
|
||||
import type {WorkflowsPluginConfig} from "./config-types.js"
|
||||
|
||||
import {createWorkflowCollection} from '../collections/Workflow.js'
|
||||
import {WorkflowRunsCollection} from '../collections/WorkflowRuns.js'
|
||||
import {WorkflowExecutor} from '../core/workflow-executor.js'
|
||||
import {generateCronTasks, registerCronJobs} from './cron-scheduler.js'
|
||||
import {initCollectionHooks} from "./init-collection-hooks.js"
|
||||
import {initGlobalHooks} from "./init-global-hooks.js"
|
||||
import {initStepTasks} from "./init-step-tasks.js"
|
||||
import {initWebhookEndpoint} from "./init-webhook.js"
|
||||
import {initWorkflowHooks} from './init-workflow-hooks.js'
|
||||
import {getConfigLogger, initializeLogger} from './logger.js'
|
||||
import {createCollectionTriggerHook} from "./collection-hook.js"
|
||||
import {createGlobalTriggerHook} from "./global-hook.js"
|
||||
|
||||
export {getLogger} from './logger.js'
|
||||
|
||||
// Global executor registry for config-phase hooks
|
||||
let globalExecutor: WorkflowExecutor | null = null
|
||||
|
||||
const setWorkflowExecutor = (executor: WorkflowExecutor) => {
|
||||
console.log('🚨 SETTING GLOBAL EXECUTOR')
|
||||
globalExecutor = executor
|
||||
}
|
||||
|
||||
const getWorkflowExecutor = (): WorkflowExecutor | null => {
|
||||
return globalExecutor
|
||||
}
|
||||
|
||||
const applyCollectionsConfig = <T extends string>(pluginOptions: WorkflowsPluginConfig<T>, config: Config) => {
|
||||
// Add workflow collections
|
||||
if (!config.collections) {
|
||||
@@ -39,8 +23,16 @@ const applyCollectionsConfig = <T extends string>(pluginOptions: WorkflowsPlugin
|
||||
)
|
||||
}
|
||||
|
||||
// Removed config-phase hook registration - user collections don't exist during config phase
|
||||
type AnyHook =
|
||||
CollectionConfig['hooks'] extends infer H
|
||||
? H extends Record<string, unknown>
|
||||
? NonNullable<H[keyof H]> extends (infer U)[]
|
||||
? U
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
type HookArgs = Parameters<AnyHook>[0]
|
||||
|
||||
export const workflowsPlugin =
|
||||
<TSlug extends string>(pluginOptions: WorkflowsPluginConfig<TSlug>) =>
|
||||
@@ -51,39 +43,153 @@ export const workflowsPlugin =
|
||||
}
|
||||
|
||||
applyCollectionsConfig<TSlug>(pluginOptions, config)
|
||||
|
||||
// Revert: Don't apply hooks in config phase - user collections don't exist yet
|
||||
|
||||
// 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()
|
||||
|
||||
if (config.collections && pluginOptions.collectionTriggers) {
|
||||
for (const [collectionSlug, triggerConfig] of Object.entries(pluginOptions.collectionTriggers)) {
|
||||
if (!triggerConfig) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the collection config that matches
|
||||
const collectionIndex = config.collections.findIndex(c => c.slug === collectionSlug)
|
||||
if (collectionIndex === -1) {
|
||||
logger.warn(`Collection '${collectionSlug}' not found in config.collections`)
|
||||
continue
|
||||
}
|
||||
|
||||
const collection = config.collections[collectionIndex]
|
||||
|
||||
// Initialize hooks if needed
|
||||
if (!collection.hooks) {
|
||||
collection.hooks = {}
|
||||
}
|
||||
|
||||
// Determine which hooks to register based on config
|
||||
const hooksToRegister = triggerConfig === true
|
||||
? {
|
||||
afterChange: true,
|
||||
afterDelete: true,
|
||||
afterRead: true,
|
||||
}
|
||||
: triggerConfig
|
||||
|
||||
// Register each configured hook
|
||||
Object.entries(hooksToRegister).forEach(([hookName, enabled]) => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const hookKey = hookName as keyof typeof collection.hooks
|
||||
|
||||
// Initialize the hook array if needed
|
||||
if (!collection.hooks![hookKey]) {
|
||||
collection.hooks![hookKey] = []
|
||||
}
|
||||
|
||||
// Create the automation hook for this specific collection and hook type
|
||||
const automationHook = createCollectionTriggerHook(collectionSlug, hookKey)
|
||||
|
||||
// Mark it for debugging
|
||||
Object.defineProperty(automationHook, '__isAutomationHook', {
|
||||
value: true,
|
||||
enumerable: false
|
||||
})
|
||||
Object.defineProperty(automationHook, '__hookType', {
|
||||
value: hookKey,
|
||||
enumerable: false
|
||||
})
|
||||
|
||||
// Add the hook to the collection
|
||||
;(collection.hooks![hookKey] as Array<unknown>).push(automationHook)
|
||||
|
||||
logger.debug(`Registered ${hookKey} hook for collection '${collectionSlug}'`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle global triggers similarly to collection triggers
|
||||
if (config.globals && pluginOptions.globalTriggers) {
|
||||
for (const [globalSlug, triggerConfig] of Object.entries(pluginOptions.globalTriggers)) {
|
||||
if (!triggerConfig) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the global config that matches
|
||||
const globalIndex = config.globals.findIndex(g => g.slug === globalSlug)
|
||||
if (globalIndex === -1) {
|
||||
logger.warn(`Global '${globalSlug}' not found in config.globals`)
|
||||
continue
|
||||
}
|
||||
|
||||
const global = config.globals[globalIndex]
|
||||
|
||||
// Initialize hooks if needed
|
||||
if (!global.hooks) {
|
||||
global.hooks = {}
|
||||
}
|
||||
|
||||
// Determine which hooks to register based on config
|
||||
const hooksToRegister = triggerConfig === true
|
||||
? {
|
||||
afterChange: true,
|
||||
afterRead: true,
|
||||
}
|
||||
: triggerConfig
|
||||
|
||||
// Register each configured hook
|
||||
Object.entries(hooksToRegister).forEach(([hookName, enabled]) => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const hookKey = hookName as keyof typeof global.hooks
|
||||
|
||||
// Initialize the hook array if needed
|
||||
if (!global.hooks![hookKey]) {
|
||||
global.hooks![hookKey] = []
|
||||
}
|
||||
|
||||
// Create the automation hook for this specific global and hook type
|
||||
const automationHook = createGlobalTriggerHook(globalSlug, hookKey)
|
||||
|
||||
// Mark it for debugging
|
||||
Object.defineProperty(automationHook, '__isAutomationHook', {
|
||||
value: true,
|
||||
enumerable: false
|
||||
})
|
||||
Object.defineProperty(automationHook, '__hookType', {
|
||||
value: hookKey,
|
||||
enumerable: false
|
||||
})
|
||||
|
||||
// Add the hook to the global
|
||||
;(global.hooks![hookKey] as Array<unknown>).push(automationHook)
|
||||
|
||||
logger.debug(`Registered ${hookKey} hook for global '${globalSlug}'`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.jobs) {
|
||||
config.jobs = {tasks: []}
|
||||
}
|
||||
|
||||
const configLogger = getConfigLogger()
|
||||
configLogger.info(`Configuring workflow plugin with ${Object.keys(pluginOptions.collectionTriggers || {}).length} collection triggers`)
|
||||
|
||||
// Generate cron tasks for workflows with cron triggers
|
||||
generateCronTasks(config)
|
||||
|
||||
for (const step of pluginOptions.steps) {
|
||||
if (!config.jobs?.tasks?.find(task => task.slug === step.slug)) {
|
||||
configLogger.debug(`Registering task: ${step.slug}`)
|
||||
config.jobs?.tasks?.push(step)
|
||||
} else {
|
||||
configLogger.debug(`Task ${step.slug} already registered, skipping`)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize webhook endpoint
|
||||
initWebhookEndpoint(config, pluginOptions.webhookPrefix || 'webhook')
|
||||
|
||||
// Set up onInit to register collection hooks and initialize features
|
||||
// Set up onInit to initialize features
|
||||
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')
|
||||
await incomingOnInit(payload)
|
||||
}
|
||||
|
||||
@@ -91,69 +197,8 @@ export const workflowsPlugin =
|
||||
const logger = initializeLogger(payload)
|
||||
logger.info('Logger initialized with payload instance')
|
||||
|
||||
// Log collection trigger configuration
|
||||
logger.info(`Plugin configuration: ${Object.keys(pluginOptions.collectionTriggers || {}).length} collection triggers, ${pluginOptions.steps?.length || 0} steps`)
|
||||
|
||||
// Create workflow executor instance
|
||||
console.log('🚨 CREATING WORKFLOW EXECUTOR INSTANCE')
|
||||
const executor = new WorkflowExecutor(payload, logger)
|
||||
console.log('🚨 EXECUTOR CREATED:', typeof executor)
|
||||
console.log('🚨 EXECUTOR METHODS:', Object.getOwnPropertyNames(Object.getPrototypeOf(executor)))
|
||||
|
||||
// Register executor globally
|
||||
setWorkflowExecutor(executor)
|
||||
|
||||
// DIRECT RUNTIME HOOK REGISTRATION - bypass all abstractions
|
||||
logger.info('Applying hooks directly to runtime collections...')
|
||||
|
||||
for (const [collectionSlug, triggerConfig] of Object.entries(pluginOptions.collectionTriggers || {})) {
|
||||
if (!triggerConfig) continue
|
||||
|
||||
const collection = payload.collections[collectionSlug as TSlug]
|
||||
if (!collection) {
|
||||
logger.warn(`Collection '${collectionSlug}' not found at runtime`)
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(`🚨 DIRECTLY MANIPULATING ${collectionSlug} COLLECTION`)
|
||||
console.log(`🚨 Current afterChange hooks:`, collection.config.hooks?.afterChange?.length || 0)
|
||||
|
||||
// Ensure hooks array exists
|
||||
if (!collection.config.hooks) {
|
||||
collection.config.hooks = {} as any // PayloadCMS hooks type is complex, bypass for direct manipulation
|
||||
}
|
||||
if (!collection.config.hooks.afterChange) {
|
||||
collection.config.hooks.afterChange = []
|
||||
}
|
||||
|
||||
// Add ultra-simple test hook
|
||||
const ultraSimpleHook = async (change: { collection: { slug: string }, operation: string, doc?: { id?: string } }) => {
|
||||
console.log('🎯 ULTRA SIMPLE HOOK EXECUTED! 🎯')
|
||||
console.log('🎯 Collection:', change.collection.slug)
|
||||
console.log('🎯 Operation:', change.operation)
|
||||
console.log('🎯 SUCCESS - Direct runtime registration works!')
|
||||
}
|
||||
|
||||
// Insert at beginning to ensure it runs first
|
||||
collection.config.hooks.afterChange.unshift(ultraSimpleHook)
|
||||
|
||||
console.log(`🚨 Added hook to ${collectionSlug} - new count:`, collection.config.hooks.afterChange.length)
|
||||
|
||||
logger.info(`Direct hook registration completed for: ${collectionSlug}`)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Register cron jobs for workflows with cron triggers
|
||||
logger.info('Registering cron jobs...')
|
||||
await registerCronJobs(payload, logger)
|
||||
// Log trigger configuration
|
||||
logger.info(`Plugin configuration: ${Object.keys(pluginOptions.collectionTriggers || {}).length} collection triggers, ${Object.keys(pluginOptions.globalTriggers || {}).length} global triggers, ${pluginOptions.steps?.length || 0} steps`)
|
||||
|
||||
logger.info('Plugin initialized successfully - all hooks registered')
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import type {Payload} from "payload"
|
||||
import type {Logger} from "pino"
|
||||
|
||||
import type { WorkflowExecutor } from "../core/workflow-executor.js"
|
||||
import type {CollectionTriggerConfigCrud, WorkflowsPluginConfig} from "./config-types.js"
|
||||
|
||||
export function initCollectionHooks<T extends string>(pluginOptions: WorkflowsPluginConfig<T>, payload: Payload, logger: Payload['logger'], executor: WorkflowExecutor) {
|
||||
|
||||
if (!pluginOptions.collectionTriggers || Object.keys(pluginOptions.collectionTriggers).length === 0) {
|
||||
logger.warn('No collection triggers configured in plugin options')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info({
|
||||
configuredCollections: Object.keys(pluginOptions.collectionTriggers),
|
||||
availableCollections: Object.keys(payload.collections)
|
||||
}, 'Starting collection hook registration')
|
||||
|
||||
// Add hooks to configured collections
|
||||
for (const [collectionSlug, triggerConfig] of Object.entries(pluginOptions.collectionTriggers)) {
|
||||
if (!triggerConfig) {
|
||||
logger.debug({collectionSlug}, 'Skipping collection with falsy trigger config')
|
||||
continue
|
||||
}
|
||||
|
||||
const collection = payload.collections[collectionSlug as T]
|
||||
const crud: CollectionTriggerConfigCrud = triggerConfig === true ? {
|
||||
create: true,
|
||||
delete: true,
|
||||
read: true,
|
||||
update: true,
|
||||
} : triggerConfig
|
||||
|
||||
if (!collection.config.hooks) {
|
||||
collection.config.hooks = {} as typeof collection.config.hooks
|
||||
}
|
||||
|
||||
if (crud.update || crud.create) {
|
||||
collection.config.hooks.afterChange = collection.config.hooks.afterChange || []
|
||||
collection.config.hooks.afterChange.push(async (change) => {
|
||||
const operation = change.operation as 'create' | 'update'
|
||||
|
||||
// AGGRESSIVE LOGGING - this should ALWAYS appear
|
||||
console.log('🚨 AUTOMATION PLUGIN HOOK CALLED! 🚨')
|
||||
console.log('Collection:', change.collection.slug)
|
||||
console.log('Operation:', operation)
|
||||
console.log('Doc ID:', change.doc?.id)
|
||||
console.log('Has executor?', !!executor)
|
||||
console.log('Executor type:', typeof executor)
|
||||
|
||||
logger.info({
|
||||
slug: change.collection.slug,
|
||||
operation,
|
||||
docId: change.doc?.id,
|
||||
previousDocId: change.previousDoc?.id,
|
||||
hasExecutor: !!executor,
|
||||
executorType: typeof executor
|
||||
}, 'AUTOMATION PLUGIN: Collection hook triggered')
|
||||
|
||||
try {
|
||||
console.log('🚨 About to call executeTriggeredWorkflows')
|
||||
|
||||
// Execute workflows for this trigger
|
||||
await executor.executeTriggeredWorkflows(
|
||||
change.collection.slug,
|
||||
operation,
|
||||
change.doc,
|
||||
change.previousDoc,
|
||||
change.req
|
||||
)
|
||||
|
||||
console.log('🚨 executeTriggeredWorkflows completed without error')
|
||||
|
||||
logger.info({
|
||||
slug: change.collection.slug,
|
||||
operation,
|
||||
docId: change.doc?.id
|
||||
}, 'AUTOMATION PLUGIN: executeTriggeredWorkflows completed successfully')
|
||||
} catch (error) {
|
||||
console.log('🚨 AUTOMATION PLUGIN ERROR:', error)
|
||||
|
||||
logger.error({
|
||||
slug: change.collection.slug,
|
||||
operation,
|
||||
docId: change.doc?.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined
|
||||
}, 'AUTOMATION PLUGIN: executeTriggeredWorkflows failed')
|
||||
// Don't re-throw to avoid breaking other hooks
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (crud.read) {
|
||||
collection.config.hooks.afterRead = collection.config.hooks.afterRead || []
|
||||
collection.config.hooks.afterRead.push(async (change) => {
|
||||
logger.debug({
|
||||
slug: change.collection.slug,
|
||||
operation: 'read',
|
||||
}, 'Collection hook triggered')
|
||||
|
||||
// Execute workflows for this trigger
|
||||
await executor.executeTriggeredWorkflows(
|
||||
change.collection.slug,
|
||||
'read',
|
||||
change.doc,
|
||||
undefined,
|
||||
change.req
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (crud.delete) {
|
||||
collection.config.hooks.afterDelete = collection.config.hooks.afterDelete || []
|
||||
collection.config.hooks.afterDelete.push(async (change) => {
|
||||
logger.debug({
|
||||
slug: change.collection.slug,
|
||||
operation: 'delete',
|
||||
}, 'Collection hook triggered')
|
||||
|
||||
// Execute workflows for this trigger
|
||||
await executor.executeTriggeredWorkflows(
|
||||
change.collection.slug,
|
||||
'delete',
|
||||
change.doc,
|
||||
undefined,
|
||||
change.req
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
logger.info({
|
||||
collectionSlug,
|
||||
hooksRegistered: {
|
||||
afterChange: crud.update || crud.create,
|
||||
afterRead: crud.read,
|
||||
afterDelete: crud.delete
|
||||
}
|
||||
}, 'Collection hooks registered successfully')
|
||||
} else {
|
||||
logger.error({
|
||||
collectionSlug,
|
||||
availableCollections: Object.keys(payload.collections)
|
||||
}, 'Collection not found for trigger configuration - check collection slug spelling')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import type { Payload, PayloadRequest } from "payload"
|
||||
import type { Logger } from "pino"
|
||||
|
||||
import type { WorkflowExecutor, PayloadWorkflow } from "../core/workflow-executor.js"
|
||||
|
||||
export function initGlobalHooks(payload: Payload, logger: Payload['logger'], executor: WorkflowExecutor) {
|
||||
// Get all globals from the config
|
||||
const globals = payload.config.globals || []
|
||||
|
||||
for (const globalConfig of globals) {
|
||||
const globalSlug = globalConfig.slug
|
||||
|
||||
// Add afterChange hook to global
|
||||
if (!globalConfig.hooks) {
|
||||
globalConfig.hooks = {
|
||||
afterChange: [],
|
||||
afterRead: [],
|
||||
beforeChange: [],
|
||||
beforeRead: [],
|
||||
beforeValidate: []
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalConfig.hooks.afterChange) {
|
||||
globalConfig.hooks.afterChange = []
|
||||
}
|
||||
|
||||
globalConfig.hooks.afterChange.push(async (change) => {
|
||||
logger.debug({
|
||||
global: globalSlug,
|
||||
operation: 'update'
|
||||
}, 'Global hook triggered')
|
||||
|
||||
// Execute workflows for this global trigger
|
||||
await executeTriggeredGlobalWorkflows(
|
||||
globalSlug,
|
||||
'update',
|
||||
change.doc,
|
||||
change.previousDoc,
|
||||
change.req,
|
||||
payload,
|
||||
logger,
|
||||
executor
|
||||
)
|
||||
})
|
||||
|
||||
logger.info({ globalSlug }, 'Global hooks registered')
|
||||
}
|
||||
}
|
||||
|
||||
async function executeTriggeredGlobalWorkflows(
|
||||
globalSlug: string,
|
||||
operation: 'update',
|
||||
doc: Record<string, any>,
|
||||
previousDoc: Record<string, any>,
|
||||
req: PayloadRequest,
|
||||
payload: Payload,
|
||||
logger: Payload['logger'],
|
||||
executor: WorkflowExecutor
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Find workflows with matching global triggers
|
||||
const workflows = await payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2,
|
||||
limit: 100,
|
||||
req,
|
||||
where: {
|
||||
'triggers.global': {
|
||||
equals: globalSlug
|
||||
},
|
||||
'triggers.globalOperation': {
|
||||
equals: operation
|
||||
},
|
||||
'triggers.type': {
|
||||
equals: 'global-trigger'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for (const workflow of workflows.docs) {
|
||||
logger.info({
|
||||
globalSlug,
|
||||
operation,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Triggering global workflow')
|
||||
|
||||
// Create execution context
|
||||
const context = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
type: 'global',
|
||||
doc,
|
||||
global: globalSlug,
|
||||
operation,
|
||||
previousDoc,
|
||||
req
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the workflow
|
||||
await executor.execute(workflow as PayloadWorkflow, context, req)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
globalSlug,
|
||||
operation
|
||||
}, 'Failed to execute triggered global workflows')
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type {Payload} from "payload"
|
||||
import type {Logger} from "pino"
|
||||
|
||||
import type {WorkflowsPluginConfig} from "./config-types.js"
|
||||
|
||||
export function initStepTasks<T extends string>(pluginOptions: WorkflowsPluginConfig<T>, payload: Payload, logger: Payload['logger']) {
|
||||
logger.info({ stepCount: pluginOptions.steps.length, steps: pluginOptions.steps.map(s => s.slug) }, 'Initializing step tasks')
|
||||
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import type {Config, PayloadRequest} from 'payload'
|
||||
|
||||
import {type PayloadWorkflow, WorkflowExecutor} from '../core/workflow-executor.js'
|
||||
import {getConfigLogger, initializeLogger} from './logger.js'
|
||||
|
||||
export function initWebhookEndpoint(config: Config, webhookPrefix = 'webhook'): void {
|
||||
const logger = getConfigLogger()
|
||||
// Ensure the prefix starts with a slash
|
||||
const normalizedPrefix = webhookPrefix.startsWith('/') ? webhookPrefix : `/${webhookPrefix}`
|
||||
logger.debug(`Adding webhook endpoint to config with prefix: ${normalizedPrefix}`)
|
||||
logger.debug('Current config.endpoints length:', config.endpoints?.length || 0)
|
||||
|
||||
// Define webhook endpoint
|
||||
const webhookEndpoint = {
|
||||
handler: async (req: PayloadRequest) => {
|
||||
const {path} = req.routeParams as { path: string }
|
||||
const webhookData = req.body || {}
|
||||
|
||||
logger.debug('Webhook endpoint handler called, path: ' + path)
|
||||
|
||||
try {
|
||||
// Find workflows with matching webhook triggers
|
||||
const workflows = await req.payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2,
|
||||
limit: 100,
|
||||
req,
|
||||
where: {
|
||||
'triggers.type': {
|
||||
equals: 'webhook-trigger'
|
||||
},
|
||||
'triggers.webhookPath': {
|
||||
equals: path
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (workflows.docs.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({error: 'No workflows found for this webhook path'}),
|
||||
{
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
status: 404
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Create a workflow executor for this request
|
||||
const logger = initializeLogger(req.payload)
|
||||
const executor = new WorkflowExecutor(req.payload, logger)
|
||||
|
||||
const executionPromises = workflows.docs.map(async (workflow) => {
|
||||
try {
|
||||
// Create execution context for the webhook trigger
|
||||
const context = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
type: 'webhook',
|
||||
data: webhookData,
|
||||
headers: Object.fromEntries(req.headers?.entries() || []),
|
||||
path,
|
||||
req
|
||||
}
|
||||
}
|
||||
|
||||
// Find the matching trigger and check its condition if present
|
||||
const triggers = workflow.triggers as Array<{
|
||||
condition?: string
|
||||
type: string
|
||||
webhookPath?: string
|
||||
}>
|
||||
|
||||
const matchingTrigger = triggers?.find(trigger =>
|
||||
trigger.type === 'webhook-trigger' &&
|
||||
trigger.webhookPath === path
|
||||
)
|
||||
|
||||
// Check trigger condition if present
|
||||
if (matchingTrigger?.condition) {
|
||||
logger.debug({
|
||||
condition: matchingTrigger.condition,
|
||||
path,
|
||||
webhookData: JSON.stringify(webhookData).substring(0, 200),
|
||||
headers: Object.keys(context.trigger.headers || {}),
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Evaluating webhook trigger condition')
|
||||
|
||||
const conditionMet = executor.evaluateCondition(matchingTrigger.condition, context)
|
||||
|
||||
if (!conditionMet) {
|
||||
logger.info({
|
||||
condition: matchingTrigger.condition,
|
||||
path,
|
||||
webhookDataSnapshot: JSON.stringify(webhookData).substring(0, 200),
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Webhook trigger condition not met, skipping workflow')
|
||||
|
||||
return { reason: 'Condition not met', status: 'skipped', workflowId: workflow.id }
|
||||
}
|
||||
|
||||
logger.info({
|
||||
condition: matchingTrigger.condition,
|
||||
path,
|
||||
webhookDataSnapshot: JSON.stringify(webhookData).substring(0, 200),
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Webhook trigger condition met')
|
||||
}
|
||||
|
||||
// Execute the workflow
|
||||
await executor.execute(workflow as PayloadWorkflow, context, req)
|
||||
|
||||
return { status: 'triggered', workflowId: workflow.id }
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
status: 'failed',
|
||||
workflowId: workflow.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(executionPromises)
|
||||
const resultsData = results.map((result, index) => {
|
||||
const baseResult = { workflowId: workflows.docs[index].id }
|
||||
if (result.status === 'fulfilled') {
|
||||
return { ...baseResult, ...result.value }
|
||||
} else {
|
||||
return { ...baseResult, error: result.reason, status: 'failed' }
|
||||
}
|
||||
})
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: `Triggered ${workflows.docs.length} workflow(s)`,
|
||||
results: resultsData
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
}
|
||||
)
|
||||
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
error: 'Failed to process webhook'
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 500
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
method: 'post' as const,
|
||||
path: `${normalizedPrefix}/:path`
|
||||
}
|
||||
|
||||
// Check if the webhook endpoint already exists to avoid duplicates
|
||||
const existingEndpoint = config.endpoints?.find(endpoint =>
|
||||
endpoint.path === webhookEndpoint.path && endpoint.method === webhookEndpoint.method
|
||||
)
|
||||
|
||||
if (!existingEndpoint) {
|
||||
// Combine existing endpoints with the webhook endpoint
|
||||
config.endpoints = [...(config.endpoints || []), webhookEndpoint]
|
||||
logger.debug(`Webhook endpoint added at path: ${webhookEndpoint.path}`)
|
||||
logger.debug('New config.endpoints length:', config.endpoints.length)
|
||||
} else {
|
||||
logger.debug(`Webhook endpoint already exists at path: ${webhookEndpoint.path}`)
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type {Payload} from 'payload'
|
||||
|
||||
import {updateWorkflowCronJobs, removeWorkflowCronJobs} from './cron-scheduler.js'
|
||||
|
||||
/**
|
||||
* Initialize hooks for the workflows collection itself
|
||||
* to manage cron jobs when workflows are created/updated
|
||||
*/
|
||||
export function initWorkflowHooks(payload: Payload, logger: Payload['logger']): void {
|
||||
// Add afterChange hook to workflows collection to update cron jobs
|
||||
const workflowsCollection = payload.collections.workflows
|
||||
|
||||
if (!workflowsCollection) {
|
||||
logger.warn('Workflows collection not found, cannot initialize workflow hooks')
|
||||
return
|
||||
}
|
||||
|
||||
// Add afterChange hook to register/update cron jobs
|
||||
if (!workflowsCollection.config.hooks?.afterChange) {
|
||||
if (!workflowsCollection.config.hooks) {
|
||||
// @ts-expect-error - hooks object will be populated by Payload
|
||||
workflowsCollection.config.hooks = {}
|
||||
}
|
||||
workflowsCollection.config.hooks.afterChange = []
|
||||
}
|
||||
|
||||
workflowsCollection.config.hooks.afterChange.push(async ({ doc, operation }) => {
|
||||
if (operation === 'create' || operation === 'update') {
|
||||
logger.debug({
|
||||
operation,
|
||||
workflowId: doc.id,
|
||||
workflowName: doc.name
|
||||
}, 'Workflow changed, updating cron jobs selectively')
|
||||
|
||||
// Update cron jobs for this specific workflow only
|
||||
await updateWorkflowCronJobs(doc.id, payload, logger)
|
||||
}
|
||||
})
|
||||
|
||||
// Add afterDelete hook to clean up cron jobs
|
||||
if (!workflowsCollection.config.hooks?.afterDelete) {
|
||||
workflowsCollection.config.hooks.afterDelete = []
|
||||
}
|
||||
|
||||
workflowsCollection.config.hooks.afterDelete.push(async ({ doc }) => {
|
||||
logger.debug({
|
||||
workflowId: doc.id,
|
||||
workflowName: doc.name
|
||||
}, 'Workflow deleted, removing cron jobs')
|
||||
|
||||
// Remove cron jobs for the deleted workflow
|
||||
removeWorkflowCronJobs(doc.id, payload, logger)
|
||||
})
|
||||
|
||||
logger.info('Workflow hooks initialized for cron job management')
|
||||
}
|
||||
@@ -3,25 +3,40 @@ import type { Payload } from 'payload'
|
||||
// Global logger instance - use Payload's logger type
|
||||
let pluginLogger: null | Payload['logger'] = null
|
||||
|
||||
/**
|
||||
* Get the configured log level from environment variables
|
||||
* Supports: PAYLOAD_AUTOMATION_LOG_LEVEL for unified control
|
||||
* Or separate: PAYLOAD_AUTOMATION_CONFIG_LOG_LEVEL and PAYLOAD_AUTOMATION_LOG_LEVEL
|
||||
*/
|
||||
function getConfigLogLevel(): string {
|
||||
return process.env.PAYLOAD_AUTOMATION_CONFIG_LOG_LEVEL ||
|
||||
process.env.PAYLOAD_AUTOMATION_LOG_LEVEL ||
|
||||
'warn' // Default to warn level for production
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple config-time logger for use during plugin configuration
|
||||
* Uses console with plugin prefix since Payload logger isn't available yet
|
||||
*/
|
||||
const configLogger = {
|
||||
debug: <T>(message: string, ...args: T[]) => {
|
||||
if (!process.env.PAYLOAD_AUTOMATION_CONFIG_LOGGING) {return}
|
||||
console.log(`[payload-automation] ${message}`, ...args)
|
||||
const level = getConfigLogLevel()
|
||||
if (level === 'silent' || (level !== 'debug' && level !== 'trace')) {return}
|
||||
console.debug(`[payload-automation] ${message}`, ...args)
|
||||
},
|
||||
error: <T>(message: string, ...args: T[]) => {
|
||||
if (!process.env.PAYLOAD_AUTOMATION_CONFIG_LOGGING) {return}
|
||||
const level = getConfigLogLevel()
|
||||
if (level === 'silent') {return}
|
||||
console.error(`[payload-automation] ${message}`, ...args)
|
||||
},
|
||||
info: <T>(message: string, ...args: T[]) => {
|
||||
if (!process.env.PAYLOAD_AUTOMATION_CONFIG_LOGGING) {return}
|
||||
console.log(`[payload-automation] ${message}`, ...args)
|
||||
const level = getConfigLogLevel()
|
||||
if (level === 'silent' || level === 'error' || level === 'warn') {return}
|
||||
console.info(`[payload-automation] ${message}`, ...args)
|
||||
},
|
||||
warn: <T>(message: string, ...args: T[]) => {
|
||||
if (!process.env.PAYLOAD_AUTOMATION_CONFIG_LOGGING) {return}
|
||||
const level = getConfigLogLevel()
|
||||
if (level === 'silent' || level === 'error') {return}
|
||||
console.warn(`[payload-automation] ${message}`, ...args)
|
||||
}
|
||||
}
|
||||
@@ -39,8 +54,13 @@ export function getConfigLogger() {
|
||||
*/
|
||||
export function initializeLogger(payload: Payload): Payload['logger'] {
|
||||
// Create a child logger with plugin identification
|
||||
// Use PAYLOAD_AUTOMATION_LOG_LEVEL as the primary env var
|
||||
const logLevel = process.env.PAYLOAD_AUTOMATION_LOG_LEVEL ||
|
||||
process.env.PAYLOAD_AUTOMATION_LOGGING || // Legacy support
|
||||
'warn' // Default to warn level for production
|
||||
|
||||
pluginLogger = payload.logger.child({
|
||||
level: process.env.PAYLOAD_AUTOMATION_LOGGING || 'silent',
|
||||
level: logLevel,
|
||||
plugin: '@xtr-dev/payload-automation'
|
||||
})
|
||||
return pluginLogger
|
||||
|
||||
@@ -18,7 +18,7 @@ export const CreateDocumentStepTask = {
|
||||
name: 'data',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'The document data to create'
|
||||
description: 'The document data to create. Use JSONPath to reference trigger data (e.g., {"title": "$.trigger.doc.title", "author": "$.trigger.doc.author"})'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
|
||||
@@ -18,14 +18,14 @@ export const DeleteDocumentStepTask = {
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The ID of a specific document to delete (leave empty to delete multiple)'
|
||||
description: 'The ID of a specific document to delete. Use JSONPath (e.g., "$.trigger.doc.id"). Leave empty to delete multiple.'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'where',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Query conditions to find documents to delete (used when ID is not provided)'
|
||||
description: 'Query conditions to find documents to delete when ID is not provided. Use JSONPath in values (e.g., {"author": "$.trigger.doc.author"})'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,14 +1,280 @@
|
||||
import type {TaskHandler} from "payload"
|
||||
|
||||
export const httpStepHandler: TaskHandler<'http-request-step'> = async ({input}) => {
|
||||
if (!input) {
|
||||
throw new Error('No input provided')
|
||||
interface HttpRequestInput {
|
||||
url: string
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
body?: any
|
||||
timeout?: number
|
||||
authentication?: {
|
||||
type?: 'none' | 'bearer' | 'basic' | 'apikey'
|
||||
token?: string
|
||||
username?: string
|
||||
password?: string
|
||||
headerName?: string
|
||||
headerValue?: string
|
||||
}
|
||||
const response = await fetch(input.url)
|
||||
return {
|
||||
output: {
|
||||
response: await response.text()
|
||||
},
|
||||
state: response.ok ? 'succeeded' : undefined
|
||||
retries?: number
|
||||
retryDelay?: number
|
||||
}
|
||||
|
||||
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 {
|
||||
output: {
|
||||
status: 0,
|
||||
statusText: 'Invalid Input',
|
||||
headers: {},
|
||||
body: '',
|
||||
data: null,
|
||||
duration: 0,
|
||||
error: 'URL is required for HTTP request'
|
||||
},
|
||||
state: 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
const typedInput = input as HttpRequestInput
|
||||
|
||||
// Validate URL
|
||||
try {
|
||||
new URL(typedInput.url)
|
||||
} catch (error) {
|
||||
return {
|
||||
output: {
|
||||
status: 0,
|
||||
statusText: 'Invalid URL',
|
||||
headers: {},
|
||||
body: '',
|
||||
data: null,
|
||||
duration: 0,
|
||||
error: `Invalid URL: ${typedInput.url}`
|
||||
},
|
||||
state: 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare request options
|
||||
const method = (typedInput.method || 'GET').toUpperCase()
|
||||
const timeout = typedInput.timeout || 30000
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'PayloadCMS-Automation/1.0',
|
||||
...typedInput.headers
|
||||
}
|
||||
|
||||
// Handle authentication
|
||||
if (typedInput.authentication) {
|
||||
switch (typedInput.authentication.type) {
|
||||
case 'bearer':
|
||||
if (typedInput.authentication.token) {
|
||||
headers['Authorization'] = `Bearer ${typedInput.authentication.token}`
|
||||
}
|
||||
break
|
||||
case 'basic':
|
||||
if (typedInput.authentication.username && typedInput.authentication.password) {
|
||||
const credentials = btoa(`${typedInput.authentication.username}:${typedInput.authentication.password}`)
|
||||
headers['Authorization'] = `Basic ${credentials}`
|
||||
}
|
||||
break
|
||||
case 'apikey':
|
||||
if (typedInput.authentication.headerName && typedInput.authentication.headerValue) {
|
||||
headers[typedInput.authentication.headerName] = typedInput.authentication.headerValue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare request body
|
||||
let requestBody: string | undefined
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method) && typedInput.body) {
|
||||
if (typeof typedInput.body === 'string') {
|
||||
requestBody = typedInput.body
|
||||
} else {
|
||||
requestBody = JSON.stringify(typedInput.body)
|
||||
if (!headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create abort controller for timeout
|
||||
const abortController = new AbortController()
|
||||
const timeoutId = setTimeout(() => abortController.abort(), timeout)
|
||||
|
||||
// Retry logic
|
||||
const maxRetries = Math.min(Math.max(typedInput.retries || 0, 0), 5)
|
||||
const retryDelay = Math.max(typedInput.retryDelay || 1000, 100)
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// Add delay for retry attempts
|
||||
if (attempt > 0) {
|
||||
req?.payload?.logger?.info({
|
||||
attempt: attempt + 1,
|
||||
maxRetries: maxRetries + 1,
|
||||
url: typedInput.url,
|
||||
delay: retryDelay
|
||||
}, 'HTTP request retry attempt')
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay))
|
||||
}
|
||||
|
||||
const response = await fetch(typedInput.url, {
|
||||
method,
|
||||
headers,
|
||||
body: requestBody,
|
||||
signal: abortController.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
// Parse response
|
||||
const responseText = await response.text()
|
||||
let parsedData: any = null
|
||||
|
||||
try {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/json') || contentType.includes('text/json')) {
|
||||
parsedData = JSON.parse(responseText)
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Not JSON, that's fine
|
||||
}
|
||||
|
||||
// Convert headers to plain object
|
||||
const responseHeaders: Record<string, string> = {}
|
||||
response.headers.forEach((value, key) => {
|
||||
responseHeaders[key] = value
|
||||
})
|
||||
|
||||
const output = {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders,
|
||||
body: responseText,
|
||||
data: parsedData,
|
||||
duration
|
||||
}
|
||||
|
||||
req?.payload?.logger?.info({
|
||||
url: typedInput.url,
|
||||
method,
|
||||
status: response.status,
|
||||
duration,
|
||||
attempt: attempt + 1
|
||||
}, 'HTTP request completed')
|
||||
|
||||
return {
|
||||
output,
|
||||
// Always return 'succeeded' for completed HTTP requests, even with error status codes (4xx/5xx).
|
||||
// This preserves error information in the output for workflow conditional logic.
|
||||
// Only network errors, timeouts, and connection failures should result in 'failed' state.
|
||||
// This design allows workflows to handle HTTP errors gracefully rather than failing completely.
|
||||
state: 'succeeded'
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
// Handle specific error types
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
lastError = new Error(`Request timeout after ${timeout}ms`)
|
||||
} else if (error.message.includes('fetch')) {
|
||||
lastError = new Error(`Network error: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
req?.payload?.logger?.warn({
|
||||
url: typedInput.url,
|
||||
method,
|
||||
attempt: attempt + 1,
|
||||
maxRetries: maxRetries + 1,
|
||||
error: lastError.message
|
||||
}, 'HTTP request attempt failed')
|
||||
|
||||
// Don't retry on certain errors
|
||||
if (lastError.message.includes('Invalid URL') ||
|
||||
lastError.message.includes('TypeError') ||
|
||||
attempt >= maxRetries) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
// All retries exhausted
|
||||
const finalError = lastError || new Error('HTTP request failed')
|
||||
|
||||
req?.payload?.logger?.error({
|
||||
url: typedInput.url,
|
||||
method,
|
||||
totalAttempts: maxRetries + 1,
|
||||
duration,
|
||||
error: finalError.message
|
||||
}, 'HTTP request failed after all retries')
|
||||
|
||||
// Include detailed error information in the output
|
||||
// Even though PayloadCMS will discard this for failed tasks,
|
||||
// we include it here for potential future PayloadCMS improvements
|
||||
const errorDetails = {
|
||||
errorType: finalError.message.includes('timeout') ? 'timeout' :
|
||||
finalError.message.includes('ENOTFOUND') ? 'dns' :
|
||||
finalError.message.includes('ECONNREFUSED') ? 'connection' : 'network',
|
||||
duration,
|
||||
attempts: maxRetries + 1,
|
||||
finalError: finalError.message,
|
||||
context: {
|
||||
url: typedInput.url,
|
||||
method,
|
||||
timeout: typedInput.timeout,
|
||||
headers: typedInput.headers
|
||||
}
|
||||
}
|
||||
|
||||
// Return comprehensive output (PayloadCMS will discard it for failed state, but we try anyway)
|
||||
return {
|
||||
output: {
|
||||
status: 0,
|
||||
statusText: 'Request Failed',
|
||||
headers: {},
|
||||
body: '',
|
||||
data: null,
|
||||
duration,
|
||||
error: finalError.message,
|
||||
errorDetails // Include detailed error info (will be discarded by PayloadCMS)
|
||||
},
|
||||
state: 'failed'
|
||||
}
|
||||
} catch (unexpectedError) {
|
||||
// Handle any unexpected errors that weren't caught above
|
||||
const error = unexpectedError instanceof Error ? unexpectedError : new Error('Unexpected error')
|
||||
|
||||
req?.payload?.logger?.error({
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
input: (input as any)?.url || 'unknown'
|
||||
}, 'Unexpected error in HTTP request handler')
|
||||
|
||||
return {
|
||||
output: {
|
||||
status: 0,
|
||||
statusText: 'Handler Error',
|
||||
headers: {},
|
||||
body: '',
|
||||
data: null,
|
||||
duration: Date.now() - startTime,
|
||||
error: `HTTP request handler error: ${error.message}`
|
||||
},
|
||||
state: 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,171 @@ export const HttpRequestStepTask = {
|
||||
{
|
||||
name: 'url',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The URL to make the HTTP request to'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'method',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'GET', value: 'GET' },
|
||||
{ label: 'POST', value: 'POST' },
|
||||
{ label: 'PUT', value: 'PUT' },
|
||||
{ label: 'DELETE', value: 'DELETE' },
|
||||
{ label: 'PATCH', value: 'PATCH' }
|
||||
],
|
||||
defaultValue: 'GET',
|
||||
admin: {
|
||||
description: 'HTTP method to use'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'headers',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'HTTP headers as JSON object (e.g., {"Content-Type": "application/json"})'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: 'json',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.method !== 'GET' && siblingData?.method !== 'DELETE',
|
||||
description: 'Request body data. Use JSONPath to reference values (e.g., {"postId": "$.trigger.doc.id", "title": "$.trigger.doc.title"})'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'timeout',
|
||||
type: 'number',
|
||||
defaultValue: 30000,
|
||||
admin: {
|
||||
description: 'Request timeout in milliseconds (default: 30000)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'authentication',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Bearer Token', value: 'bearer' },
|
||||
{ label: 'Basic Auth', value: 'basic' },
|
||||
{ label: 'API Key Header', value: 'apikey' }
|
||||
],
|
||||
defaultValue: 'none',
|
||||
admin: {
|
||||
description: 'Authentication method'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'bearer',
|
||||
description: 'Bearer token value'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'basic',
|
||||
description: 'Basic auth username'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'basic',
|
||||
description: 'Basic auth password'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'headerName',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'apikey',
|
||||
description: 'API key header name (e.g., "X-API-Key")'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'headerValue',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'apikey',
|
||||
description: 'API key value'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'retries',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
min: 0,
|
||||
max: 5,
|
||||
admin: {
|
||||
description: 'Number of retry attempts on failure (max: 5)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'retryDelay',
|
||||
type: 'number',
|
||||
defaultValue: 1000,
|
||||
admin: {
|
||||
condition: (_, siblingData) => (siblingData?.retries || 0) > 0,
|
||||
description: 'Delay between retries in milliseconds'
|
||||
}
|
||||
}
|
||||
],
|
||||
outputSchema: [
|
||||
{
|
||||
name: 'response',
|
||||
name: 'status',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'HTTP status code'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'statusText',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'HTTP status text'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'headers',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Response headers'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Response body'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'data',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Parsed response data (if JSON)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'duration',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Request duration in milliseconds'
|
||||
}
|
||||
}
|
||||
]
|
||||
} satisfies TaskConfig<'http-request-step'>
|
||||
|
||||
@@ -18,14 +18,14 @@ export const ReadDocumentStepTask = {
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The ID of a specific document to read (leave empty to find multiple)'
|
||||
description: 'The ID of a specific document to read. Use JSONPath (e.g., "$.trigger.doc.relatedId"). Leave empty to find multiple.'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'where',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Query conditions to find documents (used when ID is not provided)'
|
||||
description: 'Query conditions to find documents when ID is not provided. Use JSONPath in values (e.g., {"category": "$.trigger.doc.category", "status": "published"})'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ export const SendEmailStepTask = {
|
||||
name: 'to',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Recipient email address'
|
||||
description: 'Recipient email address. Use JSONPath for dynamic values (e.g., "$.trigger.doc.email" or "$.trigger.user.email")'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
@@ -18,14 +18,14 @@ export const SendEmailStepTask = {
|
||||
name: 'from',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Sender email address (optional, uses default if not provided)'
|
||||
description: 'Sender email address. Use JSONPath if needed (e.g., "$.trigger.doc.senderEmail"). Uses default if not provided.'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'subject',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Email subject line'
|
||||
description: 'Email subject line. Can include JSONPath references (e.g., "Order #$.trigger.doc.orderNumber received")'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
@@ -33,14 +33,14 @@ export const SendEmailStepTask = {
|
||||
name: 'text',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Plain text email content'
|
||||
description: 'Plain text email content. Use JSONPath to include dynamic content (e.g., "Dear $.trigger.doc.customerName, your order #$.trigger.doc.id has been received.")'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'html',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'HTML email content (optional)'
|
||||
description: 'HTML email content. Use JSONPath for dynamic values (e.g., "<h1>Order #$.trigger.doc.orderNumber</h1>")'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ export const UpdateDocumentStepTask = {
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The ID of the document to update'
|
||||
description: 'The ID of the document to update. Use JSONPath to reference IDs (e.g., "$.trigger.doc.id" or "$.steps.previousStep.output.id")'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
@@ -26,7 +26,7 @@ export const UpdateDocumentStepTask = {
|
||||
name: 'data',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'The data to update the document with'
|
||||
description: 'The data to update the document with. Use JSONPath to reference values (e.g., {"status": "$.trigger.doc.status", "updatedBy": "$.trigger.user.id"})'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
describe('PayloadCMS Automation Plugin', () => {
|
||||
it('should export the plugin function from server export', async () => {
|
||||
const { workflowsPlugin } = await import('../exports/server.js')
|
||||
expect(workflowsPlugin).toBeDefined()
|
||||
expect(typeof workflowsPlugin).toBe('function')
|
||||
})
|
||||
|
||||
it('should have the correct package name', async () => {
|
||||
// Basic test to ensure the plugin can be imported
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
36
src/triggers/collection-trigger.ts
Normal file
36
src/triggers/collection-trigger.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type {TriggerConfig} from '../plugin/config-types.js'
|
||||
|
||||
export const collectionTrigger: TriggerConfig = ({collectionTriggers}) => ({
|
||||
slug: 'collection-hook',
|
||||
parameters: [
|
||||
{
|
||||
name: 'collectionSlug',
|
||||
type: 'select',
|
||||
options: Object.keys(collectionTriggers || {}),
|
||||
},
|
||||
{
|
||||
name: 'hook',
|
||||
type: 'select',
|
||||
options: [
|
||||
"afterChange",
|
||||
"afterDelete",
|
||||
"afterError",
|
||||
"afterForgotPassword",
|
||||
"afterLogin",
|
||||
"afterLogout",
|
||||
"afterMe",
|
||||
"afterOperation",
|
||||
"afterRead",
|
||||
"afterRefresh",
|
||||
"beforeChange",
|
||||
"beforeDelete",
|
||||
"beforeLogin",
|
||||
"beforeOperation",
|
||||
"beforeRead",
|
||||
"beforeValidate",
|
||||
"me",
|
||||
"refresh"
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
29
src/triggers/global-trigger.ts
Normal file
29
src/triggers/global-trigger.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type {TriggerConfig} from '../plugin/config-types.js'
|
||||
|
||||
export const globalTrigger: TriggerConfig = ({globalTriggers}) => ({
|
||||
slug: 'global-hook',
|
||||
parameters: [
|
||||
{
|
||||
name: 'global',
|
||||
type: 'select',
|
||||
admin: {
|
||||
description: 'Global that triggers the workflow',
|
||||
},
|
||||
options: Object.keys(globalTriggers || {}),
|
||||
},
|
||||
{
|
||||
name: 'operation',
|
||||
type: 'select',
|
||||
admin: {
|
||||
description: 'Global hook that triggers the workflow',
|
||||
},
|
||||
options: [
|
||||
"afterChange",
|
||||
"afterRead",
|
||||
"beforeChange",
|
||||
"beforeRead",
|
||||
"beforeValidate"
|
||||
],
|
||||
}
|
||||
]
|
||||
})
|
||||
2
src/triggers/index.ts
Normal file
2
src/triggers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { collectionTrigger } from './collection-trigger.js'
|
||||
export { globalTrigger } from './global-trigger.js'
|
||||
6
src/triggers/types.ts
Normal file
6
src/triggers/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type {Field} from "payload"
|
||||
|
||||
export type Trigger = {
|
||||
slug: string
|
||||
parameters: Field[]
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
// Isolated JSONPath condition testing
|
||||
import { JSONPath } from 'jsonpath-plus'
|
||||
|
||||
function testJSONPathCondition() {
|
||||
console.log('🧪 Testing JSONPath condition evaluation in isolation')
|
||||
|
||||
// Simulate the exact context structure from workflow execution
|
||||
const testContext = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
type: 'collection',
|
||||
collection: 'orders',
|
||||
doc: {
|
||||
id: '12345',
|
||||
orderName: 'Test Order',
|
||||
status: 'Paid', // This is the updated status
|
||||
customerEmail: 'test@example.com',
|
||||
totalPrice: 2500
|
||||
},
|
||||
operation: 'update',
|
||||
previousDoc: {
|
||||
id: '12345',
|
||||
orderName: 'Test Order',
|
||||
status: 'Unpaid', // This was the previous status
|
||||
customerEmail: 'test@example.com',
|
||||
totalPrice: 2500
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Test context:')
|
||||
console.log(' - trigger.doc.status:', testContext.trigger.doc.status)
|
||||
console.log(' - trigger.previousDoc.status:', testContext.trigger.previousDoc.status)
|
||||
|
||||
// Test different JSONPath expressions
|
||||
const testCases = [
|
||||
'$.trigger.doc.status',
|
||||
'$.doc.status', // This is what your condition uses but might be wrong!
|
||||
'$.trigger.doc.status == "Paid"',
|
||||
'$.trigger.doc.status == "Unpaid"'
|
||||
]
|
||||
|
||||
console.log('\n📋 Testing JSONPath expressions:')
|
||||
|
||||
for (const expression of testCases) {
|
||||
try {
|
||||
const result = JSONPath({
|
||||
json: testContext,
|
||||
path: expression,
|
||||
wrap: false
|
||||
})
|
||||
|
||||
console.log(` ✅ ${expression} => ${JSON.stringify(result)} (${typeof result})`)
|
||||
} catch (error) {
|
||||
console.log(` ❌ ${expression} => ERROR: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Test comparison logic manually
|
||||
console.log('\n🔍 Testing comparison logic:')
|
||||
|
||||
const condition = '$.doc.status == "Paid"' // Your original condition
|
||||
const correctCondition = '$.trigger.doc.status == "Paid"' // Likely correct path
|
||||
|
||||
console.log(`\nTesting: ${condition}`)
|
||||
try {
|
||||
const leftResult = JSONPath({
|
||||
json: testContext,
|
||||
path: '$.doc.status',
|
||||
wrap: false
|
||||
})
|
||||
console.log(` - Left side result: ${JSON.stringify(leftResult)}`)
|
||||
console.log(` - Is undefined/null? ${leftResult === undefined || leftResult === null}`)
|
||||
console.log(` - Comparison result: ${leftResult === 'Paid'}`)
|
||||
} catch (error) {
|
||||
console.log(` - Error: ${error.message}`)
|
||||
}
|
||||
|
||||
console.log(`\nTesting: ${correctCondition}`)
|
||||
try {
|
||||
const leftResult = JSONPath({
|
||||
json: testContext,
|
||||
path: '$.trigger.doc.status',
|
||||
wrap: false
|
||||
})
|
||||
console.log(` - Left side result: ${JSON.stringify(leftResult)}`)
|
||||
console.log(` - Comparison result: ${leftResult === 'Paid'}`)
|
||||
} catch (error) {
|
||||
console.log(` - Error: ${error.message}`)
|
||||
}
|
||||
|
||||
// Test regex parsing
|
||||
console.log('\n📝 Testing regex parsing:')
|
||||
const testConditions = [
|
||||
'$.trigger.doc.status == "Paid"',
|
||||
'$.doc.status == "Paid"',
|
||||
'$.trigger.doc.status=="Paid"', // No spaces
|
||||
"$.trigger.doc.status == 'Paid'" // Single quotes
|
||||
]
|
||||
|
||||
for (const cond of testConditions) {
|
||||
const comparisonMatch = cond.match(/^(.+?)\s*(==|!=|>|<|>=|<=)\s*(.+)$/)
|
||||
if (comparisonMatch) {
|
||||
const [, leftExpr, operator, rightExpr] = comparisonMatch
|
||||
console.log(` ✅ ${cond}`)
|
||||
console.log(` - Left: "${leftExpr.trim()}"`)
|
||||
console.log(` - Operator: "${operator}"`)
|
||||
console.log(` - Right: "${rightExpr.trim()}"`)
|
||||
} else {
|
||||
console.log(` ❌ ${cond} - No regex match`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testJSONPathCondition()
|
||||
@@ -1,44 +0,0 @@
|
||||
// Test script to verify published workflow filtering
|
||||
console.log('🔍 Testing published workflow filtering...')
|
||||
|
||||
// This will be run from the dev environment
|
||||
// Start the dev server first: pnpm dev
|
||||
// Then in another terminal: node test-published-workflows.js
|
||||
|
||||
const testData = {
|
||||
// Simulate what the workflow executor should find
|
||||
allWorkflows: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Draft Workflow',
|
||||
_status: 'draft',
|
||||
triggers: [{ type: 'collection-trigger', collectionSlug: 'orders', operation: 'update' }]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Published Workflow',
|
||||
_status: 'published',
|
||||
triggers: [{ type: 'collection-trigger', collectionSlug: 'orders', operation: 'update' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Test filtering logic
|
||||
const publishedOnly = testData.allWorkflows.filter(wf => wf._status === 'published')
|
||||
|
||||
console.log('All workflows:', testData.allWorkflows.length)
|
||||
console.log('Published workflows:', publishedOnly.length)
|
||||
console.log('Published workflow names:', publishedOnly.map(wf => wf.name))
|
||||
|
||||
console.log('\n✅ The published status filter should work!')
|
||||
console.log('💡 Make sure your workflow has _status: "published" in the database')
|
||||
|
||||
// Instructions for manual verification
|
||||
console.log('\n📋 Manual verification steps:')
|
||||
console.log('1. Start dev server: pnpm dev')
|
||||
console.log('2. Go to http://localhost:3000/admin/collections/workflows')
|
||||
console.log('3. Find your workflow and ensure it shows as "Published" (not "Draft")')
|
||||
console.log('4. If it shows as "Draft", click it and click "Publish"')
|
||||
console.log('5. Then test your order status change again')
|
||||
|
||||
process.exit(0)
|
||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
// Test script to create workflow with correct v0.0.15 schema structure
|
||||
const { getPayload } = require('payload')
|
||||
|
||||
async function testWorkflowCreation() {
|
||||
const payload = await getPayload({
|
||||
config: require('./dev/payload.config.ts').default
|
||||
})
|
||||
|
||||
console.log('🚀 Creating workflow with v0.0.15 schema...')
|
||||
|
||||
try {
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Order Status Workflow v0.0.15',
|
||||
description: 'Test workflow that triggers when order status changes to Paid',
|
||||
enabled: true,
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'orders',
|
||||
operation: 'update',
|
||||
// v0.0.15 uses 'condition' (singular) with JSONPath expressions
|
||||
// instead of 'conditions' array
|
||||
condition: '$.doc.status == "Paid"'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
// v0.0.15 uses 'step' field instead of 'type'
|
||||
step: 'uppercaseText',
|
||||
name: 'Test Uppercase Step',
|
||||
// v0.0.15 uses 'input' (singular) instead of 'inputs'
|
||||
input: {
|
||||
inputText: 'Order {{$.trigger.doc.orderName}} has been paid!'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Workflow created successfully!')
|
||||
console.log('📋 Workflow details:')
|
||||
console.log(' - ID:', workflow.id)
|
||||
console.log(' - Name:', workflow.name)
|
||||
console.log(' - Triggers:', JSON.stringify(workflow.triggers, null, 2))
|
||||
console.log(' - Steps:', JSON.stringify(workflow.steps, null, 2))
|
||||
|
||||
// Now test with an order update
|
||||
console.log('\n🔄 Testing order status change...')
|
||||
|
||||
// First create a test order
|
||||
const order = await payload.create({
|
||||
collection: 'orders',
|
||||
data: {
|
||||
orderName: 'Test Order - ' + Date.now(),
|
||||
status: 'Unpaid',
|
||||
customerEmail: 'test@example.com',
|
||||
totalPrice: 2500,
|
||||
items: [
|
||||
{
|
||||
name: 'Test Item',
|
||||
quantity: 1,
|
||||
price: 2500
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
console.log('📦 Test order created:', order.id)
|
||||
|
||||
// Update order status to trigger workflow
|
||||
const updatedOrder = await payload.update({
|
||||
collection: 'orders',
|
||||
id: order.id,
|
||||
data: {
|
||||
status: 'Paid'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('💰 Order status updated to:', updatedOrder.status)
|
||||
|
||||
// Wait a moment for async workflow execution
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// Check for workflow runs
|
||||
const workflowRuns = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`\n📊 Workflow runs found: ${workflowRuns.docs.length}`)
|
||||
|
||||
if (workflowRuns.docs.length > 0) {
|
||||
const run = workflowRuns.docs[0]
|
||||
console.log(' - Run ID:', run.id)
|
||||
console.log(' - Status:', run.status)
|
||||
console.log(' - Context:', JSON.stringify(run.context, null, 2))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error.message)
|
||||
console.error('Stack:', error.stack)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
testWorkflowCreation()
|
||||
@@ -31,4 +31,8 @@
|
||||
"./src/**/*.tsx",
|
||||
"./dev/next-env.d.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"./src/test",
|
||||
"./test-results"
|
||||
]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user