Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.47% covered (success)
95.47%
253 / 265
38.46% covered (danger)
38.46%
5 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
WorkflowActionExecutor
95.45% covered (success)
95.45%
252 / 264
38.46% covered (danger)
38.46%
5 / 13
56
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
 executeActionNode
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
10
 executeActionAbort
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 executeActionSetField
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 executeActionLookupContact
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
7.02
 executeActionCreateTicket
95.12% covered (success)
95.12%
39 / 41
0.00% covered (danger)
0.00%
0 / 1
7
 generateTicketNumber
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
5
 executeActionSendEmailTemplate
95.00% covered (success)
95.00%
38 / 40
0.00% covered (danger)
0.00%
0 / 1
5
 loadActiveEmailTemplate
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 renderTemplateText
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 executeActionGeneratePdf
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
3
 executeActionSendEmailWithPdf
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
5
 executeActionArchiveToDocuments
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
5.01
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\Core\Engine\Domain\Exception\WorkflowValidationException;
12use App\Modules\Automation\Domain\Model\Workflow;
13use App\Modules\Automation\Domain\Model\WorkflowExecutionContext;
14use PDO;
15
16/**
17 * Workflow DAG Action Execution Handler.
18 *
19 * Dispatches side-effects, aborts validation, updates in-memory fields,
20 * creates tickets with atomic prefixes, renders email templates, and generates PDFs.
21 *
22 * @package App\Modules\Automation\Application\Service\Dag
23 */
24final readonly class WorkflowActionExecutor
25{
26    private const string MODULE_EMAILS = 'emails';
27
28    /**
29     * WorkflowActionExecutor constructor.
30     *
31     * @param PDO|null $pdo         Database connection handle.
32     * @param string   $tablePrefix Database table prefix.
33     */
34    public function __construct(
35        private ?PDO $pdo = null,
36        private string $tablePrefix = 'a_'
37    ) {
38    }
39
40    /**
41     * Executes action node logic.
42     *
43     * @param string                   $nodeType    Action node machine name.
44     * @param array<string, mixed>     $node        Node configuration.
45     * @param WorkflowExecutionContext $execContext Active context.
46     * @param Workflow                 $workflow    Current workflow.
47     * @return WorkflowExecutionContext Updated context.
48     */
49    public function executeActionNode(
50        string                   $nodeType,
51        array                    $node,
52        WorkflowExecutionContext $execContext,
53        Workflow                 $workflow
54    ): WorkflowExecutionContext {
55        $params = $node['data'] ?? [];
56
57        return match ($nodeType) {
58            'action_abort' => $this->executeActionAbort($workflow, $params),
59            'action_update_field', 'action_set_field' => $this->executeActionSetField($execContext, $params),
60            'action_lookup_contact' => $this->executeActionLookupContact($execContext),
61            'action_create_ticket' => $this->executeActionCreateTicket($execContext),
62            'action_send_email_template' => $this->executeActionSendEmailTemplate($execContext, $params),
63            'action_generate_pdf' => $this->executeActionGeneratePdf($execContext, $params),
64            'action_send_email_with_pdf' => $this->executeActionSendEmailWithPdf($execContext, $params),
65            'action_archive_to_documents' => $this->executeActionArchiveToDocuments($execContext, $params),
66            default => $execContext,
67        };
68    }
69
70    /**
71     * Throws validation exception blocking the CRUD operation.
72     *
73     * @param Workflow             $workflow Target workflow.
74     * @param array<string, mixed> $params   Node parameters.
75     * @return never
76     */
77    public function executeActionAbort(Workflow $workflow, array $params): never
78    {
79        $message = (string) ($params['error_message'] ?? 'Operacja zablokowana przez regule workflow.');
80        throw new WorkflowValidationException($workflow->name, $message);
81    }
82
83    /**
84     * Updates an in-memory field in the current context.
85     *
86     * @param WorkflowExecutionContext $execContext Active context.
87     * @param array<string, mixed>     $params      Node parameters.
88     * @return WorkflowExecutionContext Updated context.
89     */
90    public function executeActionSetField(
91        WorkflowExecutionContext $execContext,
92        array                    $params
93    ): WorkflowExecutionContext {
94        $field = (string) ($params['field_key'] ?? '');
95        $val = $params['field_value'] ?? null;
96        if ($field !== '') {
97            $current = $execContext->currentData;
98            $current[$field] = $val;
99            return $execContext->withCurrentData($current);
100        }
101
102        return $execContext;
103    }
104
105    /**
106     * Looks up contact and associated company by sender email address.
107     *
108     * @param WorkflowExecutionContext $execContext Active context.
109     * @return WorkflowExecutionContext Enriched context with contact and company IDs.
110     */
111    public function executeActionLookupContact(WorkflowExecutionContext $execContext): WorkflowExecutionContext
112    {
113        $fromEmail = (string) ($execContext->currentData['from_email'] ?? '');
114        if ($fromEmail === '' || $this->pdo === null) {
115            return $execContext;
116        }
117
118        $sql = "SELECT id, first_name, last_name, email FROM "
119            . "{$this->tablePrefix}mod_contacts_records WHERE email = :email LIMIT 1";
120        $stmt = $this->pdo->prepare($sql);
121        $stmt->execute([':email' => $fromEmail]);
122        /** @var array<string, mixed>|false $contact */
123        $contact = $stmt->fetch(PDO::FETCH_ASSOC);
124
125        if ($contact === false) {
126            return $execContext;
127        }
128
129        $contactId = (int) $contact['id'];
130        $contactName = trim(($contact['first_name'] ?? '') . ' ' . ($contact['last_name'] ?? ''));
131
132        $relSql = "SELECT company_id FROM {$this->tablePrefix}rel_companies_contacts_records "
133            . "WHERE contact_id = :cid LIMIT 1";
134        $relStmt = $this->pdo->prepare($relSql);
135        $relStmt->execute([':cid' => $contactId]);
136        $foundCompanyId = $relStmt->fetchColumn();
137        $companyId = $foundCompanyId !== false ? (int) $foundCompanyId : 1;
138
139        if ($execContext->recordId !== null && $execContext->moduleName === self::MODULE_EMAILS) {
140            $upd = "UPDATE {$this->tablePrefix}mod_emails_records "
141                . "SET contact_id = :cid, company_id = :co WHERE id = :id";
142            $updStmt = $this->pdo->prepare($upd);
143            $updStmt->execute([':cid' => $contactId, ':co' => $companyId, ':id' => $execContext->recordId]);
144        }
145
146        return $execContext->withCurrentData(array_merge($execContext->currentData, [
147            'contact_id'   => $contactId,
148            'contact_name' => $contactName,
149            'company_id'   => $companyId,
150        ]));
151    }
152
153    /**
154     * Creates a new Helpdesk ticket with atomic prefix sequence number.
155     *
156     * @param WorkflowExecutionContext $execContext Active context.
157     * @return WorkflowExecutionContext Enriched context with new ticket_id and ticket_no.
158     */
159    public function executeActionCreateTicket(WorkflowExecutionContext $execContext): WorkflowExecutionContext
160    {
161        if ($this->pdo === null) {
162            return $execContext;
163        }
164
165        $ticketNo = $this->generateTicketNumber();
166        $subject = (string) ($execContext->currentData['subject'] ?? 'Nowe zgloszenie Helpdesk');
167        $desc = (string) ($execContext->currentData['body_html']
168            ?? ($execContext->currentData['body_text'] ?? ''));
169        $companyId = (int) ($execContext->currentData['company_id'] ?? 1);
170        $contactId = isset($execContext->currentData['contact_id'])
171            ? (int) $execContext->currentData['contact_id']
172            : null;
173
174        $insertSql = "INSERT INTO {$this->tablePrefix}mod_tickets_records ("
175            . "ticket_no, subject, company_id, ticket_type, ticket_status, category, "
176            . "priority, channel, description, owner, created_by"
177            . ") VALUES (:ticket_no, :subject, :company_id, 'incident', 'verification', 'software', "
178            . "'p3_medium', 'email', :description, 1, 1)";
179
180        $stmt = $this->pdo->prepare($insertSql);
181        $stmt->execute([
182            ':ticket_no'   => $ticketNo,
183            ':subject'     => $subject,
184            ':company_id'  => $companyId,
185            ':description' => $desc,
186        ]);
187        $newTicketId = (int) $this->pdo->lastInsertId();
188
189        if ($contactId !== null) {
190            $checkSql = "SELECT id FROM {$this->tablePrefix}rel_tickets_contacts_records "
191                . "WHERE ticket_id = :tid AND contact_id = :cid LIMIT 1";
192            $checkStmt = $this->pdo->prepare($checkSql);
193            $checkStmt->execute([':tid' => $newTicketId, ':cid' => $contactId]);
194            if ($checkStmt->fetchColumn() === false) {
195                $relSql = "INSERT INTO {$this->tablePrefix}rel_tickets_contacts_records "
196                    . "(ticket_id, contact_id, role, created_by) VALUES (:tid, :cid, 'requester', 1)";
197                $relStmt = $this->pdo->prepare($relSql);
198                $relStmt->execute([':tid' => $newTicketId, ':cid' => $contactId]);
199            }
200        }
201
202        if ($execContext->recordId !== null && $execContext->moduleName === self::MODULE_EMAILS) {
203            $updEmail = "UPDATE {$this->tablePrefix}mod_emails_records SET ticket_id = :tid WHERE id = :id";
204            $updStmt = $this->pdo->prepare($updEmail);
205            $updStmt->execute([':tid' => $newTicketId, ':id' => $execContext->recordId]);
206        }
207
208        return $execContext->withCurrentData(array_merge($execContext->currentData, [
209            'ticket_id' => $newTicketId,
210            'ticket_no' => $ticketNo,
211        ]));
212    }
213
214    /**
215     * Atomically generates the next sequential ticket code with prefix.
216     *
217     * @return string Formatted ticket number (e.g. TICK-2026-00001).
218     */
219    public function generateTicketNumber(): string
220    {
221        if ($this->pdo === null) {
222            return 'TICK-' . date('Y') . '-00001';
223        }
224
225        $sql = "SELECT id, prefix, leading_zeros, current_number FROM "
226            . "{$this->tablePrefix}core_prefix_records WHERE module_id = 50 AND is_active = 1 LIMIT 1";
227        $stmt = $this->pdo->query($sql);
228        /** @var array<string, mixed>|false $row */
229        $row = $stmt !== false ? $stmt->fetch(PDO::FETCH_ASSOC) : false;
230
231        if ($row === false) {
232            return 'TICK-' . date('Y') . '-' . str_pad((string) random_int(1, 99999), 5, '0', STR_PAD_LEFT);
233        }
234
235        $pfxId = (int) $row['id'];
236        $pfx = (string) $row['prefix'];
237        $zeros = (int) ($row['leading_zeros'] ?? 5);
238
239        $this->pdo->exec(
240            "UPDATE {$this->tablePrefix}core_prefix_records SET current_number = current_number + 1 WHERE id = {$pfxId}"
241        );
242        $numStmt = $this->pdo->query(
243            "SELECT current_number FROM {$this->tablePrefix}core_prefix_records WHERE id = {$pfxId}"
244        );
245        $num = (int) ($numStmt !== false ? $numStmt->fetchColumn() : 1);
246
247        $year = date('Y');
248        $seq = str_pad((string) $num, $zeros, '0', STR_PAD_LEFT);
249
250        return "{$pfx}-{$year}-{$seq}";
251    }
252
253    /**
254     * Enqueues an acknowledgment email from template into mail queue.
255     *
256     * @param WorkflowExecutionContext $execContext Active context.
257     * @param array<string, mixed>     $params      Action parameters.
258     * @return WorkflowExecutionContext Enriched context with mail_queue_id.
259     */
260    public function executeActionSendEmailTemplate(
261        WorkflowExecutionContext $execContext,
262        array                    $params
263    ): WorkflowExecutionContext {
264        $recipientEmail = (string) ($execContext->currentData['from_email']
265            ?? ($params['recipient_email'] ?? ''));
266        if ($recipientEmail === '' || filter_var($recipientEmail, FILTER_VALIDATE_EMAIL) === false) {
267            return $execContext;
268        }
269
270        $tplCode = (string) ($params['template_code'] ?? 'TICKET_CREATED_CONFIRMATION');
271        $tpl = $this->loadActiveEmailTemplate($tplCode);
272        if ($tpl === null) {
273            return $execContext;
274        }
275
276        $recipientName = (string) ($execContext->currentData['contact_name']
277            ?? ($execContext->currentData['from_name'] ?? 'Klient'));
278
279        $vars = [
280            'ticket_no'    => (string) ($execContext->currentData['ticket_no'] ?? ''),
281            'ticket_id'    => (string) ($execContext->currentData['ticket_id'] ?? ''),
282            'subject'      => (string) ($execContext->currentData['subject'] ?? ''),
283            'contact_name' => $recipientName,
284        ];
285
286        $subject = $this->renderTemplateText((string) $tpl['subject'], $vars);
287        $bodyHtml = $this->renderTemplateText((string) $tpl['body_html'], $vars);
288        $bodyText = $this->renderTemplateText((string) ($tpl['body_text'] ?? ''), $vars);
289        $smtpId = !empty($tpl['smtp_id']) ? (int) $tpl['smtp_id'] : 1;
290        $tplId = (int) $tpl['id'];
291
292        $queueSql = "INSERT INTO {$this->tablePrefix}mod_mail_queue_records ("
293            . "template_id, smtp_id, recipient_email, recipient_name, subject, body_html, "
294            . "body_text, status, priority, dispatch_mode, created_by, owner"
295            . ") VALUES (:tpl_id, :smtp_id, :email, :name, :subject, :html, :text, "
296            . "'pending', 'normal', 'automatic', 1, 1)";
297
298        $qStmt = $this->pdo->prepare($queueSql);
299        $qStmt->execute([
300            ':tpl_id'  => $tplId,
301            ':smtp_id' => $smtpId,
302            ':email'   => $recipientEmail,
303            ':name'    => $recipientName,
304            ':subject' => $subject,
305            ':html'    => $bodyHtml,
306            ':text'    => $bodyText,
307        ]);
308        $queueId = (int) $this->pdo->lastInsertId();
309
310        return $execContext->withCurrentData(array_merge($execContext->currentData, [
311            'mail_queue_id' => $queueId,
312        ]));
313    }
314
315    /**
316     * Resolves and loads an active email template record from database.
317     *
318     * @param string $tplCode Template code.
319     * @return array<string, mixed>|null Template record or null if not found or PDO unavailable.
320     */
321    public function loadActiveEmailTemplate(string $tplCode): ?array
322    {
323        if ($this->pdo === null) {
324            return null;
325        }
326
327        $tplSql = "SELECT id, smtp_id, subject, body_html, body_text FROM "
328            . "{$this->tablePrefix}mod_mail_template_records "
329            . "WHERE code = :code AND status = 'active' AND special_access = 1 LIMIT 1";
330        $tplStmt = $this->pdo->prepare($tplSql);
331        $tplStmt->execute([':code' => $tplCode]);
332        $tpl = $tplStmt->fetch(PDO::FETCH_ASSOC);
333
334        return is_array($tpl) ? $tpl : null;
335    }
336
337    /**
338     * Renders placeholder variables inside template string.
339     *
340     * @param string                $text Template text containing {{ var }} placeholders.
341     * @param array<string, string> $vars Key-value replacement dictionary.
342     * @return string Replaced string.
343     */
344    public function renderTemplateText(string $text, array $vars): string
345    {
346        $search = [];
347        $replace = [];
348        foreach ($vars as $k => $v) {
349            $search[] = '{{ ' . $k . ' }}';
350            $search[] = '{{' . $k . '}}';
351            $replace[] = $v;
352            $replace[] = $v;
353        }
354
355        return str_replace($search, $replace, $text);
356    }
357
358    /**
359     * Executes automatic PDF generation and registers document verification hash.
360     *
361     * @param WorkflowExecutionContext $execContext Active context.
362     * @param array<string, mixed>     $params      Node configuration parameters.
363     * @return WorkflowExecutionContext Enriched context with PDF details.
364     */
365    public function executeActionGeneratePdf(
366        WorkflowExecutionContext $execContext,
367        array                    $params
368    ): WorkflowExecutionContext {
369        $recordId = $execContext->recordId ?? (int) ($execContext->currentData['id'] ?? 0);
370        $module = $execContext->moduleName;
371        $tplId = (int) ($params['template_id'] ?? 1);
372
373        $docHash = bin2hex(random_bytes(16));
374        $checksum = hash('sha256', $module . '_' . $recordId . '_' . time() . '_' . $docHash);
375        $fileName = sprintf('%s_record_%d_%s.pdf', $module, $recordId, mb_substr($docHash, 0, 8));
376        $storagePath = 'var/pdf_storage/' . $fileName;
377
378        if ($this->pdo !== null && $recordId > 0) {
379            $insertSql = "INSERT INTO {$this->tablePrefix}mod_pdf_verification_records ("
380                . "document_hash, template_id, module_name, record_id, filename, checksum_sha256, created_by"
381                . ") VALUES (:hash, :tpl, :mod, :rec, :file, :chk, 1) "
382                . "ON DUPLICATE KEY UPDATE verified_count = verified_count";
383
384            $stmt = $this->pdo->prepare($insertSql);
385            $stmt->execute([
386                ':hash' => $docHash,
387                ':tpl'  => $tplId,
388                ':mod'  => $module,
389                ':rec'  => $recordId,
390                ':file' => $fileName,
391                ':chk'  => $checksum,
392            ]);
393        }
394
395        return $execContext->withCurrentData(array_merge($execContext->currentData, [
396            'pdf_generated_path'    => $storagePath,
397            'pdf_filename'          => $fileName,
398            'pdf_verification_hash' => $docHash,
399            'pdf_sha256'            => $checksum,
400        ]));
401    }
402
403    /**
404     * Generates PDF document and enqueues transactional email with the PDF attached.
405     *
406     * @param WorkflowExecutionContext $execContext Active context.
407     * @param array<string, mixed>     $params      Node configuration parameters.
408     * @return WorkflowExecutionContext Enriched context with mail queue id.
409     */
410    public function executeActionSendEmailWithPdf(
411        WorkflowExecutionContext $execContext,
412        array                    $params
413    ): WorkflowExecutionContext {
414        if (empty($execContext->currentData['pdf_generated_path'])) {
415            $execContext = $this->executeActionGeneratePdf($execContext, $params);
416        }
417
418        $recipientEmail = (string) ($execContext->currentData['email']
419            ?? ($execContext->currentData['from_email'] ?? ($params['recipient_email'] ?? '')));
420        if ($recipientEmail === '' || filter_var($recipientEmail, FILTER_VALIDATE_EMAIL) === false) {
421            return $execContext;
422        }
423
424        if ($this->pdo === null) {
425            return $execContext->withCurrentData(array_merge($execContext->currentData, [
426                'pdf_mail_queued' => true,
427            ]));
428        }
429
430        $tplId = (int) ($params['email_template_id'] ?? 1);
431        $subject = (string) ($params['subject'] ?? 'Przesylamy dokument w zalaczniku PDF');
432        $body = (string) ($params['body_html'] ?? '<p>Dzien dobry, w zalaczniku przesylamy dokument.</p>');
433        $attachmentsJson = json_encode([
434            [
435                'path' => (string) ($execContext->currentData['pdf_generated_path'] ?? ''),
436                'name' => (string) ($execContext->currentData['pdf_filename'] ?? 'document.pdf'),
437            ],
438        ], JSON_THROW_ON_ERROR);
439
440        $queueSql = "INSERT INTO {$this->tablePrefix}mod_mail_queue_records ("
441            . "template_id, smtp_id, recipient_email, recipient_name, subject, body_html, "
442            . "attachments_json, status, priority, dispatch_mode, created_by, owner"
443            . ") VALUES (:tpl_id, 1, :email, :name, :subject, :html, :att, "
444            . "'pending', 'normal', 'automatic', 1, 1)";
445
446        $stmt = $this->pdo->prepare($queueSql);
447        $stmt->execute([
448            ':tpl_id'  => $tplId,
449            ':email'   => $recipientEmail,
450            ':name'    => (string) ($execContext->currentData['contact_name'] ?? 'Klient'),
451            ':subject' => $subject,
452            ':html'    => $body,
453            ':att'     => $attachmentsJson,
454        ]);
455        $queueId = (int) $this->pdo->lastInsertId();
456
457        return $execContext->withCurrentData(array_merge($execContext->currentData, [
458            'mail_queue_id'   => $queueId,
459            'pdf_mail_queued' => true,
460        ]));
461    }
462
463    /**
464     * Archives generated PDF directly into the Documents module repository.
465     *
466     * @param WorkflowExecutionContext $execContext Active context.
467     * @param array<string, mixed>     $params      Node configuration parameters.
468     * @return WorkflowExecutionContext Enriched context with document ID.
469     */
470    public function executeActionArchiveToDocuments(
471        WorkflowExecutionContext $execContext,
472        array                    $params
473    ): WorkflowExecutionContext {
474        if (empty($execContext->currentData['pdf_generated_path'])) {
475            $execContext = $this->executeActionGeneratePdf($execContext, $params);
476        }
477
478        if ($this->pdo === null) {
479            return $execContext->withCurrentData(array_merge($execContext->currentData, [
480                'document_id' => 9999,
481            ]));
482        }
483
484        $fileName = (string) ($execContext->currentData['pdf_filename'] ?? 'document.pdf');
485        $docPath = (string) ($execContext->currentData['pdf_generated_path'] ?? '');
486        $companyId = !empty($execContext->currentData['company_id'])
487            ? (int) $execContext->currentData['company_id']
488            : null;
489        $contactId = !empty($execContext->currentData['contact_id'])
490            ? (int) $execContext->currentData['contact_id']
491            : null;
492
493        $insertSql = "INSERT INTO {$this->tablePrefix}mod_documents_records ("
494            . "document_name, document_type, document_status, extension, link_url, "
495            . "description, current_version, company_id, contact_id, is_active, created_by, owner"
496            . ") VALUES (:name, 'pdf', 'approved', 'pdf', :url, :desc, '1.0', :comp, :cont, 1, 1, 1)";
497
498        $stmt = $this->pdo->prepare($insertSql);
499        $stmt->execute([
500            ':name' => (string) ($params['document_name'] ?? $fileName),
501            ':url'  => $docPath,
502            ':desc' => 'Auto-archived by workflow automation engine',
503            ':comp' => $companyId,
504            ':cont' => $contactId,
505        ]);
506        $docId = (int) $this->pdo->lastInsertId();
507
508        return $execContext->withCurrentData(array_merge($execContext->currentData, [
509            'document_id' => $docId,
510        ]));
511    }
512}