Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 232
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SmsQueue
0.00% covered (danger)
0.00%
0 / 232
0.00% covered (danger)
0.00%
0 / 12
4970
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getStoreIdFromTypeNum
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 getTypeNumFromStoreId
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 add
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
110
 getPending
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
56
 markSent
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 markFailed
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
110
 getRetryable
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 purgeOld
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 getQueueStats
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
56
 validatePhoneNumber
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 log
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace BuyerKiosk\SellerMarketing;
4
5/**
6 * SmsQueue Class
7 *
8 * Manages SMS queue operations for the seller marketing trigger system.
9 * Handles adding messages to queue, status tracking, and retry logic.
10 *
11 * REFACTORED: Now uses central seller_marketing_queue table in kiosk_buykiosk database
12 * instead of store-specific sms_queue tables.
13 *
14 * Table: seller_marketing_queue (central database: kiosk_buykiosk)
15 *
16 * @package BuyerKiosk\SellerMarketing
17 */
18class SmsQueue
19{
20    /**
21     * @var \PDO Database connection (central database)
22     */
23    private $db;
24
25    /**
26     * @var mixed Optional logger instance
27     */
28    private $logger;
29
30    /**
31     * Valid status values (updated for new schema)
32     */
33    const STATUS_PENDING = 'pending';
34    const STATUS_SENT = 'sent';
35    const STATUS_FAILED = 'failed';
36    const STATUS_CANCELLED = 'cancelled';
37
38    /**
39     * Maximum retry attempts
40     */
41    const DEFAULT_MAX_ATTEMPTS = 3;
42
43    /**
44     * Default message priority
45     */
46    const DEFAULT_PRIORITY = 5;
47
48    /**
49     * Constructor
50     *
51     * @param \PDO $db Database connection (central database: kiosk_buykiosk)
52     * @param mixed $logger Optional logger instance
53     */
54    public function __construct($db, $logger = null)
55    {
56        $this->db = $db;
57        $this->logger = $logger;
58    }
59
60    /**
61     * Get store ID from typeNum
62     *
63     * Queries the stores table to convert typeNum (e.g., 'ou00') to store ID.
64     *
65     * @param string $typeNum Store identifier (e.g., 'ou00', 'pa00')
66     * @return int|false Store ID on success, false on failure
67     */
68    private function getStoreIdFromTypeNum($typeNum)
69    {
70        try {
71            $sql = "SELECT id FROM stores WHERE typeNum = :type_num LIMIT 1";
72            $stmt = $this->db->prepare($sql);
73            $stmt->bindValue(':type_num', $typeNum, \PDO::PARAM_STR);
74
75            if ($stmt->execute()) {
76                $result = $stmt->fetch(\PDO::FETCH_ASSOC);
77                if ($result && isset($result['id'])) {
78                    return (int)$result['id'];
79                }
80            }
81
82            $this->log("Store not found for typeNum: {$typeNum}");
83            return false;
84
85        } catch (\Exception $e) {
86            $this->log("Error getting store ID from typeNum: " . $e->getMessage());
87            return false;
88        }
89    }
90
91    /**
92     * Get typeNum from store ID
93     *
94     * Queries the stores table to convert store ID to typeNum.
95     *
96     * @param int $storeId Store ID
97     * @return string|false TypeNum on success, false on failure
98     */
99    private function getTypeNumFromStoreId($storeId)
100    {
101        try {
102            $sql = "SELECT typeNum FROM stores WHERE id = :store_id LIMIT 1";
103            $stmt = $this->db->prepare($sql);
104            $stmt->bindValue(':store_id', $storeId, \PDO::PARAM_INT);
105
106            if ($stmt->execute()) {
107                $result = $stmt->fetch(\PDO::FETCH_ASSOC);
108                if ($result && isset($result['typeNum'])) {
109                    return $result['typeNum'];
110                }
111            }
112
113            $this->log("TypeNum not found for store ID: {$storeId}");
114            return false;
115
116        } catch (\Exception $e) {
117            $this->log("Error getting typeNum from store ID: " . $e->getMessage());
118            return false;
119        }
120    }
121
122    /**
123     * Add a new message to the queue
124     *
125     * REFACTORED: Simplified parameters and updated to use seller_marketing_queue table.
126     *
127     * @param string $typeNum Store identifier (e.g., 'ou00', 'pa00')
128     * @param string $phone Customer phone number (E.164 format recommended)
129     * @param string $message SMS content with variables already replaced
130     * @param int $triggerId Reference to seller_marketing_triggers.id
131     * @return int|false Queue ID on success, false on failure
132     */
133    public function add($typeNum, $phone, $message, $triggerId)
134    {
135        try {
136            // Validate inputs
137            if (empty($typeNum) || empty($phone) || empty($message) || empty($triggerId)) {
138                $this->log("Failed to add message to queue: Missing required fields");
139                return false;
140            }
141
142            // Get store ID from typeNum
143            $storeId = $this->getStoreIdFromTypeNum($typeNum);
144            if ($storeId === false) {
145                $this->log("Failed to add message to queue: Invalid typeNum: {$typeNum}");
146                return false;
147            }
148
149            // Validate phone number format (basic validation)
150            if (!$this->validatePhoneNumber($phone)) {
151                $this->log("Failed to add message to queue: Invalid phone number format: {$phone}");
152                return false;
153            }
154
155            $sql = "INSERT INTO seller_marketing_queue
156                    (store_id, phone, message, message_type, trigger_id, send_at,
157                     status, provider, priority, attempts, max_attempts, created_at)
158                    VALUES
159                    (:store_id, :phone, :message, :message_type, :trigger_id, NOW(),
160                     :status, :provider, :priority, 0, :max_attempts, NOW())";
161
162            $stmt = $this->db->prepare($sql);
163            $stmt->bindValue(':store_id', $storeId, \PDO::PARAM_INT);
164            $stmt->bindValue(':phone', $phone, \PDO::PARAM_STR);
165            $stmt->bindValue(':message', $message, \PDO::PARAM_STR);
166            $stmt->bindValue(':message_type', 'trigger', \PDO::PARAM_STR);
167            $stmt->bindValue(':trigger_id', $triggerId, \PDO::PARAM_INT);
168            $stmt->bindValue(':status', self::STATUS_PENDING, \PDO::PARAM_STR);
169            $stmt->bindValue(':provider', 'twilio', \PDO::PARAM_STR);
170            $stmt->bindValue(':priority', self::DEFAULT_PRIORITY, \PDO::PARAM_INT);
171            $stmt->bindValue(':max_attempts', self::DEFAULT_MAX_ATTEMPTS, \PDO::PARAM_INT);
172
173            if ($stmt->execute()) {
174                $queueId = $this->db->lastInsertId();
175                $this->log("Added message to queue: ID={$queueId}, store={$typeNum}, phone={$phone}, trigger={$triggerId}");
176                return (int)$queueId;
177            }
178
179            $this->log("Failed to add message to queue: Database error");
180            return false;
181
182        } catch (\PDOException $e) {
183            $this->log("Database error adding message to queue: " . $e->getMessage());
184            return false;
185        } catch (\Exception $e) {
186            $this->log("Error adding message to queue: " . $e->getMessage());
187            return false;
188        }
189    }
190
191    /**
192     * Get pending messages for a store
193     *
194     * REFACTORED: Returns messages with status='pending' AND send_at <= NOW()
195     * ordered by priority and send_at. Converts store_id to typeNum in results.
196     *
197     * @param string $typeNum Store identifier
198     * @param int $limit Maximum number of messages to retrieve (default: 10)
199     * @return array Array of message records, empty array on error
200     */
201    public function getPending($typeNum, $limit = 10)
202    {
203        try {
204            // Validate inputs
205            if (empty($typeNum)) {
206                $this->log("Failed to get pending messages: Missing typeNum");
207                return [];
208            }
209
210            // Get store ID from typeNum
211            $storeId = $this->getStoreIdFromTypeNum($typeNum);
212            if ($storeId === false) {
213                $this->log("Failed to get pending messages: Invalid typeNum: {$typeNum}");
214                return [];
215            }
216
217            $limit = max(1, min(1000, (int)$limit)); // Enforce reasonable limits
218
219            $sql = "SELECT * FROM seller_marketing_queue
220                    WHERE store_id = :store_id
221                    AND status = :status
222                    AND send_at <= NOW()
223                    ORDER BY priority ASC, send_at ASC
224                    LIMIT :limit";
225
226            $stmt = $this->db->prepare($sql);
227            $stmt->bindValue(':store_id', $storeId, \PDO::PARAM_INT);
228            $stmt->bindValue(':status', self::STATUS_PENDING, \PDO::PARAM_STR);
229            $stmt->bindValue(':limit', $limit, \PDO::PARAM_INT);
230
231            if ($stmt->execute()) {
232                $messages = $stmt->fetchAll(\PDO::FETCH_ASSOC);
233
234                // Add typeNum to each message for backward compatibility
235                foreach ($messages as &$message) {
236                    $message['type_num'] = $typeNum;
237                }
238
239                $this->log("Retrieved " . count($messages) . " pending messages for {$typeNum}");
240                return $messages;
241            }
242
243            $this->log("Failed to get pending messages: Query execution failed");
244            return [];
245
246        } catch (\PDOException $e) {
247            $this->log("Database error getting pending messages: " . $e->getMessage());
248            return [];
249        } catch (\Exception $e) {
250            $this->log("Error getting pending messages: " . $e->getMessage());
251            return [];
252        }
253    }
254
255    /**
256     * Mark a message as sent with provider ID
257     *
258     * REFACTORED: Removed 'sending' status - goes directly from pending to sent.
259     * Uses provider_id instead of twilio_sid to support multiple providers.
260     *
261     * @param int $queueId Queue message ID
262     * @param string $providerId Provider message ID (Twilio SID or Vonage ID)
263     * @return bool True on success, false on failure
264     */
265    public function markSent($queueId, $providerId)
266    {
267        try {
268            // Validate inputs
269            if (empty($queueId) || !is_numeric($queueId)) {
270                $this->log("Failed to mark as sent: Invalid queue ID");
271                return false;
272            }
273
274            $sql = "UPDATE seller_marketing_queue
275                    SET status = :status,
276                        sent_at = NOW(),
277                        provider_id = :provider_id
278                    WHERE id = :id";
279
280            $stmt = $this->db->prepare($sql);
281            $stmt->bindValue(':status', self::STATUS_SENT, \PDO::PARAM_STR);
282            $stmt->bindValue(':provider_id', $providerId, \PDO::PARAM_STR);
283            $stmt->bindValue(':id', (int)$queueId, \PDO::PARAM_INT);
284
285            if ($stmt->execute()) {
286                $this->log("Marked message as sent: ID={$queueId}, provider_id={$providerId}");
287                return true;
288            }
289
290            $this->log("Failed to mark as sent: Query execution failed for ID={$queueId}");
291            return false;
292
293        } catch (\PDOException $e) {
294            $this->log("Database error marking as sent: " . $e->getMessage());
295            return false;
296        } catch (\Exception $e) {
297            $this->log("Error marking as sent: " . $e->getMessage());
298            return false;
299        }
300    }
301
302    /**
303     * Mark a message as failed with error message
304     *
305     * REFACTORED: Includes retry logic. If attempts < max_attempts, resets to pending.
306     * If max attempts reached, leaves status as failed.
307     *
308     * @param int $queueId Queue message ID
309     * @param string $errorMessage Error details
310     * @return bool True on success, false on failure
311     */
312    public function markFailed($queueId, $errorMessage)
313    {
314        try {
315            // Validate inputs
316            if (empty($queueId) || !is_numeric($queueId)) {
317                $this->log("Failed to mark as failed: Invalid queue ID");
318                return false;
319            }
320
321            // First, get current attempts and max_attempts
322            $sql = "SELECT attempts, max_attempts FROM seller_marketing_queue WHERE id = :id";
323            $stmt = $this->db->prepare($sql);
324            $stmt->bindValue(':id', (int)$queueId, \PDO::PARAM_INT);
325
326            if (!$stmt->execute()) {
327                $this->log("Failed to get message info for ID={$queueId}");
328                return false;
329            }
330
331            $message = $stmt->fetch(\PDO::FETCH_ASSOC);
332            if (!$message) {
333                $this->log("Message not found: ID={$queueId}");
334                return false;
335            }
336
337            $newAttempts = $message['attempts'] + 1;
338            $maxAttempts = $message['max_attempts'];
339
340            // Determine new status: if we haven't hit max attempts, reset to pending for retry
341            $newStatus = ($newAttempts >= $maxAttempts) ? self::STATUS_FAILED : self::STATUS_PENDING;
342
343            $sql = "UPDATE seller_marketing_queue
344                    SET status = :status,
345                        error_message = :error_message,
346                        attempts = :attempts
347                    WHERE id = :id";
348
349            $stmt = $this->db->prepare($sql);
350            $stmt->bindValue(':status', $newStatus, \PDO::PARAM_STR);
351            $stmt->bindValue(':error_message', $errorMessage, \PDO::PARAM_STR);
352            $stmt->bindValue(':attempts', $newAttempts, \PDO::PARAM_INT);
353            $stmt->bindValue(':id', (int)$queueId, \PDO::PARAM_INT);
354
355            if ($stmt->execute()) {
356                if ($newStatus === self::STATUS_PENDING) {
357                    $this->log("Marked message for retry: ID={$queueId}, attempts={$newAttempts}/{$maxAttempts}, error={$errorMessage}");
358                } else {
359                    $this->log("Marked message as permanently failed: ID={$queueId}, attempts={$newAttempts}/{$maxAttempts}, error={$errorMessage}");
360                }
361                return true;
362            }
363
364            $this->log("Failed to mark as failed: Query execution failed for ID={$queueId}");
365            return false;
366
367        } catch (\PDOException $e) {
368            $this->log("Database error marking as failed: " . $e->getMessage());
369            return false;
370        } catch (\Exception $e) {
371            $this->log("Error marking as failed: " . $e->getMessage());
372            return false;
373        }
374    }
375
376    /**
377     * Get messages eligible for retry
378     *
379     * REFACTORED: Returns messages with status='pending' AND attempts > 0.
380     * This identifies messages that have been retried but are ready to send again.
381     *
382     * @param string $typeNum Store identifier
383     * @return array Array of message records, empty array on error
384     */
385    public function getRetryable($typeNum)
386    {
387        try {
388            // Validate inputs
389            if (empty($typeNum)) {
390                $this->log("Failed to get retryable messages: Missing typeNum");
391                return [];
392            }
393
394            // Get store ID from typeNum
395            $storeId = $this->getStoreIdFromTypeNum($typeNum);
396            if ($storeId === false) {
397                $this->log("Failed to get retryable messages: Invalid typeNum: {$typeNum}");
398                return [];
399            }
400
401            $sql = "SELECT * FROM seller_marketing_queue
402                    WHERE store_id = :store_id
403                    AND status = :status
404                    AND attempts > 0
405                    ORDER BY created_at ASC";
406
407            $stmt = $this->db->prepare($sql);
408            $stmt->bindValue(':store_id', $storeId, \PDO::PARAM_INT);
409            $stmt->bindValue(':status', self::STATUS_PENDING, \PDO::PARAM_STR);
410
411            if ($stmt->execute()) {
412                $messages = $stmt->fetchAll(\PDO::FETCH_ASSOC);
413
414                // Add typeNum to each message for backward compatibility
415                foreach ($messages as &$message) {
416                    $message['type_num'] = $typeNum;
417                }
418
419                $this->log("Retrieved " . count($messages) . " retryable messages for {$typeNum}");
420                return $messages;
421            }
422
423            $this->log("Failed to get retryable messages: Query execution failed");
424            return [];
425
426        } catch (\PDOException $e) {
427            $this->log("Database error getting retryable messages: " . $e->getMessage());
428            return [];
429        } catch (\Exception $e) {
430            $this->log("Error getting retryable messages: " . $e->getMessage());
431            return [];
432        }
433    }
434
435    /**
436     * Delete old successfully sent messages
437     *
438     * REFACTORED: Removes sent messages older than specified days to keep queue clean.
439     * Uses sent_at instead of delivered_at (which no longer exists).
440     *
441     * @param int $daysOld Age threshold in days (default: 90)
442     * @return int|false Number of records deleted, false on failure
443     */
444    public function purgeOld($daysOld = 90)
445    {
446        try {
447            // Validate input
448            $daysOld = max(1, (int)$daysOld); // Minimum 1 day to prevent accidents
449
450            $sql = "DELETE FROM seller_marketing_queue
451                    WHERE status = :status
452                    AND sent_at < DATE_SUB(NOW(), INTERVAL :days DAY)";
453
454            $stmt = $this->db->prepare($sql);
455            $stmt->bindValue(':status', self::STATUS_SENT, \PDO::PARAM_STR);
456            $stmt->bindValue(':days', $daysOld, \PDO::PARAM_INT);
457
458            if ($stmt->execute()) {
459                $deletedCount = $stmt->rowCount();
460                $this->log("Purged {$deletedCount} old sent messages (older than {$daysOld} days)");
461                return $deletedCount;
462            }
463
464            $this->log("Failed to purge old messages: Query execution failed");
465            return false;
466
467        } catch (\PDOException $e) {
468            $this->log("Database error purging old messages: " . $e->getMessage());
469            return false;
470        } catch (\Exception $e) {
471            $this->log("Error purging old messages: " . $e->getMessage());
472            return false;
473        }
474    }
475
476    /**
477     * Get queue statistics for a store
478     *
479     * REFACTORED: Updated to use new status values (pending, sent, failed, cancelled).
480     * Returns counts of messages grouped by status.
481     *
482     * @param string $typeNum Store identifier
483     * @return array|false Array with status counts, false on failure
484     * Format: ['pending' => 5, 'sent' => 10, 'failed' => 2, 'cancelled' => 1]
485     */
486    public function getQueueStats($typeNum)
487    {
488        try {
489            // Validate input
490            if (empty($typeNum)) {
491                $this->log("Failed to get queue stats: Missing typeNum");
492                return false;
493            }
494
495            // Get store ID from typeNum
496            $storeId = $this->getStoreIdFromTypeNum($typeNum);
497            if ($storeId === false) {
498                $this->log("Failed to get queue stats: Invalid typeNum: {$typeNum}");
499                return false;
500            }
501
502            $sql = "SELECT status, COUNT(*) as count
503                    FROM seller_marketing_queue
504                    WHERE store_id = :store_id
505                    GROUP BY status";
506
507            $stmt = $this->db->prepare($sql);
508            $stmt->bindValue(':store_id', $storeId, \PDO::PARAM_INT);
509
510            if ($stmt->execute()) {
511                $results = $stmt->fetchAll(\PDO::FETCH_ASSOC);
512
513                // Initialize all statuses to 0
514                $stats = [
515                    self::STATUS_PENDING => 0,
516                    self::STATUS_SENT => 0,
517                    self::STATUS_FAILED => 0,
518                    self::STATUS_CANCELLED => 0
519                ];
520
521                // Fill in actual counts
522                foreach ($results as $row) {
523                    $stats[$row['status']] = (int)$row['count'];
524                }
525
526                $this->log("Retrieved queue stats for {$typeNum}" . json_encode($stats));
527                return $stats;
528            }
529
530            $this->log("Failed to get queue stats: Query execution failed");
531            return false;
532
533        } catch (\PDOException $e) {
534            $this->log("Database error getting queue stats: " . $e->getMessage());
535            return false;
536        } catch (\Exception $e) {
537            $this->log("Error getting queue stats: " . $e->getMessage());
538            return false;
539        }
540    }
541
542    /**
543     * Validate phone number format
544     *
545     * Basic validation to ensure phone number is reasonable.
546     * Accepts E.164 format (+12345678901) or digits only.
547     *
548     * @param string $phone Phone number to validate
549     * @return bool True if valid format
550     */
551    private function validatePhoneNumber($phone)
552    {
553        // Remove whitespace
554        $phone = trim($phone);
555
556        // Check for empty
557        if (empty($phone)) {
558            return false;
559        }
560
561        // Must be 10-15 characters (with or without +)
562        // E.164 format: +[country code][number] (max 15 digits)
563        $length = strlen($phone);
564        if ($length < 10 || $length > 16) {
565            return false;
566        }
567
568        // Must contain only digits and optionally start with +
569        if (!preg_match('/^\+?\d{10,15}$/', $phone)) {
570            return false;
571        }
572
573        return true;
574    }
575
576    /**
577     * Log a message
578     *
579     * Uses logger if provided, otherwise falls back to error_log.
580     *
581     * @param string $message Log message
582     * @return void
583     */
584    private function log($message)
585    {
586        $logMessage = "[SmsQueue] " . $message;
587
588        if ($this->logger !== null && method_exists($this->logger, 'info')) {
589            $this->logger->info($logMessage);
590        } else {
591            error_log($logMessage);
592        }
593    }
594}