Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 209
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
TriggerProcessor
0.00% covered (danger)
0.00%
0 / 209
0.00% covered (danger)
0.00%
0 / 16
3422
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
6
 processStore
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
72
 processTrigger
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
132
 getTriggerClass
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 saveRunHistory
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 shouldRunTrigger
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 getActiveTriggers
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getMessageTemplate
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getStoreDatabase
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getStoreData
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 normalizePhoneNumber
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 registerTriggerClass
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setMaxCustomersPerTrigger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setMaxTriggersPerRun
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logInfo
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 logError
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace BuyerKiosk\SellerMarketing;
4
5use Exception;
6use InvalidArgumentException;
7use PDO;
8
9/**
10 * TriggerProcessor
11 *
12 * Orchestrates the processing of seller marketing triggers for a store.
13 * Finds matching customers based on trigger criteria and queues personalized
14 * messages for delivery. Tracks execution metrics and handles errors gracefully.
15 *
16 * This class serves as the main entry point for automated trigger processing.
17 * It loads active triggers, instantiates the appropriate trigger handler classes,
18 * finds matching customers, personalizes messages, and queues them for sending.
19 *
20 * Responsibilities:
21 * - Load and validate active triggers for a store
22 * - Instantiate appropriate trigger class (factory pattern)
23 * - Find customers matching trigger criteria
24 * - Personalize message templates with customer/store data
25 * - Queue messages via SmsQueue
26 * - Track execution statistics
27 * - Handle errors without halting entire process
28 *
29 * Usage Example:
30 * <code>
31 * $processor = new TriggerProcessor($centralDb, $logger);
32 * $stats = $processor->processStore('ou00');
33 * echo "Processed {$stats['triggers_processed']} triggers, queued {$stats['messages_queued']} messages";
34 * </code>
35 *
36 * @package BuyerKiosk\SellerMarketing
37 */
38class TriggerProcessor
39{
40    /**
41     * @var PDO Central database connection (kiosk_buykiosk)
42     */
43    private $centralDb;
44
45    /**
46     * @var object Optional logger instance
47     */
48    private $logger;
49
50    /**
51     * @var int Maximum customers to process per trigger (safety limit)
52     */
53    private $maxCustomersPerTrigger = 500;
54
55    /**
56     * @var int Maximum triggers to process per run (safety limit)
57     */
58    private $maxTriggersPerRun = 50;
59
60    /**
61     * @var array Map of trigger types to their implementing classes
62     */
63    private $triggerClassMap = [
64        'days_since_sold' => 'BuyerKiosk\\SellerMarketing\\DaysSinceSoldTrigger',
65        'days_since_event' => 'BuyerKiosk\\SellerMarketing\\DaysSinceEventTrigger',
66        'birthday' => 'BuyerKiosk\\SellerMarketing\\BirthdayTrigger',
67        'expiring_points' => 'BuyerKiosk\\SellerMarketing\\ExpiringPointsTrigger',
68    ];
69
70    /**
71     * Constructor
72     *
73     * @param PDO $centralDb Central database connection (kiosk_buykiosk)
74     * @param object|null $logger Optional logger instance (PSR-3 compatible)
75     * @throws InvalidArgumentException If database connection is invalid
76     */
77    public function __construct($centralDb, $logger = null)
78    {
79        if (!($centralDb instanceof PDO)) {
80            throw new InvalidArgumentException('Central database must be a valid PDO instance');
81        }
82
83        $this->centralDb = $centralDb;
84        $this->logger = $logger;
85    }
86
87    /**
88     * Process all active triggers for a store
89     *
90     * Loads all active triggers for the specified store, processes each one
91     * to find matching customers, and queues personalized messages. Returns
92     * comprehensive statistics about the processing run.
93     *
94     * Processing steps:
95     * 1. Load store object and connect to store database
96     * 2. Get all active triggers that should run
97     * 3. For each trigger, process it (find customers, queue messages)
98     * 4. Track statistics and errors
99     * 5. Return summary results
100     *
101     * Error handling: If one trigger fails, processing continues with the next.
102     * All errors are logged and included in the returned statistics.
103     *
104     * @param string $typeNum Store identifier (e.g., 'ou00', 'pa00')
105     * @return array Statistics array with keys:
106     *               - triggers_processed: Number of triggers successfully processed
107     *               - messages_queued: Total messages added to queue
108     *               - customers_found: Total customers matched across all triggers
109     *               - errors: Array of error messages
110     *               - execution_time: Total time in seconds
111     *               - store: Store identifier
112     * @throws Exception If store cannot be loaded or database connection fails
113     */
114    public function processStore($typeNum)
115    {
116        $startTime = microtime(true);
117
118        $stats = [
119            'store' => $typeNum,
120            'triggers_processed' => 0,
121            'messages_queued' => 0,
122            'customers_found' => 0,
123            'errors' => [],
124            'execution_time' => 0
125        ];
126
127        try {
128            // Validate store identifier
129            if (empty($typeNum) || !preg_match('/^[a-z]{2}\d{2}$/', $typeNum)) {
130                throw new InvalidArgumentException("Invalid store identifier: {$typeNum}");
131            }
132
133            $this->logInfo("Starting trigger processing for store: {$typeNum}");
134
135            // Load store object
136            $store = new \Store();
137            $store->createStore($typeNum);
138
139            if (!$store || !$store->getStoreID()) {
140                throw new Exception("Failed to load store: {$typeNum}");
141            }
142
143            // Connect to store database
144            $storeDb = $this->getStoreDatabase($store);
145
146            // Get all active triggers for this store
147            $triggers = $this->getActiveTriggers($storeDb);
148
149            $this->logInfo("Found " . count($triggers) . " active trigger(s) for {$typeNum}");
150
151            // Process each trigger
152            foreach ($triggers as $triggerRow) {
153                try {
154                    $triggerStats = $this->processTrigger($typeNum, $triggerRow, $store, $storeDb);
155
156                    $stats['triggers_processed']++;
157                    $stats['messages_queued'] += $triggerStats['messages_queued'];
158                    $stats['customers_found'] += $triggerStats['customers_found'];
159
160                } catch (Exception $e) {
161                    $error = sprintf(
162                        "Trigger %d (%s): %s",
163                        $triggerRow['id'],
164                        $triggerRow['name'],
165                        $e->getMessage()
166                    );
167                    $stats['errors'][] = $error;
168                    $this->logError($error);
169                }
170            }
171
172            $endTime = microtime(true);
173            $stats['execution_time'] = round($endTime - $startTime, 2);
174
175            $this->logInfo("Completed trigger processing for {$typeNum}", $stats);
176
177            return $stats;
178
179        } catch (Exception $e) {
180            $endTime = microtime(true);
181            $stats['execution_time'] = round($endTime - $startTime, 2);
182            $stats['errors'][] = $e->getMessage();
183
184            $this->logError("Failed to process store {$typeNum}" . $e->getMessage());
185
186            return $stats;
187        }
188    }
189
190    /**
191     * Process a single trigger
192     *
193     * Processes one trigger by:
194     * 1. Determining if the trigger should run (based on last run time)
195     * 2. Instantiating the appropriate trigger class
196     * 3. Finding matching customers
197     * 4. Loading the message template
198     * 5. Building personalized messages
199     * 6. Queuing messages via SmsQueue
200     * 7. Saving execution statistics
201     *
202     * @param string $typeNum Store identifier
203     * @param array $trigger Trigger database row with keys: id, name, type, config, message_id
204     * @param \Store $store Store object
205     * @param PDO $storeDb Store database connection
206     * @return array Statistics with keys: customers_found, messages_queued, execution_time
207     * @throws Exception If trigger processing fails critically
208     */
209    public function processTrigger($typeNum, $trigger, $store, $storeDb)
210    {
211        $triggerStartTime = microtime(true);
212
213        $stats = [
214            'trigger_id' => $trigger['id'],
215            'trigger_name' => $trigger['name'],
216            'customers_found' => 0,
217            'messages_queued' => 0,
218            'execution_time' => 0
219        ];
220
221        try {
222            // Check if trigger should run (not run recently)
223            if (!$this->shouldRunTrigger($trigger['id'], $storeDb)) {
224                $this->logInfo("Trigger {$trigger['id']} skipped (already run recently)");
225                return $stats;
226            }
227
228            $this->logInfo("Processing trigger {$trigger['id']}{$trigger['name']} (type: {$trigger['type']})");
229
230            // Parse trigger configuration
231            $config = json_decode($trigger['config'], true);
232            if ($config === null && json_last_error() !== JSON_ERROR_NONE) {
233                throw new Exception("Invalid trigger configuration JSON: " . json_last_error_msg());
234            }
235
236            // Get the trigger class and instantiate it
237            $triggerInstance = $this->getTriggerClass($trigger['type'], $storeDb);
238
239            // Find matching customers
240            $customers = $triggerInstance->findCustomers($typeNum, $config);
241            $stats['customers_found'] = count($customers);
242
243            $this->logInfo("Found {$stats['customers_found']} matching customer(s) for trigger {$trigger['id']}");
244
245            // Apply safety limit
246            if (count($customers) > $this->maxCustomersPerTrigger) {
247                $customers = array_slice($customers, 0, $this->maxCustomersPerTrigger);
248                $this->logInfo("Limited to {$this->maxCustomersPerTrigger} customers (safety limit)");
249            }
250
251            // If no customers found, we're done
252            if (empty($customers)) {
253                $this->saveRunHistory($trigger['id'], $stats, $storeDb);
254                return $stats;
255            }
256
257            // Load message template
258            $messageTemplate = $this->getMessageTemplate($trigger['message_id'], $storeDb);
259
260            // Get store data for personalization
261            $storeData = $this->getStoreData($store);
262
263            // Initialize SMS queue
264            $smsQueue = new SmsQueue($storeDb, $this->logger);
265
266            // Process each customer
267            foreach ($customers as $customer) {
268                try {
269                    // Build personalized message
270                    $personalizedMessage = $triggerInstance->buildMessage(
271                        $messageTemplate,
272                        $customer,
273                        $storeData
274                    );
275
276                    // Validate phone number
277                    $phone = $this->normalizePhoneNumber($customer['phone']);
278                    if (!$phone) {
279                        $this->logError("Invalid phone number for customer {$customer['customerID']}{$customer['phone']}");
280                        continue;
281                    }
282
283                    // Get customer name
284                    $customerName = trim(($customer['firstName'] ?? '') . ' ' . ($customer['lastName'] ?? ''));
285
286                    // Queue the message
287                    $queueId = $smsQueue->add(
288                        $typeNum,
289                        $customer['customerID'] ?? null,
290                        $phone,
291                        $customerName,
292                        $personalizedMessage,
293                        $trigger['id']
294                    );
295
296                    if ($queueId) {
297                        $stats['messages_queued']++;
298                    } else {
299                        $this->logError("Failed to queue message for customer {$customer['customerID']}");
300                    }
301
302                } catch (Exception $e) {
303                    $this->logError("Error processing customer {$customer['customerID']}" . $e->getMessage());
304                    continue;
305                }
306            }
307
308            // Calculate execution time
309            $triggerEndTime = microtime(true);
310            $stats['execution_time'] = round(($triggerEndTime - $triggerStartTime) * 1000); // milliseconds
311
312            // Save run history
313            $this->saveRunHistory($trigger['id'], $stats, $storeDb);
314
315            $this->logInfo("Completed trigger {$trigger['id']}: queued {$stats['messages_queued']} message(s)");
316
317            return $stats;
318
319        } catch (Exception $e) {
320            $this->logError("Failed to process trigger {$trigger['id']}" . $e->getMessage());
321            throw $e;
322        }
323    }
324
325    /**
326     * Factory method to instantiate the correct trigger class
327     *
328     * Uses the trigger type to determine which concrete trigger class to instantiate.
329     * Supports dependency injection of the database connection and logger.
330     *
331     * @param string $triggerType Trigger type identifier (e.g., 'days_since_sold')
332     * @param PDO $storeDb Store database connection
333     * @return TriggerInterface Instantiated trigger object
334     * @throws InvalidArgumentException If trigger type is unknown
335     */
336    public function getTriggerClass($triggerType, $storeDb)
337    {
338        if (!isset($this->triggerClassMap[$triggerType])) {
339            throw new InvalidArgumentException("Unknown trigger type: {$triggerType}");
340        }
341
342        $className = $this->triggerClassMap[$triggerType];
343
344        if (!class_exists($className)) {
345            throw new InvalidArgumentException("Trigger class not found: {$className}");
346        }
347
348        $instance = new $className($storeDb, $this->logger);
349
350        if (!($instance instanceof Triggers\TriggerInterface)) {
351            throw new InvalidArgumentException("Trigger class must implement TriggerInterface: {$className}");
352        }
353
354        return $instance;
355    }
356
357    /**
358     * Save trigger execution statistics to database
359     *
360     * Records when the trigger was run, how many customers were found,
361     * how many messages were queued, and how long it took to execute.
362     * This data is used for analytics and to prevent triggers from running too frequently.
363     *
364     * @param int $triggerId Trigger ID
365     * @param array $stats Statistics array with keys: customers_found, messages_queued, execution_time
366     * @param PDO $storeDb Store database connection
367     * @return bool True on success, false on failure
368     */
369    public function saveRunHistory($triggerId, $stats, $storeDb)
370    {
371        try {
372            $sql = "INSERT INTO trigger_last_run
373                    (trigger_id, last_run_at, customers_found, messages_queued, execution_time_ms)
374                    VALUES
375                    (:trigger_id, NOW(), :customers_found, :messages_queued, :execution_time_ms)";
376
377            $stmt = $storeDb->prepare($sql);
378            $stmt->bindValue(':trigger_id', $triggerId, PDO::PARAM_INT);
379            $stmt->bindValue(':customers_found', $stats['customers_found'], PDO::PARAM_INT);
380            $stmt->bindValue(':messages_queued', $stats['messages_queued'], PDO::PARAM_INT);
381            $stmt->bindValue(':execution_time_ms', $stats['execution_time'], PDO::PARAM_INT);
382
383            $success = $stmt->execute();
384
385            if ($success) {
386                // Also update the last_processed timestamp on the trigger itself
387                $updateSql = "UPDATE seller_marketing_triggers
388                             SET last_processed = NOW()
389                             WHERE id = :trigger_id";
390                $updateStmt = $storeDb->prepare($updateSql);
391                $updateStmt->bindValue(':trigger_id', $triggerId, PDO::PARAM_INT);
392                $updateStmt->execute();
393            }
394
395            return $success;
396
397        } catch (Exception $e) {
398            $this->logError("Failed to save run history for trigger {$triggerId}" . $e->getMessage());
399            return false;
400        }
401    }
402
403    /**
404     * Determine if a trigger should run now
405     *
406     * Checks the last run time to prevent triggers from running too frequently.
407     * Triggers should typically run at most once per day (or per hour for time-sensitive triggers).
408     *
409     * Logic:
410     * - If never run before: YES, run it
411     * - If last run was more than 23 hours ago: YES, run it
412     * - Otherwise: NO, skip it
413     *
414     * @param int $triggerId Trigger ID
415     * @param PDO $storeDb Store database connection
416     * @return bool True if trigger should run, false to skip
417     */
418    public function shouldRunTrigger($triggerId, $storeDb)
419    {
420        try {
421            $sql = "SELECT MAX(last_run_at) as last_run
422                    FROM trigger_last_run
423                    WHERE trigger_id = :trigger_id";
424
425            $stmt = $storeDb->prepare($sql);
426            $stmt->bindValue(':trigger_id', $triggerId, PDO::PARAM_INT);
427            $stmt->execute();
428
429            $row = $stmt->fetch(PDO::FETCH_ASSOC);
430
431            // If never run, definitely should run
432            if (!$row || !$row['last_run']) {
433                return true;
434            }
435
436            // Check if last run was more than 23 hours ago
437            $lastRunTime = strtotime($row['last_run']);
438            $hoursAgo = (time() - $lastRunTime) / 3600;
439
440            return $hoursAgo >= 23;
441
442        } catch (Exception $e) {
443            $this->logError("Error checking if trigger should run: " . $e->getMessage());
444            // On error, default to allowing it to run
445            return true;
446        }
447    }
448
449    /**
450     * Get all active triggers for a store
451     *
452     * Loads triggers that are:
453     * - status = 'active'
454     * - not expired (expire_date is NULL or in the future)
455     *
456     * @param PDO $storeDb Store database connection
457     * @return array Array of trigger database rows
458     * @throws Exception If query fails
459     */
460    private function getActiveTriggers($storeDb)
461    {
462        try {
463            $sql = "SELECT *
464                    FROM seller_marketing_triggers
465                    WHERE status = 'active'
466                    AND (expire_date IS NULL OR expire_date >= CURDATE())
467                    ORDER BY id ASC
468                    LIMIT :max_triggers";
469
470            $stmt = $storeDb->prepare($sql);
471            $stmt->bindValue(':max_triggers', $this->maxTriggersPerRun, PDO::PARAM_INT);
472            $stmt->execute();
473
474            return $stmt->fetchAll(PDO::FETCH_ASSOC);
475
476        } catch (Exception $e) {
477            $this->logError("Failed to load active triggers: " . $e->getMessage());
478            throw $e;
479        }
480    }
481
482    /**
483     * Get message template content from database
484     *
485     * @param int $messageId Message template ID
486     * @param PDO $storeDb Store database connection
487     * @return string Message template with variable placeholders
488     * @throws Exception If message not found or inactive
489     */
490    private function getMessageTemplate($messageId, $storeDb)
491    {
492        try {
493            $sql = "SELECT content
494                    FROM seller_marketing_messages
495                    WHERE id = :id
496                    AND active = 1";
497
498            $stmt = $storeDb->prepare($sql);
499            $stmt->bindValue(':id', $messageId, PDO::PARAM_INT);
500            $stmt->execute();
501
502            $row = $stmt->fetch(PDO::FETCH_ASSOC);
503
504            if (!$row) {
505                throw new Exception("Message template not found or inactive: {$messageId}");
506            }
507
508            return $row['content'];
509
510        } catch (Exception $e) {
511            $this->logError("Failed to load message template {$messageId}" . $e->getMessage());
512            throw $e;
513        }
514    }
515
516    /**
517     * Get store database connection
518     *
519     * @param \Store $store Store object
520     * @return PDO Store database connection
521     * @throws Exception If connection fails
522     */
523    private function getStoreDatabase($store)
524    {
525        try {
526            $dbName = $store->getDbName();
527
528            if (empty($dbName)) {
529                throw new Exception("Store database name is empty");
530            }
531
532            // Use global function from codebase
533            $db = dbConnectByName($dbName);
534
535            if (!($db instanceof PDO)) {
536                throw new Exception("Failed to connect to store database: {$dbName}");
537            }
538
539            return $db;
540
541        } catch (Exception $e) {
542            $this->logError("Database connection error: " . $e->getMessage());
543            throw $e;
544        }
545    }
546
547    /**
548     * Extract store data for message personalization
549     *
550     * @param \Store $store Store object
551     * @return array Store data array with keys: companyName, city, phone, etc.
552     */
553    private function getStoreData($store)
554    {
555        return [
556            'companyName' => $store->getCompanyName() ?? 'Our Store',
557            'city' => $store->getCity() ?? '',
558            'phone' => $store->getPhone() ?? '',
559            'typeNum' => $store->getTypeNum() ?? '',
560        ];
561    }
562
563    /**
564     * Normalize phone number to standard format
565     *
566     * Converts phone number to 10-digit format with no special characters.
567     * Returns false if phone number is invalid.
568     *
569     * @param string $phone Phone number in any format
570     * @return string|false Normalized phone number (10 digits) or false if invalid
571     */
572    private function normalizePhoneNumber($phone)
573    {
574        if (empty($phone)) {
575            return false;
576        }
577
578        // Remove all non-digit characters
579        $digits = preg_replace('/\D/', '', $phone);
580
581        // Handle 11-digit numbers (1 + area code + number)
582        if (strlen($digits) === 11 && substr($digits, 0, 1) === '1') {
583            $digits = substr($digits, 1);
584        }
585
586        // Must be exactly 10 digits
587        if (strlen($digits) !== 10) {
588            return false;
589        }
590
591        return $digits;
592    }
593
594    /**
595     * Register a custom trigger class
596     *
597     * Allows adding new trigger types at runtime without modifying this class.
598     *
599     * @param string $triggerType Trigger type identifier
600     * @param string $className Full class name (must implement TriggerInterface)
601     * @return void
602     */
603    public function registerTriggerClass($triggerType, $className)
604    {
605        $this->triggerClassMap[$triggerType] = $className;
606    }
607
608    /**
609     * Set maximum customers per trigger
610     *
611     * @param int $max Maximum customers to process per trigger
612     * @return void
613     */
614    public function setMaxCustomersPerTrigger($max)
615    {
616        $this->maxCustomersPerTrigger = max(1, (int)$max);
617    }
618
619    /**
620     * Set maximum triggers per run
621     *
622     * @param int $max Maximum triggers to process per run
623     * @return void
624     */
625    public function setMaxTriggersPerRun($max)
626    {
627        $this->maxTriggersPerRun = max(1, (int)$max);
628    }
629
630    /**
631     * Log informational message
632     *
633     * @param string $message Log message
634     * @param array $context Additional context
635     * @return void
636     */
637    private function logInfo($message, $context = [])
638    {
639        $logMessage = '[TriggerProcessor] ' . $message;
640
641        if (!empty($context)) {
642            $logMessage .= ' | ' . json_encode($context);
643        }
644
645        if ($this->logger !== null && method_exists($this->logger, 'info')) {
646            $this->logger->info($logMessage, $context);
647        } else {
648            error_log($logMessage);
649        }
650    }
651
652    /**
653     * Log error message
654     *
655     * @param string $message Error message
656     * @param array $context Additional context
657     * @return void
658     */
659    private function logError($message, $context = [])
660    {
661        $logMessage = '[TriggerProcessor] ERROR: ' . $message;
662
663        if (!empty($context)) {
664            $logMessage .= ' | ' . json_encode($context);
665        }
666
667        if ($this->logger !== null && method_exists($this->logger, 'error')) {
668            $this->logger->error($logMessage, $context);
669        } else {
670            error_log($logMessage);
671        }
672    }
673}