Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 258
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatWebhookController
0.00% covered (danger)
0.00%
0 / 258
0.00% covered (danger)
0.00%
0 / 13
1806
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 handleInbound
0.00% covered (danger)
0.00%
0 / 108
0.00% covered (danger)
0.00%
0 / 1
110
 extractPayloadData
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 detectOptOutCommand
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 processOptOut
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
30
 sendOptOutConfirmation
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 closeThreadsForPhone
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 findOrCreateThread
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
12
 saveInboundMessage
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 unlockFreetext
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 updateThreadTimestamps
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 publishAblyEvents
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 logUnroutableMessage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\Chat\Controllers;
4
5use BuyerKiosk\Chat\Models\ChatThread;
6use BuyerKiosk\Chat\Models\ChatMessage;
7use BuyerKiosk\Chat\Services\ChatMatchingService;
8use BuyerKiosk\Chat\Events\ChatAblyPublisher;
9use DateTime;
10use PDO;
11
12/**
13 * ChatWebhookController - Handles inbound SMS webhooks from providers
14 *
15 * This controller receives POST requests from Vonage/Twilio when customers
16 * send SMS messages. It routes messages to the appropriate store, handles
17 * opt-out commands, and publishes real-time events.
18 *
19 * Webhook Flow (from SDD lines 1036-1072):
20 * 1. Customer sends SMS to shortcode
21 * 2. Provider calls POST /api/webhooks/chat-inbound
22 * 3. Extract phone and message from provider-specific payload
23 * 4. ChatMatchingService finds store by latest buy
24 * 5. If STOP command, process opt-out and return
25 * 6. Find or create thread for customer
26 * 7. Save inbound message
27 * 8. If first customer message, unlock freetext
28 * 9. Publish Ably event to store channel
29 * 10. Return 200 OK to provider
30 *
31 * @package BuyerKiosk\Chat\Controllers
32 */
33class ChatWebhookController
34{
35    // Provider constants
36    public const PROVIDER_VONAGE = 'vonage';
37    public const PROVIDER_TWILIO = 'twilio';
38
39    // Opt-out type constants
40    public const OPTOUT_ALL = 'all';
41    public const OPTOUT_MARKETING = 'marketing';
42
43    // Opt-out command patterns (case insensitive)
44    private const OPTOUT_ALL_PATTERNS = [
45        '/^stop$/i',
46        '/^unsubscribe$/i',
47        '/^cancel$/i',
48        '/^quit$/i',
49        '/^end$/i',
50        '/^leave me alone$/i',
51    ];
52
53    private const OPTOUT_MARKETING_PATTERNS = [
54        '/^stop marketing$/i',
55        '/^stop promo$/i',
56        '/^stop promotions$/i',
57    ];
58
59    /**
60     * @var ChatMatchingService Service for routing phone to store
61     */
62    private ChatMatchingService $matchingService;
63
64    /**
65     * @var PDO Central database connection
66     */
67    private PDO $centralDb;
68
69    /**
70     * @var callable Function to connect to store database
71     */
72    private $dbConnector;
73
74    /**
75     * @var callable|null Function to send SMS (for opt-out confirmations)
76     */
77    private $smsSender;
78
79    /**
80     * @var callable|null Factory for creating ChatAblyPublisher instances
81     */
82    private $ablyPublisherFactory;
83
84    /**
85     * Create a new ChatWebhookController
86     *
87     * @param ChatMatchingService $matchingService Service for phone-to-store routing
88     * @param PDO $centralDb Central database connection
89     * @param callable $dbConnector Function to get store database connection
90     * @param callable|null $smsSender Optional SMS sender function
91     * @param callable|null $ablyPublisherFactory Optional factory for Ably publisher
92     */
93    public function __construct(
94        ChatMatchingService $matchingService,
95        PDO $centralDb,
96        callable $dbConnector,
97        ?callable $smsSender = null,
98        ?callable $ablyPublisherFactory = null
99    ) {
100        $this->matchingService = $matchingService;
101        $this->centralDb = $centralDb;
102        $this->dbConnector = $dbConnector;
103        $this->smsSender = $smsSender;
104        $this->ablyPublisherFactory = $ablyPublisherFactory;
105    }
106
107    /**
108     * Handle inbound SMS webhook
109     *
110     * Main entry point for processing inbound messages from SMS providers.
111     * Always returns 200 OK to provider to prevent retries.
112     *
113     * @param array $payload The webhook payload from provider
114     * @return array Response data with keys: success, message, data
115     */
116    public function handleInbound(array $payload): array
117    {
118        try {
119            // Detect provider and extract data
120            $extracted = $this->extractPayloadData($payload);
121
122            if ($extracted === null) {
123                // Invalid payload - log but return 200 to prevent retries
124                error_log("ChatWebhook: Invalid payload - missing required fields");
125                return [
126                    'success' => false,
127                    'message' => 'Invalid payload',
128                    'data' => null,
129                ];
130            }
131
132            $phone = $extracted['phone'];
133            $messageText = $extracted['message'];
134            $provider = $extracted['provider'];
135            $providerMessageId = $extracted['providerMessageId'];
136            $timestamp = $extracted['timestamp'];
137
138            // Normalize phone
139            $normalizedPhone = $this->matchingService->normalizePhone($phone);
140
141            if ($normalizedPhone === null) {
142                error_log("ChatWebhook: Invalid phone format - {$phone}");
143                return [
144                    'success' => false,
145                    'message' => 'Invalid phone format',
146                    'data' => null,
147                ];
148            }
149
150            // Check for opt-out command FIRST
151            $optoutType = $this->detectOptOutCommand($messageText);
152
153            if ($optoutType !== null) {
154                $result = $this->processOptOut($normalizedPhone, $optoutType);
155                return [
156                    'success' => true,
157                    'message' => 'Opt-out processed',
158                    'data' => ['optoutType' => $optoutType],
159                ];
160            }
161
162            // Route to store
163            $routeResult = $this->matchingService->findStoreByPhone($normalizedPhone);
164
165            if ($routeResult === null) {
166                // Unroutable - log for admin review
167                $this->logUnroutableMessage($normalizedPhone, $messageText, $provider, $providerMessageId);
168                return [
169                    'success' => true,
170                    'message' => 'Unroutable - no customer match',
171                    'data' => null,
172                ];
173            }
174
175            // Check if already opted out
176            if (isset($routeResult['optedOut']) && $routeResult['optedOut'] === true) {
177                error_log("ChatWebhook: Phone {$normalizedPhone} is opted out");
178                return [
179                    'success' => true,
180                    'message' => 'Customer opted out',
181                    'data' => ['optedOut' => true],
182                ];
183            }
184
185            $typeNum = $routeResult['typeNum'];
186            $customerId = (int) $routeResult['customer']['customerID'];
187            $buyId = $routeResult['buyId'];
188            $customerName = trim(($routeResult['customer']['firstName'] ?? '') . ' ' . ($routeResult['customer']['lastName'] ?? ''));
189
190            // Get store database connection
191            $storeDb = ($this->dbConnector)('kiosk_' . $typeNum);
192
193            if (!$storeDb) {
194                error_log("ChatWebhook: Cannot connect to store database for {$typeNum}");
195                return [
196                    'success' => false,
197                    'message' => 'Database connection error',
198                    'data' => null,
199                ];
200            }
201
202            // Find or create thread
203            $thread = $this->findOrCreateThread(
204                $storeDb,
205                $typeNum,
206                $customerId,
207                $normalizedPhone,
208                $buyId
209            );
210
211            $isNewThread = $thread['isNew'];
212            $threadId = $thread['threadId'];
213            $wasFreetextLocked = !$thread['staffCanFreetext'];
214
215            // Save the inbound message
216            $messageId = $this->saveInboundMessage(
217                $storeDb,
218                $threadId,
219                $typeNum,
220                $messageText,
221                $provider,
222                $providerMessageId,
223                $timestamp
224            );
225
226            // Unlock freetext if this is customer's first reply
227            $freetextUnlocked = false;
228            if ($wasFreetextLocked) {
229                $this->unlockFreetext($storeDb, $threadId);
230                $freetextUnlocked = true;
231            }
232
233            // Update thread timestamps
234            $this->updateThreadTimestamps($storeDb, $threadId);
235
236            // Publish Ably events
237            $this->publishAblyEvents(
238                $typeNum,
239                $threadId,
240                $messageId,
241                $messageText,
242                $customerId,
243                $customerName,
244                $isNewThread,
245                $freetextUnlocked
246            );
247
248            return [
249                'success' => true,
250                'message' => 'Message processed',
251                'data' => [
252                    'threadId' => $threadId,
253                    'messageId' => $messageId,
254                    'typeNum' => $typeNum,
255                    'isNewThread' => $isNewThread,
256                    'freetextUnlocked' => $freetextUnlocked,
257                ],
258            ];
259        } catch (\Exception $e) {
260            error_log("ChatWebhook: Exception - " . $e->getMessage());
261            return [
262                'success' => false,
263                'message' => 'Internal error',
264                'data' => null,
265            ];
266        }
267    }
268
269    /**
270     * Extract phone and message from provider-specific payload
271     *
272     * Vonage payload: msisdn, text, message-timestamp, messageId
273     * Twilio payload: From, Body, MessageSid, DateSent
274     *
275     * @param array $payload Raw webhook payload
276     * @return array|null Extracted data or null if invalid
277     */
278    public function extractPayloadData(array $payload): ?array
279    {
280        // Try Vonage format first (uses msisdn)
281        if (isset($payload['msisdn'])) {
282            return [
283                'provider' => self::PROVIDER_VONAGE,
284                'phone' => $payload['msisdn'],
285                'message' => $payload['text'] ?? '',
286                'providerMessageId' => $payload['messageId'] ?? null,
287                'timestamp' => $payload['message-timestamp'] ?? null,
288            ];
289        }
290
291        // Try Twilio format (uses From)
292        if (isset($payload['From'])) {
293            return [
294                'provider' => self::PROVIDER_TWILIO,
295                'phone' => $payload['From'],
296                'message' => $payload['Body'] ?? '',
297                'providerMessageId' => $payload['MessageSid'] ?? null,
298                'timestamp' => $payload['DateSent'] ?? null,
299            ];
300        }
301
302        // Invalid payload
303        return null;
304    }
305
306    /**
307     * Detect if message is an opt-out command
308     *
309     * Checks message against known opt-out patterns.
310     * Per PRD lines 207-214:
311     * - STOP alone â†’ 'all'
312     * - STOP MARKETING â†’ 'marketing'
313     * - Variations (unsubscribe, cancel, quit) â†’ 'all'
314     *
315     * @param string $message The message text
316     * @return string|null 'all', 'marketing', or null if not opt-out
317     */
318    public function detectOptOutCommand(string $message): ?string
319    {
320        $trimmed = trim($message);
321
322        // Check marketing-specific patterns first (more specific)
323        foreach (self::OPTOUT_MARKETING_PATTERNS as $pattern) {
324            if (preg_match($pattern, $trimmed)) {
325                return self::OPTOUT_MARKETING;
326            }
327        }
328
329        // Check general opt-out patterns
330        foreach (self::OPTOUT_ALL_PATTERNS as $pattern) {
331            if (preg_match($pattern, $trimmed)) {
332                return self::OPTOUT_ALL;
333            }
334        }
335
336        return null;
337    }
338
339    /**
340     * Process opt-out request
341     *
342     * Per SDD lines 1084-1115:
343     * 1. Check existing opt-out status
344     * 2. Insert/update opt-out record
345     * 3. Send confirmation message (within 5 minutes per TCPA)
346     * 4. Close all active threads for this phone
347     *
348     * @param string $phone Normalized 10-digit phone
349     * @param string $optoutType 'all' or 'marketing'
350     * @return bool True if processed successfully
351     */
352    public function processOptOut(string $phone, string $optoutType): bool
353    {
354        try {
355            // Check existing opt-out status
356            $stmt = $this->centralDb->prepare(
357                "SELECT id, optout_type FROM loyaltyDoNotTextList WHERE phone = :phone LIMIT 1"
358            );
359            $stmt->execute([':phone' => $phone]);
360            $existing = $stmt->fetch(PDO::FETCH_ASSOC);
361
362            if ($existing) {
363                // Upgrade marketing to all if needed
364                if ($existing['optout_type'] === self::OPTOUT_MARKETING && $optoutType === self::OPTOUT_ALL) {
365                    $stmt = $this->centralDb->prepare(
366                        "UPDATE loyaltyDoNotTextList
367                         SET optout_type = :optout_type,
368                             optout_source = 'sms',
369                             updated_at = NOW()
370                         WHERE id = :id"
371                    );
372                    $stmt->execute([
373                        ':optout_type' => self::OPTOUT_ALL,
374                        ':id' => $existing['id'],
375                    ]);
376                }
377                // Already opted out at same or higher level - send confirmation anyway
378            } else {
379                // Insert new opt-out record
380                $stmt = $this->centralDb->prepare(
381                    "INSERT INTO loyaltyDoNotTextList (phone, date, optout_type, optout_source, created_at, updated_at)
382                     VALUES (:phone, NOW(), :optout_type, 'sms', NOW(), NOW())"
383                );
384                $stmt->execute([
385                    ':phone' => $phone,
386                    ':optout_type' => $optoutType,
387                ]);
388            }
389
390            // Send confirmation message (TCPA requirement - within 5 minutes)
391            $this->sendOptOutConfirmation($phone, $optoutType);
392
393            // Close all active threads for this phone
394            $this->closeThreadsForPhone($phone);
395
396            return true;
397        } catch (\Exception $e) {
398            error_log("ChatWebhook: Opt-out processing error - " . $e->getMessage());
399            return false;
400        }
401    }
402
403    /**
404     * Send opt-out confirmation message
405     *
406     * @param string $phone Phone number
407     * @param string $optoutType 'all' or 'marketing'
408     * @return void
409     */
410    private function sendOptOutConfirmation(string $phone, string $optoutType): void
411    {
412        if ($this->smsSender === null) {
413            error_log("ChatWebhook: No SMS sender configured for opt-out confirmation");
414            return;
415        }
416
417        $typeLabel = $optoutType === self::OPTOUT_MARKETING ? 'marketing' : 'all';
418        $message = "You've been unsubscribed from {$typeLabel} messages from BuyerKiosk stores. Reply START to resubscribe.";
419
420        try {
421            ($this->smsSender)($phone, $message);
422        } catch (\Exception $e) {
423            error_log("ChatWebhook: Failed to send opt-out confirmation - " . $e->getMessage());
424        }
425    }
426
427    /**
428     * Close all active threads for a phone number
429     *
430     * Used when processing opt-out requests.
431     *
432     * @param string $phone Normalized phone number
433     * @return void
434     */
435    private function closeThreadsForPhone(string $phone): void
436    {
437        // Get all stores that have threads for this phone
438        // Since threads are in per-store databases, we need to find them via systemBuys
439        // For now, we'll log this and handle it when we have the full context
440        error_log("ChatWebhook: TODO - Close threads for phone {$phone} across all stores");
441    }
442
443    /**
444     * Find or create a chat thread for the customer
445     *
446     * @param PDO $storeDb Store database connection
447     * @param string $typeNum Store identifier
448     * @param int $customerId Customer ID
449     * @param string $phone Customer phone
450     * @param int|null $buyId Associated buy transaction ID
451     * @return array Thread data with keys: threadId, isNew, staffCanFreetext
452     */
453    private function findOrCreateThread(
454        PDO $storeDb,
455        string $typeNum,
456        int $customerId,
457        string $phone,
458        ?int $buyId
459    ): array {
460        // Check for existing open thread
461        $stmt = $storeDb->prepare(
462            "SELECT id, status, staff_can_freetext
463             FROM chat_threads
464             WHERE customer_phone = :phone
465               AND status IN ('pending', 'active')
466             ORDER BY created_at DESC
467             LIMIT 1"
468        );
469        $stmt->execute([':phone' => $phone]);
470        $existing = $stmt->fetch(PDO::FETCH_ASSOC);
471
472        if ($existing) {
473            // Reopen if closed
474            if ($existing['status'] === ChatThread::STATUS_CLOSED) {
475                $stmt = $storeDb->prepare(
476                    "UPDATE chat_threads
477                     SET status = 'active',
478                         closed_by_employee_id = NULL,
479                         closed_at = NULL,
480                         updated_at = NOW()
481                     WHERE id = :id"
482                );
483                $stmt->execute([':id' => $existing['id']]);
484            }
485
486            return [
487                'threadId' => (int) $existing['id'],
488                'isNew' => false,
489                'staffCanFreetext' => !empty($existing['staff_can_freetext']),
490            ];
491        }
492
493        // Create new thread
494        $stmt = $storeDb->prepare(
495            "INSERT INTO chat_threads
496             (typeNum, customer_id, customer_phone, buy_id, status, staff_can_freetext, created_at, updated_at)
497             VALUES
498             (:typeNum, :customerId, :phone, :buyId, 'pending', 0, NOW(), NOW())"
499        );
500        $stmt->execute([
501            ':typeNum' => $typeNum,
502            ':customerId' => $customerId,
503            ':phone' => $phone,
504            ':buyId' => $buyId,
505        ]);
506
507        return [
508            'threadId' => (int) $storeDb->lastInsertId(),
509            'isNew' => true,
510            'staffCanFreetext' => false,
511        ];
512    }
513
514    /**
515     * Save an inbound message to the database
516     *
517     * @param PDO $storeDb Store database connection
518     * @param int $threadId Thread ID
519     * @param string $typeNum Store identifier
520     * @param string $content Message content
521     * @param string $provider SMS provider
522     * @param string|null $providerMessageId Provider's message ID
523     * @param string|null $timestamp Provider's timestamp
524     * @return int The new message ID
525     */
526    private function saveInboundMessage(
527        PDO $storeDb,
528        int $threadId,
529        string $typeNum,
530        string $content,
531        string $provider,
532        ?string $providerMessageId,
533        ?string $timestamp
534    ): int {
535        $segmentCount = ChatMessage::calculateSegmentCountStatic($content);
536        $characterCount = mb_strlen($content);
537
538        $stmt = $storeDb->prepare(
539            "INSERT INTO chat_messages
540             (thread_id, typeNum, direction, sender_type, content, character_count, sms_segment_count,
541              category, provider, provider_message_id, delivery_status, created_at)
542             VALUES
543             (:threadId, :typeNum, 'inbound', 'customer', :content, :charCount, :segmentCount,
544              'interactive', :provider, :providerMessageId, 'delivered', :createdAt)"
545        );
546
547        $createdAt = $timestamp ? (new DateTime($timestamp))->format('Y-m-d H:i:s') : date('Y-m-d H:i:s');
548
549        $stmt->execute([
550            ':threadId' => $threadId,
551            ':typeNum' => $typeNum,
552            ':content' => $content,
553            ':charCount' => $characterCount,
554            ':segmentCount' => $segmentCount,
555            ':provider' => $provider,
556            ':providerMessageId' => $providerMessageId,
557            ':createdAt' => $createdAt,
558        ]);
559
560        return (int) $storeDb->lastInsertId();
561    }
562
563    /**
564     * Unlock freetext messaging for a thread
565     *
566     * Called when customer replies for the first time.
567     *
568     * @param PDO $storeDb Store database connection
569     * @param int $threadId Thread ID
570     * @return void
571     */
572    private function unlockFreetext(PDO $storeDb, int $threadId): void
573    {
574        $stmt = $storeDb->prepare(
575            "UPDATE chat_threads
576             SET staff_can_freetext = 1,
577                 status = 'active',
578                 updated_at = NOW()
579             WHERE id = :id"
580        );
581        $stmt->execute([':id' => $threadId]);
582    }
583
584    /**
585     * Update thread timestamps after receiving a message
586     *
587     * @param PDO $storeDb Store database connection
588     * @param int $threadId Thread ID
589     * @return void
590     */
591    private function updateThreadTimestamps(PDO $storeDb, int $threadId): void
592    {
593        $stmt = $storeDb->prepare(
594            "UPDATE chat_threads
595             SET last_message_at = NOW(),
596                 last_customer_message_at = NOW(),
597                 updated_at = NOW()
598             WHERE id = :id"
599        );
600        $stmt->execute([':id' => $threadId]);
601    }
602
603    /**
604     * Publish Ably events for real-time updates
605     *
606     * @param string $typeNum Store identifier
607     * @param int $threadId Thread ID
608     * @param int $messageId Message ID
609     * @param string $messageText Message content
610     * @param int $customerId Customer ID
611     * @param string $customerName Customer name
612     * @param bool $isNewThread Whether this is a new thread
613     * @param bool $freetextUnlocked Whether freetext was just unlocked
614     * @return void
615     */
616    private function publishAblyEvents(
617        string $typeNum,
618        int $threadId,
619        int $messageId,
620        string $messageText,
621        int $customerId,
622        string $customerName,
623        bool $isNewThread,
624        bool $freetextUnlocked
625    ): void {
626        if ($this->ablyPublisherFactory === null) {
627            return;
628        }
629
630        try {
631            $ably = ($this->ablyPublisherFactory)($typeNum);
632
633            // Publish new thread event if this is a new thread
634            if ($isNewThread) {
635                $ably->publishNewThread($threadId, [
636                    'customerId' => $customerId,
637                    'customerName' => $customerName,
638                ]);
639            }
640
641            // Publish new message event
642            $ably->publishNewMessage($threadId, [
643                'messageId' => $messageId,
644                'direction' => 'inbound',
645                'body' => $messageText,
646                'timestamp' => date('c'),
647                'customerId' => $customerId,
648                'customerName' => $customerName,
649            ]);
650
651            // Publish freetext unlocked event if applicable
652            if ($freetextUnlocked) {
653                $ably->publishFreetextUnlocked($threadId);
654            }
655        } catch (\Exception $e) {
656            error_log("ChatWebhook: Ably publish error - " . $e->getMessage());
657        }
658    }
659
660    /**
661     * Log unroutable message for admin review
662     *
663     * @param string $phone Phone number
664     * @param string $message Message text
665     * @param string $provider SMS provider
666     * @param string|null $providerMessageId Provider's message ID
667     * @return void
668     */
669    private function logUnroutableMessage(
670        string $phone,
671        string $message,
672        string $provider,
673        ?string $providerMessageId
674    ): void {
675        error_log("ChatWebhook: Unroutable message from {$phone} via {$provider} (ID: {$providerMessageId}): {$message}");
676
677        // Could also store in a database table for admin dashboard
678        // For now, just log it
679    }
680}