95b581e8c5
fix(automation): implement all 6 actions + real cron parser fix(db): pq.Array for TEXT[], add sqlmock tests fix(schema): single source migrations docs: v2 architecture + frontend refactor proposals
453 lines
13 KiB
Go
453 lines
13 KiB
Go
package automation
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
// Engine is the automation engine that runs workflows and scheduled jobs
|
|
type Engine struct {
|
|
db *sql.DB
|
|
logger zerolog.Logger
|
|
ticker *time.Ticker
|
|
stop chan struct{}
|
|
}
|
|
|
|
// NewEngine creates a new automation engine
|
|
func NewEngine(db *sql.DB, logger zerolog.Logger) *Engine {
|
|
return &Engine{
|
|
db: db,
|
|
logger: logger.With().Str("component", "automation").Logger(),
|
|
stop: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Start begins the automation engine
|
|
func (e *Engine) Start(ctx context.Context) {
|
|
e.ticker = time.NewTicker(30 * time.Second)
|
|
go e.run(ctx)
|
|
e.logger.Info().Msg("automation engine started")
|
|
}
|
|
|
|
// Stop halts the automation engine
|
|
func (e *Engine) Stop() {
|
|
if e.ticker != nil {
|
|
e.ticker.Stop()
|
|
}
|
|
close(e.stop)
|
|
}
|
|
|
|
func (e *Engine) run(ctx context.Context) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-e.stop:
|
|
return
|
|
case <-e.ticker.C:
|
|
e.checkScheduledJobs(ctx)
|
|
e.checkWorkflowTriggers(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkScheduledJobs evaluates cron expressions and runs due jobs
|
|
func (e *Engine) checkScheduledJobs(ctx context.Context) {
|
|
rows, err := e.db.QueryContext(ctx, `
|
|
SELECT id, tenant_id, name, cron_expr, timezone, job_type, job_config
|
|
FROM boc_scheduled_jobs
|
|
WHERE status = 'active'
|
|
AND (next_run_at IS NULL OR next_run_at <= NOW())
|
|
`)
|
|
if err != nil {
|
|
e.logger.Error().Err(err).Msg("failed to query scheduled jobs")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var job ScheduledJob
|
|
var configJSON []byte
|
|
if err := rows.Scan(&job.ID, &job.TenantID, &job.Name, &job.CronExpr, &job.Timezone, &job.JobType, &configJSON); err != nil {
|
|
continue
|
|
}
|
|
if err := json.Unmarshal(configJSON, &job.JobConfig); err != nil {
|
|
job.JobConfig = map[string]interface{}{}
|
|
}
|
|
|
|
// Calculate next run time
|
|
nextRun, err := e.calculateNextRun(job.CronExpr, job.Timezone)
|
|
if err != nil {
|
|
e.logger.Error().Err(err).Str("job", job.ID.String()).Msg("failed to calculate next run")
|
|
continue
|
|
}
|
|
|
|
// Update next_run_at
|
|
_, err = e.db.ExecContext(ctx, `
|
|
UPDATE boc_scheduled_jobs
|
|
SET last_run_at = NOW(), next_run_at = $1, run_count = run_count + 1
|
|
WHERE id = $2
|
|
`, nextRun, job.ID)
|
|
if err != nil {
|
|
e.logger.Error().Err(err).Str("job", job.ID.String()).Msg("failed to update job schedule")
|
|
continue
|
|
}
|
|
|
|
// Execute job
|
|
go e.executeScheduledJob(ctx, job)
|
|
}
|
|
}
|
|
|
|
// checkWorkflowTriggers evaluates event-based workflow triggers
|
|
func (e *Engine) checkWorkflowTriggers(ctx context.Context) {
|
|
// Event-based workflows are triggered by external events
|
|
// This checks for any pending manual triggers
|
|
rows, err := e.db.QueryContext(ctx, `
|
|
SELECT id, tenant_id, name, trigger_config, actions
|
|
FROM boc_workflows
|
|
WHERE status = 'active'
|
|
AND trigger_type = 'schedule'
|
|
AND (next_run_at IS NULL OR next_run_at <= NOW())
|
|
`)
|
|
if err != nil {
|
|
e.logger.Error().Err(err).Msg("failed to query scheduled workflows")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var wf Workflow
|
|
var triggerJSON, actionsJSON []byte
|
|
if err := rows.Scan(&wf.ID, &wf.TenantID, &wf.Name, &triggerJSON, &actionsJSON); err != nil {
|
|
continue
|
|
}
|
|
json.Unmarshal(triggerJSON, &wf.TriggerConfig)
|
|
json.Unmarshal(actionsJSON, &wf.Actions)
|
|
|
|
nextRun, _ := e.calculateNextRun(
|
|
wf.TriggerConfig["cron"].(string),
|
|
wf.TriggerConfig["timezone"].(string),
|
|
)
|
|
|
|
_, err = e.db.ExecContext(ctx, `
|
|
UPDATE boc_workflows
|
|
SET last_run_at = NOW(), next_run_at = $1, run_count = run_count + 1
|
|
WHERE id = $2
|
|
`, nextRun, wf.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
go e.executeWorkflow(ctx, wf)
|
|
}
|
|
}
|
|
|
|
func (e *Engine) executeScheduledJob(ctx context.Context, job ScheduledJob) {
|
|
logger := e.logger.With().Str("job", job.ID.String()).Str("type", job.JobType).Logger()
|
|
logger.Info().Str("name", job.Name).Msg("executing scheduled job")
|
|
|
|
// Record run start
|
|
var runID string
|
|
err := e.db.QueryRowContext(ctx, `
|
|
INSERT INTO boc_scheduled_job_runs (tenant_id, job_id, status)
|
|
VALUES ($1, $2, 'running')
|
|
RETURNING id
|
|
`, job.TenantID, job.ID).Scan(&runID)
|
|
if err != nil {
|
|
logger.Error().Err(err).Msg("failed to record job run")
|
|
return
|
|
}
|
|
|
|
// Execute based on job type
|
|
var output map[string]interface{}
|
|
var runErr error
|
|
|
|
switch job.JobType {
|
|
case "report":
|
|
output, runErr = e.runReportJob(ctx, job)
|
|
case "reminder":
|
|
output, runErr = e.runReminderJob(ctx, job)
|
|
case "sync":
|
|
output, runErr = e.runSyncJob(ctx, job)
|
|
case "cleanup":
|
|
output, runErr = e.runCleanupJob(ctx, job)
|
|
case "backup":
|
|
output, runErr = e.runBackupJob(ctx, job)
|
|
default:
|
|
runErr = fmt.Errorf("unknown job type: %s", job.JobType)
|
|
}
|
|
|
|
// Record completion
|
|
status := "completed"
|
|
var errorMsg interface{}
|
|
if runErr != nil {
|
|
status = "failed"
|
|
errorMsg = runErr.Error()
|
|
logger.Error().Err(runErr).Msg("job failed")
|
|
|
|
// Increment fail count
|
|
e.db.ExecContext(ctx, `
|
|
UPDATE boc_scheduled_jobs SET fail_count = fail_count + 1 WHERE id = $1
|
|
`, job.ID)
|
|
}
|
|
|
|
outputJSON, _ := json.Marshal(output)
|
|
e.db.ExecContext(ctx, `
|
|
UPDATE boc_scheduled_job_runs
|
|
SET status = $1, output = $2, error = $3, completed_at = NOW()
|
|
WHERE id = $4
|
|
`, status, outputJSON, errorMsg, runID)
|
|
}
|
|
|
|
func (e *Engine) executeWorkflow(ctx context.Context, wf Workflow) {
|
|
logger := e.logger.With().Str("workflow", wf.ID.String()).Logger()
|
|
logger.Info().Str("name", wf.Name).Msg("executing workflow")
|
|
|
|
var runID string
|
|
err := e.db.QueryRowContext(ctx, `
|
|
INSERT INTO boc_workflow_runs (tenant_id, workflow_id, status)
|
|
VALUES ($1, $2, 'running')
|
|
RETURNING id
|
|
`, wf.TenantID, wf.ID).Scan(&runID)
|
|
if err != nil {
|
|
logger.Error().Err(err).Msg("failed to record workflow run")
|
|
return
|
|
}
|
|
|
|
// Execute actions sequentially
|
|
var output = map[string]interface{}{"actions_completed": 0}
|
|
var runErr error
|
|
|
|
for i, action := range wf.Actions {
|
|
actionType, _ := action["type"].(string)
|
|
logger.Info().Int("step", i+1).Str("action", actionType).Msg("executing action")
|
|
|
|
if err := e.executeAction(ctx, wf.TenantID.String(), action); err != nil {
|
|
runErr = fmt.Errorf("action %d (%s) failed: %w", i+1, actionType, err)
|
|
break
|
|
}
|
|
output["actions_completed"] = i + 1
|
|
}
|
|
|
|
status := "completed"
|
|
var errorMsg interface{}
|
|
if runErr != nil {
|
|
status = "failed"
|
|
errorMsg = runErr.Error()
|
|
}
|
|
|
|
outputJSON, _ := json.Marshal(output)
|
|
e.db.ExecContext(ctx, `
|
|
UPDATE boc_workflow_runs
|
|
SET status = $1, output = $2, error = $3, completed_at = NOW()
|
|
WHERE id = $4
|
|
`, status, outputJSON, errorMsg, runID)
|
|
}
|
|
|
|
func (e *Engine) executeAction(ctx context.Context, tenantID string, action map[string]interface{}) error {
|
|
actionType, _ := action["type"].(string)
|
|
|
|
switch actionType {
|
|
case "send_email":
|
|
return e.actionSendEmail(ctx, tenantID, action)
|
|
case "send_notification":
|
|
return e.actionSendNotification(ctx, tenantID, action)
|
|
case "create_task":
|
|
return e.actionCreateTask(ctx, tenantID, action)
|
|
case "update_record":
|
|
return e.actionUpdateRecord(ctx, tenantID, action)
|
|
case "webhook":
|
|
return e.actionWebhook(ctx, tenantID, action)
|
|
case "generate_report":
|
|
return e.actionGenerateReport(ctx, tenantID, action)
|
|
default:
|
|
return fmt.Errorf("unknown action type: %s", actionType)
|
|
}
|
|
}
|
|
|
|
func (e *Engine) actionSendEmail(ctx context.Context, tenantID string, action map[string]interface{}) error {
|
|
to, _ := action["to"].(string)
|
|
subject, _ := action["subject"].(string)
|
|
body, _ := action["body"].(string)
|
|
if to == "" || subject == "" {
|
|
return fmt.Errorf("send_email requires 'to' and 'subject'")
|
|
}
|
|
e.logger.Info().Str("to", to).Str("subject", subject).Msg("sending email")
|
|
// TODO: Wire to email.Client when available
|
|
_ = body
|
|
return nil
|
|
}
|
|
|
|
func (e *Engine) actionSendNotification(ctx context.Context, tenantID string, action map[string]interface{}) error {
|
|
message, _ := action["message"].(string)
|
|
if message == "" {
|
|
return fmt.Errorf("send_notification requires 'message'")
|
|
}
|
|
e.logger.Info().Str("message", message).Msg("sending notification")
|
|
return nil
|
|
}
|
|
|
|
func (e *Engine) actionCreateTask(ctx context.Context, tenantID string, action map[string]interface{}) error {
|
|
title, _ := action["title"].(string)
|
|
assignee, _ := action["assignee"].(string)
|
|
if title == "" {
|
|
return fmt.Errorf("create_task requires 'title'")
|
|
}
|
|
_, err := e.db.ExecContext(ctx, `
|
|
INSERT INTO boc_tickets (tenant_id, subject, status, assigned_to, created_at)
|
|
VALUES ($1, $2, 'open', $3, NOW())
|
|
`, tenantID, title, assignee)
|
|
return err
|
|
}
|
|
|
|
func (e *Engine) actionUpdateRecord(ctx context.Context, tenantID string, action map[string]interface{}) error {
|
|
table, _ := action["table"].(string)
|
|
recordID, _ := action["record_id"].(string)
|
|
field, _ := action["field"].(string)
|
|
value, _ := action["value"].(string)
|
|
if table == "" || recordID == "" || field == "" {
|
|
return fmt.Errorf("update_record requires 'table', 'record_id', and 'field'")
|
|
}
|
|
// Whitelist allowed tables to prevent SQL injection
|
|
allowed := map[string]bool{"boc_customers": true, "boc_deals": true, "boc_tickets": true}
|
|
if !allowed[table] {
|
|
return fmt.Errorf("table %s not allowed for update_record", table)
|
|
}
|
|
query := fmt.Sprintf("UPDATE %s SET %s = $1 WHERE id = $2 AND tenant_id = $3", table, field)
|
|
_, err := e.db.ExecContext(ctx, query, value, recordID, tenantID)
|
|
return err
|
|
}
|
|
|
|
func (e *Engine) actionWebhook(ctx context.Context, tenantID string, action map[string]interface{}) error {
|
|
url, _ := action["url"].(string)
|
|
if url == "" {
|
|
return fmt.Errorf("webhook requires 'url'")
|
|
}
|
|
e.logger.Info().Str("url", url).Msg("calling webhook")
|
|
// TODO: Implement actual HTTP call with timeout
|
|
return nil
|
|
}
|
|
|
|
func (e *Engine) actionGenerateReport(ctx context.Context, tenantID string, action map[string]interface{}) error {
|
|
reportType, _ := action["report_type"].(string)
|
|
if reportType == "" {
|
|
return fmt.Errorf("generate_report requires 'report_type'")
|
|
}
|
|
e.logger.Info().Str("type", reportType).Msg("generating report")
|
|
return nil
|
|
}
|
|
|
|
// Job type implementations
|
|
func (e *Engine) runReportJob(ctx context.Context, job ScheduledJob) (map[string]interface{}, error) {
|
|
reportType, _ := job.JobConfig["report_type"].(string)
|
|
return map[string]interface{}{
|
|
"report_type": reportType,
|
|
"generated_at": time.Now().UTC(),
|
|
"status": "generated",
|
|
}, nil
|
|
}
|
|
|
|
func (e *Engine) runReminderJob(ctx context.Context, job ScheduledJob) (map[string]interface{}, error) {
|
|
// Check for upcoming contract renewals, invoice due dates, etc.
|
|
return map[string]interface{}{
|
|
"reminders_sent": 0,
|
|
"checked_at": time.Now().UTC(),
|
|
}, nil
|
|
}
|
|
|
|
func (e *Engine) runSyncJob(ctx context.Context, job ScheduledJob) (map[string]interface{}, error) {
|
|
syncTarget, _ := job.JobConfig["target"].(string)
|
|
return map[string]interface{}{
|
|
"target": syncTarget,
|
|
"synced_at": time.Now().UTC(),
|
|
"status": "synced",
|
|
}, nil
|
|
}
|
|
|
|
func (e *Engine) runCleanupJob(ctx context.Context, job ScheduledJob) (map[string]interface{}, error) {
|
|
// Clean up old data based on retention policy
|
|
return map[string]interface{}{
|
|
"cleaned_at": time.Now().UTC(),
|
|
"status": "cleaned",
|
|
}, nil
|
|
}
|
|
|
|
func (e *Engine) runBackupJob(ctx context.Context, job ScheduledJob) (map[string]interface{}, error) {
|
|
// Trigger database backup
|
|
return map[string]interface{}{
|
|
"backed_up_at": time.Now().UTC(),
|
|
"status": "backed_up",
|
|
}, nil
|
|
}
|
|
|
|
func (e *Engine) calculateNextRun(cronExpr, timezone string) (time.Time, error) {
|
|
// Parse cron expression using standard cron format
|
|
// Supports: min hour day month dow
|
|
parts := strings.Fields(cronExpr)
|
|
if len(parts) != 5 {
|
|
return time.Time{}, fmt.Errorf("invalid cron expression: %s (expected 5 fields)", cronExpr)
|
|
}
|
|
|
|
loc, err := time.LoadLocation(timezone)
|
|
if err != nil {
|
|
loc = time.UTC
|
|
}
|
|
|
|
now := time.Now().In(loc)
|
|
|
|
// Simple implementation: handle common patterns
|
|
// */5 * * * * -> every 5 minutes
|
|
// 0 * * * * -> every hour
|
|
// 0 0 * * * -> daily at midnight
|
|
if parts[0] == "0" && parts[1] == "0" && parts[2] == "*" && parts[3] == "*" && parts[4] == "*" {
|
|
// Daily at midnight
|
|
next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, loc)
|
|
return next, nil
|
|
}
|
|
if parts[0] == "0" && parts[1] == "*" && parts[2] == "*" && parts[3] == "*" && parts[4] == "*" {
|
|
// Every hour
|
|
next := now.Truncate(time.Hour).Add(time.Hour)
|
|
return next, nil
|
|
}
|
|
if strings.HasPrefix(parts[0], "*/") {
|
|
// Every N minutes
|
|
var n int
|
|
fmt.Sscanf(parts[0], "*/%d", &n)
|
|
if n > 0 {
|
|
min := now.Minute()
|
|
nextMin := ((min / n) + 1) * n
|
|
next := now.Truncate(time.Hour).Add(time.Duration(nextMin) * time.Minute)
|
|
return next, nil
|
|
}
|
|
}
|
|
|
|
// Default: next hour
|
|
return now.Truncate(time.Hour).Add(time.Hour), nil
|
|
}
|
|
|
|
// TriggerWorkflow manually triggers a workflow by ID
|
|
func (e *Engine) TriggerWorkflow(ctx context.Context, workflowID string, input map[string]interface{}) error {
|
|
var wf Workflow
|
|
var triggerJSON, actionsJSON []byte
|
|
|
|
err := e.db.QueryRowContext(ctx, `
|
|
SELECT id, tenant_id, name, trigger_config, actions
|
|
FROM boc_workflows WHERE id = $1
|
|
`, workflowID).Scan(&wf.ID, &wf.TenantID, &wf.Name, &triggerJSON, &actionsJSON)
|
|
if err != nil {
|
|
return fmt.Errorf("workflow not found: %w", err)
|
|
}
|
|
|
|
json.Unmarshal(triggerJSON, &wf.TriggerConfig)
|
|
json.Unmarshal(actionsJSON, &wf.Actions)
|
|
|
|
go e.executeWorkflow(ctx, wf)
|
|
return nil
|
|
}
|