Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.17% covered (success)
98.17%
107 / 109
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentService
98.15% covered (success)
98.15%
106 / 108
81.82% covered (warning)
81.82%
9 / 11
24
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
 getStream
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 createComment
98.48% covered (success)
98.48%
65 / 66
0.00% covered (danger)
0.00%
0 / 1
5
 processRawAttachments
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 togglePin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 toggleVerify
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deleteComment
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 findAttachmentByToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findCommentById
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderFormattedContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assertCommentExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
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\CommentStreamDto;
12use App\Modules\Comments\Application\DTO\CreateCommentDto;
13use App\Modules\Comments\Domain\Exception\CommentNotFoundException;
14use App\Modules\Comments\Domain\Exception\CommentValidationException;
15use App\Modules\Comments\Domain\Model\Comment;
16use App\Modules\Comments\Domain\Model\CommentAttachment;
17use App\Modules\Comments\Domain\Model\CommentFilterScope;
18use App\Modules\Comments\Domain\Repository\CommentRepositoryInterface;
19
20/**
21 * Main application service orchestrating timeline conversation flows and comment lifecycle.
22 *
23 * @package App\Modules\Comments\Application\Service
24 */
25final readonly class CommentService implements CommentServiceInterface
26{
27    /**
28     * CommentService constructor.
29     */
30    public function __construct(
31        private CommentRepositoryInterface $repository,
32        private CommentHierarchyRollupServiceInterface $rollupService,
33        private CommentAttachmentServiceInterface $attachmentService,
34        private CommentMentionParserService $mentionParser
35    ) {
36    }
37
38    /**
39     * Retrieves conversation stream with filter scope and aggregate tab counts.
40     */
41    public function getStream(
42        string $module,
43        int $recordId,
44        CommentFilterScope $scope,
45        ?int $currentUserId,
46        int $limit = 100,
47        int $offset = 0
48    ): CommentStreamDto {
49        $comments = $this->repository->findStream($module, $recordId, $scope, $currentUserId, $limit, $offset);
50        $counts = $this->repository->getStreamCounts($module, $recordId, $currentUserId);
51        $total = $counts[$scope->value] ?? count($comments);
52
53        return new CommentStreamDto(
54            comments:        $comments,
55            scope:           $scope,
56            counts:          $counts,
57            totalCount:      $total,
58            targetModule:    $module,
59            targetRecordId:  $recordId,
60            currentUserId:   $currentUserId
61        );
62    }
63
64    /**
65     * Posts a new comment or reply with automatic hierarchy rollup and attachments.
66     */
67    public function createComment(CreateCommentDto $dto, int $authorUserId, string $authorName): Comment
68    {
69        $trimmedContent = trim($dto->content);
70        if ($trimmedContent === '') {
71            throw CommentValidationException::forEmptyContent();
72        }
73
74        if ($dto->targetRecordId <= 0) {
75            throw CommentValidationException::forInvalidTarget($dto->targetModule, $dto->targetRecordId);
76        }
77
78        $hierarchy = $this->rollupService->resolveHierarchy($dto->targetModule, $dto->targetRecordId);
79        $now = date('Y-m-d H:i:s');
80
81        $comment = new Comment(
82            id:                0,
83            parentId:          $dto->parentId,
84            content:           $trimmedContent,
85            isPinned:          $dto->isPinned,
86            isVerified:        $dto->isVerified,
87            targetModule:      $dto->targetModule,
88            targetRecordId:    $dto->targetRecordId,
89            relatedPartyRef:   $hierarchy['related_party_ref'],
90            companyId:         $hierarchy['company_id'],
91            partnerId:         $hierarchy['partner_id'],
92            contactId:         $hierarchy['contact_id'],
93            processRef:        $hierarchy['process_ref'],
94            projectId:         $hierarchy['project_id'],
95            contractId:        $hierarchy['contract_id'],
96            subprocessRef:     $hierarchy['subprocess_ref'],
97            ticketId:          $hierarchy['ticket_id'],
98            taskId:            $hierarchy['task_id'],
99            stageId:           $hierarchy['stage_id'],
100            owner:             $authorUserId,
101            authorName:        $authorName,
102            authorAvatar:      null,
103            createdAt:         $now,
104            updatedAt:         $now,
105            attachments:       [],
106            parentComment:     null
107        );
108
109        $newId = $this->repository->save($comment);
110
111        $storedAttachments = $this->processRawAttachments($dto->attachments, $newId, $authorUserId);
112        $parent = $dto->parentId !== null && $dto->parentId > 0
113            ? $this->repository->findById($dto->parentId)
114            : null;
115
116        return new Comment(
117            id:                $newId,
118            parentId:          $comment->parentId,
119            content:           $comment->content,
120            isPinned:          $comment->isPinned,
121            isVerified:        $comment->isVerified,
122            targetModule:      $comment->targetModule,
123            targetRecordId:    $comment->targetRecordId,
124            relatedPartyRef:   $comment->relatedPartyRef,
125            companyId:         $comment->companyId,
126            partnerId:         $comment->partnerId,
127            contactId:         $comment->contactId,
128            processRef:        $comment->processRef,
129            projectId:         $comment->projectId,
130            contractId:        $comment->contractId,
131            subprocessRef:     $comment->subprocessRef,
132            ticketId:          $comment->ticketId,
133            taskId:            $comment->taskId,
134            stageId:           $comment->stageId,
135            owner:             $comment->owner,
136            authorName:        $comment->authorName,
137            authorAvatar:      $comment->authorAvatar,
138            createdAt:         $comment->createdAt,
139            updatedAt:         $comment->updatedAt,
140            attachments:       $storedAttachments,
141            parentComment:     $parent
142        );
143    }
144
145    /**
146     * @param array<int, array<string, mixed>> $rawFiles
147     * @return array<int, CommentAttachment>
148     */
149    private function processRawAttachments(array $rawFiles, int $commentId, int $userId): array
150    {
151        $stored = [];
152        foreach ($rawFiles as $file) {
153            $tmpPath = (string) ($file['tmp_name'] ?? '');
154            $name = (string) ($file['name'] ?? '');
155            $size = (int) ($file['size'] ?? 0);
156
157            if ($tmpPath !== '' && $name !== '' && $size > 0) {
158                $stored[] = $this->attachmentService->storeAttachment(
159                    $tmpPath,
160                    $name,
161                    $size,
162                    $commentId,
163                    $userId
164                );
165            }
166        }
167
168        return $stored;
169    }
170
171    /**
172     * Toggles comment pinned highlight state.
173     */
174    public function togglePin(int $commentId): bool
175    {
176        $this->assertCommentExists($commentId);
177
178        return $this->repository->togglePin($commentId);
179    }
180
181    /**
182     * Toggles comment verified state for client portal.
183     */
184    public function toggleVerify(int $commentId): bool
185    {
186        $this->assertCommentExists($commentId);
187
188        return $this->repository->toggleVerify($commentId);
189    }
190
191    /**
192     * Deletes a comment with authorization verification.
193     */
194    public function deleteComment(int $commentId, ?int $currentUserId, bool $isSuperuser = false): bool
195    {
196        $comment = $this->repository->findById($commentId);
197        if ($comment === null) {
198            throw CommentNotFoundException::forId($commentId);
199        }
200
201        if (!$isSuperuser && ($currentUserId === null || $comment->owner !== $currentUserId)) {
202            return false;
203        }
204
205        return $this->repository->delete($commentId);
206    }
207
208    /**
209     * Finds single comment attachment by secure public token.
210     */
211    public function findAttachmentByToken(string $token): ?CommentAttachment
212    {
213        return $this->repository->findAttachmentByToken($token);
214    }
215
216    /**
217     * Finds single comment by primary key ID.
218     */
219    public function findCommentById(int $commentId): ?Comment
220    {
221        return $this->repository->findById($commentId);
222    }
223
224    /**
225     * Transforms comment content to rich sanitized HTML.
226     */
227    public function renderFormattedContent(string $rawContent): string
228    {
229        return $this->mentionParser->parseToHtml($rawContent);
230    }
231
232    private function assertCommentExists(int $commentId): void
233    {
234        if ($this->repository->findById($commentId) === null) {
235            throw CommentNotFoundException::forId($commentId);
236        }
237    }
238}