Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.25% covered (danger)
18.25%
23 / 126
6.25% covered (danger)
6.25%
1 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Automation_Engine
18.25% covered (danger)
18.25%
23 / 126
6.25% covered (danger)
6.25%
1 / 16
914.02
0.00% covered (danger)
0.00%
0 / 1
 instance
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 set_automation_logger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register_data_transformer
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 register_trigger
81.82% covered (warning)
81.82%
18 / 22
0.00% covered (danger)
0.00%
0 / 1
5.15
 register_step
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 get_step_class
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 add_workflow
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 build_add_workflow
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 init_workflows
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 execute_workflow
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
42
 maybe_transform_data_type
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 get_registered_step
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 get_registered_steps
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_trigger_class
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 get_logger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_registered_triggers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Defines Jetpack CRM Automation engine.
4 *
5 * @package automattic/jetpack-crm
6 * @since 6.2.0
7 */
8
9namespace Automattic\Jetpack\CRM\Automation;
10
11use Automattic\Jetpack\CRM\Automation\Data_Transformers\Data_Transformer_Base;
12use Automattic\Jetpack\CRM\Automation\Data_Types\Data_Type;
13use Automattic\Jetpack\CRM\Automation\Data_Types\Data_Type_Base;
14
15/**
16 * Automation Engine.
17 *
18 * @since 6.2.0
19 */
20class Automation_Engine {
21
22    /**
23     * Instance singleton.
24     *
25     * @since 6.2.0
26     * @var Automation_Engine
27     */
28    private static $instance = null;
29
30    /**
31     * The triggers map name => classname.
32     *
33     * @since 6.2.0
34     * @var string[]
35     */
36    private $triggers_map = array();
37
38    /**
39     * The steps map name => classname.
40     *
41     * @since 6.2.0
42     * @var string[]
43     */
44    private $steps_map = array();
45
46    /**
47     * The Automation logger.
48     *
49     * @since 6.2.0
50     * @var ?Automation_Logger
51     */
52    private $automation_logger = null;
53
54    /**
55     * The list of registered workflows.
56     *
57     * @since 6.2.0
58     * @var Automation_Workflow[]
59     */
60    private $workflows = array();
61
62    /**
63     * An array of supported data types.
64     *
65     * @since 6.2.0
66     *
67     * @var Data_Type_Base[]
68     */
69    private $data_types = array();
70
71    /**
72     * An array of supported data transformers.
73     *
74     * @since 6.2.0
75     *
76     * @var Data_Transformer_Base[]
77     */
78    private $data_transformers = array();
79
80    /**
81     * An array of data type that represents support between types.
82     *
83     * @since 6.2.0
84     *
85     * @var string[]
86     */
87    public $data_transform_map = array();
88
89    /**
90     * Instance singleton object.
91     *
92     * @since 6.2.0
93     *
94     * @param bool $force Whether to force a new Automation_Engine instance.
95     * @return Automation_Engine The Automation_Engine instance.
96     */
97    public static function instance( bool $force = false ): Automation_Engine {
98        if ( ! self::$instance || $force ) {
99            self::$instance = new self();
100        }
101
102        return self::$instance;
103    }
104
105    /**
106     * Set the automation logger.
107     *
108     * @since 6.2.0
109     *
110     * @param Automation_Logger $logger The automation logger.
111     */
112    public function set_automation_logger( Automation_Logger $logger ) {
113        $this->automation_logger = $logger;
114    }
115
116    /**
117     * Register data transformer.
118     *
119     * @since 6.2.0
120     *
121     * @param string $class_name The fully qualified class name for the data transformer.
122     * @return void
123     *
124     * @throws Data_Transformer_Exception Throws an exception if the data transformer class do not look valid.
125     */
126    public function register_data_transformer( string $class_name ): void {
127        if ( ! class_exists( $class_name ) ) {
128            throw new Data_Transformer_Exception(
129                sprintf( 'Data Transformer class do not exist: %s', $class_name ),
130                Data_Transformer_Exception::CLASS_NOT_FOUND
131            );
132        }
133
134        // Make sure that the class implements the Data_Transformer base class,
135        // so we're certain that required logic exists to use the object.
136        if ( ! is_subclass_of( $class_name, Data_Transformer_Base::class ) ) {
137            throw new Data_Transformer_Exception(
138                sprintf( 'Data Transformer class do not extend base class: %s', $class_name ),
139                Data_Transformer_Exception::DO_NOT_EXTEND_BASE
140            );
141        }
142
143        if ( isset( $this->data_transformers[ $class_name ] ) ) {
144            throw new Data_Transformer_Exception(
145                sprintf( 'Data Transformer slug already exist: %s', $class_name ),
146                Data_Transformer_Exception::SLUG_EXISTS
147            );
148        }
149
150        $this->data_transformers[ $class_name ] = $class_name;
151
152        if ( ! isset( $this->data_transform_map[ $class_name::get_from() ] ) ) {
153            $this->data_transform_map[ $class_name::get_from() ] = array();
154        }
155
156        $this->data_transform_map[ $class_name::get_from() ][ $class_name::get_to() ] = $class_name;
157    }
158
159    /**
160     * Register a trigger.
161     *
162     * @since 6.2.0
163     *
164     * @param string $trigger_classname Trigger classname to add to the mapping.
165     *
166     * @throws Automation_Exception Throws an exception if the trigger class does not match the expected conditions.
167     */
168    public function register_trigger( string $trigger_classname ) {
169
170        if ( ! class_exists( $trigger_classname ) ) {
171            throw new Automation_Exception(
172                /* Translators: %s is the name of the trigger class that does not exist. */
173                sprintf( __( 'Trigger class %s does not exist', 'zero-bs-crm' ), $trigger_classname ),
174                Automation_Exception::TRIGGER_CLASS_NOT_FOUND
175            );
176        }
177
178        // Check if the trigger implements the interface
179        if ( ! in_array( Trigger::class, class_implements( $trigger_classname ), true ) ) {
180            throw new Automation_Exception(
181                /* Translators: %s is the name of the trigger class that does not implement the Trigger interface. */
182                sprintf( __( 'Trigger class %s does not implement the Trigger interface', 'zero-bs-crm' ), $trigger_classname ),
183                Automation_Exception::TRIGGER_CLASS_NOT_FOUND
184            );
185        }
186
187        // Check if the trigger has proper slug
188        $trigger_slug = $trigger_classname::get_slug();
189
190        if ( empty( $trigger_slug ) ) {
191            throw new Automation_Exception(
192                __( 'The trigger must have a non-empty slug', 'zero-bs-crm' ),
193                Automation_Exception::TRIGGER_SLUG_EMPTY
194            );
195        }
196
197        if ( array_key_exists( $trigger_slug, $this->triggers_map ) ) {
198            throw new Automation_Exception(
199                /* Translators: %s is the name of the trigger slug that already exists. */
200                sprintf( __( 'Trigger slug already exists: %s', 'zero-bs-crm' ), $trigger_slug ),
201                Automation_Exception::TRIGGER_SLUG_EXISTS
202            );
203        }
204
205        $this->triggers_map[ $trigger_slug ] = $trigger_classname;
206    }
207
208    /**
209     * Register a step in the automation engine.
210     *
211     * @since 6.2.0
212     *
213     * @param string $class_name The name of the class in which the step should belong.
214     *
215     * @throws Automation_Exception Throws an exception if the step class does not exist.
216     */
217    public function register_step( string $class_name ) {
218        if ( ! class_exists( $class_name ) ) {
219            throw new Automation_Exception(
220                /* Translators: %s is the name of the step class that does not exist. */
221                sprintf( __( 'Step class %s does not exist', 'zero-bs-crm' ), $class_name ),
222                Step_Exception::DO_NOT_EXIST
223            );
224        }
225
226        if ( ! in_array( Step::class, class_implements( $class_name ), true ) ) {
227            throw new Automation_Exception(
228                sprintf( 'Step class %s does not implement the Base_Step interface', $class_name ),
229                Step_Exception::DO_NOT_EXTEND_BASE
230            );
231        }
232
233        $step_slug                     = $class_name::get_slug();
234        $this->steps_map[ $step_slug ] = $class_name;
235    }
236
237    /**
238     * Get a step class by name.
239     *
240     * @since 6.2.0
241     *
242     * @param string $step_name The name of the step whose class we will be retrieving.
243     * @return string The name of the step class.
244     *
245     * @throws Automation_Exception Throws an exception if the step class does not exist.
246     */
247    public function get_step_class( string $step_name ): string {
248        if ( ! isset( $this->steps_map[ $step_name ] ) ) {
249            throw new Automation_Exception(
250                /* Translators: %s is the name of the step class that does not exist. */
251                sprintf( __( 'Step %s does not exist', 'zero-bs-crm' ), $step_name ),
252                Automation_Exception::STEP_CLASS_NOT_FOUND
253            );
254        }
255        return $this->steps_map[ $step_name ];
256    }
257
258    /**
259     * Add a workflow.
260     *
261     * @since 6.2.0
262     *
263     * @param Automation_Workflow $workflow The workflow class instance to be added.
264     * @param bool                $init_workflow Whether or not to initialize the workflow.
265     *
266     * @throws Workflow_Exception Throws an exception if the workflow is not valid.
267     */
268    public function add_workflow( Automation_Workflow $workflow, bool $init_workflow = false ) {
269        $workflow->set_engine( $this );
270
271        $this->workflows[] = $workflow;
272
273        if ( $init_workflow ) {
274            $workflow->init_triggers();
275        }
276    }
277
278    /**
279     * Build and add a workflow.
280     *
281     * @since 6.2.0
282     *
283     * @param array $workflow_data The workflow data to be added.
284     * @param bool  $init_workflow Whether or not to initialize the workflow.
285     * @return Automation_Workflow The workflow class instance to be added.
286     *
287     * @throws Workflow_Exception Throws an exception if the workflow is not valid.
288     */
289    public function build_add_workflow( array $workflow_data, bool $init_workflow = false ): Automation_Workflow {
290        $workflow = new Automation_Workflow( $workflow_data );
291        $this->add_workflow( $workflow, $init_workflow );
292
293        return $workflow;
294    }
295
296    /**
297     * Init automation workflows.
298     *
299     * @since 6.2.0
300     *
301     * @throws Workflow_Exception Throws an exception if the workflow is not valid.
302     */
303    public function init_workflows() {
304
305        /** @var Automation_Workflow $workflow */
306        foreach ( $this->workflows as $workflow ) {
307            $workflow->init_triggers();
308        }
309    }
310
311    /**
312     * Execute workflow.
313     *
314     * @since 6.2.0
315     *
316     * @param Automation_Workflow $workflow The workflow to be executed.
317     * @param Trigger             $trigger The trigger that started the execution process.
318     * @param Data_Type           $trigger_data_type The data that was passed along by the trigger.
319     * @return bool
320     *
321     * @throws Automation_Exception Throws exception if an error executing the workflow.
322     * @throws Data_Transformer_Exception Throws exception if an error transforming the data.
323     */
324    public function execute_workflow( Automation_Workflow $workflow, Trigger $trigger, Data_Type $trigger_data_type ): bool {
325        $this->get_logger()->log( sprintf( 'Trigger activated: %s', $trigger->get_slug() ) );
326        $this->get_logger()->log( sprintf( 'Executing workflow: %s', $workflow->name ) );
327
328        $step_data = $workflow->get_initial_step();
329
330        while ( $step_data ) {
331            try {
332                $step_class = $step_data['class_name'] ?? $this->get_step_class( $step_data['slug'] );
333
334                if ( ! class_exists( $step_class ) ) {
335                    throw new Automation_Exception(
336                    /* Translators: %s is the name of the step class that does not exist. */
337                        sprintf( __( 'The step class %s does not exist.', 'zero-bs-crm' ), $step_class ),
338                        Automation_Exception::STEP_CLASS_NOT_FOUND
339                    );
340                }
341
342                /** @var Step $step */
343                $step = new $step_class( $step_data );
344
345                $step_slug = $step->get_slug();
346
347                $this->get_logger()->log( '[' . $step->get_slug() . '] Executing step. Type: ' . $step::get_data_type() );
348
349                $data_type = $this->maybe_transform_data_type( $trigger_data_type, $step::get_data_type() );
350
351                $step->validate_and_execute( $data_type );
352
353                // todo: return Step instance instead of array
354                $step_id   = $step->get_next_step_id();
355                $step_data = $workflow->get_step( $step_id );
356
357                $this->get_logger()->log( '[' . $step->get_slug() . '] Step executed!' );
358
359                if ( ! $step_data ) {
360                    $this->get_logger()->log( 'Workflow execution finished: No more steps found.' );
361                    return true;
362                }
363            } catch ( Automation_Exception $automation_exception ) {
364
365                $this->get_logger()->log( 'Error executing the workflow on step: ' . $step_slug . ' - ' . $automation_exception->getMessage() );
366
367                throw $automation_exception;
368            } catch ( Data_Transformer_Exception $transformer_exception ) {
369                $this->get_logger()->log( 'Error executing the workflow on step ' . $step_slug . '. Transformer error: ' . $transformer_exception->getMessage() );
370
371                throw $transformer_exception;
372            }
373        }
374
375        return false;
376    }
377
378    /**
379     * Maybe transform data type.
380     *
381     * @since 6.2.0
382     *
383     * @param Data_Type $data_type The current data type.
384     * @param string    $new_data_type_class The new data type to transform the data to.
385     * @return Data_Type The transformed data type.
386     *
387     * @throws Data_Transformer_Exception Throws an exception if the data type cannot be transformed.
388     */
389    protected function maybe_transform_data_type( Data_Type $data_type, string $new_data_type_class ): Data_Type_Base {
390
391        // Bail early if we do not have to transform the data.
392        if ( $data_type instanceof $new_data_type_class ) {
393            return $data_type;
394        }
395
396        $data_type_class = get_class( $data_type );
397
398        if ( ! isset( $this->data_transform_map[ $data_type_class ][ $new_data_type_class ] ) ) {
399            throw new Data_Transformer_Exception(
400                sprintf( 'Transforming from "%s" to "%s" is not supported', $data_type_class, $new_data_type_class ),
401                Data_Transformer_Exception::TRANSFORM_IS_NOT_SUPPORTED
402            );
403        }
404
405        $transformer = new $this->data_transform_map[ $data_type_class ][ $new_data_type_class ]();
406
407        return $transformer->transform( $data_type );
408    }
409
410    /**
411     * Get a step instance.
412     *
413     * @since 6.2.0
414     *
415     * @param array $step_data The step data hydrate the step with.
416     * @return Step A step class instance.
417     *
418     * @throws Automation_Exception Throws an exception if the step class does not exist.
419     */
420    public function get_registered_step( array $step_data ): Step {
421
422        $step_class = $this->get_step_class( $step_data['slug'] );
423
424        if ( ! class_exists( $step_class ) ) {
425            throw new Automation_Exception(
426                /* Translators: %s is the name of the step class that does not exist. */
427                sprintf( __( 'Step class %s does not exist', 'zero-bs-crm' ), $step_class ),
428                Automation_Exception::STEP_CLASS_NOT_FOUND
429            );
430        }
431
432        return new $step_class( $step_data );
433    }
434
435    /**
436     * Get registered steps.
437     *
438     * @since 6.2.0
439     *
440     * @return string[] The registered steps.
441     */
442    public function get_registered_steps(): array {
443        return $this->steps_map;
444    }
445
446    /**
447     * Get trigger instance.
448     *
449     * @since 6.2.0
450     *
451     * @param string $trigger_slug The name of the trigger slug with which to retrieve the trigger class.
452     * @return string The name of the trigger class.
453     *
454     * @throws Automation_Exception Throws an exception if the step class does not exist.
455     */
456    public function get_trigger_class( string $trigger_slug ): string {
457
458        if ( ! isset( $this->triggers_map[ $trigger_slug ] ) ) {
459            throw new Automation_Exception(
460                /* Translators: %s is the name of the step class that does not exist. */
461                sprintf( __( 'Trigger %s does not exist', 'zero-bs-crm' ), $trigger_slug ),
462                Automation_Exception::TRIGGER_CLASS_NOT_FOUND
463            );
464        }
465
466        return $this->triggers_map[ $trigger_slug ];
467    }
468
469    /**
470     * Get Automation logger.
471     *
472     * @since 6.2.0
473     *
474     * @return Automation_Logger Return an instance of the Automation_Logger class.
475     */
476    public function get_logger(): Automation_Logger {
477        return $this->automation_logger ?? Automation_Logger::instance();
478    }
479
480    /**
481     * Get the registered triggers.
482     *
483     * @since 6.2.0
484     *
485     * @return string[] The registered triggers.
486     */
487    public function get_registered_triggers(): array {
488        return $this->triggers_map;
489    }
490}