Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 87 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
| ChatTemplateService | |
0.00% |
0 / 87 |
|
0.00% |
0 / 12 |
1056 | |
0.00% |
0 / 1 |
| render | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| renderString | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| buildReplacementMap | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
20 | |||
| getCustomerFirstName | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
12 | |||
| getCustomerLastName | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
12 | |||
| getTodayHours | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| formatPhone | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
42 | |||
| getAvailableWildcards | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
| calculateSegmentCount | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
| containsUnicodeCharacters | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| validateTemplate | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
20 | |||
| findUnknownWildcards | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace BuyerKiosk\Chat\Services; |
| 4 | |
| 5 | use BuyerKiosk\Chat\Models\ChatTemplate; |
| 6 | use BuyerKiosk\Core\Store; |
| 7 | use BuyerKiosk\Core\Customer; |
| 8 | use BuyerKiosk\Core\Buy; |
| 9 | |
| 10 | /** |
| 11 | * ChatTemplateService |
| 12 | * |
| 13 | * Handles template wildcard processing and SMS segment calculations for Two-Way SMS Chat. |
| 14 | * |
| 15 | * Key Responsibilities: |
| 16 | * - Render templates with wildcard substitution ({{customer_name}}, etc.) |
| 17 | * - Calculate SMS segment count based on GSM-7 or Unicode encoding |
| 18 | * - Validate templates against character limits (max 320 chars) |
| 19 | * - Provide wildcard documentation for UI |
| 20 | * |
| 21 | * Per SDD lines 939-989: |
| 22 | * - Wildcards: customer_name, customer_full_name, store_name, store_phone, |
| 23 | * store_address, store_city, store_hours, buy_number, buy_date |
| 24 | * |
| 25 | * Per PRD lines 173-180: |
| 26 | * - Maximum 320 characters per message (2 SMS segments) |
| 27 | * - Character count and SMS segment count shown during editing |
| 28 | * |
| 29 | * @package BuyerKiosk\Chat\Services |
| 30 | */ |
| 31 | class ChatTemplateService |
| 32 | { |
| 33 | // Maximum character limit per PRD |
| 34 | private const MAX_CHARACTERS = 320; |
| 35 | |
| 36 | // SMS segment sizes |
| 37 | private const GSM_SINGLE_SEGMENT = 160; |
| 38 | private const GSM_MULTI_SEGMENT = 153; |
| 39 | private const UNICODE_SINGLE_SEGMENT = 70; |
| 40 | private const UNICODE_MULTI_SEGMENT = 67; |
| 41 | |
| 42 | // GSM-7 Basic Character Set (simplified regex pattern) |
| 43 | // Includes: A-Z, a-z, 0-9, standard punctuation, and GSM extended chars |
| 44 | private const GSM7_PATTERN = '/^[@\x{00A3}\x{00A5}\x{00E8}\x{00E9}\x{00F9}\x{00EC}\x{00F2}' |
| 45 | . '\x{00C7}\n\x{00D8}\x{00F8}\r\x{00C5}\x{00E5}\x{0394}\x{005F}' |
| 46 | . '\x{03A6}\x{0393}\x{039B}\x{03A9}\x{03A0}\x{03A8}\x{03A3}' |
| 47 | . '\x{0398}\x{039E}\x{00C6}\x{00E6}\x{00DF}\x{00C9} !"#\$%&\'()*+,' |
| 48 | . '\-.\/0-9:;<=>?\x{00A1}A-Z\x{00C4}\x{00D6}\x{00D1}\x{00DC}' |
| 49 | . '\x{00A7}\x{00BF}a-z\x{00E4}\x{00F6}\x{00F1}\x{00FC}\x{00E0}' |
| 50 | . '\^{}\\\[~\]\|\x{20AC}]*$/u'; |
| 51 | |
| 52 | /** |
| 53 | * Valid wildcards supported by the template system |
| 54 | */ |
| 55 | private const VALID_WILDCARDS = [ |
| 56 | '{{customer_name}}', |
| 57 | '{{customer_full_name}}', |
| 58 | '{{store_name}}', |
| 59 | '{{store_phone}}', |
| 60 | '{{store_address}}', |
| 61 | '{{store_city}}', |
| 62 | '{{store_hours}}', |
| 63 | '{{buy_number}}', |
| 64 | '{{buy_date}}', |
| 65 | ]; |
| 66 | |
| 67 | /** |
| 68 | * Render a template with wildcard substitution |
| 69 | * |
| 70 | * Replaces all supported wildcards with actual values from customer, store, and buy data. |
| 71 | * Missing customer name falls back to "Customer". |
| 72 | * Buy wildcards remain as placeholders if no buy is provided. |
| 73 | * |
| 74 | * @param ChatTemplate $template The template to render |
| 75 | * @param Customer|object $customer Customer object with getFirstName(), getLastName() |
| 76 | * @param Store|object $store Store object with getCompanyName(), getPhone(), etc. |
| 77 | * @param Buy|object|null $buy Optional buy object with getDailyNum(), getTimeEntered() |
| 78 | * @return string Rendered message content |
| 79 | */ |
| 80 | public function render( |
| 81 | ChatTemplate $template, |
| 82 | $customer, |
| 83 | $store, |
| 84 | $buy = null |
| 85 | ): string { |
| 86 | return $this->renderString($template->getContent(), $customer, $store, $buy); |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Render a string template with wildcard substitution (for previews) |
| 91 | * |
| 92 | * Useful for rendering preview content without a ChatTemplate object. |
| 93 | * |
| 94 | * @param string $content Template content with wildcards |
| 95 | * @param Customer|object $customer Customer object |
| 96 | * @param Store|object $store Store object |
| 97 | * @param Buy|object|null $buy Optional buy object |
| 98 | * @return string Rendered message content |
| 99 | */ |
| 100 | public function renderString( |
| 101 | string $content, |
| 102 | $customer, |
| 103 | $store, |
| 104 | $buy = null |
| 105 | ): string { |
| 106 | // Build replacement map |
| 107 | $replacements = $this->buildReplacementMap($customer, $store, $buy); |
| 108 | |
| 109 | // Perform replacements |
| 110 | foreach ($replacements as $wildcard => $value) { |
| 111 | $content = str_replace($wildcard, $value, $content); |
| 112 | } |
| 113 | |
| 114 | return $content; |
| 115 | } |
| 116 | |
| 117 | /** |
| 118 | * Build the wildcard replacement map |
| 119 | * |
| 120 | * @param Customer|object $customer |
| 121 | * @param Store|object $store |
| 122 | * @param Buy|object|null $buy |
| 123 | * @return array Wildcard => value mapping |
| 124 | */ |
| 125 | private function buildReplacementMap($customer, $store, $buy = null): array |
| 126 | { |
| 127 | // Customer wildcards |
| 128 | $firstName = $this->getCustomerFirstName($customer); |
| 129 | $lastName = $this->getCustomerLastName($customer); |
| 130 | $fullName = trim($firstName . ' ' . $lastName); |
| 131 | |
| 132 | $replacements = [ |
| 133 | '{{customer_name}}' => $firstName ?: 'Customer', |
| 134 | '{{customer_full_name}}' => $fullName ?: 'Customer', |
| 135 | '{{store_name}}' => $store->getCompanyName() ?? '', |
| 136 | '{{store_phone}}' => $this->formatPhone($store->getPhone() ?? ''), |
| 137 | '{{store_address}}' => $store->getAddress() ?? '', |
| 138 | '{{store_city}}' => $store->getCity() ?? '', |
| 139 | '{{store_hours}}' => $this->getTodayHours($store), |
| 140 | ]; |
| 141 | |
| 142 | // Buy wildcards (only if buy provided) |
| 143 | if ($buy !== null) { |
| 144 | $replacements['{{buy_number}}'] = '#' . $buy->getDailyNum(); |
| 145 | $replacements['{{buy_date}}'] = date('m/d', strtotime($buy->getTimeEntered())); |
| 146 | } |
| 147 | |
| 148 | return $replacements; |
| 149 | } |
| 150 | |
| 151 | /** |
| 152 | * Get customer first name safely |
| 153 | * |
| 154 | * @param Customer|object $customer |
| 155 | * @return string|null |
| 156 | */ |
| 157 | private function getCustomerFirstName($customer): ?string |
| 158 | { |
| 159 | $firstName = $customer->getFirstName(); |
| 160 | return (is_string($firstName) && trim($firstName) !== '') ? $firstName : null; |
| 161 | } |
| 162 | |
| 163 | /** |
| 164 | * Get customer last name safely |
| 165 | * |
| 166 | * @param Customer|object $customer |
| 167 | * @return string|null |
| 168 | */ |
| 169 | private function getCustomerLastName($customer): ?string |
| 170 | { |
| 171 | $lastName = $customer->getLastName(); |
| 172 | return (is_string($lastName) && trim($lastName) !== '') ? $lastName : null; |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * Get today's store hours |
| 177 | * |
| 178 | * Note: This is a placeholder implementation. In production, this would |
| 179 | * query the store's hours configuration for the current day. |
| 180 | * |
| 181 | * @param Store|object $store |
| 182 | * @return string Hours string like "9am - 9pm" |
| 183 | */ |
| 184 | private function getTodayHours($store): string |
| 185 | { |
| 186 | // Placeholder - would need to look up actual store hours |
| 187 | // For now, return a sensible default |
| 188 | return '9am - 9pm'; |
| 189 | } |
| 190 | |
| 191 | /** |
| 192 | * Format phone number as readable (XXX) XXX-XXXX |
| 193 | * |
| 194 | * @param string $phone Raw phone number |
| 195 | * @return string Formatted phone number |
| 196 | */ |
| 197 | public function formatPhone(string $phone): string |
| 198 | { |
| 199 | // Strip all non-digits |
| 200 | $digits = preg_replace('/\D/', '', $phone); |
| 201 | |
| 202 | // Handle empty input |
| 203 | if ($digits === '' || $digits === null) { |
| 204 | return ''; |
| 205 | } |
| 206 | |
| 207 | // Remove country code if present (11 digits starting with 1) |
| 208 | if (strlen($digits) === 11 && $digits[0] === '1') { |
| 209 | $digits = substr($digits, 1); |
| 210 | } |
| 211 | |
| 212 | // Only format 10-digit numbers |
| 213 | if (strlen($digits) !== 10) { |
| 214 | return $phone; // Return as-is if not 10 digits |
| 215 | } |
| 216 | |
| 217 | // Format as (XXX) XXX-XXXX |
| 218 | return sprintf( |
| 219 | '(%s) %s-%s', |
| 220 | substr($digits, 0, 3), |
| 221 | substr($digits, 3, 3), |
| 222 | substr($digits, 6, 4) |
| 223 | ); |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * Get available wildcards with descriptions |
| 228 | * |
| 229 | * Returns an associative array mapping wildcard syntax to human-readable descriptions. |
| 230 | * Used for documenting wildcards in the UI. |
| 231 | * |
| 232 | * @return array Wildcard => description mapping |
| 233 | */ |
| 234 | public function getAvailableWildcards(): array |
| 235 | { |
| 236 | return [ |
| 237 | '{{customer_name}}' => 'Customer first name', |
| 238 | '{{customer_full_name}}' => 'Customer full name', |
| 239 | '{{store_name}}' => 'Store name', |
| 240 | '{{store_phone}}' => 'Store phone number', |
| 241 | '{{store_address}}' => 'Store street address', |
| 242 | '{{store_city}}' => 'Store city', |
| 243 | '{{store_hours}}' => "Today's hours", |
| 244 | '{{buy_number}}' => 'Buy daily number (e.g., #47)', |
| 245 | '{{buy_date}}' => 'Buy date', |
| 246 | ]; |
| 247 | } |
| 248 | |
| 249 | /** |
| 250 | * Calculate the number of SMS segments required for a message |
| 251 | * |
| 252 | * SMS messages are segmented based on character encoding: |
| 253 | * - GSM-7 (standard chars): 160 chars for single, 153 for multipart |
| 254 | * - Unicode (emojis, special): 70 chars for single, 67 for multipart |
| 255 | * |
| 256 | * @param string $message The message content |
| 257 | * @return int Number of SMS segments (0 for empty message) |
| 258 | */ |
| 259 | public function calculateSegmentCount(string $message): int |
| 260 | { |
| 261 | $length = mb_strlen($message, 'UTF-8'); |
| 262 | |
| 263 | if ($length === 0) { |
| 264 | return 0; |
| 265 | } |
| 266 | |
| 267 | // Check if message requires Unicode encoding |
| 268 | $isUnicode = $this->containsUnicodeCharacters($message); |
| 269 | |
| 270 | if ($isUnicode) { |
| 271 | // Unicode encoding |
| 272 | if ($length <= self::UNICODE_SINGLE_SEGMENT) { |
| 273 | return 1; |
| 274 | } |
| 275 | return (int) ceil($length / self::UNICODE_MULTI_SEGMENT); |
| 276 | } |
| 277 | |
| 278 | // GSM-7 encoding |
| 279 | if ($length <= self::GSM_SINGLE_SEGMENT) { |
| 280 | return 1; |
| 281 | } |
| 282 | return (int) ceil($length / self::GSM_MULTI_SEGMENT); |
| 283 | } |
| 284 | |
| 285 | /** |
| 286 | * Check if message contains characters outside GSM-7 character set |
| 287 | * |
| 288 | * @param string $message |
| 289 | * @return bool True if message requires Unicode encoding |
| 290 | */ |
| 291 | private function containsUnicodeCharacters(string $message): bool |
| 292 | { |
| 293 | return !preg_match(self::GSM7_PATTERN, $message); |
| 294 | } |
| 295 | |
| 296 | /** |
| 297 | * Validate a template against business rules |
| 298 | * |
| 299 | * Checks: |
| 300 | * - Character count within limit (320 max) |
| 301 | * - Content is not empty |
| 302 | * - Warns about unknown wildcards |
| 303 | * |
| 304 | * @param string $content Template content to validate |
| 305 | * @return array Validation result with keys: valid, charCount, segments, errors, warnings |
| 306 | */ |
| 307 | public function validateTemplate(string $content): array |
| 308 | { |
| 309 | $charCount = mb_strlen($content, 'UTF-8'); |
| 310 | $segments = $this->calculateSegmentCount($content); |
| 311 | $errors = []; |
| 312 | $warnings = []; |
| 313 | |
| 314 | // Check for empty content |
| 315 | if ($charCount === 0) { |
| 316 | $errors[] = 'Template content is required'; |
| 317 | } |
| 318 | |
| 319 | // Check character limit |
| 320 | if ($charCount > self::MAX_CHARACTERS) { |
| 321 | $errors[] = sprintf( |
| 322 | 'Content exceeds maximum of %d characters (current: %d)', |
| 323 | self::MAX_CHARACTERS, |
| 324 | $charCount |
| 325 | ); |
| 326 | } |
| 327 | |
| 328 | // Check for unknown wildcards |
| 329 | $unknownWildcards = $this->findUnknownWildcards($content); |
| 330 | if (!empty($unknownWildcards)) { |
| 331 | $warnings[] = 'Unknown wildcards: ' . implode(', ', $unknownWildcards); |
| 332 | } |
| 333 | |
| 334 | return [ |
| 335 | 'valid' => empty($errors), |
| 336 | 'charCount' => $charCount, |
| 337 | 'segments' => $segments, |
| 338 | 'errors' => $errors, |
| 339 | 'warnings' => $warnings, |
| 340 | ]; |
| 341 | } |
| 342 | |
| 343 | /** |
| 344 | * Find wildcards in content that are not in the valid list |
| 345 | * |
| 346 | * @param string $content |
| 347 | * @return array List of unknown wildcards |
| 348 | */ |
| 349 | private function findUnknownWildcards(string $content): array |
| 350 | { |
| 351 | preg_match_all('/\{\{([a-z_]+)\}\}/', $content, $matches); |
| 352 | $foundWildcards = $matches[0] ?? []; |
| 353 | |
| 354 | return array_values(array_diff($foundWildcards, self::VALID_WILDCARDS)); |
| 355 | } |
| 356 | } |