>, total: int, page: int, perPage: int} * * @since 0.1.0 */ public function list( int $page, int $per_page ): array { $all = $this->automations->all(); $total = count( $all ); $slice = array_slice( $all, max( 0, ( $page - 1 ) * $per_page ), $per_page ); $items = []; foreach ( $slice as $automation ) { $items[] = $this->to_list_array( $automation ); } return [ 'items' => $items, 'total' => $total, 'page' => $page, 'perPage' => $per_page, ]; } /** * Get one. * * @param int $id Id. * @return array * * @since 0.1.0 */ public function get( int $id ): array { return $this->to_detail_array( $this->require_automation( $id ) ); } /** * Create. * * @param array $payload Payload. * @return array * * @since 0.1.0 */ public function create( array $payload ): array { $now = $this->clock->now(); $auto = new Automation( null, Uuid::generate(), $this->require_name( $payload ), $this->nullable_string( $payload['description'] ?? null ), null, $this->parse_trigger( $payload['trigger'] ?? null ), $this->parse_conditions( $payload['conditions'] ?? [] ), $this->parse_actions( $payload['actions'] ?? [] ), $this->parse_cancel_if( $payload['cancel_if'] ?? null ), $this->nullable_int( $payload['pause_period_hours'] ?? null ), false, 0, null, $now, $now ); $this->automations->save( $auto ); return $this->to_detail_array( $auto ); } /** * Update definition (not enable). * * @param int $id Id. * @param array $payload Payload. * @return array * * @since 0.1.0 */ public function update( int $id, array $payload ): array { $auto = $this->require_automation( $id ); $auto->replace_definition( isset( $payload['name'] ) ? $this->require_name( $payload ) : $auto->name(), array_key_exists( 'description', $payload ) ? $this->nullable_string( $payload['description'] ) : $auto->description(), isset( $payload['trigger'] ) ? $this->parse_trigger( $payload['trigger'] ) : $auto->trigger(), isset( $payload['conditions'] ) ? $this->parse_conditions( $payload['conditions'] ) : $auto->conditions(), isset( $payload['actions'] ) ? $this->parse_actions( $payload['actions'] ) : $auto->actions(), array_key_exists( 'cancel_if', $payload ) ? $this->parse_cancel_if( $payload['cancel_if'] ) : $auto->cancel_if(), array_key_exists( 'pause_period_hours', $payload ) ? $this->nullable_int( $payload['pause_period_hours'] ) : $auto->pause_period_hours(), $this->clock->now() ); $this->automations->save( $auto ); return $this->to_detail_array( $auto ); } /** * Delete. * * @param int $id Id. * @return array{ok: bool} * * @since 0.1.0 */ public function delete( int $id ): array { $this->automations->delete( $this->require_automation( $id ) ); return [ 'ok' => true ]; } /** * Enable with ceremony acknowledgement. * * @param int $id Id. * @param array $payload Payload. * @return array * * @throws DomainException When acknowledgement is missing. * * @since 0.1.0 */ public function enable( int $id, array $payload ): array { $auto = $this->require_automation( $id ); if ( true !== ( $payload['acknowledged'] ?? false ) ) { throw new DomainException( 'automation.enable_ack', 'Enabling an automation requires acknowledgement.' ); } if ( $this->has_destructive( $auto ) && 'ENABLE' !== (string) ( $payload['destructive_ack'] ?? '' ) ) { throw new DomainException( 'automation.destructive_ack', 'Destructive actions require a typed ENABLE acknowledgement.' ); } $this->assert_full_set_allowed( $auto->actions() ); $auto->enable( $this->clock->now() ); $this->automations->save( $auto ); return $this->to_detail_array( $auto ); } /** * Disable. * * @param int $id Id. * @return array * * @since 0.1.0 */ public function disable( int $id ): array { $auto = $this->require_automation( $id ); $auto->disable( $this->clock->now() ); $this->automations->save( $auto ); return $this->to_detail_array( $auto ); } /** * Historical match count. * * @param int $id Id. * @return array * * @since 0.1.0 */ public function preview( int $id ): array { $auto = $this->require_automation( $id ); $to = $this->clock->now(); $from = $to->sub( new DateInterval( 'P30D' ) ); $matched = 0; $window = $this->bookings->find_for_admin_calendar( $from, $to, null ); foreach ( $window as $booking ) { $customer = $this->customers->find_by_id( $booking->customer_id() ); $ctx = $this->factory->for_booking( $auto->trigger()->event(), 'preview', $booking, $customer ); if ( $this->matcher->conditions_still_pass( $auto, $ctx ) ) { ++$matched; } } return [ 'match_count' => $matched, 'window_days' => 30, 'outbound_live' => OutboundGuard::allows_outbound(), 'rate_cap' => AutomationMatcher::RATE_CAP, 'has_destructive' => $this->has_destructive( $auto ), 'reminder_overlap' => $this->reminder_overlap( $auto ), ]; } /** * Test run against a subject (outbound forced draft via test flag + guard). * * @param int $id Id. * @param array $payload Payload. * @return array * * @throws DomainException When the subject is missing, the automation is not enabled for a live run, or the run does not start. * * @since 0.1.0 */ public function test( int $id, array $payload ): array { $auto = $this->require_automation( $id ); $subject_id = (int) ( $payload['subject_id'] ?? 0 ); $subject = isset( $payload['subject_type'] ) ? (string) $payload['subject_type'] : SubjectType::Booking->value; $live = true === ( $payload['live'] ?? false ); if ( $subject_id < 1 ) { throw new DomainException( 'automation.test_subject', 'A subject_id is required for a test run.' ); } if ( $live && ! $auto->is_enabled() ) { throw new DomainException( 'automation.not_enabled', 'Only enabled automations can be run live.' ); } $this->assert_full_set_allowed( $auto->actions() ); $ctx = $this->context_for_subject( $auto, $subject, $subject_id, ! $live ); $run = $this->matcher->handle_one( $auto, $ctx ); if ( null === $run ) { throw new DomainException( 'automation.test_no_run', 'The test run did not start.' ); } return $this->run_to_array( $run ); } /** * Gallery cards plus in-use status. * * @return array{items: list>} * * @since 0.1.0 */ public function recipes(): array { $items = []; foreach ( $this->recipes->cards() as $card ) { $key = (string) $card['key']; if ( $this->recipes->uses_full_set( $key ) && ! $this->named_features()->allows( NamedFeature::AutomationsFull ) ) { continue; } $clone = null; try { $clone = $this->automations->find_by_recipe_key( $key ); } catch ( \Throwable ) { $clone = null; } $card['status'] = null === $clone ? 'not_in_use' : ( $clone->is_enabled() ? 'in_use' : 'not_in_use' ); $card['cloned_id'] = $clone?->id(); $items[] = $card; } return [ 'items' => $items ]; } /** * Clone a recipe into a disabled automation. * * @param string $key Recipe key. * @return array * * @throws DomainException When the recipe key is unknown. * * @since 0.1.0 */ public function from_recipe( string $key ): array { $existing = null; try { $existing = $this->automations->find_by_recipe_key( $key ); } catch ( \Throwable ) { $existing = null; } if ( null !== $existing ) { return $this->to_detail_array( $existing ); } $auto = $this->recipes->materialise( $key, $this->clock ); if ( null === $auto ) { throw new DomainException( 'automation.unknown_recipe', 'Unknown recipe key.' ); } $this->assert_full_set_allowed( $auto->actions() ); $this->automations->save( $auto ); return $this->to_detail_array( $auto ); } /** * Trigger-scoped catalogue. * * @param string|null $trigger Trigger identifier. * @return array * * @since 0.1.0 */ public function catalogue( ?string $trigger ): array { $triggers = []; foreach ( $this->triggers->all() as $item ) { $triggers[] = [ 'id' => $item->identifier(), 'label' => $item->label(), 'kind' => $item->kind()->value, 'subject_type' => $item->subject_type()->value, 'variable_groups' => $item->variable_groups(), ]; } $subject = null; if ( is_string( $trigger ) && '' !== $trigger ) { $found = $this->triggers->get( $trigger ); $subject = $found?->subject_type(); } $conditions = []; foreach ( ( null === $subject ? $this->conditions->all() : $this->conditions->for_subject( $subject ) ) as $condition ) { $conditions[] = [ 'id' => $condition->identifier(), 'label' => $condition->label(), 'subject_types' => array_map( static fn( $s ) => $s->value, $condition->subject_types() ), 'schema' => $condition->schema(), ]; } $actions = []; $full = $this->named_features()->allows( NamedFeature::AutomationsFull ); foreach ( ( null === $subject ? $this->actions->all() : $this->actions->for_subject( $subject ) ) as $action ) { if ( ! $full && AutomationActionTier::requires_full_set( $action->identifier() ) ) { continue; } $actions[] = [ 'id' => $action->identifier(), 'label' => $action->label(), 'subject_types' => array_map( static fn( $s ) => $s->value, $action->subject_types() ), 'is_send' => $action->is_send(), 'is_destructive' => $action->is_destructive(), 'schema' => $action->schema(), ]; } $events = []; $seen = []; foreach ( $this->triggers->all() as $item ) { if ( TriggerKind::Event !== $item->kind() ) { continue; } $events[] = [ 'id' => $item->identifier(), 'label' => $item->label(), ]; $seen[ $item->identifier() ] = true; } if ( ! isset( $seen['intent.recovered'] ) ) { $events[] = [ 'id' => 'intent.recovered', 'label' => 'Intent recovered', ]; } return [ 'triggers' => $triggers, 'conditions' => $conditions, 'actions' => $actions, 'events' => $events, ]; } /** * Run history / queue. * * @param array $filters Filters. * @param int $page Page. * @param int $per_page Per page. * @return array{items: list>, total: int, page: int, perPage: int} * * @since 0.1.0 */ public function list_runs( array $filters, int $page, int $per_page ): array { $result = $this->runs->search( $this->normalise_run_filters( $filters ), $page, $per_page ); $items = []; foreach ( $result['items'] as $run ) { $items[] = $this->run_to_array( $run ); } return [ 'items' => $items, 'total' => $result['total'], 'page' => $page, 'perPage' => $per_page, ]; } /** * One run. * * @param int $id Id. * @return array * * @throws DomainException When the run does not exist. * * @since 0.1.0 */ public function get_run( int $id ): array { $run = $this->runs->find_by_id( $id ); if ( null === $run ) { throw new DomainException( 'automation_run.not_found', 'Automation run not found.' ); } return $this->run_to_array( $run ); } /** * Cancel a pending/running run. * * @param int $id Id. * @return array * * @throws DomainException When the run does not exist. * * @since 0.1.0 */ public function cancel_run( int $id ): array { $run = $this->runs->find_by_id( $id ); if ( null === $run ) { throw new DomainException( 'automation_run.not_found', 'Automation run not found.' ); } $this->executor->cancel_run( $run, SkipReason::CancelIf ); $saved = $this->runs->find_by_id( $id ); return $this->run_to_array( $saved ?? $run ); } /** * Re-run against the same subject. * * @param int $id Id. * @return array * * @throws DomainException When the run or subject is missing. * * @since 0.1.0 */ public function rerun( int $id ): array { $source = $this->runs->find_by_id( $id ); if ( null === $source ) { throw new DomainException( 'automation_run.not_found', 'Automation run not found.' ); } $auto = $this->require_automation( $source->automation_id() ); $ctx = $this->rerun_context( $source, $auto ); $run = $this->matcher->handle_rerun( $auto, $ctx ); if ( null === $run ) { throw new DomainException( 'automation.rerun_failed', 'Could not start a re-run.' ); } return $this->run_to_array( $run ); } /** * Process the current queued step immediately. * * @param int $id Run id. * @return array * * @throws DomainException When the run is missing or not pending. * * @since 0.1.0 */ public function process_run( int $id ): array { $run = $this->require_open_run( $id ); if ( AutomationRunStatus::Pending !== $run->status() ) { throw new DomainException( 'automation_run.not_pending', 'Only pending runs can be processed now.' ); } $auto = $this->require_automation( $run->automation_id() ); $this->executor->process_now( $run, $auto ); $saved = $this->runs->find_by_id( $id ); return $this->run_to_array( $saved ?? $run ); } /** * Skip a pending wait step and continue. * * @param int $id Run id. * @return array * * @throws DomainException When the run is not on a wait step. * * @since 0.1.0 */ public function skip_wait( int $id ): array { $run = $this->require_open_run( $id ); $auto = $this->require_automation( $run->automation_id() ); $step = $auto->step_at( $run->current_step() ); if ( null === $step || 'wait' !== $step->type() ) { throw new DomainException( 'automation_run.not_on_wait', 'This run is not waiting on a wait step.' ); } if ( AutomationRunStatus::Pending !== $run->status() ) { throw new DomainException( 'automation_run.not_pending', 'Only pending runs can skip a wait.' ); } $this->executor->skip_wait( $run, $auto ); $saved = $this->runs->find_by_id( $id ); return $this->run_to_array( $saved ?? $run ); } /** * Process every pending run once (operator batch). * * @return array{processed: int, items: list>} * * @since 0.1.0 */ public function process_all_queued(): array { $result = $this->runs->search( [ 'statuses' => [ 'pending' ] ], 1, 100 ); $processed = []; foreach ( $result['items'] as $run ) { if ( ! $run instanceof AutomationRun ) { continue; } if ( AutomationRunStatus::Pending !== $run->status() ) { continue; } try { $processed[] = $this->process_run( (int) $run->id() ); } catch ( DomainException ) { continue; } } return [ 'processed' => count( $processed ), 'items' => $processed, ]; } /** * Load an open run or throw. * * @param int $id Run id. * * @throws DomainException When the run is missing or closed. * * @since 0.1.0 */ private function require_open_run( int $id ): AutomationRun { $run = $this->runs->find_by_id( $id ); if ( null === $run ) { throw new DomainException( 'automation_run.not_found', 'Automation run not found.' ); } if ( ! $run->is_open() ) { throw new DomainException( 'automation_run.closed', 'This run has already finished.' ); } return $run; } /** * Load automation or 404. * * @param int $id Id. * * @throws DomainException When the automation does not exist. * * @since 0.1.0 */ private function require_automation( int $id ): Automation { if ( $id < 1 ) { throw new DomainException( 'automation.not_found', 'Automation not found.' ); } $auto = $this->automations->find_by_id( $id ); if ( null === $auto ) { throw new DomainException( 'automation.not_found', 'Automation not found.' ); } return $auto; } /** * Require a non-empty name. * * @param array $payload Payload. * * @throws DomainException When the name is empty. */ private function require_name( array $payload ): string { $name = isset( $payload['name'] ) ? trim( (string) $payload['name'] ) : ''; if ( '' === $name ) { throw new DomainException( 'automation.name_empty', 'Automation name must not be empty.' ); } return $name; } /** * Trim a string or return null. * * @param mixed $value Value. */ private function nullable_string( mixed $value ): ?string { if ( null === $value ) { return null; } $s = trim( (string) $value ); return '' === $s ? null : $s; } /** * Cast to int or return null. * * @param mixed $value Value. */ private function nullable_int( mixed $value ): ?int { if ( null === $value || '' === $value ) { return null; } return (int) $value; } /** * Parse a trigger payload. * * @param mixed $raw Raw trigger. * * @throws DomainException When the trigger is not an object. */ private function parse_trigger( mixed $raw ): TriggerDefinition { if ( ! is_array( $raw ) ) { throw new DomainException( 'automation.trigger_invalid', 'A trigger object is required.' ); } return TriggerDefinition::from_array( $raw ); } /** * Parse condition rows. * * @param mixed $raw Raw conditions. * @return list */ private function parse_conditions( mixed $raw ): array { if ( ! is_array( $raw ) ) { return []; } $out = []; foreach ( $raw as $row ) { if ( is_array( $row ) ) { $out[] = ConditionDefinition::from_array( $row ); } } return $out; } /** * Parse action steps. * * @param mixed $raw Raw actions. * @return list * * @throws DomainException When no valid actions are present or a send is incomplete. */ private function parse_actions( mixed $raw ): array { if ( ! is_array( $raw ) || [] === $raw ) { throw new DomainException( 'automation.no_actions', 'An automation must have at least one action.' ); } $out = []; foreach ( $raw as $row ) { if ( ! is_array( $row ) ) { continue; } $step = ActionStep::from_array( $row ); $action = $this->actions->get( $step->type() ); if ( null !== $action && $action->is_send() ) { if ( null === $step->recipient() ) { throw new DomainException( 'automation.recipient_required', 'Send actions require a recipient.' ); } if ( null === $step->intent() ) { throw new DomainException( 'automation.intent_required', 'Send actions require an intent.' ); } unset( $action ); } $out[] = $step; } $runnable = array_filter( $out, static function ( ActionStep $step ): bool { return ! in_array( $step->type(), [ 'flow_layout', 'goto' ], true ); } ); if ( [] === $runnable ) { throw new DomainException( 'automation.no_actions', 'An automation must have at least one action.' ); } $this->assert_full_set_allowed( $out ); return $out; } /** * Parse a cancel-if definition. * * @param mixed $raw Raw cancel-if. */ private function parse_cancel_if( mixed $raw ): ?CancelIfDefinition { if ( null === $raw || [] === $raw ) { return null; } if ( ! is_array( $raw ) ) { return null; } $def = CancelIfDefinition::from_array( $raw ); return [] === $def->events() ? null : $def; } /** * Destructive steps? * * @param Automation $auto Automation. */ private function has_destructive( Automation $auto ): bool { foreach ( $auto->actions() as $step ) { $action = $this->actions->get( $step->type() ); if ( null !== $action && $action->is_destructive() ) { return true; } } return false; } /** * Reminder rule overlap warning. * * @param Automation $auto Automation. */ private function reminder_overlap( Automation $auto ): bool { $key = $auto->recipe_key(); if ( ! in_array( $key, [ 'appointment_reminder_24h', 'appointment_reminder_2h' ], true ) ) { return false; } return [] !== $this->reminders->all_enabled(); } /** * Build a live or test context. * * @param Automation $auto Automation. * @param string $subject Subject type. * @param int $subject_id Subject id. * @param bool $is_test Test. * * @throws DomainException When the subject cannot be loaded. */ private function context_for_subject( Automation $auto, string $subject, int $subject_id, bool $is_test ): AutomationContext { $event = $auto->trigger()->event(); $eid = ( $is_test ? 'test:' : 'manual:' ) . (string) $auto->id() . ':' . (string) $subject_id . ':' . Uuid::generate()->to_string(); $type = SubjectType::from( $subject ); if ( SubjectType::Customer === $type ) { $customer = $this->customers->find_by_id( $subject_id ); if ( null === $customer ) { throw new DomainException( 'automation.subject_missing', 'Subject not found.' ); } return $this->factory->for_customer( $event, $eid, $customer, $is_test ); } if ( SubjectType::Intent === $type ) { $intent = $this->intents->find_by_id( $subject_id ); if ( null === $intent ) { throw new DomainException( 'automation.subject_missing', 'Subject not found.' ); } $ctx = $this->factory->for_intent( $event, $eid, $intent, $is_test ); return $is_test ? $ctx->as_test() : $ctx; } $booking = $this->bookings->find_by_id( $subject_id ); if ( null === $booking ) { throw new DomainException( 'automation.subject_missing', 'Subject not found.' ); } $customer = $this->customers->find_by_id( $booking->customer_id() ); return $this->factory->for_booking( $event, $eid, $booking, $customer, $is_test ); } /** * Build a fresh context for a re-run, falling back to the stored snapshot. * * @param AutomationRun $source Source run. * @param Automation $auto Automation. * * @throws DomainException When the subject cannot be resolved. */ private function rerun_context( AutomationRun $source, Automation $auto ): AutomationContext { $eid = 'rerun:' . (string) $source->id() . ':' . $this->clock->now()->format( 'YmdHis' ) . ':' . Uuid::generate()->to_string(); $stored = $source->context(); $is_test = $stored->is_test(); $fields = $stored->fields(); $event = $stored->event(); $subject = $stored->subject_type()->value; $subject_id = $stored->subject_id(); $customer_id = $stored->customer_id(); $snapshot_at = $this->clock->now(); try { $fresh = $this->context_for_subject( $auto, $subject, $subject_id, $is_test ); $fields = $fresh->fields(); $event = $fresh->event(); $customer_id = $fresh->customer_id(); $snapshot_at = $fresh->snapshot_at(); } catch ( DomainException $e ) { if ( 'automation.subject_missing' !== $e->code() ) { throw $e; } } return new AutomationContext( $eid, $event, $stored->subject_type(), $subject_id, $customer_id, $snapshot_at, $fields, 0, [], $is_test ); } /** * List row. * * @param Automation $auto Automation. * @return array */ private function to_list_array( Automation $auto ): array { $trig = $this->triggers->get( $auto->trigger()->event() ); return [ 'id' => $auto->id(), 'uuid' => $auto->uuid()->to_string(), 'name' => $auto->name(), 'description' => $auto->description(), 'recipe_key' => $auto->recipe_key(), 'trigger' => $trig?->label() ?? $auto->trigger()->event(), 'trigger_def' => $auto->trigger()->to_array(), 'subject_type' => $trig?->subject_type()->value ?? 'booking', 'enabled' => $auto->is_enabled(), 'is_enabled' => $auto->is_enabled(), 'run_count' => $auto->run_count(), 'last_run_at' => $auto->last_run_at()?->format( DATE_ATOM ), 'pause_period_hours' => $auto->pause_period_hours(), ]; } /** * Detail row. * * @param Automation $auto Automation. * @return array */ private function to_detail_array( Automation $auto ): array { $conditions = []; foreach ( $auto->conditions() as $c ) { $conditions[] = $c->to_array(); } $actions = []; foreach ( $auto->actions() as $a ) { $actions[] = $a->to_array(); } $base = $this->to_list_array( $auto ); $base['conditions'] = $conditions; $base['actions'] = $actions; $base['cancel_if'] = $auto->cancel_if()?->to_array(); $base['has_destructive'] = $this->has_destructive( $auto ); $base['created_at'] = $auto->created_at()->format( DATE_ATOM ); $base['updated_at'] = $auto->updated_at()->format( DATE_ATOM ); return $base; } /** * Run payload. Secrets are not included (context fields are ids/flags only). * * @param AutomationRun $run Run. * @return array */ private function run_to_array( AutomationRun $run ): array { $auto = $this->automations->find_by_id( $run->automation_id() ); $customer = null !== $run->customer_id() ? $this->customers->find_by_id( (int) $run->customer_id() ) : null; $name = ''; $email = ''; if ( null !== $customer ) { $name = trim( $customer->first_name() . ' ' . ( $customer->last_name() ?? '' ) ); $email = $customer->email()->to_string(); } return [ 'id' => $run->id(), 'automation_id' => $run->automation_id(), 'automation_name' => $auto?->name(), 'trigger_event_id' => $run->trigger_event_id(), 'subject_type' => $run->subject_type()->value, 'subject_id' => $run->subject_id(), 'customer_id' => $run->customer_id(), 'customer_name' => '' !== $name ? $name : null, 'customer_email' => '' !== $email ? $email : null, 'status' => $run->status()->value, 'skip_reason' => $run->skip_reason()?->value, 'current_step' => $run->current_step(), 'scheduled_for' => $run->scheduled_for()?->format( DATE_ATOM ), 'started_at' => $run->started_at()?->format( DATE_ATOM ), 'completed_at' => $run->completed_at()?->format( DATE_ATOM ), 'error' => $run->error(), 'steps' => $run->steps(), 'is_test' => $run->context()->is_test(), 'created_at' => $run->created_at()->format( DATE_ATOM ), 'pending_step' => $this->pending_step_payload( $run, $auto ), ]; } /** * Queue detail for the step about to execute. * * @param AutomationRun $run Run. * @param Automation|null $auto Automation. * @return array|null */ private function pending_step_payload( AutomationRun $run, ?Automation $auto ): ?array { if ( ! $run->is_open() || null === $auto ) { return null; } $step = $auto->step_at( $run->current_step() ); if ( null === $step ) { return null; } $pending = AutomationRunStatus::Pending === $run->status(); return [ 'type' => $step->type(), 'delay_minutes' => $step->delay_minutes(), 'can_process_now' => $pending, 'can_skip_wait' => $pending && 'wait' === $step->type(), ]; } /** * Drop empty/zero REST params so a missing automation_id cannot mean id 0. * * @param array $filters Raw filters. * @return array */ private function normalise_run_filters( array $filters ): array { $out = []; $aid = isset( $filters['automation_id'] ) ? (int) $filters['automation_id'] : 0; if ( $aid > 0 ) { $out['automation_id'] = $aid; } $cid = isset( $filters['customer_id'] ) ? (int) $filters['customer_id'] : 0; if ( $cid > 0 ) { $out['customer_id'] = $cid; } $search = isset( $filters['search'] ) && is_string( $filters['search'] ) ? trim( $filters['search'] ) : ''; if ( '' !== $search ) { $out['search'] = $search; } $raw = isset( $filters['status'] ) && is_string( $filters['status'] ) ? trim( $filters['status'] ) : ''; if ( 'queue' === $raw ) { $out['statuses'] = [ 'pending', 'running' ]; } elseif ( '' !== $raw ) { $statuses = []; foreach ( explode( ',', $raw ) as $piece ) { $key = trim( $piece ); if ( '' !== $key ) { $statuses[] = $key; } } if ( [] !== $statuses ) { $out['statuses'] = $statuses; } } return $out; } /** * Plan map, or the open directory gate. */ private function named_features(): NamedFeatureGate { return $this->named ?? new OpenNamedFeatureGate(); } /** * Refuse full-set actions on email-only commercial plans. * * @param list $steps Steps. * * @throws NamedFeatureDenied When the plan is email-only. */ private function assert_full_set_allowed( array $steps ): void { if ( ! AutomationActionTier::steps_require_full_set( $steps ) ) { return; } $this->named_features()->assert_allows( NamedFeature::AutomationsFull ); } }