package automation import ( "context" "database/sql" "encoding/json" "fmt" "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": // TODO: Implement email sending return nil case "send_notification": // TODO: Implement notification return nil case "create_task": // TODO: Create task in system return nil case "update_record": // TODO: Update database record return nil case "webhook": // TODO: Call external webhook return nil case "generate_report": // TODO: Generate and send report return nil default: return fmt.Errorf("unknown action type: %s", actionType) } } // 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) { // Simple implementation: for now, just add 1 hour // TODO: Implement proper cron parsing return time.Now().UTC().Add(1 * 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 }