Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.67% covered (warning)
86.67%
91 / 105
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentsHtmxController
86.54% covered (warning)
86.54%
90 / 104
54.55% covered (warning)
54.55%
6 / 11
28.78
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
 actionAutocomplete
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 actionStream
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 actionCreate
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
4
 actionTogglePin
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 actionToggleVerify
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 actionDelete
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
3.05
 renderCommentItemResponse
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 extractUploadedFiles
50.00% covered (danger)
50.00%
7 / 14
0.00% covered (danger)
0.00%
0 / 1
10.50
 htmlResponse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 htmlError
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
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\Htmx;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Application\Security\PermissionContextFactory;
12use App\Modules\Comments\Application\DTO\CreateCommentDto;
13use App\Modules\Comments\Application\Service\CommentServiceInterface;
14use App\Modules\Comments\Domain\Model\Comment;
15use App\Modules\Comments\Domain\Model\CommentFilterScope;
16use App\Modules\Comments\Presentation\Api\CommentsApiController;
17use Psr\Http\Message\ResponseFactoryInterface;
18use Psr\Http\Message\ResponseInterface;
19use Psr\Http\Message\ServerRequestInterface;
20use Psr\Http\Message\UploadedFileInterface;
21use Throwable;
22use Twig\Environment as TwigEnvironment;
23
24/**
25 * HTMX Controller for ultra-fast conversational comments timeline swapping.
26 *
27 * @package App\Modules\Comments\Presentation\Htmx
28 */
29final readonly class CommentsHtmxController
30{
31    private const string TEMPLATE_TIMELINE = 'modules/comments/timeline.twig';
32    private const string TEMPLATE_ITEM = 'modules/comments/partials/comment_item.twig';
33
34    /**
35     * CommentsHtmxController constructor.
36     */
37    public function __construct(
38        private CommentServiceInterface $commentService,
39        private TwigEnvironment $twig,
40        private ResponseFactoryInterface $responseFactory,
41        private PermissionContextFactory $contextFactory,
42        private ?CommentsApiController $apiController = null
43    ) {
44    }
45
46    /**
47     * Handles GET /htmx/comments/autocomplete
48     */
49    public function actionAutocomplete(ServerRequestInterface $request): ResponseInterface
50    {
51        if ($this->apiController === null) {
52            $resp = $this->responseFactory->createResponse(200);
53            $resp->getBody()->write((string) json_encode(['success' => true, 'data' => []]));
54
55            return $resp->withHeader('Content-Type', 'application/json');
56        }
57
58        $ctx = $this->contextFactory->createFromRequest($request);
59
60        return $this->apiController->actionAutocomplete($request, $ctx);
61    }
62
63    /**
64     * Handles GET /htmx/comments/{module}/{id}
65     */
66    public function actionStream(ServerRequestInterface $request, string $module, int $id): ResponseInterface
67    {
68        $ctx = $this->contextFactory->createFromRequest($request);
69        $params = $request->getQueryParams();
70        $scope = CommentFilterScope::fromValue($params['scope'] ?? 'all');
71
72        $stream = $this->commentService->getStream(
73            module:        $module,
74            recordId:      $id,
75            scope:         $scope,
76            currentUserId: $ctx->actorUserId
77        );
78
79        $template = !empty($params['partial']) ? self::TEMPLATE_ITEM : self::TEMPLATE_TIMELINE;
80        $html = $this->twig->render($template, [
81            'stream'          => $stream,
82            'comments'        => $stream->comments,
83            'counts'          => $stream->counts,
84            'active_scope'    => $scope->value,
85            'target_module'   => $module,
86            'target_id'       => $id,
87            'current_user_id' => $ctx->actorUserId,
88            'is_superuser'    => $ctx->isSuperuser,
89        ]);
90
91        return $this->htmlResponse($html);
92    }
93
94    /**
95     * Handles POST /htmx/comments/{module}/{id}
96     */
97    public function actionCreate(ServerRequestInterface $request, string $module, int $id): ResponseInterface
98    {
99        $ctx = $this->contextFactory->createFromRequest($request);
100        $parsed = (array) ($request->getParsedBody() ?? []);
101        $content = (string) ($parsed['content'] ?? '');
102        $parentId = !empty($parsed['parent_id']) ? (int) $parsed['parent_id'] : null;
103        $isPinned = (bool) ($parsed['is_pinned'] ?? false);
104        $isVerified = (bool) ($parsed['is_verified'] ?? false);
105
106        $uploadedFiles = $this->extractUploadedFiles($request);
107
108        $dto = new CreateCommentDto(
109            targetModule:   $module,
110            targetRecordId: $id,
111            content:        $content,
112            parentId:       $parentId,
113            isPinned:       $isPinned,
114            isVerified:     $isVerified,
115            attachments:    $uploadedFiles
116        );
117
118        try {
119            $authorName = $ctx->isImpersonating()
120                ? $ctx->getRealUserName()
121                : (string) ($parsed['author_name'] ?? 'User');
122            $this->commentService->createComment($dto, $ctx->getAuditActorUserId(), $authorName);
123
124            // Re-render whole timeline stream to update counts, replies, and list
125            return $this->actionStream($request, $module, $id);
126        } catch (Throwable $e) {
127            return $this->htmlError('Failed to add comment: ' . $e->getMessage());
128        }
129    }
130
131    /**
132     * Handles POST /htmx/comments/{id}/toggle-pin
133     */
134    public function actionTogglePin(ServerRequestInterface $request, int $id): ResponseInterface
135    {
136        $ctx = $this->contextFactory->createFromRequest($request);
137        try {
138            $this->commentService->togglePin($id);
139            $targetComment = $this->commentService->findCommentById($id);
140            if ($targetComment === null) {
141                return $this->htmlResponse('');
142            }
143
144            return $this->renderCommentItemResponse($targetComment, $ctx->actorUserId, $ctx->isSuperuser);
145        } catch (Throwable $e) {
146            return $this->htmlError($e->getMessage());
147        }
148    }
149
150    /**
151     * Handles POST /htmx/comments/{id}/toggle-verify
152     */
153    public function actionToggleVerify(ServerRequestInterface $request, int $id): ResponseInterface
154    {
155        $ctx = $this->contextFactory->createFromRequest($request);
156        try {
157            $this->commentService->toggleVerify($id);
158            $targetComment = $this->commentService->findCommentById($id);
159            if ($targetComment === null) {
160                return $this->htmlResponse('');
161            }
162
163            return $this->renderCommentItemResponse($targetComment, $ctx->actorUserId, $ctx->isSuperuser);
164        } catch (Throwable $e) {
165            return $this->htmlError($e->getMessage());
166        }
167    }
168
169    /**
170     * Handles DELETE /htmx/comments/{id}
171     */
172    public function actionDelete(ServerRequestInterface $request, int $id): ResponseInterface
173    {
174        $ctx = $this->contextFactory->createFromRequest($request);
175        try {
176            $success = $this->commentService->deleteComment(
177                $id,
178                $ctx->getAuditActorUserId(),
179                $ctx->isSuperuser
180            );
181            if (!$success) {
182                return $this->htmlError('Permission denied to delete comment.');
183            }
184
185            // Return empty response so HTMX removes element from DOM
186            return $this->htmlResponse('');
187        } catch (Throwable $e) {
188            return $this->htmlError($e->getMessage());
189        }
190    }
191
192    private function renderCommentItemResponse(Comment $comment, int $userId, bool $isSuperuser): ResponseInterface
193    {
194        $html = $this->twig->render(self::TEMPLATE_ITEM, [
195            'comment'         => $comment,
196            'current_user_id' => $userId,
197            'is_superuser'    => $isSuperuser,
198        ]);
199
200        return $this->htmlResponse($html);
201    }
202
203    /**
204     * @return array<int, array<string, mixed>>
205     */
206    private function extractUploadedFiles(ServerRequestInterface $request): array
207    {
208        $files = [];
209        $uploaded = $request->getUploadedFiles();
210
211        $rawAttachments = $uploaded['attachments'] ?? [];
212        if ($rawAttachments instanceof UploadedFileInterface) {
213            $rawAttachments = [$rawAttachments];
214        }
215
216        if (is_array($rawAttachments)) {
217            foreach ($rawAttachments as $file) {
218                if ($file instanceof UploadedFileInterface && $file->getError() === UPLOAD_ERR_OK) {
219                    $files[] = [
220                        'tmp_name' => $file->getStream()->getMetadata('uri') ?? '',
221                        'name'     => $file->getClientFilename() ?? 'attachment',
222                        'size'     => $file->getSize() ?? 0,
223                    ];
224                }
225            }
226        }
227
228        return $files;
229    }
230
231    private function htmlResponse(string $html, int $status = 200): ResponseInterface
232    {
233        $response = $this->responseFactory->createResponse($status);
234        $response->getBody()->write($html);
235
236        return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
237    }
238
239    private function htmlError(string $message, int $status = 422): ResponseInterface
240    {
241        $safeMsg = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
242        $html = '<div class="alert alert-danger py-2 px-3 small m-2" role="alert">' . $safeMsg . '</div>';
243
244        return $this->htmlResponse($html, $status);
245    }
246}