Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 124
0.00% covered (danger)
0.00%
0 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatTemplate
0.00% covered (danger)
0.00%
0 / 124
0.00% covered (danger)
0.00%
0 / 36
3306
0.00% covered (danger)
0.00%
0 / 1
 fromRow
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 parseDateTime
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 getValidCategories
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getWildcards
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getUnknownWildcards
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 hasUnknownWildcards
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 calculateCharacterCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 calculateSegmentCount
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 containsUnicodeCharacters
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 validate
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 canModify
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canDelete
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 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 toDbRow
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 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
 getShortName
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
 getCategory
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
 isActive
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isSystem
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSortOrder
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCreatedByEmployeeId
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
 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
 setShortName
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 / 3
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
 setIsActive
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setIsSystem
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSortOrder
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCreatedByEmployeeId
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 * ChatTemplate entity model
9 *
10 * Represents a reusable SMS message template for Two-Way SMS Chat.
11 *
12 * Business Rules:
13 * - Staff can only send templates until the customer replies (freetext locked)
14 * - Templates support wildcards for dynamic content ({{customer_name}}, etc.)
15 * - System templates cannot be modified or deleted
16 * - Templates are categorized: transactional, initial_contact, follow_up
17 * - Maximum character limit is 320 characters (per PRD)
18 *
19 * Database table: chat_templates
20 *
21 * @package BuyerKiosk\Chat\Models
22 */
23class ChatTemplate
24{
25    // Category constants
26    public const CATEGORY_TRANSACTIONAL = 'transactional';
27    public const CATEGORY_INITIAL_CONTACT = 'initial_contact';
28    public const CATEGORY_FOLLOW_UP = 'follow_up';
29
30    // Valid wildcards supported by the template system
31    public const VALID_WILDCARDS = [
32        '{{customer_name}}',
33        '{{customer_full_name}}',
34        '{{store_name}}',
35        '{{store_phone}}',
36        '{{store_address}}',
37        '{{store_city}}',
38        '{{store_hours}}',
39        '{{buy_number}}',
40        '{{buy_date}}',
41    ];
42
43    // Maximum character limit per PRD
44    public const MAX_CHARACTERS = 320;
45
46    // SMS segment size for character count calculations
47    private const SMS_SEGMENT_SIZE = 160;
48    private const SMS_SEGMENT_SIZE_UNICODE = 70;
49
50    // Properties
51    private ?int $id = null;
52    private string $typeNum = '';
53    private string $shortName = '';
54    private string $content = '';
55    private string $category = self::CATEGORY_TRANSACTIONAL;
56    private ?int $characterCount = null;
57    private ?int $smsSegmentCount = null;
58    private bool $isActive = true;
59    private bool $isSystem = false;
60    private int $sortOrder = 0;
61    private ?int $createdByEmployeeId = null;
62    private ?DateTime $createdAt = null;
63    private ?DateTime $updatedAt = null;
64
65    /**
66     * Create a ChatTemplate instance from a database row
67     *
68     * @param array $row Database row with template data
69     * @return self
70     */
71    public static function fromRow(array $row): self
72    {
73        $template = new self();
74
75        $template->id = isset($row['id']) ? (int) $row['id'] : null;
76        $template->typeNum = $row['typeNum'] ?? '';
77        $template->shortName = $row['short_name'] ?? '';
78        $template->content = $row['content'] ?? '';
79        $template->category = $row['category'] ?? self::CATEGORY_TRANSACTIONAL;
80        $template->characterCount = isset($row['character_count']) ? (int) $row['character_count'] : null;
81        $template->smsSegmentCount = isset($row['sms_segment_count']) ? (int) $row['sms_segment_count'] : null;
82        $template->isActive = !empty($row['is_active']);
83        $template->isSystem = !empty($row['is_system']);
84        $template->sortOrder = isset($row['sort_order']) ? (int) $row['sort_order'] : 0;
85        $template->createdByEmployeeId = isset($row['created_by_employee_id'])
86            ? (int) $row['created_by_employee_id']
87            : null;
88
89        // Parse timestamps
90        $template->createdAt = self::parseDateTime($row['created_at'] ?? null);
91        $template->updatedAt = self::parseDateTime($row['updated_at'] ?? null);
92
93        return $template;
94    }
95
96    /**
97     * Parse a date string into a DateTime object
98     *
99     * @param string|null $dateString
100     * @return DateTime|null
101     */
102    private static function parseDateTime(?string $dateString): ?DateTime
103    {
104        if ($dateString === null || $dateString === '' || $dateString === '0000-00-00 00:00:00') {
105            return null;
106        }
107
108        try {
109            return new DateTime($dateString);
110        } catch (\Exception $e) {
111            return null;
112        }
113    }
114
115    /**
116     * Get all valid categories
117     *
118     * @return array List of valid category constants
119     */
120    public static function getValidCategories(): array
121    {
122        return [
123            self::CATEGORY_TRANSACTIONAL,
124            self::CATEGORY_INITIAL_CONTACT,
125            self::CATEGORY_FOLLOW_UP,
126        ];
127    }
128
129    // =========================================================================
130    // Wildcard Methods
131    // =========================================================================
132
133    /**
134     * Extract all wildcards from content
135     *
136     * Matches pattern: {{wildcard_name}}
137     *
138     * @return array List of unique wildcards found in content
139     */
140    public function getWildcards(): array
141    {
142        preg_match_all('/\{\{([a-z_]+)\}\}/', $this->content, $matches);
143        return array_unique($matches[0] ?? []);
144    }
145
146    /**
147     * Get wildcards that are not in the valid list
148     *
149     * @return array List of unknown wildcards
150     */
151    public function getUnknownWildcards(): array
152    {
153        $wildcards = $this->getWildcards();
154        return array_values(array_diff($wildcards, self::VALID_WILDCARDS));
155    }
156
157    /**
158     * Check if template contains unknown wildcards
159     *
160     * @return bool True if template has wildcards not in VALID_WILDCARDS
161     */
162    public function hasUnknownWildcards(): bool
163    {
164        return count($this->getUnknownWildcards()) > 0;
165    }
166
167    // =========================================================================
168    // Character Count Methods
169    // =========================================================================
170
171    /**
172     * Calculate the character count of the template content
173     *
174     * Uses mb_strlen for accurate multibyte character counting.
175     *
176     * @return int Character count
177     */
178    public function calculateCharacterCount(): int
179    {
180        return mb_strlen($this->content, 'UTF-8');
181    }
182
183    /**
184     * Calculate the number of SMS segments required for this template
185     *
186     * SMS messages are segmented based on character encoding:
187     * - GSM-7: 160 chars per segment (or 153 for multipart)
188     * - Unicode: 70 chars per segment (or 67 for multipart)
189     *
190     * Note: Wildcards will be replaced with actual values, so the actual
191     * segment count may vary at send time.
192     *
193     * @return int Number of SMS segments
194     */
195    public function calculateSegmentCount(): int
196    {
197        $length = $this->calculateCharacterCount();
198
199        if ($length === 0) {
200            return 0;
201        }
202
203        // Check if content contains non-GSM characters (requires Unicode encoding)
204        $isUnicode = $this->containsUnicodeCharacters();
205
206        if ($isUnicode) {
207            // Unicode: 70 chars for single, 67 for multipart
208            if ($length <= self::SMS_SEGMENT_SIZE_UNICODE) {
209                return 1;
210            }
211            return (int) ceil($length / 67);
212        }
213
214        // GSM-7: 160 chars for single, 153 for multipart
215        if ($length <= self::SMS_SEGMENT_SIZE) {
216            return 1;
217        }
218        return (int) ceil($length / 153);
219    }
220
221    /**
222     * Check if content contains characters outside the GSM-7 character set
223     *
224     * @return bool True if content requires Unicode encoding
225     */
226    private function containsUnicodeCharacters(): bool
227    {
228        // GSM-7 basic character set (simplified check)
229        // Full GSM-7 includes: @ $ and some accented chars
230        $gsmPattern = '/^[@\x{00A3}\x{00A5}\x{00E8}\x{00E9}\x{00F9}\x{00EC}\x{00F2}'
231            . '\x{00C7}\n\x{00D8}\x{00F8}\r\x{00C5}\x{00E5}\x{0394}\x{005F}'
232            . '\x{03A6}\x{0393}\x{039B}\x{03A9}\x{03A0}\x{03A8}\x{03A3}'
233            . '\x{0398}\x{039E}\x{00C6}\x{00E6}\x{00DF}\x{00C9} !"#\$%&\'()*+,'
234            . '\-.\/0-9:;<=>?\x{00A1}A-Z\x{00C4}\x{00D6}\x{00D1}\x{00DC}'
235            . '\x{00A7}\x{00BF}a-z\x{00E4}\x{00F6}\x{00F1}\x{00FC}\x{00E0}'
236            . '\^{}\\\[~\]\|]*$/u';
237
238        return !preg_match($gsmPattern, $this->content);
239    }
240
241    // =========================================================================
242    // Validation Methods
243    // =========================================================================
244
245    /**
246     * Validate template
247     *
248     * Validates all business rules and constraints.
249     *
250     * @return array Array of error messages, empty if valid
251     */
252    public function validate(): array
253    {
254        $errors = [];
255
256        if (empty(trim($this->typeNum))) {
257            $errors[] = 'typeNum is required';
258        }
259
260        if (empty(trim($this->shortName))) {
261            $errors[] = 'shortName is required';
262        }
263
264        if (empty(trim($this->content))) {
265            $errors[] = 'Content is required';
266        }
267
268        if ($this->calculateCharacterCount() > self::MAX_CHARACTERS) {
269            $errors[] = 'Content exceeds maximum of ' . self::MAX_CHARACTERS . ' characters';
270        }
271
272        $unknownWildcards = $this->getUnknownWildcards();
273        if (!empty($unknownWildcards)) {
274            $errors[] = 'Unknown wildcards: ' . implode(', ', $unknownWildcards);
275        }
276
277        $validCategories = self::getValidCategories();
278        if (!in_array($this->category, $validCategories, true)) {
279            $errors[] = 'Invalid category';
280        }
281
282        return $errors;
283    }
284
285    // =========================================================================
286    // Protection Methods
287    // =========================================================================
288
289    /**
290     * Check if template can be modified
291     *
292     * System templates can still be activated/deactivated but content cannot change.
293     *
294     * @return bool True if template content can be modified
295     */
296    public function canModify(): bool
297    {
298        return !$this->isSystem;
299    }
300
301    /**
302     * Check if template can be deleted
303     *
304     * System templates cannot be deleted (per PRD).
305     *
306     * @return bool True if template can be deleted
307     */
308    public function canDelete(): bool
309    {
310        return !$this->isSystem;
311    }
312
313    // =========================================================================
314    // Serialization
315    // =========================================================================
316
317    /**
318     * Convert to array for API responses
319     *
320     * @return array Associative array representation
321     */
322    public function toArray(): array
323    {
324        return [
325            'id' => $this->id,
326            'typeNum' => $this->typeNum,
327            'shortName' => $this->shortName,
328            'content' => $this->content,
329            'category' => $this->category,
330            'characterCount' => $this->characterCount ?? $this->calculateCharacterCount(),
331            'smsSegmentCount' => $this->smsSegmentCount ?? $this->calculateSegmentCount(),
332            'isActive' => $this->isActive,
333            'isSystem' => $this->isSystem,
334            'sortOrder' => $this->sortOrder,
335            'createdByEmployeeId' => $this->createdByEmployeeId,
336            'createdAt' => $this->createdAt?->format('Y-m-d H:i:s'),
337            'updatedAt' => $this->updatedAt?->format('Y-m-d H:i:s'),
338            'wildcards' => $this->getWildcards(),
339            'canModify' => $this->canModify(),
340            'canDelete' => $this->canDelete(),
341        ];
342    }
343
344    /**
345     * Convert to database row format for INSERT/UPDATE
346     *
347     * Note: Does not include 'id', 'created_at', or 'updated_at' as these
348     * are managed by the database.
349     *
350     * @return array Database column => value mapping
351     */
352    public function toDbRow(): array
353    {
354        return [
355            'typeNum' => $this->typeNum,
356            'short_name' => $this->shortName,
357            'content' => $this->content,
358            'category' => $this->category,
359            'character_count' => $this->calculateCharacterCount(),
360            'sms_segment_count' => $this->calculateSegmentCount(),
361            'is_active' => $this->isActive ? 1 : 0,
362            'is_system' => $this->isSystem ? 1 : 0,
363            'sort_order' => $this->sortOrder,
364            'created_by_employee_id' => $this->createdByEmployeeId,
365        ];
366    }
367
368    // =========================================================================
369    // Getters
370    // =========================================================================
371
372    public function getId(): ?int
373    {
374        return $this->id;
375    }
376
377    public function getTypeNum(): string
378    {
379        return $this->typeNum;
380    }
381
382    public function getShortName(): string
383    {
384        return $this->shortName;
385    }
386
387    public function getContent(): string
388    {
389        return $this->content;
390    }
391
392    public function getCategory(): string
393    {
394        return $this->category;
395    }
396
397    public function getCharacterCount(): ?int
398    {
399        return $this->characterCount;
400    }
401
402    public function getSmsSegmentCount(): ?int
403    {
404        return $this->smsSegmentCount;
405    }
406
407    public function isActive(): bool
408    {
409        return $this->isActive;
410    }
411
412    public function isSystem(): bool
413    {
414        return $this->isSystem;
415    }
416
417    public function getSortOrder(): int
418    {
419        return $this->sortOrder;
420    }
421
422    public function getCreatedByEmployeeId(): ?int
423    {
424        return $this->createdByEmployeeId;
425    }
426
427    public function getCreatedAt(): ?DateTime
428    {
429        return $this->createdAt;
430    }
431
432    public function getUpdatedAt(): ?DateTime
433    {
434        return $this->updatedAt;
435    }
436
437    // =========================================================================
438    // Setters
439    // =========================================================================
440
441    public function setId(?int $id): void
442    {
443        $this->id = $id;
444    }
445
446    public function setTypeNum(string $typeNum): void
447    {
448        $this->typeNum = $typeNum;
449    }
450
451    public function setShortName(string $shortName): void
452    {
453        $this->shortName = $shortName;
454    }
455
456    public function setContent(string $content): void
457    {
458        $this->content = $content;
459        // Reset cached counts when content changes
460        $this->characterCount = null;
461        $this->smsSegmentCount = null;
462    }
463
464    public function setCategory(string $category): void
465    {
466        $this->category = $category;
467    }
468
469    public function setIsActive(bool $isActive): void
470    {
471        $this->isActive = $isActive;
472    }
473
474    public function setIsSystem(bool $isSystem): void
475    {
476        $this->isSystem = $isSystem;
477    }
478
479    public function setSortOrder(int $sortOrder): void
480    {
481        $this->sortOrder = $sortOrder;
482    }
483
484    public function setCreatedByEmployeeId(?int $createdByEmployeeId): void
485    {
486        $this->createdByEmployeeId = $createdByEmployeeId;
487    }
488}