Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.08% covered (warning)
77.08%
148 / 192
75.00% covered (warning)
75.00%
15 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentsApiController
76.96% covered (warning)
76.96%
147 / 191
75.00% covered (warning)
75.00%
15 / 20
101.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
 actionMentions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 actionMentionsUnreadCount
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 actionMentionsMarkAllRead
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 actionMentionsMarkRead
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 actionStream
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 actionCreate
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 actionTogglePin
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 actionToggleVerify
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 actionDelete
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 actionAutocomplete
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 searchMentions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 resolveStructureMentions
9.52% covered (danger)
9.52%
2 / 21
0.00% covered (danger)
0.00%
0 / 1
15.85
 resolveStructureTable
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 fetchStructureNodes
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 resolveUserMentions
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 searchRecords
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
8.06
 jsonSuccess
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 jsonError
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 parseJsonBody
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
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\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\PermissionContext;
12use App\Core\Search\Application\Service\GlobalSearchServiceInterface;
13use App\Core\Search\Domain\Model\SearchMode;
14use App\Core\Search\Domain\Model\SearchQuery;
15use App\Modules\Comments\Application\DTO\CreateCommentDto;
16use App\Modules\Comments\Application\Service\CommentMentionsQueryServiceInterface;
17use App\Modules\Comments\Application\Service\CommentServiceInterface;
18use App\Modules\Comments\Domain\Model\CommentFilterScope;
19use App\Modules\User\Domain\Repository\UserRepositoryInterface;
20use PDO;
21use Psr\Http\Message\ResponseFactoryInterface;
22use Psr\Http\Message\ResponseInterface;
23use Psr\Http\Message\ServerRequestInterface;
24use Throwable;
25
26/**
27 * REST API Controller providing JSON endpoints for conversation timeline integration.
28 *
29 * @package App\Modules\Comments\Presentation\Api
30 */
31final readonly class CommentsApiController
32{
33    private const ERR_MENTIONS_UNAVAILABLE = 'Mentions service unavailable';
34
35    /**
36     * CommentsApiController constructor.
37     */
38    public function __construct(
39        private CommentServiceInterface $commentService,
40        private ResponseFactoryInterface $responseFactory,
41        private ?UserRepositoryInterface $userRepository = null,
42        private ?GlobalSearchServiceInterface $searchService = null,
43        private ?CommentMentionsQueryServiceInterface $mentionsQueryService = null,
44        private ?PDO $pdo = null
45    ) {
46    }
47
48    /**
49     * Handles GET /api/v1/comments/mentions
50     */
51    public function actionMentions(ServerRequestInterface $request, PermissionContext $ctx): ResponseInterface
52    {
53        if ($this->mentionsQueryService === null) {
54            return $this->jsonError(self::ERR_MENTIONS_UNAVAILABLE, 503);
55        }
56
57        try {
58            $params = $request->getQueryParams();
59            $scope = (string) ($params['scope'] ?? 'unread');
60            $query = (string) ($params['q'] ?? '');
61            $limit = max(1, min(100, (int) ($params['limit'] ?? 50)));
62            $offset = max(0, (int) ($params['offset'] ?? 0));
63
64            $inbox = $this->mentionsQueryService->getInbox($ctx->actorUserId, $scope, $query, $limit, $offset);
65
66            return $this->jsonSuccess($inbox->toArray());
67        } catch (Throwable $e) {
68            return $this->jsonError($e->getMessage(), 400);
69        }
70    }
71
72    /**
73     * Handles GET /api/v1/comments/mentions/unread-count
74     */
75    public function actionMentionsUnreadCount(PermissionContext $ctx): ResponseInterface
76    {
77        if ($this->mentionsQueryService === null) {
78            return $this->jsonSuccess(['count' => 0]);
79        }
80
81        try {
82            $count = $this->mentionsQueryService->getUnreadCount($ctx->actorUserId);
83            return $this->jsonSuccess(['count' => $count]);
84        } catch (Throwable) {
85            return $this->jsonSuccess(['count' => 0]);
86        }
87    }
88
89    /**
90     * Handles POST /api/v1/comments/mentions/mark-all-read
91     */
92    public function actionMentionsMarkAllRead(PermissionContext $ctx): ResponseInterface
93    {
94        if ($this->mentionsQueryService === null) {
95            return $this->jsonError(self::ERR_MENTIONS_UNAVAILABLE, 503);
96        }
97
98        try {
99            $this->mentionsQueryService->markAllAsRead($ctx->actorUserId);
100            return $this->jsonSuccess(['message' => 'All mentions marked as read']);
101        } catch (Throwable $e) {
102            return $this->jsonError($e->getMessage(), 400);
103        }
104    }
105
106    /**
107     * Handles POST /api/v1/comments/mentions/{id}/mark-read
108     */
109    public function actionMentionsMarkRead(int $commentId, PermissionContext $ctx): ResponseInterface
110    {
111        if ($this->mentionsQueryService === null) {
112            return $this->jsonError(self::ERR_MENTIONS_UNAVAILABLE, 503);
113        }
114
115        try {
116            $this->mentionsQueryService->markAsReadUpToComment($ctx->actorUserId, $commentId);
117            return $this->jsonSuccess(['message' => 'Mention marked as read']);
118        } catch (Throwable $e) {
119            return $this->jsonError($e->getMessage(), 400);
120        }
121    }
122
123    /**
124     * Handles GET /api/v1/comments/{module}/{id}
125     */
126    public function actionStream(
127        ServerRequestInterface $request,
128        string $module,
129        int $id,
130        PermissionContext $ctx
131    ): ResponseInterface {
132        try {
133            $params = $request->getQueryParams();
134            $scope = CommentFilterScope::fromValue($params['scope'] ?? 'all');
135            $limit = max(1, min(200, (int) ($params['limit'] ?? 100)));
136            $offset = max(0, (int) ($params['offset'] ?? 0));
137
138            $stream = $this->commentService->getStream(
139                module:        $module,
140                recordId:      $id,
141                scope:         $scope,
142                currentUserId: $ctx->actorUserId,
143                limit:         $limit,
144                offset:        $offset
145            );
146
147            return $this->jsonSuccess($stream->toArray());
148        } catch (Throwable $e) {
149            return $this->jsonError($e->getMessage(), 400);
150        }
151    }
152
153    /**
154     * Handles POST /api/v1/comments/{module}/{id}
155     */
156    public function actionCreate(
157        ServerRequestInterface $request,
158        string $module,
159        int $id,
160        PermissionContext $ctx
161    ): ResponseInterface {
162        try {
163            $body = $this->parseJsonBody($request);
164            $content = (string) ($body['content'] ?? '');
165            $parentId = !empty($body['parent_id']) ? (int) $body['parent_id'] : null;
166            $isPinned = (bool) ($body['is_pinned'] ?? false);
167            $isVerified = (bool) ($body['is_verified'] ?? false);
168
169            $dto = new CreateCommentDto(
170                targetModule:   $module,
171                targetRecordId: $id,
172                content:        $content,
173                parentId:       $parentId,
174                isPinned:       $isPinned,
175                isVerified:     $isVerified,
176                attachments:    []
177            );
178
179            $authorName = (string) ($body['author_name'] ?? 'User');
180            $comment = $this->commentService->createComment($dto, $ctx->actorUserId, $authorName);
181
182            return $this->jsonSuccess($comment->toArray(), 201);
183        } catch (Throwable $e) {
184            return $this->jsonError($e->getMessage(), 422);
185        }
186    }
187
188    /**
189     * Handles POST /api/v1/comments/{id}/toggle-pin
190     */
191    public function actionTogglePin(int $id): ResponseInterface
192    {
193        try {
194            $newState = $this->commentService->togglePin($id);
195
196            return $this->jsonSuccess(['id' => $id, 'is_pinned' => $newState]);
197        } catch (Throwable $e) {
198            return $this->jsonError($e->getMessage(), 404);
199        }
200    }
201
202    /**
203     * Handles POST /api/v1/comments/{id}/toggle-verify
204     */
205    public function actionToggleVerify(int $id): ResponseInterface
206    {
207        try {
208            $newState = $this->commentService->toggleVerify($id);
209
210            return $this->jsonSuccess(['id' => $id, 'is_verified' => $newState]);
211        } catch (Throwable $e) {
212            return $this->jsonError($e->getMessage(), 404);
213        }
214    }
215
216    /**
217     * Handles DELETE /api/v1/comments/{id}
218     */
219    public function actionDelete(int $id, PermissionContext $ctx): ResponseInterface
220    {
221        try {
222            $success = $this->commentService->deleteComment($id, $ctx->actorUserId, $ctx->isSuperuser);
223            if (!$success) {
224                return $this->jsonError('Permission denied to delete comment.', 403);
225            }
226
227            return $this->jsonSuccess(['id' => $id, 'deleted' => true]);
228        } catch (Throwable $e) {
229            return $this->jsonError($e->getMessage(), 404);
230        }
231    }
232
233    /**
234     * Handles GET /api/v1/comments/autocomplete
235     *
236     * Returns matching users for @mentions or matching records for #record references.
237     */
238    public function actionAutocomplete(
239        ServerRequestInterface $request,
240        PermissionContext $ctx
241    ): ResponseInterface {
242        $params = $request->getQueryParams();
243        $type = (string) ($params['type'] ?? 'mention');
244        $query = trim((string) ($params['q'] ?? ''));
245        $limit = max(1, min(25, (int) ($params['limit'] ?? 8)));
246
247        if ($type === 'record' || $type === 'hash') {
248            return $this->searchRecords($query, $limit, $ctx);
249        }
250
251        return $this->searchMentions($query, $limit);
252    }
253
254    /**
255     * Searches active users and organizational structure nodes for @mention autocompletion.
256     */
257    private function searchMentions(string $query, int $limit): ResponseInterface
258    {
259        $results = $this->resolveStructureMentions($query);
260        $userLimit = max(1, $limit - count($results));
261        $userResults = $this->resolveUserMentions($query, $userLimit);
262
263        return $this->jsonSuccess(array_merge($results, $userResults));
264    }
265
266    /**
267     * @return array<int, array<string, mixed>>
268     */
269    private function resolveStructureMentions(string $query): array
270    {
271        if ($this->pdo === null) {
272            return [];
273        }
274
275        $structTable = $this->resolveStructureTable();
276        if ($structTable === null) {
277            return [];
278        }
279
280        $nodes = $this->fetchStructureNodes($structTable, $query);
281        $results = [];
282        foreach ($nodes as $node) {
283            $name = (string) ($node['name'] ?? '');
284            $results[] = [
285                'id'           => 'struct_' . $node['id'],
286                'username'     => $name,
287                'name'         => $name,
288                'email'        => 'Department / Team',
289                'initials'     => 'DZ',
290                'icon_class'   => 'bi bi-diagram-3-fill',
291                'is_structure' => true,
292                'tag'          => '@' . str_replace(' ', '_', $name),
293                'label'        => $name . ' (Department)',
294            ];
295        }
296
297        return $results;
298    }
299
300    private function resolveStructureTable(): ?string
301    {
302        if ($this->pdo === null) {
303            return null;
304        }
305
306        foreach (['c_mod_structure_records', 'a_mod_structure_records'] as $cand) {
307            try {
308                $chk = $this->pdo->query("SELECT 1 FROM `{$cand}` LIMIT 1");
309                if ($chk !== false) {
310                    return $cand;
311                }
312            } catch (\Throwable) {
313                // Table does not exist, check next candidate
314            }
315        }
316
317        return null;
318    }
319
320    /**
321     * @return array<int, array<string, mixed>>
322     */
323    private function fetchStructureNodes(string $structTable, string $query): array
324    {
325        if ($this->pdo === null) {
326            return [];
327        }
328
329        $structSql = "SELECT id, name, code, structure_type FROM `{$structTable}" .
330                     "WHERE status = 'active' AND special_access = 1 ";
331        $structParams = [];
332        if ($query !== '') {
333            $structSql .= 'AND (name LIKE :sq OR code LIKE :sq) ';
334            $structParams[':sq'] = '%' . $query . '%';
335        }
336        $structSql .= 'ORDER BY sort_order ASC, name ASC LIMIT 4';
337
338        $stmt = $this->pdo->prepare($structSql);
339        $stmt->execute($structParams);
340
341        return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
342    }
343
344    /**
345     * @return array<int, array<string, mixed>>
346     */
347    private function resolveUserMentions(string $query, int $limit): array
348    {
349        if ($this->userRepository === null) {
350            return [];
351        }
352
353        $users = $this->userRepository->searchAutocomplete($query, $limit);
354        $results = [];
355        foreach ($users as $user) {
356            $username = (string) ($user['username'] ?? '');
357            $email = (string) ($user['email'] ?? '');
358            $results[] = [
359                'id'           => $user['id'],
360                'username'     => $username,
361                'name'         => $username,
362                'email'        => $email,
363                'initials'     => mb_strtoupper(mb_substr($username, 0, 2)),
364                'is_structure' => false,
365                'tag'          => '@' . $username,
366                'label'        => $username . ($email !== '' ? ' (' . $email . ')' : ''),
367            ];
368        }
369
370        return $results;
371    }
372
373    /**
374     * Searches records across modules for #record autocompletion.
375     */
376    private function searchRecords(string $query, int $limit, PermissionContext $ctx): ResponseInterface
377    {
378        if ($this->searchService === null || $query === '') {
379            return $this->jsonSuccess([]);
380        }
381
382        try {
383            $searchQuery = new SearchQuery($query, SearchMode::SMART, [], $limit);
384            $resultSet = $this->searchService->search($searchQuery, $ctx);
385            $results = [];
386
387            foreach ($resultSet->groups as $group) {
388                foreach ($group->results as $record) {
389                    $rawTitle = trim($record->title);
390                    $cleanTitle = (string) preg_replace(
391                        '/[^\p{L}\p{N}_\.\-\/]/u',
392                        '',
393                        str_replace(' ', '_', $rawTitle)
394                    );
395                    $cleanTitle = trim($cleanTitle, '_');
396                    $tagTitle = $cleanTitle !== '' ? $cleanTitle : (string) $record->id;
397                    $tag = '#' . $tagTitle;
398
399                    $results[] = [
400                        'id'           => $record->id,
401                        'module_name'  => $record->moduleName,
402                        'module_label' => $record->moduleLabel,
403                        'icon_class'   => $record->iconClass,
404                        'title'        => $record->title,
405                        'tag'          => $tag,
406                        'label'        => $record->title . ' (' . $record->moduleLabel . ')',
407                    ];
408                    if (count($results) >= $limit) {
409                        break 2;
410                    }
411                }
412            }
413
414            return $this->jsonSuccess($results);
415        } catch (Throwable) {
416            return $this->jsonSuccess([]);
417        }
418    }
419
420    /**
421     * @param array<mixed> $data
422     */
423    private function jsonSuccess(array $data, int $status = 200): ResponseInterface
424    {
425        $response = $this->responseFactory->createResponse($status);
426        $payload = json_encode(['success' => true, 'data' => $data], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
427        $response->getBody()->write((string) $payload);
428
429        return $response->withHeader('Content-Type', 'application/json');
430    }
431
432    private function jsonError(string $message, int $status): ResponseInterface
433    {
434        $response = $this->responseFactory->createResponse($status);
435        $payload = json_encode(['success' => false, 'error' => $message], JSON_UNESCAPED_UNICODE);
436        $response->getBody()->write((string) $payload);
437
438        return $response->withHeader('Content-Type', 'application/json');
439    }
440
441    /**
442     * @return array<string, mixed>
443     */
444    private function parseJsonBody(ServerRequestInterface $request): array
445    {
446        $raw = (string) $request->getBody();
447        if ($raw === '') {
448            return [];
449        }
450
451        $decoded = json_decode($raw, true);
452
453        return is_array($decoded) ? $decoded : [];
454    }
455}