Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 96
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatMatchingService
0.00% covered (danger)
0.00%
0 / 96
0.00% covered (danger)
0.00%
0 / 5
420
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 createWithDefaults
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 normalizePhone
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 isOptedOut
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 findStoreByPhone
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2
3namespace BuyerKiosk\Chat\Services;
4
5use BuyerKiosk\Core\Store;
6use PDO;
7
8/**
9 * ChatMatchingService - Phone-to-Store Routing
10 *
11 * Routes incoming SMS messages to the appropriate store based on customer phone number.
12 * Uses the kiosk_buys.systemBuys table for efficient single-query lookup across all stores.
13 *
14 * Business Rules (from PRD lines 295-308):
15 * - Phone numbers must be normalized to 10-digit format before lookup
16 * - Route to store with most recent buy timestamp
17 * - If no buy found, return null (unroutable)
18 * - Check opt-out status BEFORE routing (opted-out phones return immediately)
19 *
20 * Performance: Uses indexed systemBuys.phoneNum for O(log n) lookup instead of
21 * iterating hundreds of store databases.
22 *
23 * @package BuyerKiosk\Chat\Services
24 */
25class ChatMatchingService
26{
27    /**
28     * @var PDO Central database connection for opt-out checks
29     */
30    private PDO $centralDb;
31
32    /**
33     * @var PDO Buys database connection for systemBuys lookups
34     */
35    private PDO $buysDb;
36
37    /**
38     * @var callable Function to connect to a store database by name
39     */
40    private $dbConnector;
41
42    /**
43     * @var callable Function to get a Store object by typeNum
44     */
45    private $storeLoader;
46
47    /**
48     * Create a new ChatMatchingService
49     *
50     * @param PDO $centralDb Central database connection (kiosk_buykiosk)
51     * @param PDO $buysDb Buys database connection (kiosk_buys)
52     * @param callable $dbConnector Function that takes dbName and returns PDO connection
53     * @param callable $storeLoader Function that takes typeNum and returns Store object
54     */
55    public function __construct(
56        PDO $centralDb,
57        PDO $buysDb,
58        callable $dbConnector,
59        callable $storeLoader
60    ) {
61        $this->centralDb = $centralDb;
62        $this->buysDb = $buysDb;
63        $this->dbConnector = $dbConnector;
64        $this->storeLoader = $storeLoader;
65    }
66
67    /**
68     * Create service with default dependencies using global functions
69     *
70     * Factory method for production use where we use the actual BaseModel functions.
71     *
72     * @return self
73     */
74    public static function createWithDefaults(): self
75    {
76        $centralDb = dbConnectByName('kiosk_buykiosk');
77        $buysDb = dbConnectByName('kiosk_buys');
78
79        $dbConnector = function (string $dbName) {
80            return dbConnectByName($dbName);
81        };
82
83        $storeLoader = function (string $typeNum) {
84            $storeController = new \BuyerKiosk\BuyerKiosk\Controllers\StoreController($typeNum);
85            return $storeController->getStore();
86        };
87
88        return new self($centralDb, $buysDb, $dbConnector, $storeLoader);
89    }
90
91    /**
92     * Normalize a phone number to 10-digit format
93     *
94     * Strips all non-digit characters and removes country code if present.
95     *
96     * @param string $phone Raw phone number input
97     * @return string|null Normalized 10-digit phone, or null if invalid
98     */
99    public function normalizePhone(string $phone): ?string
100    {
101        // Strip all non-digit characters
102        $normalized = preg_replace('/\D/', '', $phone);
103
104        // Handle empty string
105        if (empty($normalized)) {
106            return null;
107        }
108
109        // Remove leading 1 from 11-digit number (US country code)
110        if (strlen($normalized) === 11 && $normalized[0] === '1') {
111            $normalized = substr($normalized, 1);
112        }
113
114        // Validate exactly 10 digits
115        if (strlen($normalized) !== 10) {
116            return null;
117        }
118
119        return $normalized;
120    }
121
122    /**
123     * Check if a phone number is opted out
124     *
125     * Checks the loyaltyDoNotTextList table in the central database.
126     *
127     * @param string $phone Phone number to check (will be normalized)
128     * @param string $type Opt-out type to check: 'all' or 'marketing'
129     * @return bool True if opted out for the specified type
130     */
131    public function isOptedOut(string $phone, string $type = 'all'): bool
132    {
133        $normalizedPhone = $this->normalizePhone($phone);
134
135        if ($normalizedPhone === null) {
136            return false; // Invalid phone can't be in opt-out list
137        }
138
139        $stmt = $this->centralDb->prepare(
140            "SELECT phone, optout_type
141             FROM loyaltyDoNotTextList
142             WHERE phone = :phone
143             LIMIT 1"
144        );
145        $stmt->execute([':phone' => $normalizedPhone]);
146        $result = $stmt->fetch(PDO::FETCH_ASSOC);
147
148        if (!$result) {
149            return false; // Not in opt-out list
150        }
151
152        $optoutType = $result['optout_type'] ?? 'all';
153
154        // 'all' opt-out blocks everything
155        if ($optoutType === 'all') {
156            return true;
157        }
158
159        // 'marketing' opt-out only blocks marketing messages
160        // It does NOT block interactive chat (type='all' check)
161        if ($type === 'marketing' && $optoutType === 'marketing') {
162            return true;
163        }
164
165        // Marketing-only opt-out does not block interactive chat
166        return false;
167    }
168
169    /**
170     * Find the store associated with a phone number
171     *
172     * Routes to the store with the most recent buy transaction for this customer.
173     * Uses the systemBuys table for efficient single-query lookup across all stores.
174     *
175     * @param string $phone Phone number to look up
176     * @return array|null Match result with keys:
177     *                    - 'store' => Store object
178     *                    - 'customer' => array of customer/buy data
179     *                    - 'buyId' => int buy transaction ID
180     *                    - 'typeNum' => string store identifier
181     *                    OR for opted-out phones:
182     *                    - 'optedOut' => true
183     *                    - 'optoutType' => string 'all' or 'marketing'
184     *                    OR null if no match found
185     */
186    public function findStoreByPhone(string $phone): ?array
187    {
188        // Normalize phone to 10 digits
189        $normalizedPhone = $this->normalizePhone($phone);
190
191        if ($normalizedPhone === null) {
192            return null; // Invalid phone format
193        }
194
195        // Check opt-out status FIRST (before any store queries)
196        if ($this->isOptedOut($normalizedPhone, 'all')) {
197            // Get the opt-out type for the response
198            $stmt = $this->centralDb->prepare(
199                "SELECT optout_type
200                 FROM loyaltyDoNotTextList
201                 WHERE phone = :phone
202                 LIMIT 1"
203            );
204            $stmt->execute([':phone' => $normalizedPhone]);
205            $optoutResult = $stmt->fetch(PDO::FETCH_ASSOC);
206
207            return [
208                'optedOut' => true,
209                'optoutType' => $optoutResult['optout_type'] ?? 'all',
210            ];
211        }
212
213        // Single query to systemBuys - uses idx_phone_timestamp index for efficient lookup
214        // Returns the most recent buy for this phone number across ALL stores
215        $stmt = $this->buysDb->prepare(
216            "SELECT origin, customerID, buyID, timeStamp
217             FROM systemBuys
218             WHERE phoneNum = :phone
219             ORDER BY timeStamp DESC
220             LIMIT 1"
221        );
222        $stmt->execute([':phone' => $normalizedPhone]);
223        $systemBuy = $stmt->fetch(PDO::FETCH_ASSOC);
224
225        if (!$systemBuy) {
226            return null; // No buy found for this phone
227        }
228
229        $typeNum = $systemBuy['origin'];
230        $customerId = (int) $systemBuy['customerID'];
231        $buyId = (int) $systemBuy['buyID'];
232
233        // Load the Store object
234        $store = ($this->storeLoader)($typeNum);
235
236        if (!$store) {
237            return null; // Store not found
238        }
239
240        // Get full customer details from the store database
241        $storeDb = ($this->dbConnector)('kiosk_' . $typeNum);
242
243        if (!$storeDb) {
244            return null; // Can't connect to store DB
245        }
246
247        $stmt = $storeDb->prepare(
248            "SELECT c.customerID, c.firstName, c.lastName, c.phone,
249                    b.buyID, b.dailyNum, b.timeEntered, b.timeCompleted
250             FROM customers c
251             LEFT JOIN buyQueue b ON b.buyID = :buyId
252             WHERE c.customerID = :customerId
253             LIMIT 1"
254        );
255        $stmt->execute([
256            ':customerId' => $customerId,
257            ':buyId' => $buyId,
258        ]);
259        $customer = $stmt->fetch(PDO::FETCH_ASSOC);
260
261        if (!$customer) {
262            // Fallback: Customer exists in systemBuys but not in store DB (rare edge case)
263            $customer = [
264                'customerID' => $customerId,
265                'firstName' => '',
266                'lastName' => '',
267                'phone' => $normalizedPhone,
268                'buyID' => $buyId,
269                'dailyNum' => null,
270                'timeEntered' => $systemBuy['timeStamp'],
271                'timeCompleted' => null,
272            ];
273        }
274
275        return [
276            'store' => $store,
277            'customer' => $customer,
278            'buyId' => $buyId,
279            'typeNum' => $typeNum,
280        ];
281    }
282}