Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.00% covered (success)
91.00%
182 / 200
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentMentionsQueryService
90.95% covered (success)
90.95%
181 / 199
36.36% covered (danger)
36.36%
4 / 11
50.78
0.00% covered (danger)
0.00%
0 / 1
 __construct
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
7.33
 getInbox
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 getUnreadCount
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
4.09
 markAllAsRead
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 markAsReadUpToComment
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
2
 fetchMentionItems
90.91% covered (success)
90.91%
30 / 33
0.00% covered (danger)
0.00%
0 / 1
8.05
 getUserWatermark
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 resolveUserMentionTokens
94.12% covered (success)
94.12%
32 / 34
0.00% covered (danger)
0.00%
0 / 1
9.02
 buildTokenMatchSql
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 buildScopeCondition
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
7
 getCountsByScope
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
5.07
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 App\Modules\Comments\Application\DTO\CommentMentionsInboxDto;
13use DateTimeImmutable;
14use DateTimeZone;
15use PDO;
16use 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 */
25final 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}