Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
89.83% |
106 / 118 |
|
50.00% |
4 / 8 |
CRAP | |
0.00% |
0 / 1 |
| CommentMentionItemMapper | |
89.74% |
105 / 117 |
|
50.00% |
4 / 8 |
39.56 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| resolveBatchTitles | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
4 | |||
| extractEntityIds | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
5 | |||
| fetchEntityTitles | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
5.09 | |||
| mapRowToDto | |
97.37% |
37 / 38 |
|
0.00% |
0 / 1 |
10 | |||
| determineMatchedMention | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
7 | |||
| formatRelativeTime | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
| formatDayOrDate | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Comments\Application\Service; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Modules\Comments\Application\DTO\CommentMentionItemDto; |
| 12 | use PDO; |
| 13 | use 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 | */ |
| 22 | final 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 | } |