Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 92
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatEligibilityService
0.00% covered (danger)
0.00%
0 / 92
0.00% covered (danger)
0.00%
0 / 9
600
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 createWithDefaults
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 canMessageCustomer
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 canSendFreetext
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getEligibleCustomers
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
90
 hasSameDayBuy
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 findOpenThread
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getTodaysBuys
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getOpenThreadCustomers
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\Chat\Services;
4
5use BuyerKiosk\Chat\Models\ChatThread;
6use PDO;
7
8/**
9 * ChatEligibilityService - Customer Messaging Eligibility
10 *
11 * Determines whether staff can message customers based on business rules.
12 *
13 * Business Rules (from PRD lines 163-170):
14 * 1. Staff can only initiate messages to customers with same-day buys
15 * 2. Exception: Open threads from previous days remain accessible until closed
16 * 3. Customers on do-not-text list are NOT messageable
17 * 4. Eligible customer list updates in real-time as buys are entered
18 *
19 * Additional rules:
20 * - "Same-day" is based on store timezone (not UTC)
21 * - A "buy" is a record in buyQueue table with timeEntered today
22 * - Customer is eligible if they have ANY buy today (entered or completed)
23 *
24 * @package BuyerKiosk\Chat\Services
25 */
26class ChatEligibilityService
27{
28    /**
29     * @var PDO Store database connection
30     */
31    private PDO $storeDb;
32
33    /**
34     * @var PDO Central database connection for opt-out checks
35     */
36    private PDO $centralDb;
37
38    /**
39     * @var ChatMatchingService Matching service for opt-out checks
40     */
41    private ChatMatchingService $matchingService;
42
43    /**
44     * Create a new ChatEligibilityService
45     *
46     * @param PDO $storeDb Store database connection
47     * @param PDO $centralDb Central database connection
48     * @param ChatMatchingService $matchingService Matching service for opt-out checks
49     */
50    public function __construct(
51        PDO $storeDb,
52        PDO $centralDb,
53        ChatMatchingService $matchingService
54    ) {
55        $this->storeDb = $storeDb;
56        $this->centralDb = $centralDb;
57        $this->matchingService = $matchingService;
58    }
59
60    /**
61     * Create service with default dependencies using global functions
62     *
63     * Factory method for production use where we use actual BaseModel functions.
64     *
65     * @param string $typeNum Store identifier to connect to
66     * @return self
67     */
68    public static function createWithDefaults(string $typeNum): self
69    {
70        global $db_name;
71
72        // Get store database connection
73        $storeController = new \BuyerKiosk\BuyerKiosk\Controllers\StoreController($typeNum);
74        $store = $storeController->getStore();
75        $storeDb = dbConnectByName($store->getDbName());
76
77        // Get central database connection
78        $centralDb = dbConnectByName($db_name);
79
80        // Create matching service
81        $matchingService = ChatMatchingService::createWithDefaults();
82
83        return new self($storeDb, $centralDb, $matchingService);
84    }
85
86    /**
87     * Check if staff can initiate a new message to this customer
88     *
89     * Eligibility Rules:
90     * 1. Phone must be valid (non-empty)
91     * 2. Customer ID must be valid (> 0)
92     * 3. Customer must NOT be opted out of all messages
93     * 4. Customer must have either:
94     *    a. A same-day buy (timeEntered is today), OR
95     *    b. An open thread from a previous day
96     *
97     * @param string $typeNum Store identifier
98     * @param int $customerId Customer ID
99     * @param string $customerPhone Customer phone number
100     * @return array Returns ['eligible' => bool, 'reason' => string]
101     */
102    public function canMessageCustomer(
103        string $typeNum,
104        int $customerId,
105        string $customerPhone
106    ): array {
107        // Validate phone number
108        if (empty($customerPhone)) {
109            return [
110                'eligible' => false,
111                'reason' => 'Invalid phone number provided.',
112            ];
113        }
114
115        // Validate customer ID
116        if ($customerId <= 0) {
117            return [
118                'eligible' => false,
119                'reason' => 'Invalid customer ID provided.',
120            ];
121        }
122
123        // Check opt-out status FIRST (use 'all' to check for complete opt-out)
124        if ($this->matchingService->isOptedOut($customerPhone, 'all')) {
125            return [
126                'eligible' => false,
127                'reason' => 'Customer has opted out of all text messages.',
128            ];
129        }
130
131        // Check for same-day buy
132        $hasSameDayBuy = $this->hasSameDayBuy($customerId);
133
134        if ($hasSameDayBuy) {
135            return [
136                'eligible' => true,
137                'reason' => 'Customer is eligible - has same-day buy.',
138            ];
139        }
140
141        // Check for open thread (exception for old buys)
142        $openThread = $this->findOpenThread($customerId);
143
144        if ($openThread !== null) {
145            return [
146                'eligible' => true,
147                'reason' => 'Customer is eligible - has open thread from previous conversation.',
148            ];
149        }
150
151        // No same-day buy and no open thread
152        return [
153            'eligible' => false,
154            'reason' => 'Customer is not eligible - no same-day buy. Staff can only message customers who visited today.',
155        ];
156    }
157
158    /**
159     * Check if staff can send freetext (not template) in this thread
160     *
161     * Freetext Rules:
162     * 1. Thread must be open (pending or active status)
163     * 2. Thread must have staffCanFreetext flag set to true
164     *
165     * The staffCanFreetext flag is set to true after the customer sends their
166     * first reply. Before that, staff can only send approved templates.
167     *
168     * @param ChatThread $thread The thread to check
169     * @return bool True if staff can send freetext messages
170     */
171    public function canSendFreetext(ChatThread $thread): bool
172    {
173        // Thread must be open (pending or active)
174        if (!$thread->isOpen()) {
175            return false;
176        }
177
178        // Thread must have freetext unlocked (customer has replied)
179        return $thread->canStaffSendFreetext();
180    }
181
182    /**
183     * Get list of customers eligible for messaging today
184     *
185     * Returns customers who:
186     * 1. Have a same-day buy (timeEntered is today)
187     * 2. OR have an open thread from a previous conversation
188     * 3. AND are not opted out of all messages
189     *
190     * @param string $typeNum Store identifier
191     * @return array List of eligible customers with their buy/thread info
192     */
193    public function getEligibleCustomers(string $typeNum): array
194    {
195        $eligibleCustomers = [];
196        $seenCustomerIds = [];
197
198        // 1. Get customers with today's buys
199        $todaysBuys = $this->getTodaysBuys();
200
201        foreach ($todaysBuys as $buy) {
202            $phone = $buy['phone'] ?? '';
203            $customerId = (int) ($buy['customerID'] ?? 0);
204
205            // Skip if already seen or opted out
206            if (in_array($customerId, $seenCustomerIds)) {
207                continue;
208            }
209
210            if (!empty($phone) && $this->matchingService->isOptedOut($phone, 'all')) {
211                continue;
212            }
213
214            $eligibleCustomers[] = $buy;
215            $seenCustomerIds[] = $customerId;
216        }
217
218        // 2. Get customers with open threads (even if no buy today)
219        $openThreadCustomers = $this->getOpenThreadCustomers($typeNum);
220
221        foreach ($openThreadCustomers as $customer) {
222            $phone = $customer['phone'] ?? '';
223            $customerId = (int) ($customer['customerID'] ?? 0);
224
225            // Skip if already seen (from today's buys) or opted out
226            if (in_array($customerId, $seenCustomerIds)) {
227                continue;
228            }
229
230            if (!empty($phone) && $this->matchingService->isOptedOut($phone, 'all')) {
231                continue;
232            }
233
234            $eligibleCustomers[] = $customer;
235            $seenCustomerIds[] = $customerId;
236        }
237
238        return $eligibleCustomers;
239    }
240
241    /**
242     * Check if customer has a buy entered today
243     *
244     * @param int $customerId Customer ID
245     * @return bool True if customer has at least one buy today
246     */
247    private function hasSameDayBuy(int $customerId): bool
248    {
249        $stmt = $this->storeDb->prepare(
250            "SELECT COUNT(*) FROM buyQueue
251             WHERE customerID = :customerId
252             AND DATE(timeEntered) = CURDATE()"
253        );
254        $stmt->execute([':customerId' => $customerId]);
255
256        return (int) $stmt->fetchColumn() > 0;
257    }
258
259    /**
260     * Find an open thread for the given customer
261     *
262     * @param int $customerId Customer ID
263     * @return array|null Thread data if found, null otherwise
264     */
265    private function findOpenThread(int $customerId): ?array
266    {
267        $stmt = $this->storeDb->prepare(
268            "SELECT * FROM chat_threads
269             WHERE customer_id = :customerId
270             AND status IN ('pending', 'active')
271             LIMIT 1"
272        );
273        $stmt->execute([':customerId' => $customerId]);
274
275        $result = $stmt->fetch(PDO::FETCH_ASSOC);
276
277        return $result !== false ? $result : null;
278    }
279
280    /**
281     * Get all buys entered today
282     *
283     * @return array List of today's buys with customer info
284     */
285    private function getTodaysBuys(): array
286    {
287        $stmt = $this->storeDb->prepare(
288            "SELECT DISTINCT c.customerID, c.firstName, c.lastName, c.phone,
289                    b.buyID, b.dailyNum, b.timeEntered
290             FROM buyQueue b
291             JOIN customers c ON b.customerID = c.customerID
292             WHERE DATE(b.timeEntered) = CURDATE()
293             ORDER BY b.timeEntered DESC"
294        );
295        $stmt->execute();
296
297        return $stmt->fetchAll(PDO::FETCH_ASSOC);
298    }
299
300    /**
301     * Get customers with open threads, including their customer info
302     *
303     * @param string $typeNum Store identifier
304     * @return array List of customers with open threads
305     */
306    private function getOpenThreadCustomers(string $typeNum): array
307    {
308        $stmt = $this->storeDb->prepare(
309            "SELECT t.id as thread_id, c.customerID, c.firstName, c.lastName, c.phone,
310                    t.status, t.staff_can_freetext,
311                    b.buyID, b.dailyNum, b.timeEntered
312             FROM chat_threads t
313             JOIN customers c ON t.customer_id = c.customerID
314             LEFT JOIN buyQueue b ON t.buy_id = b.buyID
315             WHERE t.typeNum = :typeNum
316             AND t.status IN ('pending', 'active')
317             ORDER BY t.last_message_at DESC"
318        );
319        $stmt->execute([':typeNum' => $typeNum]);
320
321        return $stmt->fetchAll(PDO::FETCH_ASSOC);
322    }
323}