Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 120
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
DispatchWorkflowsForEventAction
0.00% covered (danger)
0.00%
0 / 120
0.00% covered (danger)
0.00%
0 / 13
992
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fromDomainEvent
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 execute
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 dispatchWorkflow
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
42
 createSkippedExecution
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 extractTenantId
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 reflectContext
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 resolveEntityId
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 fingerprint
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 allowNestedExecution
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 isDuplicateRunning
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 dedupeKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 markExecutionFinished
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Automation\Application\Actions;
6
7use Illuminate\Support\Facades\Cache;
8use Illuminate\Support\Facades\DB;
9use ImovelZapAi\Automation\Application\Services\ConditionEvaluator;
10use ImovelZapAi\Automation\Application\Services\TriggerCatalog;
11use ImovelZapAi\Automation\Domain\Enums\ExecutionStatus;
12use ImovelZapAi\Automation\Domain\Enums\WorkflowStatus;
13use ImovelZapAi\Automation\Domain\Events\WorkflowExecuted;
14use ImovelZapAi\Automation\Domain\Events\WorkflowFailed;
15use ImovelZapAi\Automation\Infrastructure\Jobs\RunWorkflowActionsJob;
16use ImovelZapAi\Automation\Infrastructure\Persistence\Eloquent\AutomationExecutionModel;
17use ImovelZapAi\Automation\Infrastructure\Persistence\Eloquent\AutomationLogModel;
18use ImovelZapAi\Automation\Infrastructure\Persistence\Eloquent\AutomationWorkflowModel;
19use ImovelZapAi\Automation\Domain\Enums\LogLevel;
20use ReflectionClass;
21
22final class DispatchWorkflowsForEventAction
23{
24    private const MAX_NESTED = 5;
25
26    private const DEDUPE_SECONDS = 60;
27
28    public function __construct(
29        private readonly ConditionEvaluator $conditions,
30    ) {}
31
32    public function fromDomainEvent(object $event): void
33    {
34        $eventKey = TriggerCatalog::keyForClass($event::class);
35
36        if ($eventKey === null) {
37            return;
38        }
39
40        $tenantId = $this->extractTenantId($event);
41
42        if ($tenantId === null) {
43            return;
44        }
45
46        $context = $this->reflectContext($event);
47        $context['_event_key'] = $eventKey;
48        $context['_event_class'] = $event::class;
49
50        $this->execute($tenantId, $eventKey, $context);
51    }
52
53    /**
54     * @param  array<string, mixed>  $context
55     */
56    public function execute(string $tenantId, string $eventKey, array $context): void
57    {
58        $workflows = AutomationWorkflowModel::query()
59            ->where('tenant_id', $tenantId)
60            ->where('status', WorkflowStatus::Active)
61            ->whereHas('triggers', fn ($q) => $q->where('event_key', $eventKey))
62            ->with(['conditions', 'actions', 'triggers'])
63            ->get();
64
65        foreach ($workflows as $workflow) {
66            $this->dispatchWorkflow($workflow, $eventKey, $context);
67        }
68    }
69
70    /**
71     * @param  array<string, mixed>  $context
72     */
73    private function dispatchWorkflow(AutomationWorkflowModel $workflow, string $eventKey, array $context): void
74    {
75        if (! $this->conditions->passes($workflow->conditions, $context)) {
76            $this->createSkippedExecution($workflow, $eventKey, $context, 'Condições não atendidas');
77
78            return;
79        }
80
81        $entityId = $this->resolveEntityId($context);
82        $fingerprint = $this->fingerprint($context);
83
84        if (! $this->allowNestedExecution($fingerprint)) {
85            $this->createSkippedExecution($workflow, $eventKey, $context, 'Limite de execuções aninhadas atingido');
86
87            return;
88        }
89
90        if ($entityId !== null && $this->isDuplicateRunning($workflow->id, $entityId)) {
91            $this->createSkippedExecution($workflow, $eventKey, $context, 'Execução duplicada recente ignorada');
92
93            return;
94        }
95
96        $startedAt = now();
97
98        $execution = DB::transaction(function () use ($workflow, $eventKey, $context, $startedAt, $entityId): AutomationExecutionModel {
99            $execution = AutomationExecutionModel::query()->create([
100                'tenant_id' => $workflow->tenant_id,
101                'workflow_id' => $workflow->id,
102                'trigger_event_key' => $eventKey,
103                'status' => ExecutionStatus::Running,
104                'context' => $context,
105                'started_at' => $startedAt,
106            ]);
107
108            if ($entityId !== null) {
109                Cache::put(
110                    $this->dedupeKey($workflow->id, $entityId),
111                    $execution->id,
112                    self::DEDUPE_SECONDS,
113                );
114            }
115
116            AutomationLogModel::query()->create([
117                'tenant_id' => $workflow->tenant_id,
118                'execution_id' => $execution->id,
119                'level' => LogLevel::Info,
120                'message' => 'Execução iniciada.',
121                'payload' => ['event_key' => $eventKey],
122                'created_at' => $startedAt,
123            ]);
124
125            return $execution;
126        });
127
128        RunWorkflowActionsJob::dispatchSync($execution->id, 0);
129    }
130
131    /**
132     * @param  array<string, mixed>  $context
133     */
134    private function createSkippedExecution(
135        AutomationWorkflowModel $workflow,
136        string $eventKey,
137        array $context,
138        string $reason,
139    ): void {
140        AutomationExecutionModel::query()->create([
141            'tenant_id' => $workflow->tenant_id,
142            'workflow_id' => $workflow->id,
143            'trigger_event_key' => $eventKey,
144            'status' => ExecutionStatus::Skipped,
145            'context' => $context,
146            'started_at' => now(),
147            'finished_at' => now(),
148            'error_message' => $reason,
149            'duration_ms' => 0,
150        ]);
151    }
152
153    private function extractTenantId(object $event): ?string
154    {
155        if (property_exists($event, 'tenantId')) {
156            return (string) $event->tenantId;
157        }
158
159        if (property_exists($event, 'tenant_id')) {
160            return (string) $event->tenant_id;
161        }
162
163        return null;
164    }
165
166    /**
167     * @return array<string, mixed>
168     */
169    private function reflectContext(object $event): array
170    {
171        $reflection = new ReflectionClass($event);
172        $context = [];
173
174        foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
175            if ($property->isStatic()) {
176                continue;
177            }
178
179            $context[$property->getName()] = $property->getValue($event);
180        }
181
182        return $context;
183    }
184
185    /**
186     * @param  array<string, mixed>  $context
187     */
188    private function resolveEntityId(array $context): ?string
189    {
190        foreach (['dealId', 'deal_id', 'visitId', 'visit_id', 'documentId', 'document_id', 'signatureRequestId', 'signature_request_id', 'ownerId', 'owner_id', 'id'] as $key) {
191            if (! empty($context[$key])) {
192                return (string) $context[$key];
193            }
194        }
195
196        return null;
197    }
198
199    /**
200     * @param  array<string, mixed>  $context
201     */
202    private function fingerprint(array $context): string
203    {
204        $relevant = array_intersect_key($context, array_flip([
205            'dealId', 'deal_id', 'visitId', 'visit_id', 'documentId', 'document_id',
206            'signatureRequestId', 'signature_request_id', 'ownerId', 'owner_id', '_event_key',
207        ]));
208
209        ksort($relevant);
210
211        return hash('sha256', json_encode($relevant));
212    }
213
214    private function allowNestedExecution(string $fingerprint): bool
215    {
216        $key = 'automation:nested:'.$fingerprint;
217        $count = (int) Cache::get($key, 0);
218
219        if ($count >= self::MAX_NESTED) {
220            return false;
221        }
222
223        Cache::put($key, $count + 1, 300);
224
225        return true;
226    }
227
228    private function isDuplicateRunning(string $workflowId, string $entityId): bool
229    {
230        return Cache::has($this->dedupeKey($workflowId, $entityId));
231    }
232
233    private function dedupeKey(string $workflowId, string $entityId): string
234    {
235        return 'automation:dedupe:'.$workflowId.':'.$entityId;
236    }
237
238    public static function markExecutionFinished(AutomationExecutionModel $execution, bool $failed = false, ?string $error = null): void
239    {
240        $finishedAt = now();
241        $duration = $execution->started_at
242            ? (int) $execution->started_at->diffInMilliseconds($finishedAt)
243            : 0;
244
245        $execution->forceFill([
246            'status' => $failed ? ExecutionStatus::Failed : ExecutionStatus::Succeeded,
247            'finished_at' => $finishedAt,
248            'duration_ms' => $duration,
249            'error_message' => $error,
250        ])->save();
251
252        if ($failed) {
253            WorkflowFailed::dispatch(
254                (string) $execution->tenant_id,
255                (string) $execution->workflow_id,
256                (string) $execution->id,
257                (string) $error,
258            );
259        } else {
260            WorkflowExecuted::dispatch(
261                (string) $execution->tenant_id,
262                (string) $execution->workflow_id,
263                (string) $execution->id,
264            );
265        }
266    }
267}