Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 132
0.00% covered (danger)
0.00%
0 / 58
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatMessage
0.00% covered (danger)
0.00%
0 / 132
0.00% covered (danger)
0.00%
0 / 58
6320
0.00% covered (danger)
0.00%
0 / 1
 fromRow
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
110
 parseDateTime
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 containsUnicode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 calculateSegmentCountStatic
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 calculateSegmentCount
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 markAsRead
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 updateDeliveryStatus
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 isInbound
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isOutbound
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFromCustomer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFromStaff
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFromSystem
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isTransactional
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isInteractive
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isPending
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isDelivered
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFailed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isRead
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 usedTemplate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 toArray
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
2
 getId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getThreadId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTypeNum
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDirection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSenderType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSenderId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTemplateId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getContentRaw
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCharacterCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSmsSegmentCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCategory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getProvider
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getProviderMessageId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDeliveryStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDeliveryTimestamp
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDeliveryError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getReadAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCreatedAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setThreadId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTypeNum
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setDirection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSenderType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSenderId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTemplateId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setContent
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setContentRaw
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCharacterCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSmsSegmentCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCategory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setProvider
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setProviderMessageId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setDeliveryStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setDeliveryTimestamp
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setDeliveryError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setReadAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCreatedAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\Chat\Models;
4
5use DateTime;
6
7/**
8 * ChatMessage entity model
9 *
10 * Represents a single SMS message within a two-way conversation thread.
11 *
12 * Business Rules:
13 * - Messages can be inbound (from customer) or outbound (to customer)
14 * - Outbound messages may use templates (templateId) or be freetext
15 * - Messages track delivery status from provider callbacks
16 * - SMS segment count is calculated based on content length and encoding
17 *
18 * SMS Segment Calculation:
19 * - GSM-7 encoding: 160 chars single, 153 chars per segment for multipart
20 * - UCS-2 encoding (Unicode): 70 chars single, 67 chars per segment for multipart
21 *
22 * @package BuyerKiosk\Chat\Models
23 */
24class ChatMessage
25{
26    // Direction constants
27    public const DIRECTION_INBOUND = 'inbound';
28    public const DIRECTION_OUTBOUND = 'outbound';
29
30    // Sender type constants
31    public const SENDER_CUSTOMER = 'customer';
32    public const SENDER_STAFF = 'staff';
33    public const SENDER_SYSTEM = 'system';
34
35    // Category constants
36    public const CATEGORY_TRANSACTIONAL = 'transactional';
37    public const CATEGORY_INTERACTIVE = 'interactive';
38
39    // Provider constants
40    public const PROVIDER_VONAGE = 'vonage';
41    public const PROVIDER_TWILIO = 'twilio';
42
43    // Delivery status constants (both naming conventions for compatibility)
44    public const DELIVERY_PENDING = 'pending';
45    public const DELIVERY_QUEUED = 'queued';
46    public const DELIVERY_SENT = 'sent';
47    public const DELIVERY_DELIVERED = 'delivered';
48    public const DELIVERY_FAILED = 'failed';
49    public const DELIVERY_UNDELIVERED = 'undelivered';
50
51    // Alias constants for STATUS_* naming convention used in tests
52    public const STATUS_PENDING = 'pending';
53    public const STATUS_QUEUED = 'queued';
54    public const STATUS_SENT = 'sent';
55    public const STATUS_DELIVERED = 'delivered';
56    public const STATUS_FAILED = 'failed';
57    public const STATUS_UNDELIVERED = 'undelivered';
58
59    // SMS segment thresholds
60    private const GSM7_SINGLE_LIMIT = 160;
61    private const GSM7_MULTIPART_SEGMENT = 153;
62    private const UCS2_SINGLE_LIMIT = 70;
63    private const UCS2_MULTIPART_SEGMENT = 67;
64
65    // Properties
66    private ?int $id = null;
67    private int $threadId;
68    private string $typeNum;
69    private string $direction;
70    private string $senderType;
71    private ?int $senderId = null;
72    private ?int $templateId = null;
73    private string $content = '';
74    private ?string $contentRaw = null;
75    private ?int $characterCount = null;
76    private int $smsSegmentCount = 1;
77    private string $category;
78    private ?string $provider = null;
79    private ?string $providerMessageId = null;
80    private string $deliveryStatus = self::DELIVERY_PENDING;
81    private ?DateTime $deliveryTimestamp = null;
82    private ?string $deliveryError = null;
83    private ?DateTime $readAt = null;
84    private ?DateTime $createdAt = null;
85
86    /**
87     * Create a ChatMessage instance from a database row
88     *
89     * @param array $row Database row with message data
90     * @return self
91     */
92    public static function fromRow(array $row): self
93    {
94        $message = new self();
95
96        $message->id = isset($row['id']) ? (int) $row['id'] : null;
97        $message->threadId = isset($row['thread_id']) ? (int) $row['thread_id'] : 0;
98        $message->typeNum = $row['typeNum'] ?? '';
99        $message->direction = $row['direction'] ?? self::DIRECTION_OUTBOUND;
100        $message->senderType = $row['sender_type'] ?? self::SENDER_SYSTEM;
101        $message->senderId = isset($row['sender_id']) && $row['sender_id'] !== null
102            ? (int) $row['sender_id']
103            : null;
104        $message->templateId = isset($row['template_id']) && $row['template_id'] !== null
105            ? (int) $row['template_id']
106            : null;
107        $message->content = $row['content'] ?? '';
108        $message->contentRaw = $row['content_raw'] ?? null;
109        $message->characterCount = isset($row['character_count']) && $row['character_count'] !== null
110            ? (int) $row['character_count']
111            : null;
112        $message->smsSegmentCount = isset($row['sms_segment_count']) ? (int) $row['sms_segment_count'] : 1;
113        $message->category = $row['category'] ?? self::CATEGORY_TRANSACTIONAL;
114        $message->provider = $row['provider'] ?? null;
115        $message->providerMessageId = $row['provider_message_id'] ?? null;
116        $message->deliveryStatus = $row['delivery_status'] ?? self::DELIVERY_PENDING;
117        $message->deliveryError = $row['delivery_error'] ?? null;
118
119        // Parse timestamps
120        $message->deliveryTimestamp = self::parseDateTime($row['delivery_timestamp'] ?? null);
121        $message->readAt = self::parseDateTime($row['read_at'] ?? null);
122        $message->createdAt = self::parseDateTime($row['created_at'] ?? null);
123
124        return $message;
125    }
126
127    /**
128     * Parse a date string into a DateTime object
129     *
130     * @param string|null $dateString
131     * @return DateTime|null
132     */
133    private static function parseDateTime(?string $dateString): ?DateTime
134    {
135        if ($dateString === null || $dateString === '' || $dateString === '0000-00-00 00:00:00') {
136            return null;
137        }
138
139        try {
140            return new DateTime($dateString);
141        } catch (\Exception $e) {
142            return null;
143        }
144    }
145
146    // =========================================================================
147    // SMS Segment Calculation
148    // =========================================================================
149
150    /**
151     * Check if text contains Unicode characters (outside GSM-7 basic character set)
152     *
153     * @param string $text The text to check
154     * @return bool True if text contains non-ASCII characters
155     */
156    public static function containsUnicode(string $text): bool
157    {
158        return preg_match('/[^\x00-\x7F]/', $text) === 1;
159    }
160
161    /**
162     * Calculate the number of SMS segments required for content (static helper)
163     *
164     * SMS messages have different segment sizes based on encoding:
165     * - GSM-7 encoding (standard ASCII): 160 chars single, 153 per segment for multipart
166     * - UCS-2 encoding (Unicode/emoji): 70 chars single, 67 per segment for multipart
167     *
168     * @param string|null $content The message content (if null, uses instance content)
169     * @return int Number of SMS segments
170     */
171    public static function calculateSegmentCountStatic(string $content): int
172    {
173        $length = mb_strlen($content);
174
175        if ($length === 0) {
176            return 0;
177        }
178
179        if (self::containsUnicode($content)) {
180            // UCS-2 encoding for Unicode content
181            if ($length <= self::UCS2_SINGLE_LIMIT) {
182                return 1;
183            }
184            return (int) ceil($length / self::UCS2_MULTIPART_SEGMENT);
185        } else {
186            // GSM-7 encoding for ASCII content
187            if ($length <= self::GSM7_SINGLE_LIMIT) {
188                return 1;
189            }
190            return (int) ceil($length / self::GSM7_MULTIPART_SEGMENT);
191        }
192    }
193
194    /**
195     * Calculate segment count - works both statically and as instance method
196     *
197     * When called statically with content: ChatMessage::calculateSegmentCount($content)
198     * When called on instance: $message->calculateSegmentCount()
199     *
200     * @param string|null $content Optional content (for static calls)
201     * @return int The calculated segment count
202     */
203    public function calculateSegmentCount(?string $content = null): int
204    {
205        // If called with content argument (static-like usage), calculate for that content
206        if ($content !== null) {
207            return self::calculateSegmentCountStatic($content);
208        }
209
210        // Instance method: calculate and update instance properties
211        $this->smsSegmentCount = self::calculateSegmentCountStatic($this->content);
212        $this->characterCount = mb_strlen($this->content);
213        return $this->smsSegmentCount;
214    }
215
216    // =========================================================================
217    // Behavior Methods
218    // =========================================================================
219
220    /**
221     * Mark the message as read
222     *
223     * @return void
224     */
225    public function markAsRead(): void
226    {
227        if ($this->readAt === null) {
228            $this->readAt = new DateTime();
229        }
230    }
231
232    /**
233     * Update the delivery status from provider callback
234     *
235     * @param string $status The new delivery status
236     * @param DateTime|null $timestamp When the status was confirmed (null preserves existing)
237     * @param string|null $error Error message if delivery failed (null preserves existing)
238     * @return void
239     */
240    public function updateDeliveryStatus(string $status, ?DateTime $timestamp = null, ?string $error = null): void
241    {
242        $this->deliveryStatus = $status;
243        // Only update timestamp and error if provided (null means "no change")
244        if ($timestamp !== null) {
245            $this->deliveryTimestamp = $timestamp;
246        }
247        if ($error !== null) {
248            $this->deliveryError = $error;
249        }
250    }
251
252    /**
253     * Check if this is an inbound message (from customer)
254     *
255     * @return bool
256     */
257    public function isInbound(): bool
258    {
259        return $this->direction === self::DIRECTION_INBOUND;
260    }
261
262    /**
263     * Check if this is an outbound message (to customer)
264     *
265     * @return bool
266     */
267    public function isOutbound(): bool
268    {
269        return $this->direction === self::DIRECTION_OUTBOUND;
270    }
271
272    /**
273     * Check if message was sent by customer
274     *
275     * @return bool
276     */
277    public function isFromCustomer(): bool
278    {
279        return $this->senderType === self::SENDER_CUSTOMER;
280    }
281
282    /**
283     * Check if message was sent by staff
284     *
285     * @return bool
286     */
287    public function isFromStaff(): bool
288    {
289        return $this->senderType === self::SENDER_STAFF;
290    }
291
292    /**
293     * Check if message was sent by system
294     *
295     * @return bool
296     */
297    public function isFromSystem(): bool
298    {
299        return $this->senderType === self::SENDER_SYSTEM;
300    }
301
302    /**
303     * Check if message is transactional
304     *
305     * @return bool
306     */
307    public function isTransactional(): bool
308    {
309        return $this->category === self::CATEGORY_TRANSACTIONAL;
310    }
311
312    /**
313     * Check if message is interactive
314     *
315     * @return bool
316     */
317    public function isInteractive(): bool
318    {
319        return $this->category === self::CATEGORY_INTERACTIVE;
320    }
321
322    /**
323     * Check if delivery is pending or queued
324     *
325     * @return bool
326     */
327    public function isPending(): bool
328    {
329        return in_array($this->deliveryStatus, [self::DELIVERY_PENDING, self::DELIVERY_QUEUED], true);
330    }
331
332    /**
333     * Check if message was delivered successfully
334     *
335     * @return bool
336     */
337    public function isDelivered(): bool
338    {
339        return $this->deliveryStatus === self::DELIVERY_DELIVERED;
340    }
341
342    /**
343     * Check if message delivery failed
344     *
345     * @return bool
346     */
347    public function isFailed(): bool
348    {
349        return in_array($this->deliveryStatus, [self::DELIVERY_FAILED, self::DELIVERY_UNDELIVERED], true);
350    }
351
352    /**
353     * Check if message has been read
354     *
355     * @return bool
356     */
357    public function isRead(): bool
358    {
359        return $this->readAt !== null;
360    }
361
362    /**
363     * Check if message used a template
364     *
365     * @return bool
366     */
367    public function usedTemplate(): bool
368    {
369        return $this->templateId !== null;
370    }
371
372    // =========================================================================
373    // Serialization
374    // =========================================================================
375
376    /**
377     * Convert to array for API responses
378     *
379     * @return array
380     */
381    public function toArray(): array
382    {
383        return [
384            'id' => $this->id,
385            'threadId' => $this->threadId,
386            'typeNum' => $this->typeNum,
387            'direction' => $this->direction,
388            'senderType' => $this->senderType,
389            'senderId' => $this->senderId,
390            'templateId' => $this->templateId,
391            'content' => $this->content,
392            'contentRaw' => $this->contentRaw,
393            'characterCount' => $this->characterCount,
394            'smsSegmentCount' => $this->smsSegmentCount,
395            'category' => $this->category,
396            'provider' => $this->provider,
397            'providerMessageId' => $this->providerMessageId,
398            'deliveryStatus' => $this->deliveryStatus,
399            'deliveryTimestamp' => $this->deliveryTimestamp?->format('Y-m-d H:i:s'),
400            'deliveryError' => $this->deliveryError,
401            'readAt' => $this->readAt?->format('Y-m-d H:i:s'),
402            'createdAt' => $this->createdAt?->format('Y-m-d H:i:s'),
403            'isInbound' => $this->isInbound(),
404            'isFromCustomer' => $this->isFromCustomer(),
405            'isDelivered' => $this->isDelivered(),
406            'isFailed' => $this->isFailed(),
407            'isRead' => $this->isRead(),
408        ];
409    }
410
411    // =========================================================================
412    // Getters
413    // =========================================================================
414
415    public function getId(): ?int
416    {
417        return $this->id;
418    }
419
420    public function getThreadId(): int
421    {
422        return $this->threadId;
423    }
424
425    public function getTypeNum(): string
426    {
427        return $this->typeNum;
428    }
429
430    public function getDirection(): string
431    {
432        return $this->direction;
433    }
434
435    public function getSenderType(): string
436    {
437        return $this->senderType;
438    }
439
440    public function getSenderId(): ?int
441    {
442        return $this->senderId;
443    }
444
445    public function getTemplateId(): ?int
446    {
447        return $this->templateId;
448    }
449
450    public function getContent(): string
451    {
452        return $this->content;
453    }
454
455    public function getContentRaw(): ?string
456    {
457        return $this->contentRaw;
458    }
459
460    public function getCharacterCount(): ?int
461    {
462        return $this->characterCount;
463    }
464
465    public function getSmsSegmentCount(): int
466    {
467        return $this->smsSegmentCount;
468    }
469
470    public function getCategory(): string
471    {
472        return $this->category;
473    }
474
475    public function getProvider(): ?string
476    {
477        return $this->provider;
478    }
479
480    public function getProviderMessageId(): ?string
481    {
482        return $this->providerMessageId;
483    }
484
485    public function getDeliveryStatus(): string
486    {
487        return $this->deliveryStatus;
488    }
489
490    public function getDeliveryTimestamp(): ?DateTime
491    {
492        return $this->deliveryTimestamp;
493    }
494
495    public function getDeliveryError(): ?string
496    {
497        return $this->deliveryError;
498    }
499
500    public function getReadAt(): ?DateTime
501    {
502        return $this->readAt;
503    }
504
505    public function getCreatedAt(): ?DateTime
506    {
507        return $this->createdAt;
508    }
509
510    // =========================================================================
511    // Setters
512    // =========================================================================
513
514    public function setId(?int $id): void
515    {
516        $this->id = $id;
517    }
518
519    public function setThreadId(int $threadId): void
520    {
521        $this->threadId = $threadId;
522    }
523
524    public function setTypeNum(string $typeNum): void
525    {
526        $this->typeNum = $typeNum;
527    }
528
529    public function setDirection(string $direction): void
530    {
531        $this->direction = $direction;
532    }
533
534    public function setSenderType(string $senderType): void
535    {
536        $this->senderType = $senderType;
537    }
538
539    public function setSenderId(?int $senderId): void
540    {
541        $this->senderId = $senderId;
542    }
543
544    public function setTemplateId(?int $templateId): void
545    {
546        $this->templateId = $templateId;
547    }
548
549    public function setContent(string $content): void
550    {
551        $this->content = $content;
552        $this->characterCount = mb_strlen($content);
553    }
554
555    public function setContentRaw(?string $contentRaw): void
556    {
557        $this->contentRaw = $contentRaw;
558    }
559
560    public function setCharacterCount(?int $characterCount): void
561    {
562        $this->characterCount = $characterCount;
563    }
564
565    public function setSmsSegmentCount(int $smsSegmentCount): void
566    {
567        $this->smsSegmentCount = $smsSegmentCount;
568    }
569
570    public function setCategory(string $category): void
571    {
572        $this->category = $category;
573    }
574
575    public function setProvider(?string $provider): void
576    {
577        $this->provider = $provider;
578    }
579
580    public function setProviderMessageId(?string $providerMessageId): void
581    {
582        $this->providerMessageId = $providerMessageId;
583    }
584
585    public function setDeliveryStatus(string $deliveryStatus): void
586    {
587        $this->deliveryStatus = $deliveryStatus;
588    }
589
590    public function setDeliveryTimestamp(?DateTime $deliveryTimestamp): void
591    {
592        $this->deliveryTimestamp = $deliveryTimestamp;
593    }
594
595    public function setDeliveryError(?string $deliveryError): void
596    {
597        $this->deliveryError = $deliveryError;
598    }
599
600    public function setReadAt(?DateTime $readAt): void
601    {
602        $this->readAt = $readAt;
603    }
604
605    public function setCreatedAt(?DateTime $createdAt): void
606    {
607        $this->createdAt = $createdAt;
608    }
609}