Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.20% covered (success)
94.20%
65 / 69
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
WorkflowConditionEvaluator
94.12% covered (success)
94.12%
64 / 68
42.86% covered (danger)
42.86%
3 / 7
44.39
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 executeConditionNode
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 evaluateCondition
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
20
 executeConditionTicketPrefix
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
8.01
 lookupTicketIdByNumber
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 updateAssociatedTicketEmail
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 lookupTicketPrefix
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Automation\Application\Service\Dag;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Automation\Domain\Model\WorkflowExecutionContext;
12use DateTimeImmutable;
13use PDO;
14
15/**
16 * Workflow DAG Condition Evaluator.
17 *
18 * Evaluates branch criteria, comparison operators, regex matches,
19 * change detection, and specialized Helpdesk ticket prefix detection.
20 *
21 * @package App\Modules\Automation\Application\Service\Dag
22 */
23final readonly class WorkflowConditionEvaluator
24{
25    public const string OUTPUT_1 = 'output_1';
26    public const string OUTPUT_2 = 'output_2';
27    private const string MODULE_EMAILS = 'emails';
28
29    /**
30     * WorkflowConditionEvaluator constructor.
31     *
32     * @param PDO|null $pdo Database connection handle.
33     * @param string $tablePrefix Database table prefix.
34     */
35    public function __construct(
36        private ?PDO $pdo = null,
37        private string $tablePrefix = 'a_'
38    ) {
39    }
40
41    /**
42     * Executes conditional branch logic.
43     *
44     * @param string                   $nodeType       Node type identifier.
45     * @param array<string, mixed>     $node           Node definition.
46     * @param WorkflowExecutionContext $execContext    Active context.
47     * @param string                   $branchToFollow Reference to selected branch port.
48     * @return WorkflowExecutionContext Context potentially enriched with detected ticket info.
49     */
50    public function executeConditionNode(
51        string                   $nodeType,
52        array                    $node,
53        WorkflowExecutionContext $execContext,
54        string                   &$branchToFollow
55    ): WorkflowExecutionContext {
56        if ($nodeType === 'condition_ticket_prefix') {
57            return $this->executeConditionTicketPrefix($execContext, $branchToFollow);
58        }
59
60        $params = $node['data'] ?? [];
61        $isMet = $this->evaluateCondition($params, $execContext);
62        $branchToFollow = $isMet ? self::OUTPUT_1 : self::OUTPUT_2;
63
64        return $execContext;
65    }
66
67    /**
68     * Evaluates a condition node against the current execution context.
69     *
70     * @param array<string, mixed>     $params      Node parameter rules.
71     * @param WorkflowExecutionContext $execContext Active context.
72     * @return bool True if condition rules are satisfied.
73     */
74    public function evaluateCondition(array $params, WorkflowExecutionContext $execContext): bool
75    {
76        $field = (string) ($params['field_key'] ?? '');
77        $operator = (string) ($params['operator'] ?? 'equals');
78        $expected = $params['value'] ?? null;
79
80        if ($field === '') {
81            return true;
82        }
83
84        $currentVal = $execContext->currentData[$field] ?? null;
85
86        return match ($operator) {
87            'equals', '=='          => (string) $currentVal === (string) $expected,
88            'not_equals', '!='      => (string) $currentVal !== (string) $expected,
89            'is_empty'              => $currentVal === null || $currentVal === '',
90            'is_not_empty'          => $currentVal !== null && $currentVal !== '',
91            'contains'              => is_string($currentVal) && str_contains($currentVal, (string) $expected),
92            'matches_regex', 'regex'=> is_string($currentVal) && (bool) preg_match((string) $expected, $currentVal),
93            'greater_than', '>'     => is_numeric($currentVal) && (float) $currentVal > (float) $expected,
94            'less_than', '<'        => is_numeric($currentVal) && (float) $currentVal < (float) $expected,
95            'is_changed'            => $execContext->hasFieldChanged($field),
96            'changed_to'            => $execContext->hasFieldChanged($field)
97                && (string) $currentVal === (string) $expected,
98            default                 => true,
99        };
100    }
101
102    /**
103     * Evaluates whether email contains existing ticket number prefix and links ticket ID.
104     *
105     * @param WorkflowExecutionContext $execContext    Active execution context.
106     * @param string                   $branchToFollow Chosen output port reference.
107     * @return WorkflowExecutionContext Context with ticket_id linked if found.
108     */
109    public function executeConditionTicketPrefix(
110        WorkflowExecutionContext $execContext,
111        string                   &$branchToFollow
112    ): WorkflowExecutionContext {
113        $branchToFollow = self::OUTPUT_2;
114        $subject = (string) ($execContext->currentData['subject'] ?? '');
115        $body = (string) ($execContext->currentData['body_text']
116            ?? ($execContext->currentData['body_html'] ?? ''));
117
118        $prefix = $this->lookupTicketPrefix();
119        $pattern = '/(?:\[#|\b)(' . preg_quote($prefix, '/') . '-\d{4}-\d+)(?:\]|\b)/i';
120
121        if (!preg_match($pattern, $subject, $m) && !preg_match($pattern, $body, $m)) {
122            return $execContext;
123        }
124
125        $ticketNo = strtoupper($m[1]);
126        $ticketId = $this->lookupTicketIdByNumber($ticketNo);
127        if ($ticketId === false && $this->pdo !== null) {
128            return $execContext;
129        }
130
131        $branchToFollow = self::OUTPUT_1;
132        if ($ticketId !== false && $this->pdo !== null) {
133            $this->updateAssociatedTicketEmail($execContext->recordId, $execContext->moduleName, (int) $ticketId);
134        }
135
136        $dataUpdates = ['ticket_no' => $ticketNo];
137        if ($ticketId !== false) {
138            $dataUpdates['ticket_id'] = (int) $ticketId;
139        }
140
141        return $execContext->withCurrentData(array_merge($execContext->currentData, $dataUpdates));
142    }
143
144    /**
145     * Lookups existing ticket record ID by ticket number.
146     *
147     * @param string $ticketNo Ticket number.
148     * @return int|false Ticket ID or false if not found or PDO not set.
149     */
150    private function lookupTicketIdByNumber(string $ticketNo): int|false
151    {
152        if ($this->pdo === null) {
153            return false;
154        }
155
156        $sql = "SELECT id FROM {$this->tablePrefix}mod_tickets_records WHERE ticket_no = :no LIMIT 1";
157        $stmt = $this->pdo->prepare($sql);
158        $stmt->execute([':no' => $ticketNo]);
159        $val = $stmt->fetchColumn();
160
161        return $val !== false ? (int) $val : false;
162    }
163
164    /**
165     * Updates email record with linked ticket ID and touches ticket timestamp.
166     *
167     * @param int|null $recordId   Email record ID.
168     * @param string   $moduleName Module name.
169     * @param int      $ticketId   Ticket record ID.
170     */
171    private function updateAssociatedTicketEmail(?int $recordId, string $moduleName, int $ticketId): void
172    {
173        if ($this->pdo === null || $recordId === null || $moduleName !== self::MODULE_EMAILS) {
174            return;
175        }
176
177        $upd = "UPDATE {$this->tablePrefix}mod_emails_records SET ticket_id = :tid WHERE id = :eid";
178        $updStmt = $this->pdo->prepare($upd);
179        $updStmt->execute([':tid' => $ticketId, ':eid' => $recordId]);
180
181        $now = (new DateTimeImmutable())->format('Y-m-d H:i:s');
182        $updTicket = "UPDATE {$this->tablePrefix}mod_tickets_records SET updated_at = :now WHERE id = :tid";
183        $updTicketStmt = $this->pdo->prepare($updTicket);
184        $updTicketStmt->execute([':now' => $now, ':tid' => $ticketId]);
185    }
186
187    /**
188     * Looks up ticket prefix rule code from core configuration.
189     *
190     * @return string Configured ticket prefix.
191     */
192    private function lookupTicketPrefix(): string
193    {
194        if ($this->pdo === null) {
195            return 'TICK';
196        }
197
198        $sql = "SELECT prefix FROM {$this->tablePrefix}core_prefix_records WHERE module_id = 50 LIMIT 1";
199        $stmt = $this->pdo->query($sql);
200        $val = $stmt !== false ? $stmt->fetchColumn() : false;
201
202        return is_string($val) && $val !== '' ? $val : 'TICK';
203    }
204}