Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
91.00% |
182 / 200 |
|
36.36% |
4 / 11 |
CRAP | |
0.00% |
0 / 1 |
| CommentMentionsQueryService | |
90.95% |
181 / 199 |
|
36.36% |
4 / 11 |
50.78 | |
0.00% |
0 / 1 |
| __construct | |
66.67% |
8 / 12 |
|
0.00% |
0 / 1 |
7.33 | |||
| getInbox | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| getUnreadCount | |
82.35% |
14 / 17 |
|
0.00% |
0 / 1 |
4.09 | |||
| markAllAsRead | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| markAsReadUpToComment | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
2 | |||
| fetchMentionItems | |
90.91% |
30 / 33 |
|
0.00% |
0 / 1 |
8.05 | |||
| getUserWatermark | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
4.25 | |||
| resolveUserMentionTokens | |
94.12% |
32 / 34 |
|
0.00% |
0 / 1 |
9.02 | |||
| buildTokenMatchSql | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| buildScopeCondition | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
7 | |||
| getCountsByScope | |
85.71% |
18 / 21 |
|
0.00% |
0 / 1 |
5.07 | |||
| 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 App\Modules\Comments\Application\DTO\CommentMentionsInboxDto; |
| 13 | use DateTimeImmutable; |
| 14 | use DateTimeZone; |
| 15 | use PDO; |
| 16 | use Throwable; |
| 17 | |
| 18 | /** |
| 19 | * Application service for querying and managing comment mentions (@) inbox. |
| 20 | * |
| 21 | * Implements watermark timestamp read tracking, time range scopes, and contextual record resolution. |
| 22 | * |
| 23 | * @package App\Modules\Comments\Application\Service |
| 24 | */ |
| 25 | final readonly class CommentMentionsQueryService implements CommentMentionsQueryServiceInterface |
| 26 | { |
| 27 | private string $tableComments; |
| 28 | private string $tableUsers; |
| 29 | private string $tableStructure; |
| 30 | private string $tableRelStructure; |
| 31 | private const string DATE_FORMAT_SQL = 'Y-m-d H:i:s'; |
| 32 | |
| 33 | private CommentMentionItemMapper $itemMapper; |
| 34 | |
| 35 | /** |
| 36 | * CommentMentionsQueryService constructor. |
| 37 | * |
| 38 | * @param PDO $pdo Active database connection. |
| 39 | * @param CommentMentionParserService $parserService Parser service. |
| 40 | * @param CommentMentionItemMapper|null $itemMapper Item mapper. |
| 41 | * @param string $tablePrefix Database table prefix (default 'c_'). |
| 42 | */ |
| 43 | public function __construct( |
| 44 | private PDO $pdo, |
| 45 | private CommentMentionParserService $parserService, |
| 46 | ?CommentMentionItemMapper $itemMapper = null, |
| 47 | private string $tablePrefix = 'c_' |
| 48 | ) { |
| 49 | if ($this->tablePrefix === 'c_') { |
| 50 | try { |
| 51 | $driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); |
| 52 | if ($driver === 'mysql') { |
| 53 | $chk = $this->pdo->query("SHOW TABLES LIKE 'a_mod_comments_records'"); |
| 54 | if ($chk !== false && $chk->fetchColumn() !== false) { |
| 55 | $this->tablePrefix = 'a_'; |
| 56 | } |
| 57 | } |
| 58 | } catch (Throwable) { |
| 59 | // Keep default prefix |
| 60 | } |
| 61 | } |
| 62 | $this->tableComments = $this->tablePrefix . 'mod_comments_records'; |
| 63 | $this->tableUsers = $this->tablePrefix . 'mod_users_records'; |
| 64 | $this->tableStructure = $this->tablePrefix . 'mod_structure_records'; |
| 65 | $this->tableRelStructure = $this->tablePrefix . 'rel_users_structure'; |
| 66 | $this->itemMapper = $itemMapper ?? new CommentMentionItemMapper($this->pdo, $this->parserService); |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Retrieves full Mentions Inbox DTO for the given user. |
| 71 | * |
| 72 | * @param int $userId Active authenticated user ID. |
| 73 | * @param string $scope Active scope tab. |
| 74 | * @param string $searchQuery Optional text filter. |
| 75 | * @param int $limit Max items to fetch. |
| 76 | * @param int $offset Pagination offset. |
| 77 | */ |
| 78 | public function getInbox( |
| 79 | int $userId, |
| 80 | string $scope = 'unread', |
| 81 | string $searchQuery = '', |
| 82 | int $limit = 50, |
| 83 | int $offset = 0 |
| 84 | ): CommentMentionsInboxDto { |
| 85 | $watermark = $this->getUserWatermark($userId); |
| 86 | $userTokens = $this->resolveUserMentionTokens($userId); |
| 87 | $counts = $this->getCountsByScope($userId, $userTokens, $watermark, $searchQuery); |
| 88 | $unreadCount = $counts['unread'] ?? 0; |
| 89 | |
| 90 | $items = $this->fetchMentionItems($userId, $userTokens, $watermark, $scope, $searchQuery, $limit, $offset); |
| 91 | |
| 92 | return new CommentMentionsInboxDto( |
| 93 | items: $items, |
| 94 | scope: $scope, |
| 95 | counts: $counts, |
| 96 | unreadCount: $unreadCount, |
| 97 | searchQuery: $searchQuery, |
| 98 | watermarkAt: $watermark |
| 99 | ); |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * Returns total unread mentions count for header notification badge. |
| 104 | */ |
| 105 | public function getUnreadCount(int $userId): int |
| 106 | { |
| 107 | try { |
| 108 | $watermark = $this->getUserWatermark($userId); |
| 109 | $userTokens = $this->resolveUserMentionTokens($userId); |
| 110 | |
| 111 | if (empty($userTokens['all'])) { |
| 112 | return 0; |
| 113 | } |
| 114 | |
| 115 | [$tokenSql, $tokenParams] = $this->buildTokenMatchSql($userTokens['all']); |
| 116 | |
| 117 | $sql = 'SELECT COUNT(DISTINCT c.id) FROM ' . $this->tableComments . ' c ' . |
| 118 | 'WHERE c.special_access = 1 AND c.owner != :uid ' . |
| 119 | 'AND c.created_at > :watermark AND (' . $tokenSql . ')'; |
| 120 | |
| 121 | $stmt = $this->pdo->prepare($sql); |
| 122 | $stmt->bindValue(':uid', $userId, PDO::PARAM_INT); |
| 123 | $stmt->bindValue(':watermark', $watermark ?? '1970-01-01 00:00:00.000000'); |
| 124 | foreach ($tokenParams as $k => $v) { |
| 125 | $stmt->bindValue($k, $v); |
| 126 | } |
| 127 | $stmt->execute(); |
| 128 | |
| 129 | return (int) $stmt->fetchColumn(); |
| 130 | } catch (Throwable) { |
| 131 | return 0; |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Marks all mentions as read up to current timestamp. |
| 137 | */ |
| 138 | public function markAllAsRead(int $userId): void |
| 139 | { |
| 140 | $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s.u'); |
| 141 | $sql = 'UPDATE ' . $this->tableUsers . ' ' . |
| 142 | 'SET comments_read_watermark_at = :now ' . |
| 143 | 'WHERE id = :uid'; |
| 144 | |
| 145 | $stmt = $this->pdo->prepare($sql); |
| 146 | $stmt->execute([':now' => $now, ':uid' => $userId]); |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * Marks mentions as read up to the creation date of a specific comment. |
| 151 | */ |
| 152 | public function markAsReadUpToComment(int $userId, int $commentId): void |
| 153 | { |
| 154 | $sqlComment = 'SELECT created_at FROM ' . $this->tableComments . ' WHERE id = :cid'; |
| 155 | $stmtC = $this->pdo->prepare($sqlComment); |
| 156 | $stmtC->execute([':cid' => $commentId]); |
| 157 | $targetCreatedAt = $stmtC->fetchColumn(); |
| 158 | if ($targetCreatedAt === false) { |
| 159 | return; |
| 160 | } |
| 161 | |
| 162 | $sql = 'UPDATE ' . $this->tableUsers . ' ' . |
| 163 | 'SET comments_read_watermark_at = CASE ' . |
| 164 | ' WHEN comments_read_watermark_at IS NULL THEN :target ' . |
| 165 | ' WHEN comments_read_watermark_at < :target2 THEN :target3 ' . |
| 166 | ' ELSE comments_read_watermark_at ' . |
| 167 | 'END ' . |
| 168 | 'WHERE id = :uid'; |
| 169 | |
| 170 | $stmt = $this->pdo->prepare($sql); |
| 171 | $stmt->execute([ |
| 172 | ':target' => (string) $targetCreatedAt, |
| 173 | ':target2' => (string) $targetCreatedAt, |
| 174 | ':target3' => (string) $targetCreatedAt, |
| 175 | ':uid' => $userId, |
| 176 | ]); |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * Fetches mention items for the given scope and search filter. |
| 181 | * |
| 182 | * @return array<int, CommentMentionItemDto> |
| 183 | */ |
| 184 | private function fetchMentionItems( |
| 185 | int $userId, |
| 186 | array $userTokens, |
| 187 | ?string $watermark, |
| 188 | string $scope, |
| 189 | string $searchQuery, |
| 190 | int $limit, |
| 191 | int $offset |
| 192 | ): array { |
| 193 | if (empty($userTokens['all'])) { |
| 194 | return []; |
| 195 | } |
| 196 | |
| 197 | [$tokenSql, $tokenParams] = $this->buildTokenMatchSql($userTokens['all']); |
| 198 | [$scopeSql, $scopeParams] = $this->buildScopeCondition($scope, $userId, $watermark); |
| 199 | |
| 200 | $searchSql = ''; |
| 201 | $searchParams = []; |
| 202 | if (trim($searchQuery) !== '') { |
| 203 | $searchSql = ' AND (c.content LIKE :sq OR u.username LIKE :sq OR u.first_name LIKE :sq)'; |
| 204 | $searchParams[':sq'] = '%' . trim($searchQuery) . '%'; |
| 205 | } |
| 206 | |
| 207 | $sql = 'SELECT c.id, c.content, c.created_at, c.owner, c.target_module, c.target_record_id, ' . |
| 208 | 'c.company_id, c.project_id, c.ticket_id, c.task_id, ' . |
| 209 | 'u.username, u.first_name, u.last_name, u.avatar_url ' . |
| 210 | 'FROM ' . $this->tableComments . ' c ' . |
| 211 | 'LEFT JOIN ' . $this->tableUsers . ' u ON u.id = c.owner ' . |
| 212 | 'WHERE c.special_access = 1 AND (' . $tokenSql . ')' . |
| 213 | $scopeSql . $searchSql . ' ' . |
| 214 | 'ORDER BY c.created_at DESC, c.id DESC ' . |
| 215 | 'LIMIT :limit OFFSET :offset'; |
| 216 | |
| 217 | $stmt = $this->pdo->prepare($sql); |
| 218 | $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); |
| 219 | $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); |
| 220 | |
| 221 | foreach (array_merge($tokenParams, $scopeParams, $searchParams) as $k => $v) { |
| 222 | $stmt->bindValue($k, $v); |
| 223 | } |
| 224 | $stmt->execute(); |
| 225 | |
| 226 | $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); |
| 227 | if (!is_array($rows) || empty($rows)) { |
| 228 | return []; |
| 229 | } |
| 230 | |
| 231 | $titles = $this->itemMapper->resolveBatchTitles($rows); |
| 232 | $watermarkTs = $watermark ? strtotime($watermark) : 0; |
| 233 | |
| 234 | $items = []; |
| 235 | foreach ($rows as $row) { |
| 236 | $items[] = $this->itemMapper->mapRowToDto($row, $userTokens, $watermarkTs, $userId, $titles); |
| 237 | } |
| 238 | |
| 239 | return $items; |
| 240 | } |
| 241 | |
| 242 | /** |
| 243 | * Resolves user watermark from database. |
| 244 | */ |
| 245 | private function getUserWatermark(int $userId): ?string |
| 246 | { |
| 247 | try { |
| 248 | $stmt = $this->pdo->prepare( |
| 249 | 'SELECT comments_read_watermark_at FROM ' . $this->tableUsers . ' WHERE id = :uid' |
| 250 | ); |
| 251 | $stmt->execute([':uid' => $userId]); |
| 252 | $val = $stmt->fetchColumn(); |
| 253 | |
| 254 | return is_string($val) && $val !== '' ? $val : null; |
| 255 | } catch (Throwable) { |
| 256 | return null; |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | /** |
| 261 | * Resolves all direct user tokens and associated structure tokens for the user. |
| 262 | * |
| 263 | * @return array{direct: array<int, string>, structure: array<int, string>, all: array<int, string>} |
| 264 | */ |
| 265 | private function resolveUserMentionTokens(int $userId): array |
| 266 | { |
| 267 | $directTokens = []; |
| 268 | try { |
| 269 | $stmtUser = $this->pdo->prepare( |
| 270 | 'SELECT username, first_name, last_name FROM ' . $this->tableUsers . ' WHERE id = :uid' |
| 271 | ); |
| 272 | $stmtUser->execute([':uid' => $userId]); |
| 273 | $userRow = $stmtUser->fetch(PDO::FETCH_ASSOC); |
| 274 | |
| 275 | if ($userRow && !empty($userRow['username'])) { |
| 276 | $directTokens[] = (string) $userRow['username']; |
| 277 | } |
| 278 | } catch (Throwable) { |
| 279 | // User table might not exist or differ in minimal contexts |
| 280 | } |
| 281 | |
| 282 | $structureTokens = []; |
| 283 | try { |
| 284 | $stmtStruct = $this->pdo->prepare( |
| 285 | 'SELECT s.name, s.code FROM ' . $this->tableStructure . ' s ' . |
| 286 | 'JOIN ' . $this->tableRelStructure . ' rel ON rel.structure_id = s.id ' . |
| 287 | "WHERE rel.user_id = :uid AND s.status = 'active' AND s.special_access = 1" |
| 288 | ); |
| 289 | $stmtStruct->execute([':uid' => $userId]); |
| 290 | $structRows = $stmtStruct->fetchAll(PDO::FETCH_ASSOC) ?: []; |
| 291 | |
| 292 | foreach ($structRows as $sRow) { |
| 293 | $name = trim((string) ($sRow['name'] ?? '')); |
| 294 | if ($name !== '') { |
| 295 | $structureTokens[] = $name; |
| 296 | $structureTokens[] = str_replace(' ', '_', $name); |
| 297 | } |
| 298 | $code = trim((string) ($sRow['code'] ?? '')); |
| 299 | if ($code !== '') { |
| 300 | $structureTokens[] = $code; |
| 301 | } |
| 302 | } |
| 303 | } catch (Throwable) { |
| 304 | // Structure relation table is only present in client instances |
| 305 | } |
| 306 | |
| 307 | $directTokens = array_values(array_unique($directTokens)); |
| 308 | $structureTokens = array_values(array_unique($structureTokens)); |
| 309 | $all = array_values(array_unique(array_merge($directTokens, $structureTokens))); |
| 310 | |
| 311 | return [ |
| 312 | 'direct' => $directTokens, |
| 313 | 'structure' => $structureTokens, |
| 314 | 'all' => $all, |
| 315 | ]; |
| 316 | } |
| 317 | |
| 318 | /** |
| 319 | * Builds SQL condition matching any of the given mention tokens. |
| 320 | * |
| 321 | * @param array<int, string> $tokens |
| 322 | * @return array{0: string, 1: array<string, string>} |
| 323 | */ |
| 324 | private function buildTokenMatchSql(array $tokens): array |
| 325 | { |
| 326 | $clauses = []; |
| 327 | $params = []; |
| 328 | foreach ($tokens as $idx => $token) { |
| 329 | $param = ':tok_' . $idx; |
| 330 | $clauses[] = 'c.content LIKE ' . $param; |
| 331 | $params[$param] = '%@' . $token . '%'; |
| 332 | } |
| 333 | |
| 334 | return [implode(' OR ', $clauses), $params]; |
| 335 | } |
| 336 | |
| 337 | /** |
| 338 | * Builds SQL time scope conditions and parameters. |
| 339 | * |
| 340 | * @return array{0: string, 1: array<string, mixed>} |
| 341 | */ |
| 342 | private function buildScopeCondition(string $scope, int $userId, ?string $watermark): array |
| 343 | { |
| 344 | $now = new DateTimeImmutable('now', new DateTimeZone('Europe/Warsaw')); |
| 345 | $todayStart = $now->setTime(0, 0, 0)->format(self::DATE_FORMAT_SQL); |
| 346 | $yesterdayStart = $now->modify('-1 day')->setTime(0, 0, 0)->format(self::DATE_FORMAT_SQL); |
| 347 | $weekStart = $now->modify('monday this week')->setTime(0, 0, 0)->format(self::DATE_FORMAT_SQL); |
| 348 | $monthStart = $now->modify('first day of this month')->setTime(0, 0, 0)->format(self::DATE_FORMAT_SQL); |
| 349 | |
| 350 | return match ($scope) { |
| 351 | 'unread' => [ |
| 352 | ' AND c.created_at > :wm AND c.owner != :cur_uid', |
| 353 | [':wm' => $watermark ?? '1970-01-01 00:00:00.000000', ':cur_uid' => $userId], |
| 354 | ], |
| 355 | 'today' => [ |
| 356 | ' AND c.created_at >= :today_st', |
| 357 | [':today_st' => $todayStart], |
| 358 | ], |
| 359 | 'yesterday' => [ |
| 360 | ' AND c.created_at >= :yest_st AND c.created_at < :today_st', |
| 361 | [':yest_st' => $yesterdayStart, ':today_st' => $todayStart], |
| 362 | ], |
| 363 | 'this_week' => [ |
| 364 | ' AND c.created_at >= :week_st', |
| 365 | [':week_st' => $weekStart], |
| 366 | ], |
| 367 | 'this_month' => [ |
| 368 | ' AND c.created_at >= :month_st', |
| 369 | [':month_st' => $monthStart], |
| 370 | ], |
| 371 | default => ['', []], |
| 372 | }; |
| 373 | } |
| 374 | |
| 375 | /** |
| 376 | * Calculates mention counts across all scope tabs. |
| 377 | * |
| 378 | * @return array<string, int> |
| 379 | */ |
| 380 | private function getCountsByScope( |
| 381 | int $userId, |
| 382 | array $userTokens, |
| 383 | ?string $watermark, |
| 384 | string $searchQuery |
| 385 | ): array { |
| 386 | if (empty($userTokens['all'])) { |
| 387 | return ['unread' => 0, 'today' => 0, 'yesterday' => 0, 'this_week' => 0, 'this_month' => 0, 'all' => 0]; |
| 388 | } |
| 389 | |
| 390 | $scopes = ['unread', 'today', 'yesterday', 'this_week', 'this_month', 'all']; |
| 391 | [$tokenSql, $tokenParams] = $this->buildTokenMatchSql($userTokens['all']); |
| 392 | |
| 393 | $searchSql = ''; |
| 394 | $searchParams = []; |
| 395 | if (trim($searchQuery) !== '') { |
| 396 | $searchSql = ' AND (c.content LIKE :sqc OR u.username LIKE :sqc)'; |
| 397 | $searchParams[':sqc'] = '%' . trim($searchQuery) . '%'; |
| 398 | } |
| 399 | |
| 400 | $counts = []; |
| 401 | foreach ($scopes as $s) { |
| 402 | [$scopeSql, $scopeParams] = $this->buildScopeCondition($s, $userId, $watermark); |
| 403 | $sql = 'SELECT COUNT(DISTINCT c.id) FROM ' . $this->tableComments . ' c ' . |
| 404 | 'LEFT JOIN ' . $this->tableUsers . ' u ON u.id = c.owner ' . |
| 405 | 'WHERE c.special_access = 1 AND (' . $tokenSql . ')' . |
| 406 | $scopeSql . $searchSql; |
| 407 | |
| 408 | $stmt = $this->pdo->prepare($sql); |
| 409 | foreach (array_merge($tokenParams, $scopeParams, $searchParams) as $k => $v) { |
| 410 | $stmt->bindValue($k, $v); |
| 411 | } |
| 412 | $stmt->execute(); |
| 413 | $counts[$s] = (int) $stmt->fetchColumn(); |
| 414 | } |
| 415 | |
| 416 | return $counts; |
| 417 | } |
| 418 | |
| 419 | } |