Initial commit

This commit is contained in:
2025-08-22 21:09:48 +02:00
commit 2d84f535f4
68 changed files with 34807 additions and 0 deletions

View 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
}

View 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
View 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
}

View 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')
}
}
}

View 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')
}
}

View 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
View 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}`)
}
}

View 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
View 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
}