Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 124 |
|
0.00% |
0 / 36 |
CRAP | |
0.00% |
0 / 1 |
| ChatTemplate | |
0.00% |
0 / 124 |
|
0.00% |
0 / 36 |
3306 | |
0.00% |
0 / 1 |
| fromRow | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
42 | |||
| parseDateTime | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
30 | |||
| getValidCategories | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
| getWildcards | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| getUnknownWildcards | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| hasUnknownWildcards | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| calculateCharacterCount | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| calculateSegmentCount | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
| containsUnicodeCharacters | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
| validate | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
56 | |||
| canModify | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| canDelete | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| toArray | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
2 | |||
| toDbRow | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
| getId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getTypeNum | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getShortName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getContent | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getCategory | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getCharacterCount | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getSmsSegmentCount | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| isActive | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| isSystem | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getSortOrder | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getCreatedByEmployeeId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getCreatedAt | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getUpdatedAt | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setTypeNum | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setShortName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setContent | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| setCategory | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setIsActive | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setIsSystem | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setSortOrder | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setCreatedByEmployeeId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace BuyerKiosk\Chat\Models; |
| 4 | |
| 5 | use 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 | */ |
| 23 | class 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 | } |