Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.83% covered (warning)
89.83%
106 / 118
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentMentionItemMapper
89.74% covered (warning)
89.74%
105 / 117
50.00% covered (danger)
50.00%
4 / 8
39.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
 resolveBatchTitles
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
4
 extractEntityIds
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 fetchEntityTitles
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
5.09
 mapRowToDto
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
10
 determineMatchedMention
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 formatRelativeTime
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 formatDayOrDate
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
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\Comments\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Comments\Application\DTO\CommentMentionItemDto;
12use PDO;
13use Throwable;
14
15/**
16 * Maps database comment mention rows into structured DTOs with resolved relational context titles.
17 *
18 * Resolves referenced companies, projects, tickets, matched mention tokens, and relative timestamps.
19 *
20 * @package App\Modules\Comments\Application\Service
21 */
22final readonly class CommentMentionItemMapper
23{
24    /**
25     * CommentMentionItemMapper constructor.
26     *
27     * @param PDO                         $pdo           Database connection.
28     * @param CommentMentionParserService $parserService Comment mention parser.
29     */
30    public function __construct(
31        private PDO $pdo,
32        private CommentMentionParserService $parserService
33    ) {
34    }
35
36    /**
37     * Resolves display titles for referenced companies, projects, tickets, and tasks in batch.
38     *
39     * @param array<int, array<string, mixed>> $rows
40     * @return array<string, string>
41     */
42    public function resolveBatchTitles(array $rows): array
43    {
44        $ids = $this->extractEntityIds($rows);
45        $titles = [];
46
47        if (!empty($ids['tickets'])) {
48            $titles = array_merge($titles, $this->fetchEntityTitles(
49                'c_mod_tickets_records',
50                'subject',
51                $ids['tickets'],
52                'tickets_',
53                'Ticket #'
54            ));
55        }
56
57        if (!empty($ids['companies'])) {
58            $titles = array_merge($titles, $this->fetchEntityTitles(
59                'c_mod_companies_records',
60                'name',
61                $ids['companies'],
62                'companies_',
63                'Company #'
64            ));
65        }
66
67        if (!empty($ids['projects'])) {
68            $titles = array_merge($titles, $this->fetchEntityTitles(
69                'c_mod_projects_records',
70                'project_name',
71                $ids['projects'],
72                'projects_',
73                'Project #'
74            ));
75        }
76
77        return $titles;
78    }
79
80    /**
81     * @param array<int, array<string, mixed>> $rows
82     * @return array<string, array<int, int>>
83     */
84    private function extractEntityIds(array $rows): array
85    {
86        $ticketIds = [];
87        $companyIds = [];
88        $projectIds = [];
89
90        foreach ($rows as $r) {
91            if (!empty($r['ticket_id'])) {
92                $ticketIds[] = (int) $r['ticket_id'];
93            }
94            if (!empty($r['company_id'])) {
95                $companyIds[] = (int) $r['company_id'];
96            }
97            if (!empty($r['project_id'])) {
98                $projectIds[] = (int) $r['project_id'];
99            }
100        }
101
102        return [
103            'tickets'   => array_values(array_unique($ticketIds)),
104            'companies' => array_values(array_unique($companyIds)),
105            'projects'  => array_values(array_unique($projectIds)),
106        ];
107    }
108
109    /**
110     * @param array<int, int> $ids
111     * @return array<string, string>
112     */
113    private function fetchEntityTitles(
114        string $table,
115        string $titleCol,
116        array $ids,
117        string $prefix,
118        string $defaultLabel
119    ): array {
120        if (empty($ids)) {
121            return [];
122        }
123
124        $titles = [];
125        try {
126            $in = implode(',', $ids);
127            $sql = "SELECT id, {$titleCol} FROM `{$table}` WHERE id IN ({$in})";
128            $stmt = $this->pdo->query($sql);
129            if ($stmt !== false) {
130                foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
131                    $rowId = (int) $row['id'];
132                    $title = (string) ($row[$titleCol] ?? ($defaultLabel . $rowId));
133                    $titles[$prefix . $rowId] = $title;
134                }
135            }
136        } catch (Throwable) {
137            // Table or column might not exist in all profiles
138        }
139
140        return $titles;
141    }
142
143    /**
144     * Maps a database row to CommentMentionItemDto.
145     *
146     * @param array<string, mixed>  $row
147     * @param array<string, mixed>  $userTokens
148     * @param int                   $watermarkTs
149     * @param int                   $userId
150     * @param array<string, string> $titles
151     */
152    public function mapRowToDto(
153        array $row,
154        array $userTokens,
155        int $watermarkTs,
156        int $userId,
157        array $titles
158    ): CommentMentionItemDto {
159        $id = (int) $row['id'];
160        $content = (string) ($row['content'] ?? '');
161        $created = (string) ($row['created_at'] ?? '');
162        $ownerId = (int) ($row['owner'] ?? 0);
163        $createdTs = strtotime($created);
164
165        $isRead = ($createdTs <= $watermarkTs) || ($ownerId === $userId);
166
167        $authorName = trim(($row['first_name'] ?? '') . ' ' . ($row['last_name'] ?? ''));
168        if ($authorName === '') {
169            $authorName = (string) ($row['username'] ?? 'User');
170        }
171
172        [$matchType, $matchLabel] = $this->determineMatchedMention($content, $userTokens);
173
174        $targetMod = (string) ($row['target_module'] ?? 'records');
175        $targetId = (int) ($row['target_record_id'] ?? 0);
176        $targetTitle = $titles[$targetMod . '_' . $targetId] ?? ('Record #' . $targetId);
177
178        $cid = !empty($row['company_id']) ? (int) $row['company_id'] : null;
179        $pid = !empty($row['project_id']) ? (int) $row['project_id'] : null;
180        $tid = !empty($row['ticket_id']) ? (int) $row['ticket_id'] : null;
181
182        return new CommentMentionItemDto(
183            id: $id,
184            content: $content,
185            formattedContent: $this->parserService->parseToHtml($content),
186            createdAt: $created,
187            createdAtRelative: $this->formatRelativeTime($created),
188            authorId: $ownerId,
189            authorName: $authorName,
190            authorAvatar: !empty($row['avatar_url']) ? (string) $row['avatar_url'] : null,
191            isRead: $isRead,
192            targetModule: $targetMod,
193            targetRecordId: $targetId,
194            targetRecordTitle: $targetTitle,
195            companyId: $cid,
196            companyTitle: $cid ? ($titles['companies_' . $cid] ?? ('Company #' . $cid)) : null,
197            projectId: $pid,
198            projectTitle: $pid ? ($titles['projects_' . $pid] ?? ('Project #' . $pid)) : null,
199            ticketId: $tid,
200            ticketTitle: $tid ? ($titles['tickets_' . $tid] ?? ('Ticket #' . $tid)) : null,
201            matchedMentionType: $matchType,
202            matchedMentionLabel: $matchLabel
203        );
204    }
205
206    /**
207     * Determines whether mention was direct user mention or structure node mention.
208     *
209     * @param string               $content    Raw comment text.
210     * @param array<string, mixed> $userTokens Resolved user tokens.
211     * @return array{0: string, 1: string}
212     */
213    public function determineMatchedMention(string $content, array $userTokens): array
214    {
215        $lower = mb_strtolower($content);
216        $directTokens = is_array($userTokens['direct'] ?? null) ? $userTokens['direct'] : [];
217        $structureTokens = is_array($userTokens['structure'] ?? null) ? $userTokens['structure'] : [];
218
219        foreach ($directTokens as $dt) {
220            if (str_contains($lower, '@' . mb_strtolower((string) $dt))) {
221                return ['direct_user', '@' . $dt];
222            }
223        }
224
225        foreach ($structureTokens as $st) {
226            if (str_contains($lower, '@' . mb_strtolower((string) $st))) {
227                return ['structure_node', 'Department: ' . str_replace('_', ' ', (string) $st)];
228            }
229        }
230
231        return ['direct_user', '@mention'];
232    }
233
234    /**
235     * Formats timestamp into friendly human relative string.
236     *
237     * @param string $datetime Raw ISO or SQL datetime.
238     * @return string Human-friendly relative label.
239     */
240    public function formatRelativeTime(string $datetime): string
241    {
242        $ts = strtotime($datetime);
243        $diff = time() - $ts;
244
245        if ($diff < 3600) {
246            return $diff < 60 ? 'just now' : ((int) floor($diff / 60)) . ' min ago';
247        }
248
249        return $this->formatDayOrDate($ts);
250    }
251
252    /**
253     * Formats day or date for older timestamps.
254     *
255     * @param int $ts Timestamp.
256     * @return string Formatted date string.
257     */
258    private function formatDayOrDate(int $ts): string
259    {
260        $commentDate = date('Y-m-d', $ts);
261        $today = date('Y-m-d');
262        $yesterday = date('Y-m-d', strtotime('-1 day'));
263
264        if ($commentDate === $today) {
265            return 'today ' . date('H:i', $ts);
266        }
267        if ($commentDate === $yesterday) {
268            return 'yesterday ' . date('H:i', $ts);
269        }
270
271        return date('Y-m-d H:i', $ts);
272    }
273}