Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
30.77% covered (danger)
30.77%
28 / 91
9.52% covered (danger)
9.52%
4 / 42
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatThread
30.77% covered (danger)
30.77%
28 / 91
9.52% covered (danger)
9.52%
4 / 42
1096.58
0.00% covered (danger)
0.00%
0 / 1
 fromRow
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
9.07
 parseDateTime
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
6.60
 canStaffSendFreetext
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unlock
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 close
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 reopen
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 isOpen
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isPending
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isClosed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isArchived
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isActive
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 / 20
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
 getTypeNum
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCustomerId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCustomerPhone
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBuyId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isStaffCanFreetext
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLastMessageAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLastCustomerMessageAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLastStaffMessageAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getOpenedByEmployeeId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getClosedByEmployeeId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getClosedAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCreatedAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUpdatedAt
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
 setTypeNum
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCustomerId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCustomerPhone
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setBuyId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setStaffCanFreetext
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setOpenedByEmployeeId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setClosedByEmployeeId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setLastMessageAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setLastCustomerMessageAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setLastStaffMessageAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setClosedAt
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
 setUpdatedAt
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 * ChatThread entity model
9 *
10 * Represents a two-way SMS conversation thread between store staff and a customer.
11 * Each thread tracks the conversation state, message timestamps, and staff permissions.
12 *
13 * Business Rules:
14 * - Staff can send templates ONLY until customer replies (staffCanFreetext = false)
15 * - After customer replies, freetext is unlocked (staffCanFreetext = true, status = active)
16 * - Thread status flow: pending -> active -> closed -> archived
17 * - Only active threads allow new messages
18 *
19 * Database table: chat_threads (per-store database)
20 *
21 * @package BuyerKiosk\Chat\Models
22 */
23class ChatThread
24{
25    // Status constants
26    public const STATUS_PENDING = 'pending';
27    public const STATUS_ACTIVE = 'active';
28    public const STATUS_CLOSED = 'closed';
29    public const STATUS_ARCHIVED = 'archived';
30
31    // Properties
32    private ?int $id = null;
33    private string $typeNum = '';
34    private int $customerId = 0;
35    private string $customerPhone = '';
36    private ?int $buyId = null;
37    private string $status = self::STATUS_PENDING;
38    private bool $staffCanFreetext = false;
39    private ?DateTime $lastMessageAt = null;
40    private ?DateTime $lastCustomerMessageAt = null;
41    private ?DateTime $lastStaffMessageAt = null;
42    private ?int $openedByEmployeeId = null;
43    private ?int $closedByEmployeeId = null;
44    private ?DateTime $closedAt = null;
45    private ?DateTime $createdAt = null;
46    private ?DateTime $updatedAt = null;
47
48    /**
49     * Create a ChatThread instance from a database row
50     *
51     * Handles mapping from snake_case database columns to camelCase properties.
52     *
53     * @param array $row Database row with thread data
54     * @return self
55     */
56    public static function fromRow(array $row): self
57    {
58        $thread = new self();
59
60        $thread->id = isset($row['id']) ? (int) $row['id'] : null;
61        $thread->typeNum = $row['typeNum'] ?? '';
62        $thread->customerId = isset($row['customer_id']) ? (int) $row['customer_id'] : 0;
63        $thread->customerPhone = $row['customer_phone'] ?? '';
64        $thread->buyId = isset($row['buy_id']) && $row['buy_id'] !== null ? (int) $row['buy_id'] : null;
65        $thread->status = $row['status'] ?? self::STATUS_PENDING;
66        $thread->staffCanFreetext = !empty($row['staff_can_freetext']);
67        $thread->openedByEmployeeId = isset($row['opened_by_employee_id']) && $row['opened_by_employee_id'] !== null
68            ? (int) $row['opened_by_employee_id']
69            : null;
70        $thread->closedByEmployeeId = isset($row['closed_by_employee_id']) && $row['closed_by_employee_id'] !== null
71            ? (int) $row['closed_by_employee_id']
72            : null;
73
74        // Parse timestamps (using snake_case column names from database)
75        $thread->lastMessageAt = self::parseDateTime($row['last_message_at'] ?? null);
76        $thread->lastCustomerMessageAt = self::parseDateTime($row['last_customer_message_at'] ?? null);
77        $thread->lastStaffMessageAt = self::parseDateTime($row['last_staff_message_at'] ?? null);
78        $thread->closedAt = self::parseDateTime($row['closed_at'] ?? null);
79        $thread->createdAt = self::parseDateTime($row['created_at'] ?? null);
80        $thread->updatedAt = self::parseDateTime($row['updated_at'] ?? null);
81
82        return $thread;
83    }
84
85    /**
86     * Parse a date string into a DateTime object
87     *
88     * @param string|null $dateString
89     * @return DateTime|null
90     */
91    private static function parseDateTime(?string $dateString): ?DateTime
92    {
93        if ($dateString === null || $dateString === '' || $dateString === '0000-00-00 00:00:00') {
94            return null;
95        }
96
97        try {
98            return new DateTime($dateString);
99        } catch (\Exception $e) {
100            return null;
101        }
102    }
103
104    // =========================================================================
105    // Business Logic Methods
106    // =========================================================================
107
108    /**
109     * Check if staff can send freetext messages (not just templates)
110     *
111     * Per business rules, this returns false until customer replies.
112     * Once a customer replies, freetext is unlocked via unlock().
113     *
114     * @return bool
115     */
116    public function canStaffSendFreetext(): bool
117    {
118        return $this->staffCanFreetext;
119    }
120
121    /**
122     * Unlock freetext messaging for staff
123     *
124     * Called when customer replies to enable staff freetext.
125     * Also sets status to active if currently pending.
126     *
127     * @return void
128     */
129    public function unlock(): void
130    {
131        $this->staffCanFreetext = true;
132        if ($this->status === self::STATUS_PENDING) {
133            $this->status = self::STATUS_ACTIVE;
134        }
135    }
136
137    /**
138     * Close the thread
139     *
140     * Sets status to closed and records who closed it and when.
141     *
142     * @param int $employeeId ID of employee closing the thread
143     * @return void
144     */
145    public function close(int $employeeId): void
146    {
147        $this->status = self::STATUS_CLOSED;
148        $this->closedByEmployeeId = $employeeId;
149        $this->closedAt = new DateTime();
150    }
151
152    /**
153     * Reopen a closed or archived thread
154     *
155     * Sets status back to active and clears the closed fields.
156     *
157     * @return void
158     */
159    public function reopen(): void
160    {
161        $this->status = self::STATUS_ACTIVE;
162        $this->closedByEmployeeId = null;
163        $this->closedAt = null;
164    }
165
166    /**
167     * Check if the thread is open (pending or active)
168     *
169     * @return bool
170     */
171    public function isOpen(): bool
172    {
173        return $this->status === self::STATUS_PENDING || $this->status === self::STATUS_ACTIVE;
174    }
175
176    /**
177     * Check if the thread is pending (waiting for customer response)
178     *
179     * @return bool
180     */
181    public function isPending(): bool
182    {
183        return $this->status === self::STATUS_PENDING;
184    }
185
186    /**
187     * Check if the thread is closed
188     *
189     * @return bool
190     */
191    public function isClosed(): bool
192    {
193        return $this->status === self::STATUS_CLOSED;
194    }
195
196    /**
197     * Check if the thread is archived
198     *
199     * @return bool
200     */
201    public function isArchived(): bool
202    {
203        return $this->status === self::STATUS_ARCHIVED;
204    }
205
206    /**
207     * Check if the thread is active (customer has responded)
208     *
209     * @return bool
210     */
211    public function isActive(): bool
212    {
213        return $this->status === self::STATUS_ACTIVE;
214    }
215
216    // =========================================================================
217    // Serialization
218    // =========================================================================
219
220    /**
221     * Convert to array for API responses
222     *
223     * Returns camelCase keys for frontend consumption.
224     *
225     * @return array
226     */
227    public function toArray(): array
228    {
229        return [
230            'id' => $this->id,
231            'typeNum' => $this->typeNum,
232            'customerId' => $this->customerId,
233            'customerPhone' => $this->customerPhone,
234            'buyId' => $this->buyId,
235            'status' => $this->status,
236            'staffCanFreetext' => $this->staffCanFreetext,
237            'lastMessageAt' => $this->lastMessageAt?->format('Y-m-d H:i:s'),
238            'lastCustomerMessageAt' => $this->lastCustomerMessageAt?->format('Y-m-d H:i:s'),
239            'lastStaffMessageAt' => $this->lastStaffMessageAt?->format('Y-m-d H:i:s'),
240            'openedByEmployeeId' => $this->openedByEmployeeId,
241            'closedByEmployeeId' => $this->closedByEmployeeId,
242            'closedAt' => $this->closedAt?->format('Y-m-d H:i:s'),
243            'createdAt' => $this->createdAt?->format('Y-m-d H:i:s'),
244            'updatedAt' => $this->updatedAt?->format('Y-m-d H:i:s'),
245            'isOpen' => $this->isOpen(),
246            'isPending' => $this->isPending(),
247            'isClosed' => $this->isClosed(),
248        ];
249    }
250
251    // =========================================================================
252    // Getters
253    // =========================================================================
254
255    /**
256     * Get thread ID
257     * @return int|null
258     */
259    public function getId(): ?int
260    {
261        return $this->id;
262    }
263
264    /**
265     * Get store identifier
266     * @return string
267     */
268    public function getTypeNum(): string
269    {
270        return $this->typeNum;
271    }
272
273    /**
274     * Get customer ID
275     * @return int
276     */
277    public function getCustomerId(): int
278    {
279        return $this->customerId;
280    }
281
282    /**
283     * Get customer phone number
284     * @return string
285     */
286    public function getCustomerPhone(): string
287    {
288        return $this->customerPhone;
289    }
290
291    /**
292     * Get associated buy transaction ID
293     * @return int|null
294     */
295    public function getBuyId(): ?int
296    {
297        return $this->buyId;
298    }
299
300    /**
301     * Get thread status
302     * @return string One of: pending, active, closed, archived
303     */
304    public function getStatus(): string
305    {
306        return $this->status;
307    }
308
309    /**
310     * Get staff freetext permission flag
311     * @return bool
312     */
313    public function isStaffCanFreetext(): bool
314    {
315        return $this->staffCanFreetext;
316    }
317
318    /**
319     * Get timestamp of most recent message
320     * @return DateTime|null
321     */
322    public function getLastMessageAt(): ?DateTime
323    {
324        return $this->lastMessageAt;
325    }
326
327    /**
328     * Get timestamp of last customer message
329     * @return DateTime|null
330     */
331    public function getLastCustomerMessageAt(): ?DateTime
332    {
333        return $this->lastCustomerMessageAt;
334    }
335
336    /**
337     * Get timestamp of last staff message
338     * @return DateTime|null
339     */
340    public function getLastStaffMessageAt(): ?DateTime
341    {
342        return $this->lastStaffMessageAt;
343    }
344
345    /**
346     * Get ID of employee who opened/activated the thread
347     * @return int|null
348     */
349    public function getOpenedByEmployeeId(): ?int
350    {
351        return $this->openedByEmployeeId;
352    }
353
354    /**
355     * Get ID of employee who closed the thread
356     * @return int|null
357     */
358    public function getClosedByEmployeeId(): ?int
359    {
360        return $this->closedByEmployeeId;
361    }
362
363    /**
364     * Get timestamp when thread was closed
365     * @return DateTime|null
366     */
367    public function getClosedAt(): ?DateTime
368    {
369        return $this->closedAt;
370    }
371
372    /**
373     * Get creation timestamp
374     * @return DateTime|null
375     */
376    public function getCreatedAt(): ?DateTime
377    {
378        return $this->createdAt;
379    }
380
381    /**
382     * Get last update timestamp
383     * @return DateTime|null
384     */
385    public function getUpdatedAt(): ?DateTime
386    {
387        return $this->updatedAt;
388    }
389
390    // =========================================================================
391    // Setters
392    // =========================================================================
393
394    /**
395     * Set thread ID
396     * @param int|null $id
397     */
398    public function setId(?int $id): void
399    {
400        $this->id = $id;
401    }
402
403    /**
404     * Set store identifier
405     * @param string $typeNum
406     */
407    public function setTypeNum(string $typeNum): void
408    {
409        $this->typeNum = $typeNum;
410    }
411
412    /**
413     * Set customer ID
414     * @param int $customerId
415     */
416    public function setCustomerId(int $customerId): void
417    {
418        $this->customerId = $customerId;
419    }
420
421    /**
422     * Set customer phone number
423     * @param string $customerPhone
424     */
425    public function setCustomerPhone(string $customerPhone): void
426    {
427        $this->customerPhone = $customerPhone;
428    }
429
430    /**
431     * Set buy transaction ID
432     * @param int|null $buyId
433     */
434    public function setBuyId(?int $buyId): void
435    {
436        $this->buyId = $buyId;
437    }
438
439    /**
440     * Set thread status
441     * @param string $status One of: pending, active, closed, archived
442     */
443    public function setStatus(string $status): void
444    {
445        $this->status = $status;
446    }
447
448    /**
449     * Set staff freetext permission
450     * @param bool $staffCanFreetext
451     */
452    public function setStaffCanFreetext(bool $staffCanFreetext): void
453    {
454        $this->staffCanFreetext = $staffCanFreetext;
455    }
456
457    /**
458     * Set ID of employee who opened the thread
459     * @param int|null $openedByEmployeeId
460     */
461    public function setOpenedByEmployeeId(?int $openedByEmployeeId): void
462    {
463        $this->openedByEmployeeId = $openedByEmployeeId;
464    }
465
466    /**
467     * Set ID of employee who closed the thread
468     * @param int|null $closedByEmployeeId
469     */
470    public function setClosedByEmployeeId(?int $closedByEmployeeId): void
471    {
472        $this->closedByEmployeeId = $closedByEmployeeId;
473    }
474
475    /**
476     * Set timestamp of most recent message
477     * @param DateTime|null $lastMessageAt
478     */
479    public function setLastMessageAt(?DateTime $lastMessageAt): void
480    {
481        $this->lastMessageAt = $lastMessageAt;
482    }
483
484    /**
485     * Set timestamp of last customer message
486     * @param DateTime|null $lastCustomerMessageAt
487     */
488    public function setLastCustomerMessageAt(?DateTime $lastCustomerMessageAt): void
489    {
490        $this->lastCustomerMessageAt = $lastCustomerMessageAt;
491    }
492
493    /**
494     * Set timestamp of last staff message
495     * @param DateTime|null $lastStaffMessageAt
496     */
497    public function setLastStaffMessageAt(?DateTime $lastStaffMessageAt): void
498    {
499        $this->lastStaffMessageAt = $lastStaffMessageAt;
500    }
501
502    /**
503     * Set timestamp when thread was closed
504     * @param DateTime|null $closedAt
505     */
506    public function setClosedAt(?DateTime $closedAt): void
507    {
508        $this->closedAt = $closedAt;
509    }
510
511    /**
512     * Set creation timestamp
513     * @param DateTime|null $createdAt
514     */
515    public function setCreatedAt(?DateTime $createdAt): void
516    {
517        $this->createdAt = $createdAt;
518    }
519
520    /**
521     * Set last update timestamp
522     * @param DateTime|null $updatedAt
523     */
524    public function setUpdatedAt(?DateTime $updatedAt): void
525    {
526        $this->updatedAt = $updatedAt;
527    }
528}