Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 489
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatApiController
0.00% covered (danger)
0.00%
0 / 489
0.00% covered (danger)
0.00%
0 / 14
6006
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
 getThreads
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
110
 getThread
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
90
 sendMessage
0.00% covered (danger)
0.00%
0 / 142
0.00% covered (danger)
0.00%
0 / 1
380
 getEligibleCustomers
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
30
 createThread
0.00% covered (danger)
0.00%
0 / 83
0.00% covered (danger)
0.00%
0 / 1
156
 closeThread
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
56
 getTemplates
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 findThread
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 findTemplate
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 markMessagesAsRead
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 renderTemplate
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 jsonResponse
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 jsonError
0.00% covered (danger)
0.00%
0 / 4
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\Models\ChatTemplate;
8use BuyerKiosk\Chat\Services\ChatEligibilityService;
9use BuyerKiosk\Chat\Services\ChatTemplateService;
10use BuyerKiosk\Chat\Services\ChatBillingService;
11use BuyerKiosk\Chat\Services\ChatMatchingService;
12use BuyerKiosk\Chat\Events\ChatAblyPublisher;
13use DateTime;
14use Exception;
15use PDO;
16
17/**
18 * ChatApiController - Staff Chat API Endpoints
19 *
20 * Handles REST API endpoints for the Two-Way SMS Chat feature.
21 * Provides thread management, message sending, and template access.
22 *
23 * Endpoints:
24 * - GET  /api/:typeNum/chat/threads           - List threads
25 * - GET  /api/:typeNum/chat/threads/:threadId - Get single thread with messages
26 * - POST /api/:typeNum/chat/threads           - Create new thread
27 * - POST /api/:typeNum/chat/threads/:threadId/messages - Send message
28 * - POST /api/:typeNum/chat/threads/:threadId/close    - Close thread
29 * - GET  /api/:typeNum/chat/eligible          - Get eligible customers
30 * - GET  /api/:typeNum/chat/templates         - Get templates
31 *
32 * @package BuyerKiosk\Chat\Controllers
33 */
34class ChatApiController
35{
36    /**
37     * Maximum characters allowed per message (2 SMS segments)
38     */
39    private const MAX_MESSAGE_CHARACTERS = 320;
40
41    /**
42     * @var \Slim\Slim Slim application instance
43     */
44    private $app;
45
46    /**
47     * @var \Store Store object
48     */
49    private \Store $store;
50
51    /**
52     * @var PDO Store database connection
53     */
54    private PDO $db;
55
56    /**
57     * @var PDO Central database connection
58     */
59    private PDO $centralDb;
60
61    /**
62     * Constructor
63     *
64     * @param \Slim\Slim $app Slim application instance
65     * @param \Store $store Store object
66     */
67    public function __construct($app, \Store $store)
68    {
69        global $db_name;
70
71        $this->app = $app;
72        $this->store = $store;
73        $this->db = dbConnectByName($store->getDbName());
74        $this->centralDb = dbConnectByName($db_name);
75    }
76
77    /**
78     * GET /api/:typeNum/chat/threads
79     *
80     * Get paginated list of chat threads for the store.
81     *
82     * Query params:
83     *   - status: string (comma-separated, default: 'active,pending')
84     *   - limit: int (default: 50, max: 100)
85     *
86     * @param string $typeNum Store identifier
87     */
88    public function getThreads(string $typeNum): void
89    {
90        try {
91            // Parse query parameters
92            $statusParam = $this->app->request->get('status');
93            $statusParam = $statusParam ? $statusParam : 'active,pending';
94            $statuses = array_map('trim', explode(',', $statusParam));
95
96            $limit = $this->app->request->get('limit');
97            $limit = $limit ? (int) $limit : 50;
98            $limit = max(1, min($limit, 100)); // Clamp between 1 and 100
99
100            // Build status placeholders for query
101            $statusPlaceholders = implode(',', array_fill(0, count($statuses), '?'));
102
103            // Query threads with customer and buy info
104            $sql = "SELECT
105                        t.id,
106                        t.customer_id,
107                        t.customer_phone,
108                        t.buy_id,
109                        t.status,
110                        t.staff_can_freetext,
111                        t.last_message_at,
112                        t.created_at,
113                        c.firstName as customer_first_name,
114                        c.lastName as customer_last_name,
115                        c.phone as customer_phone_from_customer,
116                        b.dailyNum as buy_daily_num,
117                        (SELECT content FROM chat_messages
118                         WHERE thread_id = t.id
119                         ORDER BY created_at DESC LIMIT 1) as last_message_preview,
120                        (SELECT COUNT(*) FROM chat_messages
121                         WHERE thread_id = t.id
122                         AND direction = 'inbound'
123                         AND read_at IS NULL) as unread_count
124                    FROM chat_threads t
125                    LEFT JOIN customers c ON t.customer_id = c.customerID
126                    LEFT JOIN buyQueue b ON t.buy_id = b.buyID
127                    WHERE t.typeNum = ?
128                    AND t.status IN ({$statusPlaceholders})
129                    ORDER BY t.last_message_at DESC
130                    LIMIT ?";
131
132            $stmt = $this->db->prepare($sql);
133
134            // Bind parameters
135            $paramIndex = 1;
136            $stmt->bindValue($paramIndex++, $typeNum, PDO::PARAM_STR);
137            foreach ($statuses as $status) {
138                $stmt->bindValue($paramIndex++, $status, PDO::PARAM_STR);
139            }
140            $stmt->bindValue($paramIndex, $limit + 1, PDO::PARAM_INT); // +1 to detect hasMore
141
142            $stmt->execute();
143            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
144
145            // Check if there are more results
146            $hasMore = count($rows) > $limit;
147            if ($hasMore) {
148                array_pop($rows); // Remove the extra row
149            }
150
151            // Transform to response format
152            $threads = array_map(function ($row) {
153                $customerName = trim(
154                    ($row['customer_first_name'] ?? '') . ' ' . ($row['customer_last_name'] ?? '')
155                );
156                if (empty($customerName)) {
157                    $customerName = 'Unknown Customer';
158                }
159
160                // Truncate last message preview
161                $preview = $row['last_message_preview'] ?? '';
162                if (mb_strlen($preview) > 50) {
163                    $preview = mb_substr($preview, 0, 47) . '...';
164                }
165
166                return [
167                    'id' => (int) $row['id'],
168                    'customerId' => (int) $row['customer_id'],
169                    'customerName' => $customerName,
170                    'customerPhone' => $row['customer_phone'] ?? $row['customer_phone_from_customer'] ?? '',
171                    'buyId' => $row['buy_id'] ? (int) $row['buy_id'] : null,
172                    'buyDailyNum' => $row['buy_daily_num'] ? '#' . $row['buy_daily_num'] : null,
173                    'status' => $row['status'],
174                    'staffCanFreetext' => !empty($row['staff_can_freetext']),
175                    'lastMessagePreview' => $preview,
176                    'lastMessageAt' => $row['last_message_at'],
177                    'unreadCount' => (int) ($row['unread_count'] ?? 0),
178                ];
179            }, $rows);
180
181            $this->jsonResponse([
182                'success' => true,
183                'threads' => $threads,
184                'hasMore' => $hasMore,
185            ]);
186        } catch (Exception $e) {
187            error_log("ChatApiController::getThreads error: " . $e->getMessage());
188            $this->jsonError(500, 'Failed to retrieve threads');
189        }
190    }
191
192    /**
193     * GET /api/:typeNum/chat/threads/:threadId
194     *
195     * Get a single thread with its messages, customer, and buy details.
196     *
197     * @param string $typeNum Store identifier
198     * @param int $threadId Thread ID
199     */
200    public function getThread(string $typeNum, int $threadId): void
201    {
202        try {
203            // Get thread
204            $thread = $this->findThread($threadId, $typeNum);
205            if (!$thread) {
206                $this->jsonError(404, 'Thread not found');
207                return;
208            }
209
210            // Get messages for thread
211            $messagesSql = "SELECT
212                                m.*,
213                                e.firstName as sender_first_name,
214                                e.lastName as sender_last_name
215                            FROM chat_messages m
216                            LEFT JOIN employees e ON m.sender_id = e.employeeID AND m.sender_type = 'staff'
217                            WHERE m.thread_id = :threadId
218                            ORDER BY m.created_at ASC";
219            $messagesStmt = $this->db->prepare($messagesSql);
220            $messagesStmt->execute([':threadId' => $threadId]);
221            $messageRows = $messagesStmt->fetchAll(PDO::FETCH_ASSOC);
222
223            $messages = array_map(function ($row) {
224                $message = ChatMessage::fromRow($row);
225                $arr = $message->toArray();
226
227                // Add sender name for staff messages
228                if ($message->isFromStaff() && $row['sender_first_name']) {
229                    $arr['senderName'] = trim($row['sender_first_name'] . ' ' . ($row['sender_last_name'] ?? ''));
230                }
231
232                return $arr;
233            }, $messageRows);
234
235            // Get customer info
236            $customerSql = "SELECT customerID, firstName, lastName, phone, email
237                           FROM customers WHERE customerID = :customerId";
238            $customerStmt = $this->db->prepare($customerSql);
239            $customerStmt->execute([':customerId' => $thread->getCustomerId()]);
240            $customerRow = $customerStmt->fetch(PDO::FETCH_ASSOC);
241
242            $customer = null;
243            if ($customerRow) {
244                $customer = [
245                    'id' => (int) $customerRow['customerID'],
246                    'firstName' => $customerRow['firstName'],
247                    'lastName' => $customerRow['lastName'],
248                    'phone' => $customerRow['phone'],
249                    'email' => $customerRow['email'],
250                    'fullName' => trim($customerRow['firstName'] . ' ' . $customerRow['lastName']),
251                ];
252            }
253
254            // Get buy info if associated
255            $buy = null;
256            if ($thread->getBuyId()) {
257                $buySql = "SELECT buyID, dailyNum, timeEntered, status, total
258                          FROM buyQueue WHERE buyID = :buyId";
259                $buyStmt = $this->db->prepare($buySql);
260                $buyStmt->execute([':buyId' => $thread->getBuyId()]);
261                $buyRow = $buyStmt->fetch(PDO::FETCH_ASSOC);
262
263                if ($buyRow) {
264                    $buy = [
265                        'id' => (int) $buyRow['buyID'],
266                        'dailyNum' => '#' . $buyRow['dailyNum'],
267                        'timeEntered' => $buyRow['timeEntered'],
268                        'status' => $buyRow['status'],
269                        'total' => $buyRow['total'] !== null ? (float) $buyRow['total'] : null,
270                    ];
271                }
272            }
273
274            // Mark inbound messages as read
275            $this->markMessagesAsRead($threadId);
276
277            $this->jsonResponse([
278                'success' => true,
279                'thread' => $thread->toArray(),
280                'messages' => $messages,
281                'customer' => $customer,
282                'buy' => $buy,
283            ]);
284        } catch (Exception $e) {
285            error_log("ChatApiController::getThread error: " . $e->getMessage());
286            $this->jsonError(500, 'Failed to retrieve thread');
287        }
288    }
289
290    /**
291     * POST /api/:typeNum/chat/threads/:threadId/messages
292     *
293     * Send a message in an existing thread.
294     *
295     * Request body: {
296     *   "content": string (optional if templateId provided),
297     *   "templateId": int (optional if freetext allowed),
298     *   "employeeId": int (required)
299     * }
300     *
301     * @param string $typeNum Store identifier
302     * @param int $threadId Thread ID
303     */
304    public function sendMessage(string $typeNum, int $threadId): void
305    {
306        try {
307            $data = json_decode($this->app->request->getBody(), true);
308
309            // Validate required fields
310            if (empty($data['employeeId'])) {
311                $this->jsonError(400, 'employeeId is required');
312                return;
313            }
314
315            $employeeId = (int) $data['employeeId'];
316            $templateId = !empty($data['templateId']) ? (int) $data['templateId'] : null;
317            $content = $data['content'] ?? null;
318
319            // Get thread
320            $thread = $this->findThread($threadId, $typeNum);
321            if (!$thread) {
322                $this->jsonError(404, 'Thread not found');
323                return;
324            }
325
326            // Check thread is open
327            if (!$thread->isOpen()) {
328                $this->jsonError(400, 'Cannot send messages to a closed thread');
329                return;
330            }
331
332            // Validate employee exists
333            $employeeStmt = $this->db->prepare(
334                "SELECT employeeID, firstName, lastName FROM employees WHERE employeeID = :employeeId AND active = 1"
335            );
336            $employeeStmt->execute([':employeeId' => $employeeId]);
337            $employee = $employeeStmt->fetch(PDO::FETCH_ASSOC);
338
339            if (!$employee) {
340                $this->jsonError(404, 'Employee not found or inactive');
341                return;
342            }
343
344            // Determine message content and category
345            $messageContent = null;
346            $category = ChatMessage::CATEGORY_INTERACTIVE;
347            $template = null;
348
349            if ($templateId) {
350                // Using a template
351                $template = $this->findTemplate($templateId, $typeNum);
352                if (!$template) {
353                    $this->jsonError(404, 'Template not found');
354                    return;
355                }
356
357                // Render template with wildcards
358                $messageContent = $this->renderTemplate($template, $thread);
359                $category = ($template->getCategory() === ChatTemplate::CATEGORY_TRANSACTIONAL)
360                    ? ChatMessage::CATEGORY_TRANSACTIONAL
361                    : ChatMessage::CATEGORY_INTERACTIVE;
362            } elseif ($content) {
363                // Freetext message - check permission
364                if (!$thread->canStaffSendFreetext()) {
365                    $this->jsonError(403, 'Freetext not allowed. Customer must reply first or use a template.');
366                    return;
367                }
368
369                $messageContent = trim($content);
370            } else {
371                $this->jsonError(400, 'Either content or templateId is required');
372                return;
373            }
374
375            // Validate character limit
376            if (mb_strlen($messageContent) > self::MAX_MESSAGE_CHARACTERS) {
377                $this->jsonError(400, sprintf(
378                    'Message exceeds maximum of %d characters (current: %d)',
379                    self::MAX_MESSAGE_CHARACTERS,
380                    mb_strlen($messageContent)
381                ));
382                return;
383            }
384
385            // Get customer phone
386            $customerPhone = $thread->getCustomerPhone();
387            if (empty($customerPhone)) {
388                // Fall back to customer table
389                $phoneStmt = $this->db->prepare("SELECT phone FROM customers WHERE customerID = :customerId");
390                $phoneStmt->execute([':customerId' => $thread->getCustomerId()]);
391                $phoneRow = $phoneStmt->fetch(PDO::FETCH_ASSOC);
392                $customerPhone = $phoneRow['phone'] ?? '';
393            }
394
395            if (empty($customerPhone)) {
396                $this->jsonError(400, 'Customer phone number not available');
397                return;
398            }
399
400            // Send SMS via TextMessageService
401            $customer = new \stdClass();
402            $customer->phone = preg_replace('/\D/', '', $customerPhone); // Strip non-digits
403
404            $smsResult = $this->store->textMessageService->sendCustomText($customer, $messageContent);
405
406            if ($smsResult['status'] === 'failed') {
407                $this->jsonError(500, 'Failed to send SMS: ' . ($smsResult['error'] ?? 'Unknown error'));
408                return;
409            }
410
411            // Calculate SMS segments
412            $segmentCount = ChatMessage::calculateSegmentCountStatic($messageContent);
413
414            // Create message record
415            $now = new DateTime();
416            $insertSql = "INSERT INTO chat_messages (
417                            thread_id, typeNum, direction, sender_type, sender_id,
418                            template_id, content, content_raw, character_count, sms_segment_count,
419                            category, provider, provider_message_id, delivery_status, created_at
420                          ) VALUES (
421                            :thread_id, :typeNum, :direction, :sender_type, :sender_id,
422                            :template_id, :content, :content_raw, :character_count, :sms_segment_count,
423                            :category, :provider, :provider_message_id, :delivery_status, :created_at
424                          )";
425
426            $insertStmt = $this->db->prepare($insertSql);
427            $insertStmt->execute([
428                ':thread_id' => $threadId,
429                ':typeNum' => $typeNum,
430                ':direction' => ChatMessage::DIRECTION_OUTBOUND,
431                ':sender_type' => ChatMessage::SENDER_STAFF,
432                ':sender_id' => $employeeId,
433                ':template_id' => $templateId,
434                ':content' => $messageContent,
435                ':content_raw' => $template ? $template->getContent() : null,
436                ':character_count' => mb_strlen($messageContent),
437                ':sms_segment_count' => $segmentCount,
438                ':category' => $category,
439                ':provider' => $smsResult['provider'] ?? 'vonage',
440                ':provider_message_id' => $smsResult['id'] ?? null,
441                ':delivery_status' => ChatMessage::DELIVERY_SENT,
442                ':created_at' => $now->format('Y-m-d H:i:s'),
443            ]);
444
445            $messageId = (int) $this->db->lastInsertId();
446
447            // Update thread timestamps
448            $updateThreadSql = "UPDATE chat_threads
449                               SET last_message_at = :now, last_staff_message_at = :now, updated_at = :now
450                               WHERE id = :threadId";
451            $updateThreadStmt = $this->db->prepare($updateThreadSql);
452            $updateThreadStmt->execute([
453                ':now' => $now->format('Y-m-d H:i:s'),
454                ':threadId' => $threadId,
455            ]);
456
457            // Track billing
458            try {
459                $billingService = new ChatBillingService($this->centralDb);
460                $billingService->trackUsage(
461                    $typeNum,
462                    $threadId,
463                    $messageId,
464                    ChatMessage::DIRECTION_OUTBOUND,
465                    $category,
466                    $smsResult['provider'] ?? 'vonage',
467                    $smsResult['id'] ?? null,
468                    $segmentCount,
469                    $now
470                );
471            } catch (Exception $e) {
472                error_log("ChatApiController::sendMessage billing error: " . $e->getMessage());
473                // Don't fail the request if billing fails
474            }
475
476            // Publish to Ably for real-time updates
477            try {
478                $ably = new ChatAblyPublisher($typeNum);
479                $ably->publishNewMessage($threadId, [
480                    'messageId' => $messageId,
481                    'direction' => ChatMessage::DIRECTION_OUTBOUND,
482                    'body' => $messageContent,
483                    'timestamp' => $now->format('c'),
484                    'customerId' => $thread->getCustomerId(),
485                    'employeeId' => $employeeId,
486                    'employeeName' => trim($employee['firstName'] . ' ' . ($employee['lastName'] ?? '')),
487                ]);
488            } catch (Exception $e) {
489                error_log("ChatApiController::sendMessage Ably error: " . $e->getMessage());
490                // Don't fail the request if Ably fails
491            }
492
493            // Build response message
494            $responseMessage = [
495                'id' => $messageId,
496                'threadId' => $threadId,
497                'direction' => ChatMessage::DIRECTION_OUTBOUND,
498                'senderType' => ChatMessage::SENDER_STAFF,
499                'senderId' => $employeeId,
500                'senderName' => trim($employee['firstName'] . ' ' . ($employee['lastName'] ?? '')),
501                'templateId' => $templateId,
502                'content' => $messageContent,
503                'characterCount' => mb_strlen($messageContent),
504                'smsSegmentCount' => $segmentCount,
505                'category' => $category,
506                'deliveryStatus' => ChatMessage::DELIVERY_SENT,
507                'createdAt' => $now->format('Y-m-d H:i:s'),
508            ];
509
510            $this->app->response->setStatus(201);
511            $this->jsonResponse([
512                'success' => true,
513                'message' => $responseMessage,
514            ]);
515        } catch (Exception $e) {
516            error_log("ChatApiController::sendMessage error: " . $e->getMessage());
517            $this->jsonError(500, 'Failed to send message');
518        }
519    }
520
521    /**
522     * GET /api/:typeNum/chat/eligible
523     *
524     * Get list of customers eligible for messaging today.
525     *
526     * @param string $typeNum Store identifier
527     */
528    public function getEligibleCustomers(string $typeNum): void
529    {
530        try {
531            $matchingService = ChatMatchingService::createWithDefaults();
532            $eligibilityService = new ChatEligibilityService(
533                $this->db,
534                $this->centralDb,
535                $matchingService
536            );
537
538            $eligibleCustomers = $eligibilityService->getEligibleCustomers($typeNum);
539
540            // Transform to consistent response format
541            $customers = array_map(function ($row) {
542                return [
543                    'customerId' => (int) ($row['customerID'] ?? 0),
544                    'firstName' => $row['firstName'] ?? '',
545                    'lastName' => $row['lastName'] ?? '',
546                    'fullName' => trim(($row['firstName'] ?? '') . ' ' . ($row['lastName'] ?? '')),
547                    'phone' => $row['phone'] ?? '',
548                    'buyId' => isset($row['buyID']) ? (int) $row['buyID'] : null,
549                    'buyDailyNum' => isset($row['dailyNum']) ? '#' . $row['dailyNum'] : null,
550                    'timeEntered' => $row['timeEntered'] ?? null,
551                    'hasOpenThread' => isset($row['thread_id']),
552                    'threadId' => isset($row['thread_id']) ? (int) $row['thread_id'] : null,
553                ];
554            }, $eligibleCustomers);
555
556            $this->jsonResponse([
557                'success' => true,
558                'customers' => $customers,
559            ]);
560        } catch (Exception $e) {
561            error_log("ChatApiController::getEligibleCustomers error: " . $e->getMessage());
562            $this->jsonError(500, 'Failed to retrieve eligible customers');
563        }
564    }
565
566    /**
567     * POST /api/:typeNum/chat/threads
568     *
569     * Create a new chat thread for a customer.
570     *
571     * Request body: {
572     *   "customerId": int (required),
573     *   "buyId": int (optional),
574     *   "employeeId": int (required)
575     * }
576     *
577     * @param string $typeNum Store identifier
578     */
579    public function createThread(string $typeNum): void
580    {
581        try {
582            $data = json_decode($this->app->request->getBody(), true);
583
584            // Validate required fields
585            if (empty($data['customerId'])) {
586                $this->jsonError(400, 'customerId is required');
587                return;
588            }
589            if (empty($data['employeeId'])) {
590                $this->jsonError(400, 'employeeId is required');
591                return;
592            }
593
594            $customerId = (int) $data['customerId'];
595            $buyId = !empty($data['buyId']) ? (int) $data['buyId'] : null;
596            $employeeId = (int) $data['employeeId'];
597
598            // Validate employee exists
599            $employeeStmt = $this->db->prepare(
600                "SELECT employeeID FROM employees WHERE employeeID = :employeeId AND active = 1"
601            );
602            $employeeStmt->execute([':employeeId' => $employeeId]);
603            if (!$employeeStmt->fetch()) {
604                $this->jsonError(404, 'Employee not found or inactive');
605                return;
606            }
607
608            // Get customer info
609            $customerStmt = $this->db->prepare(
610                "SELECT customerID, firstName, lastName, phone FROM customers WHERE customerID = :customerId"
611            );
612            $customerStmt->execute([':customerId' => $customerId]);
613            $customer = $customerStmt->fetch(PDO::FETCH_ASSOC);
614
615            if (!$customer) {
616                $this->jsonError(404, 'Customer not found');
617                return;
618            }
619
620            $customerPhone = $customer['phone'] ?? '';
621            if (empty($customerPhone)) {
622                $this->jsonError(400, 'Customer does not have a phone number');
623                return;
624            }
625
626            // Check eligibility
627            $matchingService = ChatMatchingService::createWithDefaults();
628            $eligibilityService = new ChatEligibilityService(
629                $this->db,
630                $this->centralDb,
631                $matchingService
632            );
633
634            $eligibility = $eligibilityService->canMessageCustomer($typeNum, $customerId, $customerPhone);
635            if (!$eligibility['eligible']) {
636                $this->jsonError(400, $eligibility['reason']);
637                return;
638            }
639
640            // Check for existing open thread
641            $existingStmt = $this->db->prepare(
642                "SELECT id FROM chat_threads
643                 WHERE typeNum = :typeNum AND customer_id = :customerId AND status IN ('pending', 'active')
644                 LIMIT 1"
645            );
646            $existingStmt->execute([':typeNum' => $typeNum, ':customerId' => $customerId]);
647            $existing = $existingStmt->fetch(PDO::FETCH_ASSOC);
648
649            if ($existing) {
650                $this->jsonError(400, 'Customer already has an open thread');
651                return;
652            }
653
654            // Create thread
655            $now = new DateTime();
656            $insertSql = "INSERT INTO chat_threads (
657                            typeNum, customer_id, customer_phone, buy_id,
658                            status, staff_can_freetext, opened_by_employee_id,
659                            created_at, updated_at
660                          ) VALUES (
661                            :typeNum, :customer_id, :customer_phone, :buy_id,
662                            :status, :staff_can_freetext, :opened_by_employee_id,
663                            :created_at, :updated_at
664                          )";
665
666            $insertStmt = $this->db->prepare($insertSql);
667            $insertStmt->execute([
668                ':typeNum' => $typeNum,
669                ':customer_id' => $customerId,
670                ':customer_phone' => $customerPhone,
671                ':buy_id' => $buyId,
672                ':status' => ChatThread::STATUS_PENDING,
673                ':staff_can_freetext' => 0,
674                ':opened_by_employee_id' => $employeeId,
675                ':created_at' => $now->format('Y-m-d H:i:s'),
676                ':updated_at' => $now->format('Y-m-d H:i:s'),
677            ]);
678
679            $threadId = (int) $this->db->lastInsertId();
680
681            // Retrieve the created thread
682            $thread = $this->findThread($threadId, $typeNum);
683
684            // Publish to Ably for real-time updates
685            try {
686                $ably = new ChatAblyPublisher($typeNum);
687                $ably->publishNewThread($threadId, [
688                    'customerId' => $customerId,
689                    'customerName' => trim($customer['firstName'] . ' ' . ($customer['lastName'] ?? '')),
690                    'customerPhone' => $customerPhone,
691                    'buyId' => $buyId,
692                    'status' => ChatThread::STATUS_PENDING,
693                ]);
694            } catch (Exception $e) {
695                error_log("ChatApiController::createThread Ably error: " . $e->getMessage());
696            }
697
698            $this->app->response->setStatus(201);
699            $this->jsonResponse([
700                'success' => true,
701                'thread' => $thread ? $thread->toArray() : ['id' => $threadId],
702            ]);
703        } catch (Exception $e) {
704            error_log("ChatApiController::createThread error: " . $e->getMessage());
705            $this->jsonError(500, 'Failed to create thread');
706        }
707    }
708
709    /**
710     * POST /api/:typeNum/chat/threads/:threadId/close
711     *
712     * Close a chat thread.
713     *
714     * Request body: {
715     *   "employeeId": int (required)
716     * }
717     *
718     * @param string $typeNum Store identifier
719     * @param int $threadId Thread ID
720     */
721    public function closeThread(string $typeNum, int $threadId): void
722    {
723        try {
724            $data = json_decode($this->app->request->getBody(), true);
725
726            // Validate required fields
727            if (empty($data['employeeId'])) {
728                $this->jsonError(400, 'employeeId is required');
729                return;
730            }
731
732            $employeeId = (int) $data['employeeId'];
733
734            // Get thread
735            $thread = $this->findThread($threadId, $typeNum);
736            if (!$thread) {
737                $this->jsonError(404, 'Thread not found');
738                return;
739            }
740
741            // Check thread is open
742            if (!$thread->isOpen()) {
743                $this->jsonError(400, 'Thread is already closed');
744                return;
745            }
746
747            // Validate employee exists
748            $employeeStmt = $this->db->prepare(
749                "SELECT employeeID FROM employees WHERE employeeID = :employeeId AND active = 1"
750            );
751            $employeeStmt->execute([':employeeId' => $employeeId]);
752            if (!$employeeStmt->fetch()) {
753                $this->jsonError(404, 'Employee not found or inactive');
754                return;
755            }
756
757            // Update thread status
758            $now = new DateTime();
759            $updateSql = "UPDATE chat_threads
760                         SET status = :status, closed_by_employee_id = :closed_by,
761                             closed_at = :closed_at, updated_at = :updated_at
762                         WHERE id = :threadId";
763
764            $updateStmt = $this->db->prepare($updateSql);
765            $updateStmt->execute([
766                ':status' => ChatThread::STATUS_CLOSED,
767                ':closed_by' => $employeeId,
768                ':closed_at' => $now->format('Y-m-d H:i:s'),
769                ':updated_at' => $now->format('Y-m-d H:i:s'),
770                ':threadId' => $threadId,
771            ]);
772
773            // Publish to Ably for real-time updates
774            try {
775                $ably = new ChatAblyPublisher($typeNum);
776                $ably->publishThreadClosed($threadId, $employeeId);
777            } catch (Exception $e) {
778                error_log("ChatApiController::closeThread Ably error: " . $e->getMessage());
779            }
780
781            $this->jsonResponse([
782                'success' => true,
783            ]);
784        } catch (Exception $e) {
785            error_log("ChatApiController::closeThread error: " . $e->getMessage());
786            $this->jsonError(500, 'Failed to close thread');
787        }
788    }
789
790    /**
791     * GET /api/:typeNum/chat/templates
792     *
793     * Get available chat templates for the store.
794     *
795     * Query params:
796     *   - category: string (optional filter)
797     *
798     * @param string $typeNum Store identifier
799     */
800    public function getTemplates(string $typeNum): void
801    {
802        try {
803            $category = $this->app->request->get('category');
804
805            // Build query
806            $sql = "SELECT * FROM chat_templates
807                    WHERE (typeNum = :typeNum OR typeNum = 'global')
808                    AND is_active = 1";
809            $params = [':typeNum' => $typeNum];
810
811            if ($category) {
812                $sql .= " AND category = :category";
813                $params[':category'] = $category;
814            }
815
816            $sql .= " ORDER BY sort_order ASC, short_name ASC";
817
818            $stmt = $this->db->prepare($sql);
819            $stmt->execute($params);
820            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
821
822            $templates = array_map(function ($row) {
823                $template = ChatTemplate::fromRow($row);
824                return $template->toArray();
825            }, $rows);
826
827            $this->jsonResponse([
828                'success' => true,
829                'templates' => $templates,
830            ]);
831        } catch (Exception $e) {
832            error_log("ChatApiController::getTemplates error: " . $e->getMessage());
833            $this->jsonError(500, 'Failed to retrieve templates');
834        }
835    }
836
837    // =========================================================================
838    // Helper Methods
839    // =========================================================================
840
841    /**
842     * Find a thread by ID and verify it belongs to the store
843     *
844     * @param int $threadId Thread ID
845     * @param string $typeNum Store identifier
846     * @return ChatThread|null
847     */
848    private function findThread(int $threadId, string $typeNum): ?ChatThread
849    {
850        $stmt = $this->db->prepare(
851            "SELECT * FROM chat_threads WHERE id = :id AND typeNum = :typeNum"
852        );
853        $stmt->execute([':id' => $threadId, ':typeNum' => $typeNum]);
854        $row = $stmt->fetch(PDO::FETCH_ASSOC);
855
856        if (!$row) {
857            return null;
858        }
859
860        return ChatThread::fromRow($row);
861    }
862
863    /**
864     * Find a template by ID and verify it belongs to the store (or is global)
865     *
866     * @param int $templateId Template ID
867     * @param string $typeNum Store identifier
868     * @return ChatTemplate|null
869     */
870    private function findTemplate(int $templateId, string $typeNum): ?ChatTemplate
871    {
872        $stmt = $this->db->prepare(
873            "SELECT * FROM chat_templates
874             WHERE id = :id AND (typeNum = :typeNum OR typeNum = 'global') AND is_active = 1"
875        );
876        $stmt->execute([':id' => $templateId, ':typeNum' => $typeNum]);
877        $row = $stmt->fetch(PDO::FETCH_ASSOC);
878
879        if (!$row) {
880            return null;
881        }
882
883        return ChatTemplate::fromRow($row);
884    }
885
886    /**
887     * Mark all unread inbound messages in a thread as read
888     *
889     * @param int $threadId Thread ID
890     */
891    private function markMessagesAsRead(int $threadId): void
892    {
893        $now = new DateTime();
894        $stmt = $this->db->prepare(
895            "UPDATE chat_messages
896             SET read_at = :now
897             WHERE thread_id = :threadId AND direction = 'inbound' AND read_at IS NULL"
898        );
899        $stmt->execute([':now' => $now->format('Y-m-d H:i:s'), ':threadId' => $threadId]);
900    }
901
902    /**
903     * Render a template with wildcard substitution
904     *
905     * @param ChatTemplate $template Template to render
906     * @param ChatThread $thread Thread for context
907     * @return string Rendered content
908     */
909    private function renderTemplate(ChatTemplate $template, ChatThread $thread): string
910    {
911        // Get customer
912        $customerStmt = $this->db->prepare(
913            "SELECT * FROM customers WHERE customerID = :customerId"
914        );
915        $customerStmt->execute([':customerId' => $thread->getCustomerId()]);
916        $customerRow = $customerStmt->fetch(PDO::FETCH_ASSOC);
917
918        // Create a simple customer object for template service
919        $customer = new \stdClass();
920        $customer->firstName = $customerRow['firstName'] ?? '';
921        $customer->lastName = $customerRow['lastName'] ?? '';
922
923        // Get buy if associated
924        $buy = null;
925        if ($thread->getBuyId()) {
926            $buyStmt = $this->db->prepare("SELECT * FROM buyQueue WHERE buyID = :buyId");
927            $buyStmt->execute([':buyId' => $thread->getBuyId()]);
928            $buyRow = $buyStmt->fetch(PDO::FETCH_ASSOC);
929
930            if ($buyRow) {
931                $buy = new \stdClass();
932                $buy->dailyNum = $buyRow['dailyNum'];
933                $buy->timeEntered = $buyRow['timeEntered'];
934            }
935        }
936
937        // Use template service for rendering
938        $templateService = new ChatTemplateService();
939
940        // Create a customer adapter with expected methods
941        $customerAdapter = new class($customer) {
942            private $data;
943            public function __construct($data) { $this->data = $data; }
944            public function getFirstName() { return $this->data->firstName ?? ''; }
945            public function getLastName() { return $this->data->lastName ?? ''; }
946        };
947
948        // Create a buy adapter if we have buy data
949        $buyAdapter = null;
950        if ($buy) {
951            $buyAdapter = new class($buy) {
952                private $data;
953                public function __construct($data) { $this->data = $data; }
954                public function getDailyNum() { return $this->data->dailyNum ?? ''; }
955                public function getTimeEntered() { return $this->data->timeEntered ?? ''; }
956            };
957        }
958
959        return $templateService->render($template, $customerAdapter, $this->store, $buyAdapter);
960    }
961
962    /**
963     * Send a JSON success response
964     *
965     * @param array $data Response data
966     */
967    private function jsonResponse(array $data): void
968    {
969        $this->app->response->headers->set('Content-Type', 'application/json');
970        $this->app->response->setBody(json_encode($data));
971    }
972
973    /**
974     * Send a JSON error response and halt
975     *
976     * @param int $statusCode HTTP status code
977     * @param string $message Error message
978     */
979    private function jsonError(int $statusCode, string $message): void
980    {
981        $this->app->response->headers->set('Content-Type', 'application/json');
982        $this->app->halt($statusCode, json_encode([
983            'error' => $message,
984        ]));
985    }
986}