Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.95% covered (success)
95.95%
166 / 173
88.46% covered (warning)
88.46%
23 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Comment
95.93% covered (success)
95.93%
165 / 172
88.46% covered (warning)
88.46%
23 / 26
83
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
 isReply
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isMine
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 hasAttachments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAuthorId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContextBadge
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 hasContextBadge
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 authorInitials
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 contentSnippet
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 relativeTime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formattedCreatedAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContentHtml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getParentAuthorName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getParentSnippet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatRefLabel
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
10
 __get
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 __isset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveAttachments
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
6.60
 resolveParentComment
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 fromArray
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
14
 formatRelativeTime
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
8.30
 copyWith
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
9
 withParent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withAttachments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toArray
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
1
 jsonSerialize
100.00% covered (success)
100.00%
1 / 1
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\Domain\Model;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use JsonSerializable;
12
13/**
14 * Pure Domain Entity representing a timeline conversation comment.
15 *
16 * @package App\Modules\Comments\Domain\Model
17 */
18final readonly class Comment implements JsonSerializable
19{
20    /**
21     * Comment constructor.
22     *
23     * @param array<int, CommentAttachment> $attachments List of attachments.
24     */
25    public function __construct(
26        public int $id,
27        public ?int $parentId,
28        public string $content,
29        public bool $isPinned,
30        public bool $isVerified,
31        public string $targetModule,
32        public int $targetRecordId,
33        public ?string $relatedPartyRef,
34        public ?int $companyId,
35        public ?int $partnerId,
36        public ?int $contactId,
37        public ?string $processRef,
38        public ?int $projectId,
39        public ?int $contractId,
40        public ?string $subprocessRef,
41        public ?int $ticketId,
42        public ?int $taskId,
43        public ?int $stageId,
44        public int $owner,
45        public string $authorName,
46        public ?string $authorAvatar,
47        public string $createdAt,
48        public string $updatedAt,
49        public array $attachments = [],
50        public ?self $parentComment = null
51    ) {
52    }
53
54    /**
55     * Checks whether this comment is a threaded reply to another comment.
56     */
57    public function isReply(): bool
58    {
59        return $this->parentId !== null && $this->parentId > 0;
60    }
61
62    /**
63     * Checks if this comment was authored by the specified user.
64     */
65    public function isMine(?int $userId): bool
66    {
67        return $userId !== null && $this->owner === $userId;
68    }
69
70    /**
71     * Checks if comment has any attached files.
72     */
73    public function hasAttachments(): bool
74    {
75        return !empty($this->attachments);
76    }
77
78    /**
79     * Author user ID alias for Twig.
80     */
81    public function getAuthorId(): int
82    {
83        return $this->owner;
84    }
85
86    /**
87     * Context badge text if this comment belongs to a different module in rollup.
88     */
89    public function getContextBadge(): ?string
90    {
91        $ref = $this->subprocessRef ?? $this->processRef ?? $this->relatedPartyRef;
92        if ($ref === null) {
93            return null;
94        }
95
96        return $this->formatRefLabel($ref);
97    }
98
99    public function hasContextBadge(): bool
100    {
101        return $this->getContextBadge() !== null;
102    }
103
104    /**
105     * Computes author initials for avatar rendering.
106     */
107    public function authorInitials(): string
108    {
109        $name = trim($this->authorName);
110        if ($name === '') {
111            return '?';
112        }
113
114        $parts = preg_split('/\s+/u', $name);
115        if (empty($parts)) {
116            return mb_strtoupper(mb_substr($name, 0, 1));
117        }
118
119        $first = mb_substr($parts[0], 0, 1);
120        $second = isset($parts[1]) ? mb_substr($parts[1], 0, 1) : '';
121
122        return mb_strtoupper($first . $second);
123    }
124
125    /**
126     * Truncates content to specified max length with ellipsis.
127     */
128    public function contentSnippet(int $limit = 50): string
129    {
130        $clean = strip_tags($this->content);
131        if (mb_strlen($clean) <= $limit) {
132            return $clean;
133        }
134
135        return mb_substr($clean, 0, $limit) . '...';
136    }
137
138    /**
139     * Relative time string alias for Twig.
140     */
141    public function relativeTime(): string
142    {
143        return $this->formatRelativeTime();
144    }
145
146    /**
147     * Formatted created at date string.
148     */
149    public function formattedCreatedAt(): string
150    {
151        return $this->createdAt;
152    }
153
154    /**
155     * Content HTML property access.
156     */
157    public function getContentHtml(): string
158    {
159        return htmlspecialchars($this->content, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
160    }
161
162    /**
163     * Parent author name if this is a reply.
164     */
165    public function getParentAuthorName(): ?string
166    {
167        return $this->parentComment?->authorName;
168    }
169
170    /**
171     * Parent content snippet if this is a reply.
172     */
173    public function getParentSnippet(): ?string
174    {
175        return $this->parentComment?->contentSnippet(60);
176    }
177
178    private function formatRefLabel(string $ref): string
179    {
180        $parts = explode(':', $ref, 2);
181        $mod = $parts[0] ?? '';
182        $id = $parts[1] ?? '';
183
184        $modName = match ($mod) {
185            'tickets'        => 'Ticket',
186            'contracts'      => 'Contract',
187            'projects'       => 'Project',
188            'project_tasks'  => 'Task',
189            'project_stages' => 'Stage',
190            'companies'      => 'Company',
191            'partners'       => 'Partner',
192            'contacts'       => 'Contact',
193            default          => ucfirst($mod),
194        };
195
196        return $modName . ' #' . $id;
197    }
198
199    /**
200     * Magic getter for Twig property access.
201     */
202    public function __get(string $name): mixed
203    {
204        return match ($name) {
205            'authorId'         => $this->owner,
206            'contentHtml'      => htmlspecialchars($this->content, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
207            'contextBadge'     => $this->getContextBadge(),
208            'parentAuthorName' => $this->getParentAuthorName(),
209            'parentSnippet'    => $this->getParentSnippet(),
210            default            => null,
211        };
212    }
213
214    public function __isset(string $name): bool
215    {
216        return in_array($name, ['authorId', 'contentHtml', 'contextBadge', 'parentAuthorName', 'parentSnippet'], true);
217    }
218
219    /**
220     * Resolves attachments from raw input data.
221     *
222     * @param array<string, mixed>          $data
223     * @param array<int, CommentAttachment> $attachments
224     * @return array<int, CommentAttachment>
225     */
226    private static function resolveAttachments(array $data, array $attachments): array
227    {
228        if ($attachments !== []) {
229            return $attachments;
230        }
231
232        $rawAttachments = (array) ($data['attachments'] ?? []);
233        $resolved = [];
234        foreach ($rawAttachments as $att) {
235            if ($att instanceof CommentAttachment) {
236                $resolved[] = $att;
237            } elseif (is_array($att)) {
238                $resolved[] = CommentAttachment::fromArray($att);
239            }
240        }
241
242        return $resolved;
243    }
244
245    /**
246     * Resolves parent comment from input data.
247     *
248     * @param array<string, mixed> $data
249     */
250    private static function resolveParentComment(array $data, ?self $parent): ?self
251    {
252        if ($parent !== null || empty($data['parent']) || !is_array($data['parent'])) {
253            return $parent;
254        }
255
256        return self::fromArray($data['parent']);
257    }
258
259    /**
260     * Static factory creating entity from associative array.
261     *
262     * @param array<string, mixed>                  $data
263     * @param array<int, CommentAttachment>         $attachments
264     * @param self|null                             $parent
265     */
266    public static function fromArray(array $data, array $attachments = [], ?self $parent = null): self
267    {
268        $resolvedAttachments = self::resolveAttachments($data, $attachments);
269        $resolvedParent = self::resolveParentComment($data, $parent);
270
271        return new self(
272            id:              (int) ($data['id'] ?? 0),
273            parentId:        !empty($data['parent_id']) ? (int) $data['parent_id'] : null,
274            content:         (string) ($data['content'] ?? ''),
275            isPinned:        (bool) ($data['is_pinned'] ?? false),
276            isVerified:      (bool) ($data['is_verified'] ?? false),
277            targetModule:    (string) ($data['target_module'] ?? ''),
278            targetRecordId:  (int) ($data['target_record_id'] ?? 0),
279            relatedPartyRef: !empty($data['related_party_ref']) ? (string) $data['related_party_ref'] : null,
280            companyId:       !empty($data['company_id']) ? (int) $data['company_id'] : null,
281            partnerId:       !empty($data['partner_id']) ? (int) $data['partner_id'] : null,
282            contactId:       !empty($data['contact_id']) ? (int) $data['contact_id'] : null,
283            processRef:      !empty($data['process_ref']) ? (string) $data['process_ref'] : null,
284            projectId:       !empty($data['project_id']) ? (int) $data['project_id'] : null,
285            contractId:      !empty($data['contract_id']) ? (int) $data['contract_id'] : null,
286            subprocessRef:   !empty($data['subprocess_ref']) ? (string) $data['subprocess_ref'] : null,
287            ticketId:        !empty($data['ticket_id']) ? (int) $data['ticket_id'] : null,
288            taskId:          !empty($data['task_id']) ? (int) $data['task_id'] : null,
289            stageId:         !empty($data['stage_id']) ? (int) $data['stage_id'] : null,
290            owner:           (int) ($data['owner'] ?? $data['author_id'] ?? 0),
291            authorName:      (string) ($data['author_name'] ?? ''),
292            authorAvatar:    !empty($data['author_avatar']) ? (string) $data['author_avatar'] : null,
293            createdAt:       (string) ($data['created_at'] ?? date('Y-m-d H:i:s')),
294            updatedAt:       (string) ($data['updated_at'] ?? date('Y-m-d H:i:s')),
295            attachments:     $resolvedAttachments,
296            parentComment:   $resolvedParent
297        );
298    }
299
300    /**
301     * Formats comment creation timestamp into human-readable relative string.
302     */
303    public function formatRelativeTime(?int $referenceTs = null): string
304    {
305        if ($this->createdAt === '') {
306            return '';
307        }
308
309        $createdTs = strtotime($this->createdAt);
310        if ($createdTs === false) {
311            return $this->createdAt;
312        }
313
314        $now = $referenceTs ?? time();
315        $diff = max(0, $now - $createdTs);
316
317        return match (true) {
318            $diff < 60 => 'just now',
319            $diff < 3600 => ((int) floor($diff / 60)) . ' min ago',
320            $diff < 86400 => ((int) floor($diff / 3600)) . ' hours ago',
321            $diff < 172800 => 'yesterday at ' . date('H:i', $createdTs),
322            default => date('Y-m-d H:i', $createdTs),
323        };
324    }
325
326    /**
327     * Creates a mutated copy of the comment entity.
328     *
329     * @param array<string, mixed> $overrides Overridden properties.
330     */
331    private function copyWith(array $overrides): self
332    {
333        $d = $this->toArray();
334        $d['attachments'] = $this->attachments;
335        $d['parentComment'] = $this->parentComment;
336        $m = array_merge($d, $overrides);
337
338        return new self(
339            id:                $m['id'],
340            parentId:          $m['parentId'] ?? $m['parent_id'],
341            content:           $m['content'],
342            isPinned:          (bool) ($m['isPinned'] ?? $m['is_pinned']),
343            isVerified:        (bool) ($m['isVerified'] ?? $m['is_verified']),
344            targetModule:      $m['targetModule'] ?? $m['target_module'],
345            targetRecordId:    (int) ($m['targetRecordId'] ?? $m['target_record_id']),
346            relatedPartyRef:   $m['relatedPartyRef'] ?? $m['related_party_ref'],
347            companyId:         isset($m['companyId']) ? (int) $m['companyId'] : null,
348            partnerId:         isset($m['partnerId']) ? (int) $m['partnerId'] : null,
349            contactId:         isset($m['contactId']) ? (int) $m['contactId'] : null,
350            processRef:        $m['processRef'] ?? $m['process_ref'],
351            projectId:         isset($m['projectId']) ? (int) $m['projectId'] : null,
352            contractId:        isset($m['contractId']) ? (int) $m['contractId'] : null,
353            subprocessRef:     $m['subprocessRef'] ?? $m['subprocess_ref'],
354            ticketId:          isset($m['ticketId']) ? (int) $m['ticketId'] : null,
355            taskId:            isset($m['taskId']) ? (int) $m['taskId'] : null,
356            stageId:           isset($m['stageId']) ? (int) $m['stageId'] : null,
357            owner:             (int) $m['owner'],
358            authorName:        $m['authorName'] ?? $m['author_name'],
359            authorAvatar:      $m['authorAvatar'] ?? $m['author_avatar'],
360            createdAt:         $m['createdAt'] ?? $m['created_at'],
361            updatedAt:         $m['updatedAt'] ?? $m['updated_at'],
362            attachments:       $m['attachments'],
363            parentComment:     $m['parentComment']
364        );
365    }
366
367    /**
368     * Returns a clone with assigned parent comment reference.
369     */
370    public function withParent(?self $parent): self
371    {
372        return $this->copyWith(['parentComment' => $parent]);
373    }
374
375    /**
376     * Returns a clone with attached files array.
377     *
378     * @param array<int, CommentAttachment> $attachments
379     */
380    public function withAttachments(array $attachments): self
381    {
382        return $this->copyWith(['attachments' => $attachments]);
383    }
384
385    /**
386     * Exports comment entity state to array.
387     *
388     * @return array<string, mixed>
389     */
390    public function toArray(): array
391    {
392        return [
393            'id'                => $this->id,
394            'parent_id'         => $this->parentId,
395            'content'           => $this->content,
396            'is_pinned'         => $this->isPinned,
397            'is_verified'       => $this->isVerified,
398            'target_module'     => $this->targetModule,
399            'target_record_id'  => $this->targetRecordId,
400            'related_party_ref' => $this->relatedPartyRef,
401            'company_id'        => $this->companyId,
402            'partner_id'        => $this->partnerId,
403            'contact_id'        => $this->contactId,
404            'process_ref'       => $this->processRef,
405            'project_id'        => $this->projectId,
406            'contract_id'       => $this->contractId,
407            'subprocess_ref'    => $this->subprocessRef,
408            'ticket_id'         => $this->ticketId,
409            'task_id'           => $this->taskId,
410            'stage_id'          => $this->stageId,
411            'owner'             => $this->owner,
412            'author_name'       => $this->authorName,
413            'author_avatar'     => $this->authorAvatar,
414            'created_at'        => $this->createdAt,
415            'updated_at'        => $this->updatedAt,
416            'relative_time'     => $this->formatRelativeTime(),
417            'is_reply'          => $this->isReply(),
418            'parent'            => $this->parentComment?->toArray(),
419            'attachments'       => array_map(
420                static fn(CommentAttachment $a): array => $a->toArray(),
421                $this->attachments
422            ),
423        ];
424    }
425
426    /**
427     * Serializes entity to JSON.
428     *
429     * @return array<string, mixed>
430     */
431    public function jsonSerialize(): array
432    {
433        return $this->toArray();
434    }
435}