mirror of
https://github.com/xtr-dev/payload-automation.git
synced 2025-12-10 00:43:23 +00:00
Initial commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.idea/
|
||||
payload-docs
|
||||
dist/
|
||||
/dev/payload.db
|
||||
tsconfig.tsbuildinfo
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/payload-workflows.iml" filepath="$PROJECT_DIR$/.idea/payload-workflows.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
15
.idea/payload-workflows.iml
generated
Normal file
15
.idea/payload-workflows.iml
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.idea/dataSources" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.idea/jsLinters" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/payload-docs" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
24
.swcrc
Normal file
24
.swcrc
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
},
|
||||
"transform": {
|
||||
"react": {
|
||||
"runtime": "automatic",
|
||||
"pragmaFrag": "React.Fragment",
|
||||
"throwIfNamespace": true,
|
||||
"development": false,
|
||||
"useBuiltins": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
}
|
||||
}
|
||||
182
CLAUDE.md
Normal file
182
CLAUDE.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a **PayloadCMS Automation Plugin** that provides comprehensive workflow automation capabilities for PayloadCMS applications. The plugin enables users to create, execute, and manage complex workflows with visual workflow building, execution tracking, and various step types including HTTP requests, document operations, and email notifications.
|
||||
|
||||
## Payload Documentation
|
||||
|
||||
A local copy of the PayloadCMS documentation is available at `./payload-docs/` for offline reference and to ensure compatibility with the specific version used in this project.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### 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 lint` - Run ESLint
|
||||
- `pnpm lint:fix` - Auto-fix ESLint issues
|
||||
|
||||
### Development Workflow Commands
|
||||
- `pnpm dev:generate-types` - Generate PayloadCMS types
|
||||
- `pnpm dev:generate-importmap` - Generate import maps
|
||||
- `pnpm dev:payload` - Run PayloadCMS CLI commands
|
||||
|
||||
### Build Commands
|
||||
- `pnpm build:swc` - Transpile TypeScript with SWC
|
||||
- `pnpm build:types` - Generate TypeScript declarations
|
||||
- `pnpm copyfiles` - Copy static assets to dist directory
|
||||
- `pnpm clean` - Clean build artifacts
|
||||
|
||||
## Architecture
|
||||
|
||||
### Plugin Structure
|
||||
This follows the standard PayloadCMS plugin architecture:
|
||||
|
||||
- **Plugin Entry Point** (`src/plugin/index.ts`): Main plugin function that extends PayloadCMS config
|
||||
- **Export System**: Multiple export paths for different use cases:
|
||||
- `.` (main): Core plugin functionality
|
||||
- `./client`: Client-side components and utilities
|
||||
- `./rsc`: React Server Components
|
||||
- `./fields`: Custom field exports
|
||||
- `./views`: Admin interface views
|
||||
- **Development Environment** (`dev/`): Test PayloadCMS application for plugin development
|
||||
|
||||
### Core Concepts
|
||||
|
||||
1. **Workflows**: Visual workflow definitions with steps and triggers
|
||||
2. **Workflow Runs**: Execution instances of workflows with tracking
|
||||
3. **Triggers**: Various ways to initiate workflows:
|
||||
- Collection hooks (create, update, delete, read)
|
||||
- Global hooks (global document updates)
|
||||
- Webhook triggers (external HTTP requests)
|
||||
- Manual execution
|
||||
4. **Steps**: Individual workflow actions with dependency management:
|
||||
- HTTP requests
|
||||
- Document CRUD operations (create, read, update, delete)
|
||||
- Email notifications
|
||||
- Conditional logic and data transformation
|
||||
5. **Parallel Execution**: Steps can run in parallel when dependencies allow
|
||||
6. **JSONPath Integration**: Dynamic data interpolation using JSONPath Plus
|
||||
|
||||
### Key Architecture Components
|
||||
|
||||
#### Workflow Execution Engine (`src/core/workflow-executor.ts`)
|
||||
- **WorkflowExecutor Class**: Core execution engine with dependency resolution
|
||||
- **Topological Sorting**: Handles step dependencies for parallel execution
|
||||
- **Context Management**: Maintains execution state and data flow
|
||||
- **Error Handling**: Comprehensive error tracking and logging
|
||||
|
||||
#### Plugin Configuration (`src/plugin/`)
|
||||
- **index.ts**: Main plugin configuration and lifecycle management
|
||||
- **config-types.ts**: TypeScript definitions for plugin options
|
||||
- **init-collection-hooks.ts**: Collection hook registration
|
||||
- **init-global-hooks.ts**: Global hook registration
|
||||
- **init-step-tasks.ts**: Step task registration
|
||||
|
||||
#### Collections (`src/collections/`)
|
||||
- **Workflow.ts**: Main workflow collection with steps and triggers
|
||||
- **WorkflowRuns.ts**: Execution tracking and history
|
||||
|
||||
#### Steps Library (`src/steps/`)
|
||||
Each step type follows a consistent pattern:
|
||||
- `{step-name}.ts`: TaskConfig definition with input/output schemas
|
||||
- `{step-name}-handler.ts`: Handler function implementation
|
||||
|
||||
Available step types:
|
||||
- HTTP Request: External API calls
|
||||
- Create Document: Create PayloadCMS documents
|
||||
- Read Document: Query PayloadCMS documents
|
||||
- Update Document: Modify PayloadCMS documents
|
||||
- Delete Document: Remove PayloadCMS documents
|
||||
- Send Email: Email notifications via PayloadCMS email system
|
||||
|
||||
### JSONPath Data Resolution
|
||||
The plugin uses JSONPath Plus for dynamic data interpolation:
|
||||
- `$.trigger.doc.id` - Access trigger document data
|
||||
- `$.steps.stepName.output` - Access previous step outputs
|
||||
- Supports complex queries and transformations
|
||||
|
||||
### Dependency Management
|
||||
Steps support a `dependencies` field (array of step names) that:
|
||||
- Creates execution order through topological sorting
|
||||
- Enables parallel execution within dependency batches
|
||||
- Prevents circular dependencies
|
||||
|
||||
## Development Environment
|
||||
|
||||
### 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
|
||||
- Proper async/await handling for onInit extensions
|
||||
- Endpoint registration at config time (not runtime)
|
||||
|
||||
## Build System
|
||||
- **SWC** for fast TypeScript transpilation
|
||||
- **TypeScript** for type generation with strict settings
|
||||
- **copyfiles** for asset management
|
||||
- Exports configured for both development and production
|
||||
- Peer dependency on PayloadCMS 3.37.0
|
||||
|
||||
## Important Implementation Notes
|
||||
|
||||
### Endpoint Registration
|
||||
Custom endpoints must be registered during plugin configuration, not in onInit hooks. The webhook endpoint pattern:
|
||||
```typescript
|
||||
config.endpoints.push({
|
||||
path: '/workflows/webhook/:path',
|
||||
method: 'post',
|
||||
handler: async (req) => { /* handler logic */ }
|
||||
})
|
||||
```
|
||||
|
||||
### Step Handler Pattern
|
||||
All step handlers follow this signature:
|
||||
```typescript
|
||||
export const stepHandler: TaskHandler<'step-name'> = async ({ input, req }) => {
|
||||
// validation, processing, and execution
|
||||
return {
|
||||
output: { /* results */ },
|
||||
state: 'succeeded' | 'failed'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hook Integration
|
||||
The plugin registers hooks for collections and globals specified in the plugin configuration, enabling automatic workflow triggering based on document operations.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Runtime
|
||||
- PayloadCMS 3.37.0 as peer dependency
|
||||
- jsonpath-plus for dynamic data resolution
|
||||
- Node.js ^18.20.2 || >=20.9.0
|
||||
- pnpm ^9 || ^10 package manager
|
||||
|
||||
### 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)
|
||||
|
||||
## Important Files for Understanding
|
||||
|
||||
- `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
|
||||
68
NOT-IMPLEMENTING.md
Normal file
68
NOT-IMPLEMENTING.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 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
|
||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# @xtr-dev/payload-automation
|
||||
|
||||
A comprehensive workflow automation plugin for PayloadCMS 3.x that enables visual workflow building, execution tracking, and parallel processing.
|
||||
|
||||
⚠️ **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
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @xtr-dev/payload-automation
|
||||
# or
|
||||
pnpm add @xtr-dev/payload-automation
|
||||
# or
|
||||
yarn add @xtr-dev/payload-automation
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { buildConfig } from 'payload'
|
||||
import { payloadAutomation } from '@xtr-dev/payload-automation'
|
||||
|
||||
export default buildConfig({
|
||||
// ... your config
|
||||
plugins: [
|
||||
payloadAutomation({
|
||||
collections: ['posts', 'users'], // Collections to monitor
|
||||
globals: ['settings'], // Globals to monitor
|
||||
enabled: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Step Types
|
||||
|
||||
- **HTTP Request** - Make external API calls
|
||||
- **Create Document** - Create PayloadCMS documents
|
||||
- **Read Document** - Query documents with filters
|
||||
- **Update Document** - Modify existing documents
|
||||
- **Delete Document** - Remove documents
|
||||
- **Send Email** - Send notifications via PayloadCMS email
|
||||
|
||||
## Data Resolution
|
||||
|
||||
Use JSONPath to access workflow data:
|
||||
|
||||
- `$.trigger.doc.id` - Access trigger document
|
||||
- `$.steps.stepName.output` - Use previous step outputs
|
||||
- `$.context` - Access workflow context
|
||||
|
||||
## Requirements
|
||||
|
||||
- PayloadCMS ^3.45.0
|
||||
- Node.js ^18.20.2 || >=20.9.0
|
||||
- pnpm ^9 || ^10
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation coming soon. For now, explore the development environment in the repository for examples and patterns.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
2
dev/.env.example
Normal file
2
dev/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
DATABASE_URI=mongodb://127.0.0.1/payload-plugin-template
|
||||
PAYLOAD_SECRET=YOUR_SECRET_HERE
|
||||
25
dev/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
25
dev/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views'
|
||||
|
||||
import { importMap } from '../importMap.js'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, importMap, params, searchParams })
|
||||
|
||||
export default NotFound
|
||||
25
dev/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
25
dev/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { generatePageMetadata, RootPage } from '@payloadcms/next/views'
|
||||
|
||||
import { importMap } from '../importMap.js'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, importMap, params, searchParams })
|
||||
|
||||
export default Page
|
||||
5
dev/app/(payload)/admin/importMap.js
Normal file
5
dev/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
|
||||
export const importMap = {
|
||||
|
||||
}
|
||||
19
dev/app/(payload)/api/[...slug]/route.ts
Normal file
19
dev/app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = REST_GET(config)
|
||||
export const POST = REST_POST(config)
|
||||
export const DELETE = REST_DELETE(config)
|
||||
export const PATCH = REST_PATCH(config)
|
||||
export const PUT = REST_PUT(config)
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
7
dev/app/(payload)/api/graphql-playground/route.ts
Normal file
7
dev/app/(payload)/api/graphql-playground/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config)
|
||||
8
dev/app/(payload)/api/graphql/route.ts
Normal file
8
dev/app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
|
||||
|
||||
export const POST = GRAPHQL_POST(config)
|
||||
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
0
dev/app/(payload)/custom.scss
Normal file
0
dev/app/(payload)/custom.scss
Normal file
32
dev/app/(payload)/layout.tsx
Normal file
32
dev/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ServerFunctionClient } from 'payload'
|
||||
|
||||
import '@payloadcms/next/css'
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
|
||||
import React from 'react'
|
||||
|
||||
import { importMap } from './admin/importMap.js'
|
||||
import './custom.scss'
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server'
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
})
|
||||
}
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
)
|
||||
|
||||
export default Layout
|
||||
12
dev/app/my-route/route.ts
Normal file
12
dev/app/my-route/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
export const GET = async (request: Request) => {
|
||||
const payload = await getPayload({
|
||||
config: configPromise,
|
||||
})
|
||||
|
||||
return Response.json({
|
||||
message: 'This is an example of a custom route.',
|
||||
})
|
||||
}
|
||||
4
dev/helpers/credentials.ts
Normal file
4
dev/helpers/credentials.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const devUser = {
|
||||
email: 'dev@payloadcms.com',
|
||||
password: 'test',
|
||||
}
|
||||
38
dev/helpers/testEmailAdapter.ts
Normal file
38
dev/helpers/testEmailAdapter.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { EmailAdapter, SendEmailOptions } from 'payload'
|
||||
|
||||
/**
|
||||
* Logs all emails to stdout
|
||||
*/
|
||||
export const testEmailAdapter: EmailAdapter<void> = ({ payload }) => ({
|
||||
name: 'test-email-adapter',
|
||||
defaultFromAddress: 'dev@payloadcms.com',
|
||||
defaultFromName: 'Payload Test',
|
||||
sendEmail: async (message) => {
|
||||
const stringifiedTo = getStringifiedToAddress(message)
|
||||
const res = `Test email to: '${stringifiedTo}', Subject: '${message.subject}'`
|
||||
payload.logger.info({ content: message, msg: res })
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
function getStringifiedToAddress(message: SendEmailOptions): string | undefined {
|
||||
let stringifiedTo: string | undefined
|
||||
|
||||
if (typeof message.to === 'string') {
|
||||
stringifiedTo = message.to
|
||||
} else if (Array.isArray(message.to)) {
|
||||
stringifiedTo = message.to
|
||||
.map((to: { address: string } | string) => {
|
||||
if (typeof to === 'string') {
|
||||
return to
|
||||
} else if (to.address) {
|
||||
return to.address
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.join(', ')
|
||||
} else if (message.to?.address) {
|
||||
stringifiedTo = message.to.address
|
||||
}
|
||||
return stringifiedTo
|
||||
}
|
||||
5
dev/next-env.d.ts
vendored
Normal file
5
dev/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
21
dev/next.config.mjs
Normal file
21
dev/next.config.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
import { fileURLToPath } from 'url'
|
||||
import path from 'path'
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
webpack: (webpackConfig) => {
|
||||
webpackConfig.resolve.extensionAlias = {
|
||||
'.cjs': ['.cts', '.cjs'],
|
||||
'.js': ['.ts', '.tsx', '.js', '.jsx'],
|
||||
'.mjs': ['.mts', '.mjs'],
|
||||
}
|
||||
|
||||
return webpackConfig
|
||||
},
|
||||
serverExternalPackages: ['mongodb-memory-server'],
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig, { devBundleServerPackages: false })
|
||||
796
dev/payload-types.ts
Normal file
796
dev/payload-types.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by Payload.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||
* and re-run `payload generate:types` to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported timezones in IANA format.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "supportedTimezones".
|
||||
*/
|
||||
export type SupportedTimezones =
|
||||
| 'Pacific/Midway'
|
||||
| 'Pacific/Niue'
|
||||
| 'Pacific/Honolulu'
|
||||
| 'Pacific/Rarotonga'
|
||||
| 'America/Anchorage'
|
||||
| 'Pacific/Gambier'
|
||||
| 'America/Los_Angeles'
|
||||
| 'America/Tijuana'
|
||||
| 'America/Denver'
|
||||
| 'America/Phoenix'
|
||||
| 'America/Chicago'
|
||||
| 'America/Guatemala'
|
||||
| 'America/New_York'
|
||||
| 'America/Bogota'
|
||||
| 'America/Caracas'
|
||||
| 'America/Santiago'
|
||||
| 'America/Buenos_Aires'
|
||||
| 'America/Sao_Paulo'
|
||||
| 'Atlantic/South_Georgia'
|
||||
| 'Atlantic/Azores'
|
||||
| 'Atlantic/Cape_Verde'
|
||||
| 'Europe/London'
|
||||
| 'Europe/Berlin'
|
||||
| 'Africa/Lagos'
|
||||
| 'Europe/Athens'
|
||||
| 'Africa/Cairo'
|
||||
| 'Europe/Moscow'
|
||||
| 'Asia/Riyadh'
|
||||
| 'Asia/Dubai'
|
||||
| 'Asia/Baku'
|
||||
| 'Asia/Karachi'
|
||||
| 'Asia/Tashkent'
|
||||
| 'Asia/Calcutta'
|
||||
| 'Asia/Dhaka'
|
||||
| 'Asia/Almaty'
|
||||
| 'Asia/Jakarta'
|
||||
| 'Asia/Bangkok'
|
||||
| 'Asia/Shanghai'
|
||||
| 'Asia/Singapore'
|
||||
| 'Asia/Tokyo'
|
||||
| 'Asia/Seoul'
|
||||
| 'Australia/Brisbane'
|
||||
| 'Australia/Sydney'
|
||||
| 'Pacific/Guam'
|
||||
| 'Pacific/Noumea'
|
||||
| 'Pacific/Auckland'
|
||||
| 'Pacific/Fiji';
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
users: UserAuthOperations;
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
posts: Post;
|
||||
media: Media;
|
||||
auditLog: AuditLog;
|
||||
workflows: Workflow;
|
||||
'workflow-runs': WorkflowRun;
|
||||
users: User;
|
||||
'payload-jobs': PayloadJob;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
auditLog: AuditLogSelect<false> | AuditLogSelect<true>;
|
||||
workflows: WorkflowsSelect<false> | WorkflowsSelect<true>;
|
||||
'workflow-runs': WorkflowRunsSelect<false> | WorkflowRunsSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: number;
|
||||
};
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
locale: null;
|
||||
user: User & {
|
||||
collection: 'users';
|
||||
};
|
||||
jobs: {
|
||||
tasks: {
|
||||
'workflow-cron-executor': TaskWorkflowCronExecutor;
|
||||
'http-request-step': TaskHttpRequestStep;
|
||||
'create-document': TaskCreateDocument;
|
||||
inline: {
|
||||
input: unknown;
|
||||
output: unknown;
|
||||
};
|
||||
};
|
||||
workflows: unknown;
|
||||
};
|
||||
}
|
||||
export interface UserAuthOperations {
|
||||
forgotPassword: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
login: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
registerFirstUser: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
unlock: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts".
|
||||
*/
|
||||
export interface Post {
|
||||
id: number;
|
||||
content?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media".
|
||||
*/
|
||||
export interface Media {
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
thumbnailURL?: string | null;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auditLog".
|
||||
*/
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
post?: (number | null) | Post;
|
||||
user?: (number | null) | User;
|
||||
message?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
resetPasswordToken?: string | null;
|
||||
resetPasswordExpiration?: string | null;
|
||||
salt?: string | null;
|
||||
hash?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
sessions?:
|
||||
| {
|
||||
id: string;
|
||||
createdAt?: string | null;
|
||||
expiresAt: string;
|
||||
}[]
|
||||
| null;
|
||||
password?: string | null;
|
||||
}
|
||||
/**
|
||||
* Create and manage automated workflows.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "workflows".
|
||||
*/
|
||||
export interface Workflow {
|
||||
id: number;
|
||||
/**
|
||||
* Human-readable name for the workflow
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Optional description of what this workflow does
|
||||
*/
|
||||
description?: string | null;
|
||||
triggers?:
|
||||
| {
|
||||
type?: ('collection-trigger' | 'webhook-trigger' | 'global-trigger' | 'cron-trigger') | null;
|
||||
/**
|
||||
* Collection that triggers the workflow
|
||||
*/
|
||||
collection?: 'posts' | null;
|
||||
/**
|
||||
* Collection operation that triggers the workflow
|
||||
*/
|
||||
operation?: ('create' | 'delete' | 'read' | 'update') | null;
|
||||
/**
|
||||
* URL path for the webhook (e.g., "my-webhook"). Full URL will be /api/workflows/webhook/my-webhook
|
||||
*/
|
||||
webhookPath?: string | null;
|
||||
/**
|
||||
* Global that triggers the workflow
|
||||
*/
|
||||
global?: string | null;
|
||||
/**
|
||||
* Global operation that triggers the workflow
|
||||
*/
|
||||
globalOperation?: 'update' | null;
|
||||
/**
|
||||
* Cron expression for scheduled execution (e.g., "0 0 * * *" for daily at midnight)
|
||||
*/
|
||||
cronExpression?: string | null;
|
||||
/**
|
||||
* Timezone for cron execution (e.g., "America/New_York", "Europe/London"). Defaults to UTC.
|
||||
*/
|
||||
timezone?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
steps?:
|
||||
| {
|
||||
step?: ('http-request-step' | 'create-document') | null;
|
||||
name?: string | null;
|
||||
input?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Step names that must complete before this step can run
|
||||
*/
|
||||
dependencies?: string[] | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "workflow-runs".
|
||||
*/
|
||||
export interface WorkflowRun {
|
||||
id: number;
|
||||
/**
|
||||
* Reference to the workflow that was executed
|
||||
*/
|
||||
workflow: number | Workflow;
|
||||
/**
|
||||
* Version of the workflow that was executed
|
||||
*/
|
||||
workflowVersion: number;
|
||||
/**
|
||||
* Current execution status
|
||||
*/
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
/**
|
||||
* When execution began
|
||||
*/
|
||||
startedAt: string;
|
||||
/**
|
||||
* When execution finished
|
||||
*/
|
||||
completedAt?: string | null;
|
||||
/**
|
||||
* Total execution time in milliseconds
|
||||
*/
|
||||
duration?: number | null;
|
||||
context?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Input data provided when the workflow was triggered
|
||||
*/
|
||||
inputs:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Final output data from completed steps
|
||||
*/
|
||||
outputs?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* User, system, or trigger type that initiated execution
|
||||
*/
|
||||
triggeredBy: string;
|
||||
/**
|
||||
* Array of step execution results
|
||||
*/
|
||||
steps:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Error message if workflow execution failed
|
||||
*/
|
||||
error?: string | null;
|
||||
/**
|
||||
* Detailed execution logs
|
||||
*/
|
||||
logs:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-jobs".
|
||||
*/
|
||||
export interface PayloadJob {
|
||||
id: number;
|
||||
/**
|
||||
* Input data provided to the job
|
||||
*/
|
||||
input?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
taskStatus?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
completedAt?: string | null;
|
||||
totalTried?: number | null;
|
||||
/**
|
||||
* If hasError is true this job will not be retried
|
||||
*/
|
||||
hasError?: boolean | null;
|
||||
/**
|
||||
* If hasError is true, this is the error that caused it
|
||||
*/
|
||||
error?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Task execution log
|
||||
*/
|
||||
log?:
|
||||
| {
|
||||
executedAt: string;
|
||||
completedAt: string;
|
||||
taskSlug: 'inline' | 'workflow-cron-executor' | 'http-request-step' | 'create-document';
|
||||
taskID: string;
|
||||
input?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
output?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
state: 'failed' | 'succeeded';
|
||||
error?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
taskSlug?: ('inline' | 'workflow-cron-executor' | 'http-request-step' | 'create-document') | null;
|
||||
queue?: string | null;
|
||||
waitUntil?: string | null;
|
||||
processing?: boolean | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: number;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'posts';
|
||||
value: number | Post;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
value: number | Media;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'auditLog';
|
||||
value: number | AuditLog;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'workflows';
|
||||
value: number | Workflow;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'workflow-runs';
|
||||
value: number | WorkflowRun;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'payload-jobs';
|
||||
value: number | PayloadJob;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences".
|
||||
*/
|
||||
export interface PayloadPreference {
|
||||
id: number;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations".
|
||||
*/
|
||||
export interface PayloadMigration {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts_select".
|
||||
*/
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
content?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media_select".
|
||||
*/
|
||||
export interface MediaSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
url?: T;
|
||||
thumbnailURL?: T;
|
||||
filename?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
focalX?: T;
|
||||
focalY?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auditLog_select".
|
||||
*/
|
||||
export interface AuditLogSelect<T extends boolean = true> {
|
||||
post?: T;
|
||||
user?: T;
|
||||
message?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "workflows_select".
|
||||
*/
|
||||
export interface WorkflowsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
description?: T;
|
||||
triggers?:
|
||||
| T
|
||||
| {
|
||||
type?: T;
|
||||
collection?: T;
|
||||
operation?: T;
|
||||
webhookPath?: T;
|
||||
global?: T;
|
||||
globalOperation?: T;
|
||||
cronExpression?: T;
|
||||
timezone?: T;
|
||||
id?: T;
|
||||
};
|
||||
steps?:
|
||||
| T
|
||||
| {
|
||||
step?: T;
|
||||
name?: T;
|
||||
input?: T;
|
||||
dependencies?: T;
|
||||
id?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "workflow-runs_select".
|
||||
*/
|
||||
export interface WorkflowRunsSelect<T extends boolean = true> {
|
||||
workflow?: T;
|
||||
workflowVersion?: T;
|
||||
status?: T;
|
||||
startedAt?: T;
|
||||
completedAt?: T;
|
||||
duration?: T;
|
||||
context?: T;
|
||||
inputs?: T;
|
||||
outputs?: T;
|
||||
triggeredBy?: T;
|
||||
steps?: T;
|
||||
error?: T;
|
||||
logs?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
resetPasswordToken?: T;
|
||||
resetPasswordExpiration?: T;
|
||||
salt?: T;
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
sessions?:
|
||||
| T
|
||||
| {
|
||||
id?: T;
|
||||
createdAt?: T;
|
||||
expiresAt?: T;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-jobs_select".
|
||||
*/
|
||||
export interface PayloadJobsSelect<T extends boolean = true> {
|
||||
input?: T;
|
||||
taskStatus?: T;
|
||||
completedAt?: T;
|
||||
totalTried?: T;
|
||||
hasError?: T;
|
||||
error?: T;
|
||||
log?:
|
||||
| T
|
||||
| {
|
||||
executedAt?: T;
|
||||
completedAt?: T;
|
||||
taskSlug?: T;
|
||||
taskID?: T;
|
||||
input?: T;
|
||||
output?: T;
|
||||
state?: T;
|
||||
error?: T;
|
||||
id?: T;
|
||||
};
|
||||
taskSlug?: T;
|
||||
queue?: T;
|
||||
waitUntil?: T;
|
||||
processing?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents_select".
|
||||
*/
|
||||
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
|
||||
document?: T;
|
||||
globalSlug?: T;
|
||||
user?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences_select".
|
||||
*/
|
||||
export interface PayloadPreferencesSelect<T extends boolean = true> {
|
||||
user?: T;
|
||||
key?: T;
|
||||
value?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations_select".
|
||||
*/
|
||||
export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
batch?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "TaskWorkflow-cron-executor".
|
||||
*/
|
||||
export interface TaskWorkflowCronExecutor {
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "TaskHttp-request-step".
|
||||
*/
|
||||
export interface TaskHttpRequestStep {
|
||||
input: {
|
||||
url?: string | null;
|
||||
};
|
||||
output: {
|
||||
response?: string | null;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "TaskCreate-document".
|
||||
*/
|
||||
export interface TaskCreateDocument {
|
||||
input: {
|
||||
/**
|
||||
* The collection slug to create a document in
|
||||
*/
|
||||
collection: string;
|
||||
/**
|
||||
* The document data to create
|
||||
*/
|
||||
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;
|
||||
};
|
||||
output: {
|
||||
/**
|
||||
* The created document
|
||||
*/
|
||||
doc?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* The ID of the created document
|
||||
*/
|
||||
id?: string | null;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
*/
|
||||
export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
126
dev/payload.config.ts
Normal file
126
dev/payload.config.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type {CollectionSlug, TypedJobs} 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 { testEmailAdapter } from './helpers/testEmailAdapter.js'
|
||||
import { seed } from './seed.js'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
if (!process.env.ROOT_DIR) {
|
||||
process.env.ROOT_DIR = dirname
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
return buildConfig({
|
||||
admin: {
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname, '..'),
|
||||
},
|
||||
},
|
||||
collections: [
|
||||
{
|
||||
slug: 'posts',
|
||||
fields: [
|
||||
{
|
||||
name: 'content',
|
||||
type: 'textarea'
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'media',
|
||||
fields: [],
|
||||
upload: {
|
||||
staticDir: path.resolve(dirname, 'media'),
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'auditLog',
|
||||
fields: [
|
||||
{
|
||||
name: 'post',
|
||||
type: 'relationship',
|
||||
relationTo: 'posts'
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: 'textarea'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
db: sqliteAdapter({
|
||||
client: {
|
||||
url: `file:${path.resolve(dirname, 'payload.db')}`,
|
||||
},
|
||||
}),
|
||||
editor: lexicalEditor(),
|
||||
email: testEmailAdapter,
|
||||
jobs: {
|
||||
deleteJobOnComplete: false,
|
||||
jobsCollectionOverrides: ({ defaultJobsCollection }) => {
|
||||
return {
|
||||
...defaultJobsCollection,
|
||||
admin: {
|
||||
...(defaultJobsCollection.admin ?? {}),
|
||||
hidden: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
tasks: []
|
||||
},
|
||||
onInit: async (payload) => {
|
||||
await seed(payload)
|
||||
},
|
||||
plugins: [
|
||||
workflowsPlugin<CollectionSlug>({
|
||||
collectionTriggers: {
|
||||
posts: true
|
||||
},
|
||||
steps: [
|
||||
HttpRequestStepTask,
|
||||
CreateDocumentStepTask
|
||||
],
|
||||
triggers: [
|
||||
|
||||
],
|
||||
webhookPrefix: '/workflows-webhook'
|
||||
}),
|
||||
],
|
||||
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
||||
sharp,
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default buildConfigWithMemoryDB()
|
||||
21
dev/seed.ts
Normal file
21
dev/seed.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { devUser } from './helpers/credentials.js'
|
||||
|
||||
export const seed = async (payload: Payload) => {
|
||||
const { totalDocs } = await payload.count({
|
||||
collection: 'users',
|
||||
where: {
|
||||
email: {
|
||||
equals: devUser.email,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!totalDocs) {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: devUser,
|
||||
})
|
||||
}
|
||||
}
|
||||
35
dev/tsconfig.json
Normal file
35
dev/tsconfig.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"exclude": [],
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.jsx",
|
||||
"**/*.mjs",
|
||||
"**/*.cjs",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"../src/**/*.ts",
|
||||
"../src/**/*.tsx",
|
||||
"next.config.mjs",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@payload-config": [
|
||||
"./payload.config.ts"
|
||||
],
|
||||
"@xtr-dev/payload-automation": [
|
||||
"../src/index.ts"
|
||||
],
|
||||
"@xtr-dev/payload-automation/client": [
|
||||
"../src/exports/client.ts"
|
||||
],
|
||||
"@xtr-dev/payload-automation/rsc": [
|
||||
"../src/exports/rsc.ts"
|
||||
]
|
||||
},
|
||||
"noEmit": true,
|
||||
"emitDeclarationOnly": false,
|
||||
}
|
||||
}
|
||||
47
eslint.config.js
Normal file
47
eslint.config.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// @ts-check
|
||||
|
||||
import payloadEsLintConfig from '@payloadcms/eslint-config'
|
||||
|
||||
export const defaultESLintIgnores = [
|
||||
'**/.temp',
|
||||
'**/.*', // ignore all dotfiles
|
||||
'**/.git',
|
||||
'**/.hg',
|
||||
'**/.pnp.*',
|
||||
'**/.svn',
|
||||
'**/playwright.config.ts',
|
||||
'**/vitest.config.js',
|
||||
'**/tsconfig.tsbuildinfo',
|
||||
'**/README.md',
|
||||
'**/eslint.config.js',
|
||||
'**/payload-types.ts',
|
||||
'**/dist/',
|
||||
'**/.yarn/',
|
||||
'**/build/',
|
||||
'**/node_modules/',
|
||||
'**/temp/',
|
||||
]
|
||||
|
||||
export default [
|
||||
...payloadEsLintConfig,
|
||||
{
|
||||
rules: {
|
||||
'no-restricted-exports': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest',
|
||||
projectService: {
|
||||
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
|
||||
allowDefaultProject: ['scripts/*.ts', '*.js', '*.mjs', '*.spec.ts', '*.d.ts'],
|
||||
},
|
||||
// projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
68
examples/README.md
Normal file
68
examples/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 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
|
||||
274
examples/custom-trigger-example.ts
Normal file
274
examples/custom-trigger-example.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
*/
|
||||
122
examples/manual-trigger-example.ts
Normal file
122
examples/manual-trigger-example.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
19131
package-lock.json
generated
Normal file
19131
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
133
package.json
Normal file
133
package.json
Normal file
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"name": "@xtr-dev/payload-automation",
|
||||
"version": "0.0.1",
|
||||
"description": "PayloadCMS Automation Plugin - Comprehensive workflow automation system with visual workflow building, execution tracking, and step types",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"import": "./dist/exports/client.js",
|
||||
"types": "./dist/exports/client.d.ts",
|
||||
"default": "./dist/exports/client.js"
|
||||
},
|
||||
"./rsc": {
|
||||
"import": "./dist/exports/rsc.js",
|
||||
"types": "./dist/exports/rsc.d.ts",
|
||||
"default": "./dist/exports/rsc.js"
|
||||
},
|
||||
"./fields": {
|
||||
"import": "./dist/exports/fields.js",
|
||||
"types": "./dist/exports/fields.d.ts",
|
||||
"default": "./dist/exports/fields.js"
|
||||
},
|
||||
"./views": {
|
||||
"import": "./dist/exports/views.js",
|
||||
"types": "./dist/exports/views.d.ts",
|
||||
"default": "./dist/exports/views.js"
|
||||
},
|
||||
"./steps": {
|
||||
"import": "./dist/steps/index.js",
|
||||
"types": "./dist/steps/index.d.ts",
|
||||
"default": "./dist/steps/index.js"
|
||||
}
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
||||
"build:types": "tsc --outDir dist --rootDir ./src",
|
||||
"clean": "rimraf {dist,*.tsbuildinfo}",
|
||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
|
||||
"dev": "next dev dev --turbo",
|
||||
"dev:generate-importmap": "pnpm dev:payload generate:importmap",
|
||||
"dev:generate-types": "pnpm dev:payload generate:types",
|
||||
"dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
|
||||
"generate:importmap": "pnpm dev:generate-importmap",
|
||||
"generate:types": "pnpm dev:generate-types",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint ./src --fix",
|
||||
"prepublishOnly": "pnpm clean && pnpm build",
|
||||
"test": "pnpm test:int && pnpm test:e2e",
|
||||
"test:e2e": "playwright test",
|
||||
"test:int": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/db-mongodb": "3.45.0",
|
||||
"@payloadcms/db-postgres": "3.45.0",
|
||||
"@payloadcms/db-sqlite": "3.45.0",
|
||||
"@payloadcms/eslint-config": "3.9.0",
|
||||
"@payloadcms/next": "3.45.0",
|
||||
"@payloadcms/richtext-lexical": "3.45.0",
|
||||
"@payloadcms/ui": "3.45.0",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@swc/cli": "0.6.0",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"copyfiles": "2.4.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.23.0",
|
||||
"graphql": "^16.8.1",
|
||||
"mongodb-memory-server": "10.1.4",
|
||||
"next": "15.4.4",
|
||||
"payload": "3.45.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"rimraf": "3.0.2",
|
||||
"sharp": "0.34.2",
|
||||
"typescript": "5.7.3",
|
||||
"vitest": "^3.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "^3.45.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.20.2 || >=20.9.0",
|
||||
"pnpm": "^9 || ^10"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"import": "./dist/exports/client.js",
|
||||
"types": "./dist/exports/client.d.ts",
|
||||
"default": "./dist/exports/client.js"
|
||||
},
|
||||
"./rsc": {
|
||||
"import": "./dist/exports/rsc.js",
|
||||
"types": "./dist/exports/rsc.d.ts",
|
||||
"default": "./dist/exports/rsc.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
"esbuild",
|
||||
"unrs-resolver"
|
||||
]
|
||||
},
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "^10.3.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"pino": "^9.9.0"
|
||||
}
|
||||
}
|
||||
10158
pnpm-lock.yaml
generated
Normal file
10158
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
231
src/collections/Workflow.ts
Normal file
231
src/collections/Workflow.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type {CollectionConfig, Field} from 'payload'
|
||||
|
||||
import type {WorkflowsPluginConfig} from "../plugin/config-types.js"
|
||||
|
||||
export const createWorkflowCollection: <T extends string>(options: WorkflowsPluginConfig<T>) => CollectionConfig = ({
|
||||
collectionTriggers,
|
||||
steps,
|
||||
triggers
|
||||
}) => ({
|
||||
slug: 'workflows',
|
||||
access: {
|
||||
create: () => true,
|
||||
delete: () => true,
|
||||
read: () => true,
|
||||
update: () => true,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['name', 'updatedAt'],
|
||||
description: 'Create and manage automated workflows.',
|
||||
group: 'Automation',
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Human-readable name for the workflow',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Optional description of what this workflow does',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'triggers',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
options: [
|
||||
'collection-trigger',
|
||||
'webhook-trigger',
|
||||
'global-trigger',
|
||||
'cron-trigger',
|
||||
...(triggers || []).map(t => t.slug)
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'collection',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'collection-trigger',
|
||||
description: 'Collection that triggers the workflow',
|
||||
},
|
||||
options: Object.keys(collectionTriggers || {})
|
||||
},
|
||||
{
|
||||
name: 'operation',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'collection-trigger',
|
||||
description: 'Collection operation that triggers the workflow',
|
||||
},
|
||||
options: [
|
||||
'create',
|
||||
'delete',
|
||||
'read',
|
||||
'update',
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'webhookPath',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'webhook-trigger',
|
||||
description: 'URL path for the webhook (e.g., "my-webhook"). Full URL will be /api/workflows/webhook/my-webhook',
|
||||
},
|
||||
validate: (value: any, {siblingData}: any) => {
|
||||
if (siblingData?.type === 'webhook-trigger' && !value) {
|
||||
return 'Webhook path is required for webhook triggers'
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'global',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'global-trigger',
|
||||
description: 'Global that triggers the workflow',
|
||||
},
|
||||
options: [] // Will be populated dynamically based on available globals
|
||||
},
|
||||
{
|
||||
name: 'globalOperation',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'global-trigger',
|
||||
description: 'Global operation that triggers the workflow',
|
||||
},
|
||||
options: [
|
||||
'update'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'cronExpression',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'cron-trigger',
|
||||
description: 'Cron expression for scheduled execution (e.g., "0 0 * * *" for daily at midnight)',
|
||||
placeholder: '0 0 * * *'
|
||||
},
|
||||
validate: (value: any, {siblingData}: any) => {
|
||||
if (siblingData?.type === 'cron-trigger' && !value) {
|
||||
return 'Cron expression is required for cron triggers'
|
||||
}
|
||||
|
||||
// Validate cron expression format if provided
|
||||
if (siblingData?.type === 'cron-trigger' && value) {
|
||||
// Basic format validation - should be 5 parts separated by spaces
|
||||
const cronParts = value.trim().split(/\s+/)
|
||||
if (cronParts.length !== 5) {
|
||||
return 'Invalid cron expression format. Expected 5 parts: "minute hour day month weekday" (e.g., "0 9 * * 1")'
|
||||
}
|
||||
|
||||
// Additional validation could use node-cron but we avoid dynamic imports here
|
||||
// The main validation happens at runtime in the cron scheduler
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'timezone',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'cron-trigger',
|
||||
description: 'Timezone for cron execution (e.g., "America/New_York", "Europe/London"). Defaults to UTC.',
|
||||
placeholder: 'UTC'
|
||||
},
|
||||
defaultValue: 'UTC',
|
||||
validate: (value: any, {siblingData}: any) => {
|
||||
if (siblingData?.type === 'cron-trigger' && value) {
|
||||
try {
|
||||
// Test if timezone is valid by trying to create a date with it
|
||||
new Intl.DateTimeFormat('en', {timeZone: value})
|
||||
return true
|
||||
} catch {
|
||||
return `Invalid timezone: ${value}. Please use a valid IANA timezone identifier (e.g., "America/New_York", "Europe/London")`
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'condition',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'JSONPath expression that must evaluate to true for this trigger to execute the workflow (e.g., "$.doc.status == \'published\'")'
|
||||
},
|
||||
required: false
|
||||
},
|
||||
...(triggers || []).flatMap(t => (t.inputs || []).map(f => ({
|
||||
...f,
|
||||
admin: {
|
||||
...(f.admin || {}),
|
||||
condition: (...args) => args[1]?.type === t.slug && (
|
||||
f.admin?.condition ?
|
||||
f.admin.condition.call(this, ...args) :
|
||||
true
|
||||
),
|
||||
},
|
||||
} as Field)))
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'steps',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'step',
|
||||
type: 'select',
|
||||
options: steps.map(t => t.slug)
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'input',
|
||||
type: 'json',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'dependencies',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Step names that must complete before this step can run'
|
||||
},
|
||||
hasMany: true,
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'condition',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'JSONPath expression that must evaluate to true for this step to execute (e.g., "$.trigger.doc.status == \'published\'")'
|
||||
},
|
||||
required: false
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: false,
|
||||
},
|
||||
maxPerDoc: 10,
|
||||
},
|
||||
})
|
||||
151
src/collections/WorkflowRuns.ts
Normal file
151
src/collections/WorkflowRuns.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const WorkflowRunsCollection: CollectionConfig = {
|
||||
slug: 'workflow-runs',
|
||||
access: {
|
||||
create: () => true,
|
||||
delete: () => true,
|
||||
read: () => true,
|
||||
update: () => true,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['workflow', 'status', 'triggeredBy', 'startedAt', 'duration'],
|
||||
group: 'Automation',
|
||||
pagination: {
|
||||
defaultLimit: 50,
|
||||
},
|
||||
useAsTitle: 'id',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'workflow',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
description: 'Reference to the workflow that was executed',
|
||||
},
|
||||
relationTo: 'workflows',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'workflowVersion',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Version of the workflow that was executed',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
admin: {
|
||||
description: 'Current execution status',
|
||||
},
|
||||
defaultValue: 'pending',
|
||||
options: [
|
||||
{
|
||||
label: 'Pending',
|
||||
value: 'pending',
|
||||
},
|
||||
{
|
||||
label: 'Running',
|
||||
value: 'running',
|
||||
},
|
||||
{
|
||||
label: 'Completed',
|
||||
value: 'completed',
|
||||
},
|
||||
{
|
||||
label: 'Failed',
|
||||
value: 'failed',
|
||||
},
|
||||
{
|
||||
label: 'Cancelled',
|
||||
value: 'cancelled',
|
||||
},
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'startedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
date: {
|
||||
displayFormat: 'yyyy-MM-dd HH:mm:ss',
|
||||
},
|
||||
description: 'When execution began',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'completedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
date: {
|
||||
displayFormat: 'yyyy-MM-dd HH:mm:ss',
|
||||
},
|
||||
description: 'When execution finished',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'duration',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Total execution time in milliseconds',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'context',
|
||||
type: 'json'
|
||||
},
|
||||
{
|
||||
name: 'inputs',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Input data provided when the workflow was triggered',
|
||||
},
|
||||
defaultValue: {},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'outputs',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Final output data from completed steps',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'triggeredBy',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'User, system, or trigger type that initiated execution',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'steps',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Array of step execution results',
|
||||
},
|
||||
defaultValue: [],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'error',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Error message if workflow execution failed',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'logs',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Detailed execution logs',
|
||||
},
|
||||
defaultValue: [],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
64
src/components/TriggerWorkflowButton.tsx
Normal file
64
src/components/TriggerWorkflowButton.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { Button, toast } from '@payloadcms/ui'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface TriggerWorkflowButtonProps {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
triggerSlug?: string
|
||||
}
|
||||
|
||||
export const TriggerWorkflowButton: React.FC<TriggerWorkflowButtonProps> = ({
|
||||
workflowId,
|
||||
workflowName,
|
||||
triggerSlug = 'manual-trigger'
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleTrigger = async () => {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/workflows/trigger-custom', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowId,
|
||||
triggerSlug,
|
||||
data: {
|
||||
triggeredAt: new Date().toISOString(),
|
||||
source: 'admin-button'
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to trigger workflow')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
toast.success(`Workflow "${workflowName}" triggered successfully! Run ID: ${result.runId}`)
|
||||
} catch (error) {
|
||||
console.error('Error triggering workflow:', error)
|
||||
toast.error(`Failed to trigger workflow: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleTrigger}
|
||||
disabled={loading}
|
||||
size="small"
|
||||
buttonStyle="secondary"
|
||||
>
|
||||
{loading ? 'Triggering...' : 'Trigger Workflow'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
288
src/core/trigger-custom-workflow.ts
Normal file
288
src/core/trigger-custom-workflow.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import type { Payload, PayloadRequest } from 'payload'
|
||||
|
||||
import { initializeLogger } from '../plugin/logger.js'
|
||||
import { type Workflow, WorkflowExecutor } from './workflow-executor.js'
|
||||
|
||||
export interface CustomTriggerOptions {
|
||||
/**
|
||||
* Data to pass to the workflow execution context
|
||||
*/
|
||||
data?: Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Optional PayloadRequest to use for the workflow execution
|
||||
* If not provided, a minimal request will be created
|
||||
*/
|
||||
req?: PayloadRequest
|
||||
|
||||
/**
|
||||
* The slug of the custom trigger to execute
|
||||
*/
|
||||
slug: string
|
||||
|
||||
/**
|
||||
* Optional user information for tracking who triggered the workflow
|
||||
*/
|
||||
user?: {
|
||||
email?: string
|
||||
id?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface TriggerResult {
|
||||
error?: string
|
||||
runId: number | string
|
||||
status: 'failed' | 'triggered'
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatically trigger workflows that have a matching custom trigger
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In your onInit or elsewhere in your code
|
||||
* await triggerCustomWorkflow(payload, {
|
||||
* slug: 'data-import',
|
||||
* data: {
|
||||
* source: 'external-api',
|
||||
* recordCount: 100,
|
||||
* importedAt: new Date().toISOString()
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export async function triggerCustomWorkflow(
|
||||
payload: Payload,
|
||||
options: CustomTriggerOptions
|
||||
): Promise<TriggerResult[]> {
|
||||
const { slug, data = {}, req, user } = options
|
||||
|
||||
const logger = initializeLogger(payload)
|
||||
|
||||
logger.info({
|
||||
hasData: Object.keys(data).length > 0,
|
||||
hasUser: !!user,
|
||||
triggerSlug: slug
|
||||
}, 'Triggering custom workflow')
|
||||
|
||||
try {
|
||||
// Find workflows with matching custom trigger
|
||||
const workflows = await payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2,
|
||||
limit: 100,
|
||||
where: {
|
||||
'triggers.type': {
|
||||
equals: slug
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (workflows.docs.length === 0) {
|
||||
logger.warn({
|
||||
triggerSlug: slug
|
||||
}, 'No workflows found for custom trigger')
|
||||
return []
|
||||
}
|
||||
|
||||
logger.info({
|
||||
triggerSlug: slug,
|
||||
workflowCount: workflows.docs.length
|
||||
}, 'Found workflows for custom trigger')
|
||||
|
||||
// Create a minimal request if not provided
|
||||
const workflowReq = req || {
|
||||
context: {},
|
||||
headers: new Headers(),
|
||||
payload,
|
||||
user: user ? {
|
||||
id: user.id,
|
||||
collection: 'users',
|
||||
email: user.email
|
||||
} : undefined
|
||||
} as PayloadRequest
|
||||
|
||||
// Create workflow executor
|
||||
const executor = new WorkflowExecutor(payload, logger)
|
||||
|
||||
// Execute all matching workflows
|
||||
const results: TriggerResult[] = []
|
||||
|
||||
for (const workflow of workflows.docs) {
|
||||
try {
|
||||
// Check if this workflow actually has the custom trigger
|
||||
const triggers = workflow.triggers as Array<{type: string}>
|
||||
const hasMatchingTrigger = triggers?.some(trigger => trigger.type === slug)
|
||||
|
||||
if (!hasMatchingTrigger) {
|
||||
continue
|
||||
}
|
||||
|
||||
logger.info({
|
||||
triggerSlug: slug,
|
||||
workflowId: workflow.id.toString(),
|
||||
workflowName: workflow.name
|
||||
}, 'Executing workflow with custom trigger')
|
||||
|
||||
// Create execution context
|
||||
const context = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
type: slug,
|
||||
data,
|
||||
req: workflowReq,
|
||||
triggeredAt: new Date().toISOString(),
|
||||
user: (user || workflowReq.user) ? {
|
||||
id: (user || workflowReq.user)?.id?.toString(),
|
||||
email: (user || workflowReq.user)?.email
|
||||
} : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the workflow
|
||||
await executor.execute(workflow as Workflow, context, workflowReq)
|
||||
|
||||
// Get the latest run for this workflow to get the run ID
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
limit: 1,
|
||||
sort: '-createdAt',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
results.push({
|
||||
runId: runs.docs[0]?.id?.toString() || 'unknown',
|
||||
status: 'triggered',
|
||||
workflowId: workflow.id.toString(),
|
||||
workflowName: workflow.name as string
|
||||
})
|
||||
|
||||
logger.info({
|
||||
triggerSlug: slug,
|
||||
workflowId: workflow.id.toString(),
|
||||
workflowName: workflow.name
|
||||
}, 'Workflow executed successfully')
|
||||
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
triggerSlug: slug,
|
||||
workflowId: workflow.id.toString(),
|
||||
workflowName: workflow.name
|
||||
}, 'Failed to execute workflow')
|
||||
|
||||
results.push({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
runId: 'failed',
|
||||
status: 'failed',
|
||||
workflowId: workflow.id.toString(),
|
||||
workflowName: workflow.name as string
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
triggerSlug: slug
|
||||
}, 'Failed to trigger custom workflows')
|
||||
|
||||
throw new Error(
|
||||
`Failed to trigger custom workflows: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to trigger a single workflow by ID with custom trigger data
|
||||
* This is useful when you know exactly which workflow you want to trigger
|
||||
*/
|
||||
export async function triggerWorkflowById(
|
||||
payload: Payload,
|
||||
workflowId: string,
|
||||
triggerSlug: string,
|
||||
data?: Record<string, unknown>,
|
||||
req?: PayloadRequest
|
||||
): Promise<TriggerResult> {
|
||||
const logger = initializeLogger(payload)
|
||||
|
||||
try {
|
||||
const workflow = await payload.findByID({
|
||||
id: workflowId,
|
||||
collection: 'workflows',
|
||||
depth: 2
|
||||
})
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error(`Workflow ${workflowId} not found`)
|
||||
}
|
||||
|
||||
// Verify the workflow has the specified custom trigger
|
||||
const triggers = workflow.triggers as Array<{type: string}>
|
||||
const hasMatchingTrigger = triggers?.some(trigger => trigger.type === triggerSlug)
|
||||
|
||||
if (!hasMatchingTrigger) {
|
||||
throw new Error(`Workflow ${workflowId} does not have trigger ${triggerSlug}`)
|
||||
}
|
||||
|
||||
// Create a minimal request if not provided
|
||||
const workflowReq = req || {
|
||||
context: {},
|
||||
headers: new Headers(),
|
||||
payload
|
||||
} as PayloadRequest
|
||||
|
||||
// Create execution context
|
||||
const context = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
type: triggerSlug,
|
||||
data: data || {},
|
||||
req: workflowReq,
|
||||
triggeredAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// Create executor and execute
|
||||
const executor = new WorkflowExecutor(payload, logger)
|
||||
await executor.execute(workflow as Workflow, context, workflowReq)
|
||||
|
||||
// Get the latest run to get the run ID
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
limit: 1,
|
||||
sort: '-createdAt',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
runId: runs.docs[0]?.id?.toString() || 'unknown',
|
||||
status: 'triggered',
|
||||
workflowId: workflow.id.toString(),
|
||||
workflowName: workflow.name as string
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
triggerSlug,
|
||||
workflowId
|
||||
}, 'Failed to trigger workflow by ID')
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
609
src/core/workflow-executor.ts
Normal file
609
src/core/workflow-executor.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
import type { Payload, PayloadRequest } from 'payload'
|
||||
|
||||
import { JSONPath } from 'jsonpath-plus'
|
||||
|
||||
export type Workflow = {
|
||||
_version?: number
|
||||
id: string
|
||||
name: string
|
||||
steps: WorkflowStep[]
|
||||
triggers: WorkflowTrigger[]
|
||||
}
|
||||
|
||||
export type WorkflowStep = {
|
||||
condition?: string
|
||||
dependencies?: string[]
|
||||
input?: null | Record<string, unknown>
|
||||
name: string
|
||||
step: string
|
||||
}
|
||||
|
||||
export interface WorkflowTrigger {
|
||||
collection?: string
|
||||
condition?: string
|
||||
global?: string
|
||||
globalOperation?: string
|
||||
operation?: string
|
||||
type: string
|
||||
webhookPath?: string
|
||||
}
|
||||
|
||||
export interface ExecutionContext {
|
||||
steps: Record<string, {
|
||||
error?: string
|
||||
input: unknown
|
||||
output: unknown
|
||||
state: 'failed' | 'pending' | 'running' | 'succeeded'
|
||||
}>
|
||||
trigger: {
|
||||
collection?: string
|
||||
data?: unknown
|
||||
doc?: unknown
|
||||
headers?: Record<string, string>
|
||||
operation?: string
|
||||
path?: string
|
||||
previousDoc?: unknown
|
||||
req?: PayloadRequest
|
||||
triggeredAt?: string
|
||||
type: string
|
||||
user?: {
|
||||
collection?: string
|
||||
email?: string
|
||||
id?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkflowExecutor {
|
||||
constructor(
|
||||
private payload: Payload,
|
||||
private logger: Payload['logger']
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Evaluate a step condition using JSONPath
|
||||
*/
|
||||
private evaluateStepCondition(condition: string, context: ExecutionContext): boolean {
|
||||
return this.evaluateCondition(condition, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single workflow step
|
||||
*/
|
||||
private async executeStep(
|
||||
step: WorkflowStep,
|
||||
stepIndex: number,
|
||||
context: ExecutionContext,
|
||||
req: PayloadRequest,
|
||||
workflowRunId?: number | string
|
||||
): Promise<void> {
|
||||
const stepName = step.name || 'step-' + stepIndex
|
||||
|
||||
this.logger.info({
|
||||
hasStep: 'step' in step,
|
||||
step: JSON.stringify(step),
|
||||
stepName
|
||||
}, 'Executing step')
|
||||
|
||||
// Check step condition if present
|
||||
if (step.condition) {
|
||||
const conditionMet = this.evaluateStepCondition(step.condition, context)
|
||||
|
||||
if (!conditionMet) {
|
||||
this.logger.info({
|
||||
condition: step.condition,
|
||||
stepName
|
||||
}, 'Step condition not met, skipping')
|
||||
|
||||
// Mark step as completed but skipped
|
||||
context.steps[stepName] = {
|
||||
error: undefined,
|
||||
input: undefined,
|
||||
output: { reason: 'Condition not met', skipped: true },
|
||||
state: 'succeeded'
|
||||
}
|
||||
|
||||
// Update workflow run context if needed
|
||||
if (workflowRunId) {
|
||||
await this.updateWorkflowRunContext(workflowRunId, context, req)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
condition: step.condition,
|
||||
stepName
|
||||
}, 'Step condition met, proceeding with execution')
|
||||
}
|
||||
|
||||
// Initialize step context
|
||||
context.steps[stepName] = {
|
||||
error: undefined,
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
state: 'running'
|
||||
}
|
||||
|
||||
// Move taskSlug declaration outside try block so it's accessible in catch
|
||||
const taskSlug = step.step // Use the 'step' field for task type
|
||||
|
||||
try {
|
||||
// Resolve input data using JSONPath
|
||||
const resolvedInput = this.resolveStepInput(step.input || {}, context)
|
||||
context.steps[stepName].input = resolvedInput
|
||||
|
||||
if (!taskSlug) {
|
||||
throw new Error(`Step ${stepName} is missing a task type`)
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
hasInput: !!resolvedInput,
|
||||
hasReq: !!req,
|
||||
stepName,
|
||||
taskSlug
|
||||
}, 'Queueing task')
|
||||
|
||||
const job = await this.payload.jobs.queue({
|
||||
input: resolvedInput,
|
||||
req,
|
||||
task: taskSlug
|
||||
})
|
||||
|
||||
// Run the job immediately
|
||||
await this.payload.jobs.run({
|
||||
limit: 1,
|
||||
req
|
||||
})
|
||||
|
||||
// Get the job result
|
||||
const completedJob = await this.payload.findByID({
|
||||
id: job.id,
|
||||
collection: 'payload-jobs',
|
||||
req
|
||||
})
|
||||
|
||||
const taskStatus = completedJob.taskStatus?.[completedJob.taskSlug]?.[completedJob.totalTried]
|
||||
const isComplete = taskStatus?.complete === true
|
||||
const hasError = completedJob.hasError || !isComplete
|
||||
|
||||
// Extract error information from job if available
|
||||
let errorMessage: string | undefined
|
||||
if (hasError) {
|
||||
// Try to get error from the latest log entry
|
||||
if (completedJob.log && completedJob.log.length > 0) {
|
||||
const latestLog = completedJob.log[completedJob.log.length - 1]
|
||||
errorMessage = latestLog.error?.message || latestLog.error
|
||||
}
|
||||
|
||||
// Fallback to top-level error
|
||||
if (!errorMessage && completedJob.error) {
|
||||
errorMessage = completedJob.error.message || completedJob.error
|
||||
}
|
||||
|
||||
// Final fallback to generic message
|
||||
if (!errorMessage) {
|
||||
errorMessage = `Task ${taskSlug} failed without detailed error information`
|
||||
}
|
||||
}
|
||||
|
||||
const result: {
|
||||
error: string | undefined
|
||||
output: unknown
|
||||
state: 'failed' | 'succeeded'
|
||||
} = {
|
||||
error: errorMessage,
|
||||
output: taskStatus?.output || {},
|
||||
state: isComplete ? 'succeeded' : 'failed'
|
||||
}
|
||||
|
||||
// Store the output and error
|
||||
context.steps[stepName].output = result.output
|
||||
context.steps[stepName].state = result.state
|
||||
if (result.error) {
|
||||
context.steps[stepName].error = result.error
|
||||
}
|
||||
|
||||
this.logger.debug({context}, 'Step execution context')
|
||||
|
||||
if (result.state !== 'succeeded') {
|
||||
throw new Error(result.error || `Step ${stepName} failed`)
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
output: result.output,
|
||||
stepName
|
||||
}, 'Step completed')
|
||||
|
||||
// Update workflow run with current step results if workflowRunId is provided
|
||||
if (workflowRunId) {
|
||||
await this.updateWorkflowRunContext(workflowRunId, context, req)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
context.steps[stepName].state = 'failed'
|
||||
context.steps[stepName].error = errorMessage
|
||||
|
||||
this.logger.error({
|
||||
error: errorMessage,
|
||||
input: context.steps[stepName].input,
|
||||
stepName,
|
||||
taskSlug
|
||||
}, 'Step execution failed')
|
||||
|
||||
// Update workflow run with current step results if workflowRunId is provided
|
||||
if (workflowRunId) {
|
||||
try {
|
||||
await this.updateWorkflowRunContext(workflowRunId, context, req)
|
||||
} catch (updateError) {
|
||||
this.logger.error({
|
||||
error: updateError instanceof Error ? updateError.message : 'Unknown error',
|
||||
stepName
|
||||
}, 'Failed to update workflow run context after step failure')
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve step execution order based on dependencies
|
||||
*/
|
||||
private resolveExecutionOrder(steps: WorkflowStep[]): WorkflowStep[][] {
|
||||
const stepMap = new Map<string, WorkflowStep>()
|
||||
const dependencyGraph = new Map<string, string[]>()
|
||||
const indegree = new Map<string, number>()
|
||||
|
||||
// Build the step map and dependency graph
|
||||
for (const step of steps) {
|
||||
const stepName = step.name || `step-${steps.indexOf(step)}`
|
||||
const dependencies = step.dependencies || []
|
||||
|
||||
stepMap.set(stepName, { ...step, name: stepName, dependencies })
|
||||
dependencyGraph.set(stepName, dependencies)
|
||||
indegree.set(stepName, dependencies.length)
|
||||
}
|
||||
|
||||
// Topological sort to determine execution batches
|
||||
const executionBatches: WorkflowStep[][] = []
|
||||
const processed = new Set<string>()
|
||||
|
||||
while (processed.size < steps.length) {
|
||||
const currentBatch: WorkflowStep[] = []
|
||||
|
||||
// Find all steps with no remaining dependencies
|
||||
for (const [stepName, inDegree] of indegree.entries()) {
|
||||
if (inDegree === 0 && !processed.has(stepName)) {
|
||||
const step = stepMap.get(stepName)
|
||||
if (step) {
|
||||
currentBatch.push(step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBatch.length === 0) {
|
||||
throw new Error('Circular dependency detected in workflow steps')
|
||||
}
|
||||
|
||||
executionBatches.push(currentBatch)
|
||||
|
||||
// Update indegrees for next iteration
|
||||
for (const step of currentBatch) {
|
||||
processed.add(step.name)
|
||||
|
||||
// Reduce indegree for steps that depend on completed steps
|
||||
for (const [otherStepName, dependencies] of dependencyGraph.entries()) {
|
||||
if (dependencies.includes(step.name) && !processed.has(otherStepName)) {
|
||||
indegree.set(otherStepName, (indegree.get(otherStepName) || 0) - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return executionBatches
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve step input using JSONPath expressions
|
||||
*/
|
||||
private resolveStepInput(config: Record<string, unknown>, context: ExecutionContext): Record<string, unknown> {
|
||||
const resolved: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (typeof value === 'string' && value.startsWith('$')) {
|
||||
// This is a JSONPath expression
|
||||
try {
|
||||
const result = JSONPath({
|
||||
json: context,
|
||||
path: value,
|
||||
wrap: false
|
||||
})
|
||||
resolved[key] = result
|
||||
} catch (error) {
|
||||
this.logger.warn({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
key,
|
||||
path: value
|
||||
}, 'Failed to resolve JSONPath')
|
||||
resolved[key] = value // Keep original value if resolution fails
|
||||
}
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Recursively resolve nested objects
|
||||
resolved[key] = this.resolveStepInput(value as Record<string, unknown>, context)
|
||||
} else {
|
||||
// Keep literal values as-is
|
||||
resolved[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workflow run with current context
|
||||
*/
|
||||
private async updateWorkflowRunContext(
|
||||
workflowRunId: number | string,
|
||||
context: ExecutionContext,
|
||||
req: PayloadRequest
|
||||
): Promise<void> {
|
||||
const serializeContext = () => ({
|
||||
steps: context.steps,
|
||||
trigger: {
|
||||
type: context.trigger.type,
|
||||
collection: context.trigger.collection,
|
||||
data: context.trigger.data,
|
||||
doc: context.trigger.doc,
|
||||
operation: context.trigger.operation,
|
||||
previousDoc: context.trigger.previousDoc,
|
||||
triggeredAt: context.trigger.triggeredAt,
|
||||
user: context.trigger.req?.user
|
||||
}
|
||||
})
|
||||
|
||||
await this.payload.update({
|
||||
id: workflowRunId,
|
||||
collection: 'workflow-runs',
|
||||
data: {
|
||||
context: serializeContext()
|
||||
},
|
||||
req
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a condition using JSONPath
|
||||
*/
|
||||
public evaluateCondition(condition: string, context: ExecutionContext): boolean {
|
||||
try {
|
||||
const result = JSONPath({
|
||||
json: context,
|
||||
path: condition,
|
||||
wrap: false
|
||||
})
|
||||
|
||||
// Handle different result types
|
||||
if (Array.isArray(result)) {
|
||||
return result.length > 0 && Boolean(result[0])
|
||||
}
|
||||
|
||||
return Boolean(result)
|
||||
} catch (error) {
|
||||
this.logger.warn({
|
||||
condition,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, 'Failed to evaluate condition')
|
||||
|
||||
// If condition evaluation fails, assume false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a workflow with the given context
|
||||
*/
|
||||
async execute(workflow: Workflow, context: ExecutionContext, req: PayloadRequest): Promise<void> {
|
||||
this.logger.info({
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Starting workflow execution')
|
||||
|
||||
const serializeContext = () => ({
|
||||
steps: context.steps,
|
||||
trigger: {
|
||||
type: context.trigger.type,
|
||||
collection: context.trigger.collection,
|
||||
data: context.trigger.data,
|
||||
doc: context.trigger.doc,
|
||||
operation: context.trigger.operation,
|
||||
previousDoc: context.trigger.previousDoc,
|
||||
triggeredAt: context.trigger.triggeredAt,
|
||||
user: context.trigger.req?.user
|
||||
}
|
||||
})
|
||||
|
||||
// Create a workflow run record
|
||||
const workflowRun = await this.payload.create({
|
||||
collection: 'workflow-runs',
|
||||
data: {
|
||||
context: serializeContext(),
|
||||
startedAt: new Date().toISOString(),
|
||||
status: 'running',
|
||||
triggeredBy: context.trigger.req?.user?.email || 'system',
|
||||
workflow: workflow.id,
|
||||
workflowVersion: workflow._version || 1
|
||||
},
|
||||
req
|
||||
})
|
||||
|
||||
try {
|
||||
// Resolve execution order based on dependencies
|
||||
const executionBatches = this.resolveExecutionOrder(workflow.steps)
|
||||
|
||||
this.logger.info({
|
||||
batchSizes: executionBatches.map(batch => batch.length),
|
||||
totalBatches: executionBatches.length
|
||||
}, 'Resolved step execution order')
|
||||
|
||||
// Execute each batch in sequence, but steps within each batch in parallel
|
||||
for (let batchIndex = 0; batchIndex < executionBatches.length; batchIndex++) {
|
||||
const batch = executionBatches[batchIndex]
|
||||
|
||||
this.logger.info({
|
||||
batchIndex,
|
||||
stepCount: batch.length,
|
||||
stepNames: batch.map(s => s.name)
|
||||
}, 'Executing batch')
|
||||
|
||||
// Execute all steps in this batch in parallel
|
||||
const batchPromises = batch.map((step, stepIndex) =>
|
||||
this.executeStep(step, stepIndex, context, req, workflowRun.id)
|
||||
)
|
||||
|
||||
// Wait for all steps in the current batch to complete
|
||||
await Promise.all(batchPromises)
|
||||
|
||||
this.logger.info({
|
||||
batchIndex,
|
||||
stepCount: batch.length
|
||||
}, 'Batch completed')
|
||||
}
|
||||
|
||||
// Update workflow run as completed
|
||||
await this.payload.update({
|
||||
id: workflowRun.id,
|
||||
collection: 'workflow-runs',
|
||||
data: {
|
||||
completedAt: new Date().toISOString(),
|
||||
context: serializeContext(),
|
||||
status: 'completed'
|
||||
},
|
||||
req
|
||||
})
|
||||
|
||||
this.logger.info({
|
||||
runId: workflowRun.id,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Workflow execution completed')
|
||||
|
||||
} catch (error) {
|
||||
// Update workflow run as failed
|
||||
await this.payload.update({
|
||||
id: workflowRun.id,
|
||||
collection: 'workflow-runs',
|
||||
data: {
|
||||
completedAt: new Date().toISOString(),
|
||||
context: serializeContext(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
status: 'failed'
|
||||
},
|
||||
req
|
||||
})
|
||||
|
||||
this.logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
runId: workflowRun.id,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Workflow execution failed')
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and execute workflows triggered by a collection operation
|
||||
*/
|
||||
async executeTriggeredWorkflows(
|
||||
collection: string,
|
||||
operation: 'create' | 'delete' | 'read' | 'update',
|
||||
doc: unknown,
|
||||
previousDoc: unknown,
|
||||
req: PayloadRequest
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Find workflows with matching triggers
|
||||
const workflows = await this.payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2, // Include steps and triggers
|
||||
limit: 100,
|
||||
req
|
||||
})
|
||||
|
||||
for (const workflow of workflows.docs) {
|
||||
// Check if this workflow has a matching trigger
|
||||
const triggers = workflow.triggers as Array<{
|
||||
collection: string
|
||||
condition?: string
|
||||
operation: string
|
||||
type: string
|
||||
}>
|
||||
|
||||
const matchingTriggers = triggers?.filter(trigger =>
|
||||
trigger.type === 'collection-trigger' &&
|
||||
trigger.collection === collection &&
|
||||
trigger.operation === operation
|
||||
) || []
|
||||
|
||||
for (const trigger of matchingTriggers) {
|
||||
// Create execution context for condition evaluation
|
||||
const context: ExecutionContext = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
type: 'collection',
|
||||
collection,
|
||||
doc,
|
||||
operation,
|
||||
previousDoc,
|
||||
req
|
||||
}
|
||||
}
|
||||
|
||||
// Check trigger condition if present
|
||||
if (trigger.condition) {
|
||||
const conditionMet = this.evaluateCondition(trigger.condition, context)
|
||||
|
||||
if (!conditionMet) {
|
||||
this.logger.info({
|
||||
collection,
|
||||
condition: trigger.condition,
|
||||
operation,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Trigger condition not met, skipping workflow')
|
||||
continue
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
collection,
|
||||
condition: trigger.condition,
|
||||
operation,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Trigger condition met')
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
collection,
|
||||
operation,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Triggering workflow')
|
||||
|
||||
// Execute the workflow
|
||||
await this.execute(workflow as Workflow, context, req)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error({ error: error instanceof Error ? error.message : 'Unknown error' }, 'Workflow execution failed')
|
||||
this.logger.error({
|
||||
collection,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
operation
|
||||
}, 'Failed to execute triggered workflows')
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/exports/client.ts
Normal file
8
src/exports/client.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Client-side components that may have CSS imports or PayloadCMS UI dependencies
|
||||
// These are separated to avoid CSS import errors during Node.js type generation
|
||||
|
||||
export { TriggerWorkflowButton } from '../components/TriggerWorkflowButton.js'
|
||||
|
||||
// Future client components can be added here:
|
||||
// export { default as WorkflowDashboard } from '../components/WorkflowDashboard/index.js'
|
||||
// export { default as WorkflowBuilder } from '../components/WorkflowBuilder/index.js'
|
||||
5
src/exports/fields.ts
Normal file
5
src/exports/fields.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Field exports for workflow plugin
|
||||
// Currently no custom fields, but this export exists for future extensibility
|
||||
export const WorkflowFields = {
|
||||
// Custom workflow fields can be added here in the future
|
||||
}
|
||||
1
src/exports/rsc.ts
Normal file
1
src/exports/rsc.ts
Normal file
@@ -0,0 +1 @@
|
||||
// Server-side exports for workflow plugin
|
||||
6
src/exports/views.ts
Normal file
6
src/exports/views.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// View exports for workflow plugin
|
||||
// Currently no custom views, but this export exists for future extensibility
|
||||
|
||||
// export { default as WorkflowDashboard } from '../components/WorkflowDashboard/index.js'
|
||||
// export { default as WorkflowBuilder } from '../components/WorkflowBuilder/index.js'
|
||||
// export { default as WorkflowStepsField } from '../components/WorkflowStepsField/index.js'
|
||||
19
src/index.ts
Normal file
19
src/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export { triggerCustomWorkflow, triggerWorkflowById } from './core/trigger-custom-workflow.js'
|
||||
export type { CustomTriggerOptions, TriggerResult } from './core/trigger-custom-workflow.js'
|
||||
export { WorkflowExecutor } from './core/workflow-executor.js'
|
||||
export type { ExecutionContext, Workflow, WorkflowStep, WorkflowTrigger } from './core/workflow-executor.js'
|
||||
export type { WorkflowsPluginConfig } from './plugin/config-types.js'
|
||||
export { workflowsPlugin } from './plugin/index.js'
|
||||
|
||||
// Export all step tasks
|
||||
export {
|
||||
CreateDocumentStepTask,
|
||||
DeleteDocumentStepTask,
|
||||
HttpRequestStepTask,
|
||||
ReadDocumentStepTask,
|
||||
SendEmailStepTask,
|
||||
UpdateDocumentStepTask
|
||||
} from './steps/index.js'
|
||||
|
||||
// UI components are exported via separate client export to avoid CSS import issues during type generation
|
||||
// Use: import { TriggerWorkflowButton } from '@xtr-dev/payload-automation/client'
|
||||
25
src/plugin/config-types.ts
Normal file
25
src/plugin/config-types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {Field, TaskConfig} from "payload"
|
||||
|
||||
export type CollectionTriggerConfigCrud = {
|
||||
create?: true
|
||||
delete?: true
|
||||
read?: true
|
||||
update?: true
|
||||
}
|
||||
|
||||
export type CollectionTriggerConfig = CollectionTriggerConfigCrud | true
|
||||
|
||||
export type CustomTriggerConfig = {
|
||||
inputs?: Field[]
|
||||
slug: string,
|
||||
}
|
||||
|
||||
export type WorkflowsPluginConfig<TSlug extends string> = {
|
||||
collectionTriggers: {
|
||||
[key in TSlug]?: CollectionTriggerConfig
|
||||
}
|
||||
enabled?: boolean
|
||||
steps: TaskConfig<string>[],
|
||||
triggers?: CustomTriggerConfig[]
|
||||
webhookPrefix?: string
|
||||
}
|
||||
632
src/plugin/cron-scheduler.ts
Normal file
632
src/plugin/cron-scheduler.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
import type {Config, Payload, TaskConfig} from 'payload'
|
||||
import * as cron from 'node-cron'
|
||||
|
||||
import {type Workflow, WorkflowExecutor} from '../core/workflow-executor.js'
|
||||
import {getConfigLogger} from './logger.js'
|
||||
|
||||
/**
|
||||
* Generate dynamic cron tasks for all workflows with cron triggers
|
||||
* This is called at config time to register all scheduled tasks
|
||||
*/
|
||||
export function generateCronTasks(config: Config): void {
|
||||
const logger = getConfigLogger()
|
||||
|
||||
// Note: We can't query the database at config time, so we'll need a different approach
|
||||
// We'll create a single task that handles all cron-triggered workflows
|
||||
const cronTask: TaskConfig = {
|
||||
slug: 'workflow-cron-executor',
|
||||
handler: async ({ input, req }) => {
|
||||
const { cronExpression, timezone, workflowId } = input as {
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
const logger = req.payload.logger.child({ plugin: '@xtr-dev/payload-automation' })
|
||||
|
||||
try {
|
||||
// Get the workflow
|
||||
const workflow = await req.payload.findByID({
|
||||
id: workflowId,
|
||||
collection: 'workflows',
|
||||
depth: 2,
|
||||
req
|
||||
})
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error(`Workflow ${workflowId} not found`)
|
||||
}
|
||||
|
||||
// Create execution context for cron trigger
|
||||
const context = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
type: 'cron',
|
||||
req,
|
||||
triggeredAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// Create executor
|
||||
const executor = new WorkflowExecutor(req.payload, logger)
|
||||
|
||||
// Find the matching cron trigger and check its condition if present
|
||||
const triggers = workflow.triggers as Array<{
|
||||
condition?: string
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
type: string
|
||||
}>
|
||||
|
||||
const matchingTrigger = triggers?.find(trigger =>
|
||||
trigger.type === 'cron-trigger' &&
|
||||
trigger.cronExpression === cronExpression
|
||||
)
|
||||
|
||||
// Check trigger condition if present
|
||||
if (matchingTrigger?.condition) {
|
||||
const conditionMet = executor.evaluateCondition(matchingTrigger.condition, context)
|
||||
|
||||
if (!conditionMet) {
|
||||
logger.info({
|
||||
condition: matchingTrigger.condition,
|
||||
cronExpression,
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Cron trigger condition not met, skipping workflow execution')
|
||||
|
||||
// Re-queue for next execution but don't run workflow
|
||||
if (cronExpression) {
|
||||
void requeueCronJob(workflowId, cronExpression, timezone, req.payload, logger)
|
||||
}
|
||||
|
||||
return {
|
||||
output: {
|
||||
executedAt: new Date().toISOString(),
|
||||
status: 'skipped',
|
||||
reason: 'Condition not met',
|
||||
workflowId
|
||||
},
|
||||
state: 'succeeded'
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({
|
||||
condition: matchingTrigger.condition,
|
||||
cronExpression,
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Cron trigger condition met')
|
||||
}
|
||||
|
||||
// Execute the workflow
|
||||
await executor.execute(workflow as Workflow, context, req)
|
||||
|
||||
// Re-queue the job for the next scheduled execution if cronExpression is provided
|
||||
if (cronExpression) {
|
||||
void requeueCronJob(workflowId, cronExpression, timezone, req.payload, logger)
|
||||
}
|
||||
|
||||
return {
|
||||
output: {
|
||||
executedAt: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
workflowId
|
||||
},
|
||||
state: 'succeeded'
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
workflowId
|
||||
}, 'Cron job execution failed')
|
||||
|
||||
// Re-queue even on failure to ensure continuity (unless it's a validation error)
|
||||
if (cronExpression && !(error instanceof Error && error.message.includes('Invalid cron'))) {
|
||||
void requeueCronJob(workflowId, cronExpression, timezone, req.payload, logger)
|
||||
.catch((requeueError) => {
|
||||
logger.error({
|
||||
error: requeueError instanceof Error ? requeueError.message : 'Unknown error',
|
||||
workflowId
|
||||
}, 'Failed to re-queue cron job after execution failure')
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
output: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
workflowId
|
||||
},
|
||||
state: 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the cron task to config if not already present
|
||||
if (!config.jobs) {
|
||||
config.jobs = { tasks: [] }
|
||||
}
|
||||
|
||||
if (!config.jobs.tasks) {
|
||||
config.jobs.tasks = []
|
||||
}
|
||||
|
||||
if (!config.jobs.tasks.find(task => task.slug === cronTask.slug)) {
|
||||
logger.debug(`Registering cron executor task: ${cronTask.slug}`)
|
||||
config.jobs.tasks.push(cronTask)
|
||||
} else {
|
||||
logger.debug(`Cron executor task ${cronTask.slug} already registered, skipping`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register cron jobs for workflows with cron triggers
|
||||
* This is called at runtime after PayloadCMS is initialized
|
||||
*/
|
||||
export async function registerCronJobs(payload: Payload, logger: Payload['logger']): Promise<void> {
|
||||
try {
|
||||
// Find all workflows with cron triggers
|
||||
const workflows = await payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
where: {
|
||||
'triggers.type': {
|
||||
equals: 'cron-trigger'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`Found ${workflows.docs.length} workflows with cron triggers`)
|
||||
|
||||
for (const workflow of workflows.docs) {
|
||||
const triggers = workflow.triggers as Array<{
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
type: string
|
||||
}>
|
||||
|
||||
// Find all cron triggers for this workflow
|
||||
const cronTriggers = triggers?.filter(t => t.type === 'cron-trigger') || []
|
||||
|
||||
for (const trigger of cronTriggers) {
|
||||
if (trigger.cronExpression) {
|
||||
try {
|
||||
// Validate cron expression before queueing
|
||||
if (!validateCronExpression(trigger.cronExpression)) {
|
||||
logger.error({
|
||||
cronExpression: trigger.cronExpression,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Invalid cron expression format')
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate timezone if provided
|
||||
if (trigger.timezone) {
|
||||
try {
|
||||
// Test if timezone is valid by trying to create a date with it
|
||||
new Intl.DateTimeFormat('en', { timeZone: trigger.timezone })
|
||||
} catch {
|
||||
logger.error({
|
||||
timezone: trigger.timezone,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Invalid timezone specified')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate next execution time
|
||||
const nextExecution = getNextCronTime(trigger.cronExpression, trigger.timezone)
|
||||
|
||||
// Queue the job
|
||||
await payload.jobs.queue({
|
||||
input: { cronExpression: trigger.cronExpression, timezone: trigger.timezone, workflowId: workflow.id },
|
||||
task: 'workflow-cron-executor',
|
||||
waitUntil: nextExecution
|
||||
})
|
||||
|
||||
logger.info({
|
||||
cronExpression: trigger.cronExpression,
|
||||
nextExecution: nextExecution.toISOString(),
|
||||
timezone: trigger.timezone || 'UTC',
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Queued initial cron job for workflow')
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
cronExpression: trigger.cronExpression,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timezone: trigger.timezone,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Failed to queue cron job')
|
||||
}
|
||||
} else {
|
||||
logger.warn({
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Cron trigger found but no cron expression specified')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, 'Failed to register cron jobs')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression
|
||||
*/
|
||||
export function validateCronExpression(cronExpression: string): boolean {
|
||||
return cron.validate(cronExpression)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next time a cron expression should run
|
||||
*/
|
||||
function getNextCronTime(cronExpression: string, timezone?: string): Date {
|
||||
if (!validateCronExpression(cronExpression)) {
|
||||
throw new Error(`Invalid cron expression: ${cronExpression}`)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const options: { timezone?: string } = timezone ? { timezone } : {}
|
||||
|
||||
// Create a task to find the next execution time
|
||||
const task = cron.schedule(cronExpression, () => {}, {
|
||||
...options
|
||||
})
|
||||
|
||||
// Parse cron expression parts
|
||||
const cronParts = cronExpression.trim().split(/\s+/)
|
||||
if (cronParts.length !== 5) {
|
||||
void task.destroy()
|
||||
throw new Error(`Invalid cron format: ${cronExpression}. Expected 5 parts.`)
|
||||
}
|
||||
|
||||
const [minutePart, hourPart, dayPart, monthPart, weekdayPart] = cronParts
|
||||
|
||||
// Calculate next execution with proper lookahead for any schedule frequency
|
||||
// Start from next minute and look ahead systematically
|
||||
let testTime = new Date(now.getTime() + 60 * 1000) // Start 1 minute from now
|
||||
testTime.setSeconds(0, 0) // Reset seconds and milliseconds
|
||||
|
||||
// Maximum iterations to prevent infinite loops (covers ~2 years)
|
||||
const maxIterations = 2 * 365 * 24 * 60 // 2 years worth of minutes
|
||||
let iterations = 0
|
||||
|
||||
while (iterations < maxIterations) {
|
||||
const minute = testTime.getMinutes()
|
||||
const hour = testTime.getHours()
|
||||
const dayOfMonth = testTime.getDate()
|
||||
const month = testTime.getMonth() + 1
|
||||
const dayOfWeek = testTime.getDay()
|
||||
|
||||
if (matchesCronPart(minute, minutePart) &&
|
||||
matchesCronPart(hour, hourPart) &&
|
||||
matchesCronPart(dayOfMonth, dayPart) &&
|
||||
matchesCronPart(month, monthPart) &&
|
||||
matchesCronPart(dayOfWeek, weekdayPart)) {
|
||||
void task.destroy()
|
||||
return testTime
|
||||
}
|
||||
|
||||
// Increment time intelligently based on cron pattern
|
||||
testTime = incrementTimeForCronPattern(testTime, cronParts)
|
||||
iterations++
|
||||
}
|
||||
|
||||
void task.destroy()
|
||||
throw new Error(`Could not calculate next execution time for cron expression: ${cronExpression} within reasonable timeframe`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligently increment time based on cron pattern to avoid unnecessary iterations
|
||||
*/
|
||||
function incrementTimeForCronPattern(currentTime: Date, cronParts: string[]): Date {
|
||||
const [minutePart, hourPart, _dayPart, _monthPart, _weekdayPart] = cronParts
|
||||
const nextTime = new Date(currentTime)
|
||||
|
||||
// If minute is specific (not wildcard), we can jump to next hour
|
||||
if (minutePart !== '*' && !minutePart.includes('/')) {
|
||||
const targetMinute = getNextValidCronValue(currentTime.getMinutes(), minutePart)
|
||||
if (targetMinute <= currentTime.getMinutes()) {
|
||||
// Move to next hour
|
||||
nextTime.setHours(nextTime.getHours() + 1, targetMinute, 0, 0)
|
||||
} else {
|
||||
nextTime.setMinutes(targetMinute, 0, 0)
|
||||
}
|
||||
return nextTime
|
||||
}
|
||||
|
||||
// If hour is specific and we're past it, jump to next day
|
||||
if (hourPart !== '*' && !hourPart.includes('/')) {
|
||||
const targetHour = getNextValidCronValue(currentTime.getHours(), hourPart)
|
||||
if (targetHour <= currentTime.getHours()) {
|
||||
// Move to next day
|
||||
nextTime.setDate(nextTime.getDate() + 1)
|
||||
nextTime.setHours(targetHour, 0, 0, 0)
|
||||
} else {
|
||||
nextTime.setHours(targetHour, 0, 0, 0)
|
||||
}
|
||||
return nextTime
|
||||
}
|
||||
|
||||
// Default: increment by 1 minute
|
||||
nextTime.setTime(nextTime.getTime() + 60 * 1000)
|
||||
return nextTime
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next valid value for a cron part
|
||||
*/
|
||||
function getNextValidCronValue(currentValue: number, cronPart: string): number {
|
||||
if (cronPart === '*') {return currentValue + 1}
|
||||
|
||||
// Handle specific values and ranges
|
||||
const values = parseCronPart(cronPart)
|
||||
return values.find(v => v > currentValue) || values[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a cron part into an array of valid values
|
||||
*/
|
||||
function parseCronPart(cronPart: string): number[] {
|
||||
if (cronPart === '*') {return []}
|
||||
|
||||
const values: number[] = []
|
||||
|
||||
// Handle comma-separated values
|
||||
if (cronPart.includes(',')) {
|
||||
cronPart.split(',').forEach(part => {
|
||||
values.push(...parseCronPart(part.trim()))
|
||||
})
|
||||
return values.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
// Handle ranges
|
||||
if (cronPart.includes('-')) {
|
||||
const [start, end] = cronPart.split('-').map(n => parseInt(n, 10))
|
||||
for (let i = start; i <= end; i++) {
|
||||
values.push(i)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// Handle step values
|
||||
if (cronPart.includes('/')) {
|
||||
const [range, step] = cronPart.split('/')
|
||||
const stepNum = parseInt(step, 10)
|
||||
|
||||
if (range === '*') {
|
||||
// For wildcards with steps, return empty - handled elsewhere
|
||||
return []
|
||||
}
|
||||
|
||||
const baseValues = parseCronPart(range)
|
||||
return baseValues.filter((_, index) => index % stepNum === 0)
|
||||
}
|
||||
|
||||
// Single value
|
||||
values.push(parseInt(cronPart, 10))
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value matches a cron expression part
|
||||
*/
|
||||
function matchesCronPart(value: number, cronPart: string): boolean {
|
||||
if (cronPart === '*') {return true}
|
||||
|
||||
// Handle step values (e.g., */5)
|
||||
if (cronPart.includes('/')) {
|
||||
const [range, step] = cronPart.split('/')
|
||||
const stepNum = parseInt(step, 10)
|
||||
|
||||
if (range === '*') {
|
||||
return value % stepNum === 0
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ranges (e.g., 1-5)
|
||||
if (cronPart.includes('-')) {
|
||||
const [start, end] = cronPart.split('-').map(n => parseInt(n, 10))
|
||||
return value >= start && value <= end
|
||||
}
|
||||
|
||||
// Handle comma-separated values (e.g., 1,3,5)
|
||||
if (cronPart.includes(',')) {
|
||||
const values = cronPart.split(',').map(n => parseInt(n, 10))
|
||||
return values.includes(value)
|
||||
}
|
||||
|
||||
// Handle single value
|
||||
const cronValue = parseInt(cronPart, 10)
|
||||
return value === cronValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle re-queueing of cron jobs after they execute
|
||||
* This ensures the job runs again at the next scheduled time
|
||||
*/
|
||||
export async function requeueCronJob(
|
||||
workflowId: string,
|
||||
cronExpression: string,
|
||||
timezone: string | undefined,
|
||||
payload: Payload,
|
||||
logger: Payload['logger']
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Queue the job to run at the next scheduled time
|
||||
await payload.jobs.queue({
|
||||
input: { cronExpression, timezone, workflowId },
|
||||
task: 'workflow-cron-executor',
|
||||
waitUntil: getNextCronTime(cronExpression, timezone)
|
||||
})
|
||||
|
||||
logger.debug({
|
||||
nextRun: getNextCronTime(cronExpression, timezone),
|
||||
timezone: timezone || 'UTC',
|
||||
workflowId
|
||||
}, 'Re-queued cron job')
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
workflowId
|
||||
}, 'Failed to re-queue cron job')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register or update cron jobs for a specific workflow
|
||||
*/
|
||||
export async function updateWorkflowCronJobs(
|
||||
workflowId: string,
|
||||
payload: Payload,
|
||||
logger: Payload['logger']
|
||||
): Promise<void> {
|
||||
try {
|
||||
// First, cancel any existing cron jobs for this workflow
|
||||
cancelWorkflowCronJobs(workflowId, payload, logger)
|
||||
|
||||
// Get the workflow
|
||||
const workflow = await payload.findByID({
|
||||
id: workflowId,
|
||||
collection: 'workflows',
|
||||
depth: 0
|
||||
})
|
||||
|
||||
if (!workflow) {
|
||||
logger.warn({ workflowId }, 'Workflow not found for cron job update')
|
||||
return
|
||||
}
|
||||
|
||||
const triggers = workflow.triggers as Array<{
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
type: string
|
||||
}>
|
||||
|
||||
// Find all cron triggers for this workflow
|
||||
const cronTriggers = triggers?.filter(t => t.type === 'cron-trigger') || []
|
||||
|
||||
if (cronTriggers.length === 0) {
|
||||
logger.debug({ workflowId }, 'No cron triggers found for workflow')
|
||||
return
|
||||
}
|
||||
|
||||
let scheduledJobs = 0
|
||||
|
||||
for (const trigger of cronTriggers) {
|
||||
if (trigger.cronExpression) {
|
||||
try {
|
||||
// Validate cron expression before queueing
|
||||
if (!validateCronExpression(trigger.cronExpression)) {
|
||||
logger.error({
|
||||
cronExpression: trigger.cronExpression,
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Invalid cron expression format')
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate timezone if provided
|
||||
if (trigger.timezone) {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en', { timeZone: trigger.timezone })
|
||||
} catch {
|
||||
logger.error({
|
||||
timezone: trigger.timezone,
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Invalid timezone specified')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate next execution time
|
||||
const nextExecution = getNextCronTime(trigger.cronExpression, trigger.timezone)
|
||||
|
||||
// Queue the job
|
||||
await payload.jobs.queue({
|
||||
input: { cronExpression: trigger.cronExpression, timezone: trigger.timezone, workflowId },
|
||||
task: 'workflow-cron-executor',
|
||||
waitUntil: nextExecution
|
||||
})
|
||||
|
||||
scheduledJobs++
|
||||
|
||||
logger.info({
|
||||
cronExpression: trigger.cronExpression,
|
||||
nextExecution: nextExecution.toISOString(),
|
||||
timezone: trigger.timezone || 'UTC',
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Scheduled cron job for workflow')
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
cronExpression: trigger.cronExpression,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timezone: trigger.timezone,
|
||||
workflowId,
|
||||
workflowName: workflow.name
|
||||
}, 'Failed to schedule cron job')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduledJobs > 0) {
|
||||
logger.info({ scheduledJobs, workflowId }, 'Updated cron jobs for workflow')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
workflowId
|
||||
}, 'Failed to update workflow cron jobs')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all cron jobs for a specific workflow
|
||||
*/
|
||||
export function cancelWorkflowCronJobs(
|
||||
workflowId: string,
|
||||
payload: Payload,
|
||||
logger: Payload['logger']
|
||||
): void {
|
||||
try {
|
||||
// Note: PayloadCMS job system doesn't have a built-in way to cancel specific jobs by input
|
||||
// This is a limitation we need to work around
|
||||
// For now, we log that we would cancel jobs for this workflow
|
||||
logger.debug({ workflowId }, 'Would cancel existing cron jobs for workflow (PayloadCMS limitation: cannot selectively cancel jobs)')
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
workflowId
|
||||
}, 'Failed to cancel workflow cron jobs')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove cron jobs for a deleted workflow
|
||||
*/
|
||||
export function removeWorkflowCronJobs(
|
||||
workflowId: string,
|
||||
payload: Payload,
|
||||
logger: Payload['logger']
|
||||
): void {
|
||||
try {
|
||||
cancelWorkflowCronJobs(workflowId, payload, logger)
|
||||
logger.info({ workflowId }, 'Removed cron jobs for deleted workflow')
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
workflowId
|
||||
}, 'Failed to remove workflow cron jobs')
|
||||
}
|
||||
}
|
||||
88
src/plugin/index.ts
Normal file
88
src/plugin/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type {Config} from 'payload'
|
||||
|
||||
import type {WorkflowsPluginConfig} from "./config-types.js"
|
||||
|
||||
import {createWorkflowCollection} from '../collections/Workflow.js'
|
||||
import {WorkflowRunsCollection} from '../collections/WorkflowRuns.js'
|
||||
import {WorkflowExecutor} from '../core/workflow-executor.js'
|
||||
import {generateCronTasks, registerCronJobs} from './cron-scheduler.js'
|
||||
import {initCollectionHooks} from "./init-collection-hooks.js"
|
||||
import {initGlobalHooks} from "./init-global-hooks.js"
|
||||
import {initStepTasks} from "./init-step-tasks.js"
|
||||
import {initWebhookEndpoint} from "./init-webhook.js"
|
||||
import {initWorkflowHooks} from './init-workflow-hooks.js'
|
||||
import {getConfigLogger, initializeLogger} from './logger.js'
|
||||
|
||||
export {getLogger} from './logger.js'
|
||||
|
||||
const applyCollectionsConfig = <T extends string>(pluginOptions: WorkflowsPluginConfig<T>, config: Config) => {
|
||||
// Add workflow collections
|
||||
if (!config.collections) {
|
||||
config.collections = []
|
||||
}
|
||||
|
||||
config.collections.push(
|
||||
createWorkflowCollection(pluginOptions),
|
||||
WorkflowRunsCollection
|
||||
)
|
||||
}
|
||||
|
||||
export const workflowsPlugin =
|
||||
<TSlug extends string>(pluginOptions: WorkflowsPluginConfig<TSlug>) =>
|
||||
(config: Config): Config => {
|
||||
// If the plugin is disabled, return config unchanged
|
||||
if (pluginOptions.enabled === false) {
|
||||
return config
|
||||
}
|
||||
|
||||
applyCollectionsConfig<TSlug>(pluginOptions, config)
|
||||
|
||||
if (!config.jobs) {
|
||||
config.jobs = {tasks: []}
|
||||
}
|
||||
|
||||
const configLogger = getConfigLogger()
|
||||
|
||||
// Generate cron tasks for workflows with cron triggers
|
||||
generateCronTasks(config)
|
||||
|
||||
for (const step of pluginOptions.steps) {
|
||||
if (!config.jobs?.tasks?.find(task => task.slug === step.slug)) {
|
||||
configLogger.debug(`Registering task: ${step.slug}`)
|
||||
config.jobs?.tasks?.push(step)
|
||||
} else {
|
||||
configLogger.debug(`Task ${step.slug} already registered, skipping`)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize webhook endpoint
|
||||
initWebhookEndpoint(config, pluginOptions.webhookPrefix || 'webhook')
|
||||
|
||||
// Set up onInit to register collection hooks and initialize features
|
||||
const incomingOnInit = config.onInit
|
||||
config.onInit = async (payload) => {
|
||||
// Execute any existing onInit functions first
|
||||
if (incomingOnInit) {
|
||||
await incomingOnInit(payload)
|
||||
}
|
||||
|
||||
// Initialize the logger with the payload instance
|
||||
const logger = initializeLogger(payload)
|
||||
|
||||
// Create workflow executor instance
|
||||
const executor = new WorkflowExecutor(payload, logger)
|
||||
|
||||
// Initialize hooks
|
||||
initCollectionHooks(pluginOptions, payload, logger, executor)
|
||||
initGlobalHooks(payload, logger, executor)
|
||||
initWorkflowHooks(payload, logger)
|
||||
initStepTasks(pluginOptions, payload, logger)
|
||||
|
||||
// Register cron jobs for workflows with cron triggers
|
||||
await registerCronJobs(payload, logger)
|
||||
|
||||
logger.info('Plugin initialized successfully')
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
91
src/plugin/init-collection-hooks.ts
Normal file
91
src/plugin/init-collection-hooks.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type {Payload} from "payload"
|
||||
import type {Logger} from "pino"
|
||||
|
||||
import type { WorkflowExecutor } from "../core/workflow-executor.js"
|
||||
import type {CollectionTriggerConfigCrud, WorkflowsPluginConfig} from "./config-types.js"
|
||||
|
||||
export function initCollectionHooks<T extends string>(pluginOptions: WorkflowsPluginConfig<T>, payload: Payload, logger: Payload['logger'], executor: WorkflowExecutor) {
|
||||
|
||||
// Add hooks to configured collections
|
||||
for (const [collectionSlug, triggerConfig] of Object.entries(pluginOptions.collectionTriggers)) {
|
||||
if (!triggerConfig) {
|
||||
continue
|
||||
}
|
||||
|
||||
const collection = payload.collections[collectionSlug as T]
|
||||
const crud: CollectionTriggerConfigCrud = triggerConfig === true ? {
|
||||
create: true,
|
||||
delete: true,
|
||||
read: true,
|
||||
update: true,
|
||||
} : triggerConfig
|
||||
|
||||
if (!collection.config.hooks) {
|
||||
collection.config.hooks = {} as typeof collection.config.hooks
|
||||
}
|
||||
|
||||
if (crud.update || crud.create) {
|
||||
collection.config.hooks.afterChange = collection.config.hooks.afterChange || []
|
||||
collection.config.hooks.afterChange.push(async (change) => {
|
||||
const operation = change.operation as 'create' | 'update'
|
||||
logger.debug({
|
||||
collection: change.collection.slug,
|
||||
operation,
|
||||
}, 'Collection hook triggered')
|
||||
|
||||
// Execute workflows for this trigger
|
||||
await executor.executeTriggeredWorkflows(
|
||||
change.collection.slug,
|
||||
operation,
|
||||
change.doc,
|
||||
change.previousDoc,
|
||||
change.req
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (crud.read) {
|
||||
collection.config.hooks.afterRead = collection.config.hooks.afterRead || []
|
||||
collection.config.hooks.afterRead.push(async (change) => {
|
||||
logger.debug({
|
||||
collection: change.collection.slug,
|
||||
operation: 'read',
|
||||
}, 'Collection hook triggered')
|
||||
|
||||
// Execute workflows for this trigger
|
||||
await executor.executeTriggeredWorkflows(
|
||||
change.collection.slug,
|
||||
'read',
|
||||
change.doc,
|
||||
undefined,
|
||||
change.req
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (crud.delete) {
|
||||
collection.config.hooks.afterDelete = collection.config.hooks.afterDelete || []
|
||||
collection.config.hooks.afterDelete.push(async (change) => {
|
||||
logger.debug({
|
||||
collection: change.collection.slug,
|
||||
operation: 'delete',
|
||||
}, 'Collection hook triggered')
|
||||
|
||||
// Execute workflows for this trigger
|
||||
await executor.executeTriggeredWorkflows(
|
||||
change.collection.slug,
|
||||
'delete',
|
||||
change.doc,
|
||||
undefined,
|
||||
change.req
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
logger.info({collectionSlug}, 'Collection hooks registered')
|
||||
} else {
|
||||
logger.warn({collectionSlug}, 'Collection not found for trigger configuration')
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/plugin/init-global-hooks.ts
Normal file
112
src/plugin/init-global-hooks.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Payload, PayloadRequest } from "payload"
|
||||
import type { Logger } from "pino"
|
||||
|
||||
import type { WorkflowExecutor, Workflow } from "../core/workflow-executor.js"
|
||||
|
||||
export function initGlobalHooks(payload: Payload, logger: Payload['logger'], executor: WorkflowExecutor) {
|
||||
// Get all globals from the config
|
||||
const globals = payload.config.globals || []
|
||||
|
||||
for (const globalConfig of globals) {
|
||||
const globalSlug = globalConfig.slug
|
||||
|
||||
// Add afterChange hook to global
|
||||
if (!globalConfig.hooks) {
|
||||
globalConfig.hooks = {
|
||||
afterChange: [],
|
||||
afterRead: [],
|
||||
beforeChange: [],
|
||||
beforeRead: [],
|
||||
beforeValidate: []
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalConfig.hooks.afterChange) {
|
||||
globalConfig.hooks.afterChange = []
|
||||
}
|
||||
|
||||
globalConfig.hooks.afterChange.push(async (change) => {
|
||||
logger.debug({
|
||||
global: globalSlug,
|
||||
operation: 'update'
|
||||
}, 'Global hook triggered')
|
||||
|
||||
// Execute workflows for this global trigger
|
||||
await executeTriggeredGlobalWorkflows(
|
||||
globalSlug,
|
||||
'update',
|
||||
change.doc,
|
||||
change.previousDoc,
|
||||
change.req,
|
||||
payload,
|
||||
logger,
|
||||
executor
|
||||
)
|
||||
})
|
||||
|
||||
logger.info({ globalSlug }, 'Global hooks registered')
|
||||
}
|
||||
}
|
||||
|
||||
async function executeTriggeredGlobalWorkflows(
|
||||
globalSlug: string,
|
||||
operation: 'update',
|
||||
doc: Record<string, any>,
|
||||
previousDoc: Record<string, any>,
|
||||
req: PayloadRequest,
|
||||
payload: Payload,
|
||||
logger: Payload['logger'],
|
||||
executor: WorkflowExecutor
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Find workflows with matching global triggers
|
||||
const workflows = await payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2,
|
||||
limit: 100,
|
||||
req,
|
||||
where: {
|
||||
'triggers.global': {
|
||||
equals: globalSlug
|
||||
},
|
||||
'triggers.globalOperation': {
|
||||
equals: operation
|
||||
},
|
||||
'triggers.type': {
|
||||
equals: 'global-trigger'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for (const workflow of workflows.docs) {
|
||||
logger.info({
|
||||
globalSlug,
|
||||
operation,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Triggering global workflow')
|
||||
|
||||
// Create execution context
|
||||
const context = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
type: 'global',
|
||||
doc,
|
||||
global: globalSlug,
|
||||
operation,
|
||||
previousDoc,
|
||||
req
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the workflow
|
||||
await executor.execute(workflow as Workflow, context, req)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
globalSlug,
|
||||
operation
|
||||
}, 'Failed to execute triggered global workflows')
|
||||
}
|
||||
}
|
||||
9
src/plugin/init-step-tasks.ts
Normal file
9
src/plugin/init-step-tasks.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type {Payload} from "payload"
|
||||
import type {Logger} from "pino"
|
||||
|
||||
import type {WorkflowsPluginConfig} from "./config-types.js"
|
||||
|
||||
export function initStepTasks<T extends string>(pluginOptions: WorkflowsPluginConfig<T>, payload: Payload, logger: Payload['logger']) {
|
||||
logger.info({ stepCount: pluginOptions.steps.length, steps: pluginOptions.steps.map(s => s.slug) }, 'Initializing step tasks')
|
||||
|
||||
}
|
||||
165
src/plugin/init-webhook.ts
Normal file
165
src/plugin/init-webhook.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type {Config, PayloadRequest} from 'payload'
|
||||
|
||||
import {type Workflow, WorkflowExecutor} from '../core/workflow-executor.js'
|
||||
import {getConfigLogger, initializeLogger} from './logger.js'
|
||||
|
||||
export function initWebhookEndpoint(config: Config, webhookPrefix = 'webhook'): void {
|
||||
const logger = getConfigLogger()
|
||||
// Ensure the prefix starts with a slash
|
||||
const normalizedPrefix = webhookPrefix.startsWith('/') ? webhookPrefix : `/${webhookPrefix}`
|
||||
logger.debug(`Adding webhook endpoint to config with prefix: ${normalizedPrefix}`)
|
||||
logger.debug('Current config.endpoints length:', config.endpoints?.length || 0)
|
||||
|
||||
// Define webhook endpoint
|
||||
const webhookEndpoint = {
|
||||
handler: async (req: PayloadRequest) => {
|
||||
const {path} = req.routeParams as { path: string }
|
||||
const webhookData = req.body || {}
|
||||
|
||||
logger.debug('Webhook endpoint handler called, path: ' + path)
|
||||
|
||||
try {
|
||||
// Find workflows with matching webhook triggers
|
||||
const workflows = await req.payload.find({
|
||||
collection: 'workflows',
|
||||
depth: 2,
|
||||
limit: 100,
|
||||
req,
|
||||
where: {
|
||||
'triggers.type': {
|
||||
equals: 'webhook-trigger'
|
||||
},
|
||||
'triggers.webhookPath': {
|
||||
equals: path
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (workflows.docs.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({error: 'No workflows found for this webhook path'}),
|
||||
{
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
status: 404
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Create workflow executor for this request
|
||||
const logger = initializeLogger(req.payload)
|
||||
const executor = new WorkflowExecutor(req.payload, logger)
|
||||
|
||||
const executionPromises = workflows.docs.map(async (workflow) => {
|
||||
try {
|
||||
// Create execution context for the webhook trigger
|
||||
const context = {
|
||||
steps: {},
|
||||
trigger: {
|
||||
type: 'webhook',
|
||||
data: webhookData,
|
||||
headers: Object.fromEntries(req.headers?.entries() || []),
|
||||
path,
|
||||
req
|
||||
}
|
||||
}
|
||||
|
||||
// Find the matching trigger and check its condition if present
|
||||
const triggers = workflow.triggers as Array<{
|
||||
condition?: string
|
||||
type: string
|
||||
webhookPath?: string
|
||||
}>
|
||||
|
||||
const matchingTrigger = triggers?.find(trigger =>
|
||||
trigger.type === 'webhook-trigger' &&
|
||||
trigger.webhookPath === path
|
||||
)
|
||||
|
||||
// Check trigger condition if present
|
||||
if (matchingTrigger?.condition) {
|
||||
const conditionMet = executor.evaluateCondition(matchingTrigger.condition, context)
|
||||
|
||||
if (!conditionMet) {
|
||||
logger.info({
|
||||
condition: matchingTrigger.condition,
|
||||
path,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Webhook trigger condition not met, skipping workflow')
|
||||
|
||||
return { status: 'skipped', workflowId: workflow.id, reason: 'Condition not met' }
|
||||
}
|
||||
|
||||
logger.info({
|
||||
condition: matchingTrigger.condition,
|
||||
path,
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name
|
||||
}, 'Webhook trigger condition met')
|
||||
}
|
||||
|
||||
// Execute the workflow
|
||||
await executor.execute(workflow as Workflow, context, req)
|
||||
|
||||
return { status: 'triggered', workflowId: workflow.id }
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
status: 'failed',
|
||||
workflowId: workflow.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(executionPromises)
|
||||
const resultsData = results.map((result, index) => {
|
||||
const baseResult = { workflowId: workflows.docs[index].id }
|
||||
if (result.status === 'fulfilled') {
|
||||
return { ...baseResult, ...result.value }
|
||||
} else {
|
||||
return { ...baseResult, error: result.reason, status: 'failed' }
|
||||
}
|
||||
})
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: `Triggered ${workflows.docs.length} workflow(s)`,
|
||||
results: resultsData
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
}
|
||||
)
|
||||
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
error: 'Failed to process webhook'
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 500
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
method: 'post' as const,
|
||||
path: `${normalizedPrefix}/:path`
|
||||
}
|
||||
|
||||
// Check if webhook endpoint already exists to avoid duplicates
|
||||
const existingEndpoint = config.endpoints?.find(endpoint =>
|
||||
endpoint.path === webhookEndpoint.path && endpoint.method === webhookEndpoint.method
|
||||
)
|
||||
|
||||
if (!existingEndpoint) {
|
||||
// Combine existing endpoints with the webhook endpoint
|
||||
config.endpoints = [...(config.endpoints || []), webhookEndpoint]
|
||||
logger.debug(`Webhook endpoint added at path: ${webhookEndpoint.path}`)
|
||||
logger.debug('New config.endpoints length:', config.endpoints.length)
|
||||
} else {
|
||||
logger.debug(`Webhook endpoint already exists at path: ${webhookEndpoint.path}`)
|
||||
}
|
||||
}
|
||||
56
src/plugin/init-workflow-hooks.ts
Normal file
56
src/plugin/init-workflow-hooks.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type {Payload} from 'payload'
|
||||
|
||||
import {updateWorkflowCronJobs, removeWorkflowCronJobs} from './cron-scheduler.js'
|
||||
|
||||
/**
|
||||
* Initialize hooks for the workflows collection itself
|
||||
* to manage cron jobs when workflows are created/updated
|
||||
*/
|
||||
export function initWorkflowHooks(payload: Payload, logger: Payload['logger']): void {
|
||||
// Add afterChange hook to workflows collection to update cron jobs
|
||||
const workflowsCollection = payload.collections.workflows
|
||||
|
||||
if (!workflowsCollection) {
|
||||
logger.warn('Workflows collection not found, cannot initialize workflow hooks')
|
||||
return
|
||||
}
|
||||
|
||||
// Add afterChange hook to register/update cron jobs
|
||||
if (!workflowsCollection.config.hooks?.afterChange) {
|
||||
if (!workflowsCollection.config.hooks) {
|
||||
// @ts-expect-error - hooks object will be populated by Payload
|
||||
workflowsCollection.config.hooks = {}
|
||||
}
|
||||
workflowsCollection.config.hooks.afterChange = []
|
||||
}
|
||||
|
||||
workflowsCollection.config.hooks.afterChange.push(async ({ doc, operation }) => {
|
||||
if (operation === 'create' || operation === 'update') {
|
||||
logger.debug({
|
||||
operation,
|
||||
workflowId: doc.id,
|
||||
workflowName: doc.name
|
||||
}, 'Workflow changed, updating cron jobs selectively')
|
||||
|
||||
// Update cron jobs for this specific workflow only
|
||||
await updateWorkflowCronJobs(doc.id, payload, logger)
|
||||
}
|
||||
})
|
||||
|
||||
// Add afterDelete hook to clean up cron jobs
|
||||
if (!workflowsCollection.config.hooks?.afterDelete) {
|
||||
workflowsCollection.config.hooks.afterDelete = []
|
||||
}
|
||||
|
||||
workflowsCollection.config.hooks.afterDelete.push(async ({ doc }) => {
|
||||
logger.debug({
|
||||
workflowId: doc.id,
|
||||
workflowName: doc.name
|
||||
}, 'Workflow deleted, removing cron jobs')
|
||||
|
||||
// Remove cron jobs for the deleted workflow
|
||||
removeWorkflowCronJobs(doc.id, payload, logger)
|
||||
})
|
||||
|
||||
logger.info('Workflow hooks initialized for cron job management')
|
||||
}
|
||||
56
src/plugin/logger.ts
Normal file
56
src/plugin/logger.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
// Global logger instance - use Payload's logger type
|
||||
let pluginLogger: Payload['logger'] | null = null
|
||||
|
||||
/**
|
||||
* Simple config-time logger for use during plugin configuration
|
||||
* Uses console with plugin prefix since Payload logger isn't available yet
|
||||
*/
|
||||
const configLogger = {
|
||||
debug: (message: string, ...args: any[]) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`[payload-automation] ${message}`, ...args)
|
||||
}
|
||||
},
|
||||
error: (message: string, ...args: any[]) => {
|
||||
console.error(`[payload-automation] ${message}`, ...args)
|
||||
},
|
||||
info: (message: string, ...args: any[]) => {
|
||||
console.log(`[payload-automation] ${message}`, ...args)
|
||||
},
|
||||
warn: (message: string, ...args: any[]) => {
|
||||
console.warn(`[payload-automation] ${message}`, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a logger for config-time use (before Payload initialization)
|
||||
*/
|
||||
export function getConfigLogger() {
|
||||
return configLogger
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the plugin logger using Payload's Pino instance
|
||||
* This creates a child logger with plugin identification
|
||||
*/
|
||||
export function initializeLogger(payload: Payload): Payload['logger'] {
|
||||
// Create a child logger with plugin identification
|
||||
pluginLogger = payload.logger.child({
|
||||
plugin: '@xtr-dev/payload-automation'
|
||||
})
|
||||
return pluginLogger
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plugin logger instance
|
||||
* Throws error if not initialized
|
||||
*/
|
||||
export function getLogger(): Payload['logger'] {
|
||||
if (!pluginLogger) {
|
||||
throw new Error('@xtr-dev/payload-automation: Logger not initialized. Make sure the plugin is properly configured.')
|
||||
}
|
||||
|
||||
return pluginLogger
|
||||
}
|
||||
42
src/steps/create-document-handler.ts
Normal file
42
src/steps/create-document-handler.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { TaskHandler } from "payload"
|
||||
|
||||
export const createDocumentHandler: TaskHandler<'create-document'> = async ({ input, req }) => {
|
||||
if (!input) {
|
||||
throw new Error('No input provided')
|
||||
}
|
||||
|
||||
const { collection, data, draft, locale } = input
|
||||
|
||||
if (!collection || typeof collection !== 'string') {
|
||||
throw new Error('Collection slug is required')
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error('Document data is required')
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedData = typeof data === 'string' ? JSON.parse(data) : data
|
||||
|
||||
const result = await req.payload.create({
|
||||
collection,
|
||||
data: parsedData,
|
||||
draft: draft || false,
|
||||
locale: locale || undefined,
|
||||
req
|
||||
})
|
||||
|
||||
return {
|
||||
output: {
|
||||
id: result.id,
|
||||
doc: result
|
||||
},
|
||||
state: 'succeeded'
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
errorMessage: error instanceof Error ? error.message : 'Failed to create document',
|
||||
state: 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/steps/create-document.ts
Normal file
56
src/steps/create-document.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { TaskConfig } from "payload"
|
||||
|
||||
import { createDocumentHandler } from "./create-document-handler.js"
|
||||
|
||||
export const CreateDocumentStepTask = {
|
||||
slug: 'create-document',
|
||||
handler: createDocumentHandler,
|
||||
inputSchema: [
|
||||
{
|
||||
name: 'collection',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The collection slug to create a document in'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'data',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'The document data to create'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
type: 'checkbox',
|
||||
admin: {
|
||||
description: 'Create as draft (if collection has drafts enabled)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'locale',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Locale for the document (if localization is enabled)'
|
||||
}
|
||||
}
|
||||
],
|
||||
outputSchema: [
|
||||
{
|
||||
name: 'doc',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'The created document'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The ID of the created document'
|
||||
}
|
||||
}
|
||||
]
|
||||
} satisfies TaskConfig<'create-document'>
|
||||
71
src/steps/delete-document-handler.ts
Normal file
71
src/steps/delete-document-handler.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { TaskHandler } from "payload"
|
||||
|
||||
export const deleteDocumentHandler: TaskHandler<'delete-document'> = async ({ input, req }) => {
|
||||
if (!input) {
|
||||
throw new Error('No input provided')
|
||||
}
|
||||
|
||||
const { id, collection, where } = input
|
||||
|
||||
if (!collection || typeof collection !== 'string') {
|
||||
throw new Error('Collection slug is required')
|
||||
}
|
||||
|
||||
try {
|
||||
// If ID is provided, delete by ID
|
||||
if (id) {
|
||||
const result = await req.payload.delete({
|
||||
id: id.toString(),
|
||||
collection,
|
||||
req
|
||||
})
|
||||
|
||||
return {
|
||||
output: {
|
||||
deletedCount: 1,
|
||||
doc: result
|
||||
},
|
||||
state: 'succeeded'
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, delete multiple documents
|
||||
if (!where) {
|
||||
throw new Error('Either ID or where conditions must be provided')
|
||||
}
|
||||
|
||||
const parsedWhere = typeof where === 'string' ? JSON.parse(where) : where
|
||||
|
||||
// First find the documents to delete
|
||||
const toDelete = await req.payload.find({
|
||||
collection,
|
||||
limit: 1000, // Set a reasonable limit
|
||||
req,
|
||||
where: parsedWhere
|
||||
})
|
||||
|
||||
// Delete each document
|
||||
const deleted = []
|
||||
for (const doc of toDelete.docs) {
|
||||
const result = await req.payload.delete({
|
||||
id: doc.id,
|
||||
collection,
|
||||
req
|
||||
})
|
||||
deleted.push(result)
|
||||
}
|
||||
|
||||
return {
|
||||
output: {
|
||||
deletedCount: deleted.length,
|
||||
doc: deleted
|
||||
},
|
||||
state: 'succeeded'
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
errorMessage: error instanceof Error ? error.message : 'Failed to delete document(s)',
|
||||
state: 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/steps/delete-document.ts
Normal file
48
src/steps/delete-document.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { TaskConfig } from "payload"
|
||||
|
||||
import { deleteDocumentHandler } from "./delete-document-handler.js"
|
||||
|
||||
export const DeleteDocumentStepTask = {
|
||||
slug: 'delete-document',
|
||||
handler: deleteDocumentHandler,
|
||||
inputSchema: [
|
||||
{
|
||||
name: 'collection',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The collection slug to delete from'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The ID of a specific document to delete (leave empty to delete multiple)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'where',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Query conditions to find documents to delete (used when ID is not provided)'
|
||||
}
|
||||
}
|
||||
],
|
||||
outputSchema: [
|
||||
{
|
||||
name: 'doc',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'The deleted document(s)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'deletedCount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Number of documents deleted'
|
||||
}
|
||||
}
|
||||
]
|
||||
} satisfies TaskConfig<'delete-document'>
|
||||
14
src/steps/http-request-handler.ts
Normal file
14
src/steps/http-request-handler.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type {TaskHandler} from "payload"
|
||||
|
||||
export const httpStepHandler: TaskHandler<'http-request-step'> = async ({input}) => {
|
||||
if (!input) {
|
||||
throw new Error('No input provided')
|
||||
}
|
||||
const response = await fetch(input.url)
|
||||
return {
|
||||
output: {
|
||||
response: await response.text()
|
||||
},
|
||||
state: response.ok ? 'succeeded' : undefined
|
||||
}
|
||||
}
|
||||
20
src/steps/http-request.ts
Normal file
20
src/steps/http-request.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type {TaskConfig} from "payload"
|
||||
|
||||
import {httpStepHandler} from "./http-request-handler.js"
|
||||
|
||||
export const HttpRequestStepTask = {
|
||||
slug: 'http-request-step',
|
||||
handler: httpStepHandler,
|
||||
inputSchema: [
|
||||
{
|
||||
name: 'url',
|
||||
type: 'text',
|
||||
}
|
||||
],
|
||||
outputSchema: [
|
||||
{
|
||||
name: 'response',
|
||||
type: 'textarea',
|
||||
}
|
||||
]
|
||||
} satisfies TaskConfig<'http-request-step'>
|
||||
13
src/steps/index.ts
Normal file
13
src/steps/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export { CreateDocumentStepTask } from './create-document.js'
|
||||
export { createDocumentHandler } from './create-document-handler.js'
|
||||
export { DeleteDocumentStepTask } from './delete-document.js'
|
||||
export { deleteDocumentHandler } from './delete-document-handler.js'
|
||||
export { HttpRequestStepTask } from './http-request.js'
|
||||
export { httpStepHandler } from './http-request-handler.js'
|
||||
|
||||
export { ReadDocumentStepTask } from './read-document.js'
|
||||
export { readDocumentHandler } from './read-document-handler.js'
|
||||
export { SendEmailStepTask } from './send-email.js'
|
||||
export { sendEmailHandler } from './send-email-handler.js'
|
||||
export { UpdateDocumentStepTask } from './update-document.js'
|
||||
export { updateDocumentHandler } from './update-document-handler.js'
|
||||
60
src/steps/read-document-handler.ts
Normal file
60
src/steps/read-document-handler.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { TaskHandler } from "payload"
|
||||
|
||||
export const readDocumentHandler: TaskHandler<'read-document'> = async ({ input, req }) => {
|
||||
if (!input) {
|
||||
throw new Error('No input provided')
|
||||
}
|
||||
|
||||
const { id, collection, depth, limit, locale, sort, where } = input
|
||||
|
||||
if (!collection || typeof collection !== 'string') {
|
||||
throw new Error('Collection slug is required')
|
||||
}
|
||||
|
||||
try {
|
||||
// If ID is provided, find by ID
|
||||
if (id) {
|
||||
const result = await req.payload.findByID({
|
||||
id: id.toString(),
|
||||
collection,
|
||||
depth: typeof depth === 'number' ? depth : undefined,
|
||||
locale: locale || undefined,
|
||||
req
|
||||
})
|
||||
|
||||
return {
|
||||
output: {
|
||||
doc: result,
|
||||
totalDocs: 1
|
||||
},
|
||||
state: 'succeeded'
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, find multiple documents
|
||||
const parsedWhere = where ? (typeof where === 'string' ? JSON.parse(where) : where) : {}
|
||||
|
||||
const result = await req.payload.find({
|
||||
collection,
|
||||
depth: typeof depth === 'number' ? depth : undefined,
|
||||
limit: typeof limit === 'number' ? limit : 10,
|
||||
locale: locale || undefined,
|
||||
req,
|
||||
sort: sort || undefined,
|
||||
where: parsedWhere
|
||||
})
|
||||
|
||||
return {
|
||||
output: {
|
||||
doc: result.docs,
|
||||
totalDocs: result.totalDocs
|
||||
},
|
||||
state: 'succeeded'
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
errorName: error instanceof Error ? error.message : 'Failed to read document(s)',
|
||||
state: 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/steps/read-document.ts
Normal file
76
src/steps/read-document.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { TaskConfig } from "payload"
|
||||
|
||||
import { readDocumentHandler } from "./read-document-handler.js"
|
||||
|
||||
export const ReadDocumentStepTask = {
|
||||
slug: 'read-document',
|
||||
handler: readDocumentHandler,
|
||||
inputSchema: [
|
||||
{
|
||||
name: 'collection',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The collection slug to read from'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The ID of a specific document to read (leave empty to find multiple)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'where',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Query conditions to find documents (used when ID is not provided)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Maximum number of documents to return (default: 10)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'sort',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Field to sort by (prefix with - for descending order)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'locale',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Locale for the document (if localization is enabled)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'depth',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Depth of relationships to populate (0-10)'
|
||||
}
|
||||
}
|
||||
],
|
||||
outputSchema: [
|
||||
{
|
||||
name: 'doc',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'The document(s) found'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'totalDocs',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Total number of documents matching the query'
|
||||
}
|
||||
}
|
||||
]
|
||||
} satisfies TaskConfig<'read-document'>
|
||||
56
src/steps/send-email-handler.ts
Normal file
56
src/steps/send-email-handler.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { TaskHandler } from "payload"
|
||||
|
||||
export const sendEmailHandler: TaskHandler<'send-email'> = async ({ input, req }) => {
|
||||
if (!input) {
|
||||
throw new Error('No input provided')
|
||||
}
|
||||
|
||||
const { bcc, cc, from, html, subject, text, to } = input
|
||||
|
||||
if (!to || typeof to !== 'string') {
|
||||
throw new Error('Recipient email address (to) is required')
|
||||
}
|
||||
|
||||
if (!subject || typeof subject !== 'string') {
|
||||
throw new Error('Subject is required')
|
||||
}
|
||||
|
||||
if (!text && !html) {
|
||||
throw new Error('Either text or html content is required')
|
||||
}
|
||||
|
||||
try {
|
||||
// Use Payload's email functionality
|
||||
const emailData = {
|
||||
bcc: Array.isArray(bcc) ? bcc.filter(email => typeof email === 'string') : undefined,
|
||||
cc: Array.isArray(cc) ? cc.filter(email => typeof email === 'string') : undefined,
|
||||
from: typeof from === 'string' ? from : undefined,
|
||||
html: typeof html === 'string' ? html : undefined,
|
||||
subject,
|
||||
text: typeof text === 'string' ? text : undefined,
|
||||
to
|
||||
}
|
||||
|
||||
// Clean up undefined values
|
||||
Object.keys(emailData).forEach(key => {
|
||||
if (emailData[key as keyof typeof emailData] === undefined) {
|
||||
delete emailData[key as keyof typeof emailData]
|
||||
}
|
||||
})
|
||||
|
||||
const result = await req.payload.sendEmail(emailData)
|
||||
|
||||
return {
|
||||
output: {
|
||||
messageId: (result && typeof result === 'object' && 'messageId' in result) ? result.messageId : 'unknown',
|
||||
response: typeof result === 'object' ? JSON.stringify(result) : String(result)
|
||||
},
|
||||
state: 'succeeded'
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
errorMessage: error instanceof Error ? error.message : 'Failed to send email',
|
||||
state: 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/steps/send-email.ts
Normal file
79
src/steps/send-email.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { TaskConfig } from "payload"
|
||||
|
||||
import { sendEmailHandler } from "./send-email-handler.js"
|
||||
|
||||
export const SendEmailStepTask = {
|
||||
slug: 'send-email',
|
||||
handler: sendEmailHandler,
|
||||
inputSchema: [
|
||||
{
|
||||
name: 'to',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Recipient email address'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'from',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Sender email address (optional, uses default if not provided)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'subject',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Email subject line'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Plain text email content'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'html',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'HTML email content (optional)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'cc',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'CC recipients'
|
||||
},
|
||||
hasMany: true
|
||||
},
|
||||
{
|
||||
name: 'bcc',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'BCC recipients'
|
||||
},
|
||||
hasMany: true
|
||||
}
|
||||
],
|
||||
outputSchema: [
|
||||
{
|
||||
name: 'messageId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Email message ID from the mail server'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'response',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Response from the mail server'
|
||||
}
|
||||
}
|
||||
]
|
||||
} satisfies TaskConfig<'send-email'>
|
||||
47
src/steps/update-document-handler.ts
Normal file
47
src/steps/update-document-handler.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { TaskHandler } from "payload"
|
||||
|
||||
export const updateDocumentHandler: TaskHandler<'update-document'> = async ({ input, req }) => {
|
||||
if (!input) {
|
||||
throw new Error('No input provided')
|
||||
}
|
||||
|
||||
const { id, collection, data, draft, locale } = input
|
||||
|
||||
if (!collection || typeof collection !== 'string') {
|
||||
throw new Error('Collection slug is required')
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new Error('Document ID is required')
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error('Update data is required')
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedData = typeof data === 'string' ? JSON.parse(data) : data
|
||||
|
||||
const result = await req.payload.update({
|
||||
id: id.toString(),
|
||||
collection,
|
||||
data: parsedData,
|
||||
draft: draft || false,
|
||||
locale: locale || undefined,
|
||||
req
|
||||
})
|
||||
|
||||
return {
|
||||
output: {
|
||||
id: result.id,
|
||||
doc: result
|
||||
},
|
||||
state: 'succeeded'
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
errorName: error instanceof Error ? error.message : 'Failed to update document',
|
||||
state: 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/steps/update-document.ts
Normal file
64
src/steps/update-document.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { TaskConfig } from "payload"
|
||||
|
||||
import { updateDocumentHandler } from "./update-document-handler.js"
|
||||
|
||||
export const UpdateDocumentStepTask = {
|
||||
slug: 'update-document',
|
||||
handler: updateDocumentHandler,
|
||||
inputSchema: [
|
||||
{
|
||||
name: 'collection',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The collection slug to update a document in'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The ID of the document to update'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'data',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'The data to update the document with'
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
type: 'checkbox',
|
||||
admin: {
|
||||
description: 'Update as draft (if collection has drafts enabled)'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'locale',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Locale for the document (if localization is enabled)'
|
||||
}
|
||||
}
|
||||
],
|
||||
outputSchema: [
|
||||
{
|
||||
name: 'doc',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'The updated document'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The ID of the updated document'
|
||||
}
|
||||
}
|
||||
]
|
||||
} satisfies TaskConfig<'update-document'>
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ES2022"
|
||||
],
|
||||
"rootDir": "./",
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"target": "ES2022",
|
||||
"composite": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.ts",
|
||||
"./src/**/*.tsx",
|
||||
"./dev/next-env.d.ts",
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user