Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.33% covered (success)
98.33%
177 / 180
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmailContextCrmResolver
98.32% covered (success)
98.32%
176 / 179
72.73% covered (warning)
72.73%
8 / 11
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
 resolveContact
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 resolveCompany
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 resolveSubprocess
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
8
 resolveProcess
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 resolveCompanyProjectOrContract
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
3
 fetchCompanyRecord
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 fetchContactRecord
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 fetchTicketRecord
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 fetchProjectRecord
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 fetchContractRecord
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
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\Mail\Application\Service\Association;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use PDO;
12
13/**
14 * Enterprise Email Context CRM Entity Resolver.
15 *
16 * Discovers and queries related CRM entities (Company, Contact, Ticket, Contract, Project)
17 * based on candidate email addresses, RFC subject markers, and relational links.
18 *
19 * @package App\Modules\Mail\Application\Service\Association
20 */
21final readonly class EmailContextCrmResolver
22{
23    private const string ROUTE_COMPANIES = '/companies/';
24    private const string ROUTE_TICKETS = '/tickets/';
25    private const string ROUTE_CONTRACTS = '/contracts/';
26    /**
27     * EmailContextCrmResolver constructor.
28     *
29     * @param PDO    $pdo         Database connection handle.
30     * @param string $tablePrefix Database table prefix (e.g. 'a_').
31     */
32    public function __construct(
33        private PDO $pdo,
34        private string $tablePrefix = 'a_'
35    ) {
36    }
37
38    /**
39     * Resolves contact matching any of the candidate email addresses.
40     *
41     * @param array<string> $addresses Candidate email addresses.
42     * @return array{id: int, name: string, email: string, url: string}|null
43     */
44    public function resolveContact(array $addresses): ?array
45    {
46        if ($addresses === []) {
47            return null;
48        }
49
50        $table = $this->tablePrefix . 'mod_contacts_records';
51        $sql = "SELECT `id`, `first_name`, `last_name`, `email` "
52            . "FROM `{$table}` WHERE (`email` = :e1 OR `secondary_email` = :e2) LIMIT 1";
53
54        $stmt = $this->pdo->prepare($sql);
55        foreach ($addresses as $email) {
56            $stmt->execute([':e1' => $email, ':e2' => $email]);
57            $row = $stmt->fetch(PDO::FETCH_ASSOC);
58            if ($row !== false) {
59                $id = (int) $row['id'];
60                $name = trim($row['first_name'] . ' ' . $row['last_name']);
61                return [
62                    'id'    => $id,
63                    'name'  => $name !== '' ? $name : (string) $row['email'],
64                    'email' => (string) $row['email'],
65                    'url'   => '/contacts/' . $id,
66                ];
67            }
68        }
69
70        return null;
71    }
72
73    /**
74     * Resolves company matching via contact relationship or email address.
75     *
76     * @param int|null      $contactId    Resolved contact ID.
77     * @param array<string> $allAddresses Candidate email addresses.
78     * @return array{id: int, name: string, url: string}|null
79     */
80    public function resolveCompany(?int $contactId, array $allAddresses): ?array
81    {
82        $cmpTable = $this->tablePrefix . 'mod_companies_records';
83
84        if ($contactId !== null && $contactId > 0) {
85            $relTable = $this->tablePrefix . 'rel_companies_contacts_records';
86            $sql = "SELECT c.`id`, c.`name` FROM `{$relTable}` r "
87                . "JOIN `{$cmpTable}` c ON c.`id` = r.`company_id` "
88                . "WHERE r.`contact_id` = :cid LIMIT 1";
89            $stmt = $this->pdo->prepare($sql);
90            $stmt->execute([':cid' => $contactId]);
91            $row = $stmt->fetch(PDO::FETCH_ASSOC);
92            if ($row !== false) {
93                $id = (int) $row['id'];
94                return ['id' => $id, 'name' => (string) $row['name'], 'url' => self::ROUTE_COMPANIES . $id];
95            }
96        }
97
98        $sql = "SELECT `id`, `name` FROM `{$cmpTable}` WHERE `email` = :em LIMIT 1";
99        $stmt = $this->pdo->prepare($sql);
100        foreach ($allAddresses as $email) {
101            $stmt->execute([':em' => $email]);
102            $row = $stmt->fetch(PDO::FETCH_ASSOC);
103            if ($row !== false) {
104                $id = (int) $row['id'];
105                return ['id' => $id, 'name' => (string) $row['name'], 'url' => self::ROUTE_COMPANIES . $id];
106            }
107        }
108
109        return null;
110    }
111
112    /**
113     * Resolves subprocess (support ticket or task) by prefix or company link.
114     *
115     * @param string   $subject   Email subject line.
116     * @param string   $textBody  Email text body.
117     * @param int|null $companyId Linked company ID.
118     * @return array{id: int, type: string, name: string, url: string, contract_id: ?int, project_id: ?int}|null
119     */
120    public function resolveSubprocess(string $subject, string $textBody, ?int $companyId): ?array
121    {
122        $tckTable = $this->tablePrefix . 'mod_tickets_records';
123
124        if (preg_match('/(?:\[#|\b)(TICK-\d{4,5}(?:-\d+)?)(?:\]|\b)/i', $subject . ' ' . $textBody, $m)) {
125            $stmt = $this->pdo->prepare(
126                "SELECT `id`, `ticket_no`, `subject`, `contract_id` FROM `{$tckTable}"
127                . "WHERE `ticket_no` = :no LIMIT 1"
128            );
129            $stmt->execute([':no' => strtoupper($m[1])]);
130            $row = $stmt->fetch(PDO::FETCH_ASSOC);
131            if ($row !== false) {
132                $id = (int) $row['id'];
133                return [
134                    'id'          => $id,
135                    'type'        => 'ticket',
136                    'name'        => $row['ticket_no'] . ' ' . $row['subject'],
137                    'url'         => self::ROUTE_TICKETS . $id,
138                    'contract_id' => !empty($row['contract_id']) ? (int) $row['contract_id'] : null,
139                    'project_id'  => null,
140                ];
141            }
142        }
143
144        if ($companyId !== null && $companyId > 0) {
145            $stmt = $this->pdo->prepare(
146                "SELECT `id`, `ticket_no`, `subject`, `contract_id` FROM `{$tckTable}"
147                . "WHERE `company_id` = :cid ORDER BY `id` DESC LIMIT 1"
148            );
149            $stmt->execute([':cid' => $companyId]);
150            $row = $stmt->fetch(PDO::FETCH_ASSOC);
151            if ($row !== false) {
152                $id = (int) $row['id'];
153                return [
154                    'id'          => $id,
155                    'type'        => 'ticket',
156                    'name'        => $row['ticket_no'] . ' ' . $row['subject'],
157                    'url'         => self::ROUTE_TICKETS . $id,
158                    'contract_id' => !empty($row['contract_id']) ? (int) $row['contract_id'] : null,
159                    'project_id'  => null,
160                ];
161            }
162        }
163
164        return null;
165    }
166
167    /**
168     * Resolves process (project or contract) linked to ticket or company.
169     *
170     * @param array<string, mixed>|null $subprocess Resolved subprocess.
171     * @param int|null                  $companyId  Linked company ID.
172     * @return array{id: int, type: string, name: string, url: string}|null
173     */
174    public function resolveProcess(?array $subprocess, ?int $companyId): ?array
175    {
176        if (!empty($subprocess['contract_id'])) {
177            $contract = $this->fetchContractRecord((int) $subprocess['contract_id']);
178            if ($contract !== null) {
179                return $contract;
180            }
181        }
182
183        if ($companyId !== null && $companyId > 0) {
184            return $this->resolveCompanyProjectOrContract($companyId);
185        }
186
187        return null;
188    }
189
190    private function resolveCompanyProjectOrContract(int $companyId): ?array
191    {
192        $prjTable = $this->tablePrefix . 'mod_projects_records';
193        $stmt = $this->pdo->prepare(
194            "SELECT `id`, `project_name` FROM `{$prjTable}` WHERE `company_id` = :cid "
195            . "ORDER BY `id` DESC LIMIT 1"
196        );
197        $stmt->execute([':cid' => $companyId]);
198        $row = $stmt->fetch(PDO::FETCH_ASSOC);
199        if ($row !== false) {
200            $id = (int) $row['id'];
201            return [
202                'id'   => $id,
203                'type' => 'project',
204                'name' => (string) $row['project_name'],
205                'url'  => '/projects/' . $id,
206            ];
207        }
208
209        $ctrTable = $this->tablePrefix . 'mod_contracts_records';
210        $stmt = $this->pdo->prepare(
211            "SELECT `id`, `contract_name` FROM `{$ctrTable}` WHERE `company_id` = :cid "
212            . "ORDER BY `id` DESC LIMIT 1"
213        );
214        $stmt->execute([':cid' => $companyId]);
215        $row = $stmt->fetch(PDO::FETCH_ASSOC);
216        if ($row !== false) {
217            $id = (int) $row['id'];
218            return [
219                'id'   => $id,
220                'type' => 'contract',
221                'name' => (string) $row['contract_name'],
222                'url'  => self::ROUTE_CONTRACTS . $id,
223            ];
224        }
225
226        return null;
227    }
228
229    /**
230     * Fetches company record by primary key.
231     *
232     * @param int $id Company record ID.
233     * @return array{id: int, name: string, url: string}|null
234     */
235    public function fetchCompanyRecord(int $id): ?array
236    {
237        $table = $this->tablePrefix . 'mod_companies_records';
238        $stmt = $this->pdo->prepare("SELECT `id`, `name` FROM `{$table}` WHERE `id` = :id LIMIT 1");
239        $stmt->execute([':id' => $id]);
240        $row = $stmt->fetch(PDO::FETCH_ASSOC);
241        if ($row === false) {
242            return null;
243        }
244
245        return ['id' => (int) $row['id'], 'name' => (string) $row['name'], 'url' => self::ROUTE_COMPANIES . $id];
246    }
247
248    /**
249     * Fetches contact record by primary key.
250     *
251     * @param int $id Contact record ID.
252     * @return array{id: int, name: string, email: string, url: string}|null
253     */
254    public function fetchContactRecord(int $id): ?array
255    {
256        $table = $this->tablePrefix . 'mod_contacts_records';
257        $stmt = $this->pdo->prepare(
258            "SELECT `id`, `first_name`, `last_name`, `email` FROM `{$table}` WHERE `id` = :id LIMIT 1"
259        );
260        $stmt->execute([':id' => $id]);
261        $row = $stmt->fetch(PDO::FETCH_ASSOC);
262        if ($row === false) {
263            return null;
264        }
265        $name = trim($row['first_name'] . ' ' . $row['last_name']);
266
267        return [
268            'id'    => (int) $row['id'],
269            'name'  => $name !== '' ? $name : (string) $row['email'],
270            'email' => (string) $row['email'],
271            'url'   => '/contacts/' . $id,
272        ];
273    }
274
275    /**
276     * Fetches ticket record by primary key.
277     *
278     * @param int $id Ticket record ID.
279     * @return array{id: int, type: string, name: string, url: string}|null
280     */
281    public function fetchTicketRecord(int $id): ?array
282    {
283        $table = $this->tablePrefix . 'mod_tickets_records';
284        $stmt = $this->pdo->prepare(
285            "SELECT `id`, `ticket_no`, `subject` FROM `{$table}` WHERE `id` = :id LIMIT 1"
286        );
287        $stmt->execute([':id' => $id]);
288        $row = $stmt->fetch(PDO::FETCH_ASSOC);
289        if ($row === false) {
290            return null;
291        }
292
293        return [
294            'id'   => (int) $row['id'],
295            'type' => 'ticket',
296            'name' => $row['ticket_no'] . ' ' . $row['subject'],
297            'url'  => self::ROUTE_TICKETS . $id,
298        ];
299    }
300
301    /**
302     * Fetches project record by primary key.
303     *
304     * @param int $id Project record ID.
305     * @return array{id: int, type: string, name: string, url: string}|null
306     */
307    public function fetchProjectRecord(int $id): ?array
308    {
309        $table = $this->tablePrefix . 'mod_projects_records';
310        $stmt = $this->pdo->prepare(
311            "SELECT `id`, `project_name` FROM `{$table}` WHERE `id` = :id LIMIT 1"
312        );
313        $stmt->execute([':id' => $id]);
314        $row = $stmt->fetch(PDO::FETCH_ASSOC);
315        if ($row === false) {
316            return null;
317        }
318
319        return [
320            'id'   => (int) $row['id'],
321            'type' => 'project',
322            'name' => (string) $row['project_name'],
323            'url'  => '/projects/' . $id,
324        ];
325    }
326
327    /**
328     * Fetches contract record by primary key.
329     *
330     * @param int $id Contract record ID.
331     * @return array{id: int, type: string, name: string, url: string}|null
332     */
333    public function fetchContractRecord(int $id): ?array
334    {
335        $table = $this->tablePrefix . 'mod_contracts_records';
336        $stmt = $this->pdo->prepare(
337            "SELECT `id`, `contract_name` FROM `{$table}` WHERE `id` = :id LIMIT 1"
338        );
339        $stmt->execute([':id' => $id]);
340        $row = $stmt->fetch(PDO::FETCH_ASSOC);
341        if ($row === false) {
342            return null;
343        }
344
345        return [
346            'id'   => (int) $row['id'],
347            'type' => 'contract',
348            'name' => (string) $row['contract_name'],
349            'url'  => self::ROUTE_CONTRACTS . $id,
350        ];
351    }
352}