* * @since 0.1.0 */ public function handle( AutomationContext $ctx ): array { if ( $ctx->depth() >= self::MAX_DEPTH ) { return []; } if ( function_exists( 'apply_filters' ) ) { $filtered = apply_filters( 'bookline/automation/context', $ctx, $ctx->event() ); if ( $filtered instanceof AutomationContext ) { $ctx = $filtered; } } $started = []; foreach ( $this->automations->find_enabled_by_event( $ctx->event() ) as $automation ) { $run = $this->consider( $automation, $ctx ); if ( null !== $run ) { $started[] = $run; } } return $started; } /** * Consider one automation (test runs, relative-time jobs). * * @param Automation $automation Automation. * @param AutomationContext $ctx Context. * * @since 0.1.0 */ public function handle_one( Automation $automation, AutomationContext $ctx ): ?AutomationRun { if ( $ctx->depth() >= self::MAX_DEPTH ) { return null; } if ( ! $automation->is_enabled() && ! $ctx->is_test() ) { return null; } return $this->consider( $automation, $ctx ); } /** * Operator re-run from history — bypasses enablement, trigger guards and rate cap. * * @param Automation $automation Automation. * @param AutomationContext $ctx Context. * * @since 0.1.0 */ public function handle_rerun( Automation $automation, AutomationContext $ctx ): ?AutomationRun { if ( $ctx->depth() >= self::MAX_DEPTH ) { return null; } return $this->consider( $automation, $ctx, true ); } /** * Consider one automation. * * @param Automation $automation Automation. * @param AutomationContext $ctx Context. * @param bool $operator_rerun Operator-initiated replay. */ private function consider( Automation $automation, AutomationContext $ctx, bool $operator_rerun = false ): ?AutomationRun { $id = (int) $automation->id(); if ( in_array( $id, $ctx->ancestor_ids(), true ) ) { return $this->open_skipped( $automation, $ctx, SkipReason::LoopGuard ); } if ( function_exists( 'apply_filters' ) ) { $allowed = apply_filters( 'bookline/automation/should_run', true, $automation, $ctx ); if ( true !== $allowed ) { return null; } } $skip = $this->lead_time_skip( $automation, $ctx ); if ( null !== $skip && ! $operator_rerun ) { return $this->open_skipped( $automation, $ctx, $skip ); } $skip = $this->pause_skip( $automation, $ctx ); if ( null !== $skip && ! $operator_rerun ) { return $this->open_skipped( $automation, $ctx, $skip ); } if ( ! $ctx->is_test() && ! $operator_rerun ) { $since = $this->clock->now()->setTime( 0, 0, 0 ); $count = $this->runs->count_since( $id, $since ); if ( $count >= $this->rate_cap ) { if ( is_callable( $this->on_rate_cap ) ) { ( $this->on_rate_cap )( $automation, $count ); } return $this->open_skipped( $automation, $ctx, SkipReason::RateCap ); } } if ( ! $operator_rerun && ! $this->conditions_pass( $automation, $ctx ) ) { return null; } $existing = $this->runs->find_by_dedupe( $id, $ctx->trigger_event_id(), $ctx->subject_id() ); if ( null !== $existing ) { return $existing; } $now = $this->clock->now(); $run = new AutomationRun( null, $id, $ctx->trigger_event_id(), $ctx->subject_type(), $ctx->subject_id(), $ctx->customer_id(), AutomationRunStatus::Pending, null, 0, $now, null, null, $ctx, null, null, [], $now, $now ); $this->persist_run( $run ); if ( ! $ctx->is_test() ) { $automation->record_run( $now ); $this->automations->save( $automation ); } $this->executor->start( $run, $automation ); if ( function_exists( 'do_action' ) ) { do_action( 'bookline/automation/matched', $automation, $ctx ); } $saved = $this->runs->find_by_id( (int) $run->id() ); return $saved ?? $run; } /** * Lead-time skip (docs/20 §3.1). * * @param Automation $automation Automation. * @param AutomationContext $ctx Context. */ private function lead_time_skip( Automation $automation, AutomationContext $ctx ): ?SkipReason { $window = $automation->trigger()->skip_if_lead_time_lt_minutes(); if ( null === $window || $window <= 0 ) { return null; } $lead = $ctx->field_int( 'lead_time_minutes', PHP_INT_MAX ); return $lead < $window ? SkipReason::LeadTime : null; } /** * Pause period (docs/20 §5.1). * * @param Automation $automation Automation. * @param AutomationContext $ctx Context. */ private function pause_skip( Automation $automation, AutomationContext $ctx ): ?SkipReason { if ( $ctx->is_test() ) { return null; } $hours = $automation->pause_period_hours(); if ( null === $hours || $hours <= 0 ) { return null; } $id = (int) $automation->id(); $latest = $this->runs->find_latest_counted( $id, $ctx->subject_type(), $ctx->subject_id() ); if ( null === $latest ) { return null; } $until = $latest->created_at()->add( new DateInterval( 'PT' . $hours . 'H' ) ); return $this->clock->now() < $until ? SkipReason::PausePeriod : null; } /** * AND conditions. * * @param Automation $automation Automation. * @param AutomationContext $ctx Context. */ private function conditions_pass( Automation $automation, AutomationContext $ctx ): bool { foreach ( $automation->conditions() as $definition ) { $condition = $this->conditions->get( $definition->identifier() ); if ( null === $condition ) { return false; } if ( ! $condition->evaluate( $ctx, $definition->config() ) ) { return false; } } return true; } /** * Persist a skipped run (still honouring dedupe). * * @param Automation $automation Automation. * @param AutomationContext $ctx Context. * @param SkipReason $reason Reason. */ private function open_skipped( Automation $automation, AutomationContext $ctx, SkipReason $reason ): AutomationRun { $id = (int) $automation->id(); $existing = $this->runs->find_by_dedupe( $id, $ctx->trigger_event_id(), $ctx->subject_id() ); if ( null !== $existing ) { return $existing; } $now = $this->clock->now(); $run = new AutomationRun( null, $id, $ctx->trigger_event_id(), $ctx->subject_type(), $ctx->subject_id(), $ctx->customer_id(), AutomationRunStatus::Skipped, $reason, 0, null, $now, $now, $ctx, null, null, [], $now, $now ); $this->persist_run( $run ); $saved = $this->runs->find_by_id( (int) $run->id() ); return $saved ?? $run; } /** * Save and require a database id so test/live runs cannot silently vanish. * * @param AutomationRun $run Run. * * @throws DomainException When the row is not persisted. */ private function persist_run( AutomationRun $run ): void { $this->runs->save( $run ); if ( null === $run->id() || $run->id() < 1 ) { throw new DomainException( 'automation_run.persist_failed', 'Could not record the automation run.' ); } } /** * Re-evaluate conditions against a (possibly fresh) context. * * @param Automation $automation Automation. * @param AutomationContext $ctx Context. * * @since 0.1.0 */ public function conditions_still_pass( Automation $automation, AutomationContext $ctx ): bool { return $this->conditions_pass( $automation, $ctx ); } }