Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 116
0.00% covered (danger)
0.00%
0 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
BaseTrigger
0.00% covered (danger)
0.00%
0 / 116
0.00% covered (danger)
0.00%
0 / 21
2970
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
 setStoreDb
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStoreDb
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getStoreDbName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTypeNum
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildMessage
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 addCustomReplacements
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCustomerName
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 validatePhoneNumber
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateConfig
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
72
 calculateDaysDifference
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getCurrentDate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setTimezone
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setMaxCustomersPerQuery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildBaseCustomerQuery
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 applyCommonFilters
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 executeCustomerQuery
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 logError
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 logInfo
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 findCustomers
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0
 getTriggerType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0
1<?php
2
3namespace BuyerKiosk\SellerMarketing;
4
5use BuyerKiosk\SellerMarketing\Triggers\TriggerInterface;
6use Exception;
7use InvalidArgumentException;
8use PDO;
9
10/**
11 * BaseTrigger
12 *
13 * Abstract base class providing common functionality for all trigger implementations.
14 * Handles database connections, message variable replacement, phone validation,
15 * error logging, and date calculations. Concrete trigger classes should extend
16 * this class and implement the abstract methods.
17 *
18 * @package BuyerKiosk\SellerMarketing
19 */
20abstract class BaseTrigger implements TriggerInterface
21{
22    /**
23     * @var PDO Store database connection
24     */
25    protected $storeDb;
26
27    /**
28     * @var object Logger instance for error and info logging
29     */
30    protected $logger;
31
32    /**
33     * @var string Store identifier (e.g., 'ou00', 'pc00')
34     */
35    protected $typeNum;
36
37    /**
38     * @var int Maximum number of customers to return per query (safety limit)
39     */
40    protected $maxCustomersPerQuery = 500;
41
42    /**
43     * @var array Default timezone for date calculations
44     */
45    protected $timezone = 'America/Chicago';
46
47    /**
48     * Constructor
49     *
50     * @param PDO|null $storeDb Store database connection (will auto-connect if null)
51     * @param object|null $logger Logger instance (will use error_log if null)
52     * @throws Exception If database connection cannot be established
53     */
54    public function __construct($storeDb = null, $logger = null)
55    {
56        $this->storeDb = $storeDb;
57        $this->logger = $logger;
58    }
59
60    /**
61     * Set the store database connection
62     *
63     * Allows dependency injection of the database connection after instantiation.
64     * This is useful for testing and when working with multiple stores.
65     *
66     * @param PDO $storeDb Store database connection
67     * @return void
68     */
69    public function setStoreDb($storeDb)
70    {
71        $this->storeDb = $storeDb;
72    }
73
74    /**
75     * Get the store database connection
76     *
77     * Lazy-loads the database connection if not already set.
78     *
79     * @return PDO Store database connection
80     * @throws Exception If database connection cannot be established
81     */
82    protected function getStoreDb()
83    {
84        if ($this->storeDb === null) {
85            if ($this->typeNum === null) {
86                throw new Exception('Store database connection not set and typeNum not specified');
87            }
88            // Use the existing global function from the codebase
89            $this->storeDb = dbConnectByName($this->getStoreDbName($this->typeNum));
90        }
91        return $this->storeDb;
92    }
93
94    /**
95     * Get store database name from typeNum
96     *
97     * @param string $typeNum Store identifier (e.g., 'ou00')
98     * @return string Database name
99     */
100    protected function getStoreDbName($typeNum)
101    {
102        // Store databases follow the pattern of the typeNum (e.g., 'ou00' -> 'ou00')
103        return $typeNum;
104    }
105
106    /**
107     * Set the store type number
108     *
109     * @param string $typeNum Store identifier (e.g., 'ou00', 'pc00')
110     * @return void
111     */
112    public function setTypeNum($typeNum)
113    {
114        $this->typeNum = $typeNum;
115    }
116
117    /**
118     * Build personalized message for a customer
119     *
120     * Replaces variable placeholders in the message template with actual
121     * customer and store data. Handles common variables used across all trigger types.
122     *
123     * @param string $messageTemplate Message template with variable placeholders
124     * @param array $customerData Customer data array
125     * @param array $storeData Store information array
126     * @return string Final message with all variables replaced
127     * @throws InvalidArgumentException If required data is missing
128     */
129    public function buildMessage($messageTemplate, $customerData, $storeData)
130    {
131        if (empty($messageTemplate)) {
132            throw new InvalidArgumentException('Message template cannot be empty');
133        }
134
135        if (empty($customerData)) {
136            throw new InvalidArgumentException('Customer data cannot be empty');
137        }
138
139        if (empty($storeData)) {
140            throw new InvalidArgumentException('Store data cannot be empty');
141        }
142
143        // Build replacement map
144        $replacements = [
145            '%customer%' => $this->getCustomerName($customerData),
146            '%firstname%' => $this->getCustomerName($customerData),
147            '%lastname%' => $customerData['lastName'] ?? '',
148            '%company%' => $storeData['companyName'] ?? 'our store',
149            '%store%' => $storeData['city'] ?? '',
150            '%coop%' => $storeData['companyName'] ?? 'our store',
151            '%points%' => isset($customerData['loyaltyPoints']) ? (string)$customerData['loyaltyPoints'] : '0',
152        ];
153
154        // Allow subclasses to add additional replacements
155        $replacements = $this->addCustomReplacements($replacements, $customerData, $storeData);
156
157        // Perform replacements
158        $message = $messageTemplate;
159        foreach ($replacements as $variable => $value) {
160            $message = str_replace($variable, $value, $message);
161        }
162
163        return $message;
164    }
165
166    /**
167     * Add custom variable replacements specific to trigger type
168     *
169     * Override this method in concrete trigger classes to add additional
170     * variable replacements specific to that trigger type (e.g., %days%, %birthday%).
171     *
172     * @param array $replacements Current replacement map
173     * @param array $customerData Customer data array
174     * @param array $storeData Store information array
175     * @return array Updated replacement map
176     */
177    protected function addCustomReplacements($replacements, $customerData, $storeData)
178    {
179        // Default implementation - override in subclasses to add custom variables
180        return $replacements;
181    }
182
183    /**
184     * Get customer name with fallback
185     *
186     * @param array $customerData Customer data array
187     * @return string Customer name or default value
188     */
189    protected function getCustomerName($customerData)
190    {
191        if (!empty($customerData['firstName'])) {
192            return $customerData['firstName'];
193        }
194        return 'Valued Customer';
195    }
196
197    /**
198     * Validate phone number format
199     *
200     * Validates that a phone number is in the correct format (10 digits).
201     * This matches the validation used elsewhere in the codebase.
202     *
203     * @param string $phone Phone number to validate
204     * @return bool True if valid, false otherwise
205     */
206    protected function validatePhoneNumber($phone)
207    {
208        // Must be exactly 10 digits (no spaces, dashes, or other characters)
209        return preg_match('/^[0-9]{10}$/', $phone) === 1;
210    }
211
212    /**
213     * Validate trigger configuration
214     *
215     * Validates common configuration fields. Concrete classes should override
216     * and call parent::validateConfig() to add trigger-specific validation.
217     *
218     * @param array $config Trigger configuration to validate
219     * @return bool True if configuration is valid, false otherwise
220     */
221    public function validateConfig($config)
222    {
223        if (!is_array($config)) {
224            $this->logError('Trigger configuration must be an array');
225            return false;
226        }
227
228        // Validate common fields
229        if (isset($config['min_rating'])) {
230            $minRating = $config['min_rating'];
231            if (!is_numeric($minRating) || $minRating < 0 || $minRating > 5) {
232                $this->logError('min_rating must be between 0 and 5');
233                return false;
234            }
235        }
236
237        if (isset($config['customer_type'])) {
238            $validTypes = ['all', 'buyers', 'sellers', 'both', 'new', 'returning'];
239            if (!in_array($config['customer_type'], $validTypes)) {
240                $this->logError('Invalid customer_type: ' . $config['customer_type']);
241                return false;
242            }
243        }
244
245        return true;
246    }
247
248    /**
249     * Calculate date difference in days
250     *
251     * Helper method to calculate the number of days between two dates.
252     * Useful for triggers based on time since last activity.
253     *
254     * @param string $dateFrom Start date (any format parseable by strtotime)
255     * @param string|null $dateTo End date (defaults to current date)
256     * @return int Number of days between dates
257     * @throws Exception If date parsing fails
258     */
259    protected function calculateDaysDifference($dateFrom, $dateTo = null)
260    {
261        if ($dateTo === null) {
262            $dateTo = date('Y-m-d');
263        }
264
265        $fromTimestamp = strtotime($dateFrom);
266        $toTimestamp = strtotime($dateTo);
267
268        if ($fromTimestamp === false || $toTimestamp === false) {
269            throw new Exception('Invalid date format provided for date calculation');
270        }
271
272        $diff = $toTimestamp - $fromTimestamp;
273        return (int)floor($diff / 86400); // 86400 seconds in a day
274    }
275
276    /**
277     * Get current date in store timezone
278     *
279     * Returns the current date adjusted for the store's timezone.
280     * Useful for date-based triggers that need to respect store hours.
281     *
282     * @param string|null $format Date format (default: 'Y-m-d')
283     * @return string Formatted date string
284     */
285    protected function getCurrentDate($format = 'Y-m-d')
286    {
287        $datetime = new \DateTime('now', new \DateTimeZone($this->timezone));
288        return $datetime->format($format);
289    }
290
291    /**
292     * Set timezone for date calculations
293     *
294     * @param string $timezone Timezone identifier (e.g., 'America/New_York')
295     * @return void
296     */
297    public function setTimezone($timezone)
298    {
299        $this->timezone = $timezone;
300    }
301
302    /**
303     * Set maximum customers per query
304     *
305     * @param int $max Maximum number of customers to return
306     * @return void
307     */
308    public function setMaxCustomersPerQuery($max)
309    {
310        $this->maxCustomersPerQuery = (int)$max;
311    }
312
313    /**
314     * Build base customer query
315     *
316     * Returns the base SQL query for finding customers with common filtering.
317     * Concrete trigger classes can extend this query with specific conditions.
318     *
319     * @return string Base SQL query
320     */
321    protected function buildBaseCustomerQuery()
322    {
323        return "SELECT
324                    customerID,
325                    firstName,
326                    lastName,
327                    phone,
328                    email,
329                    rating,
330                    loyaltyPoints,
331                    lastVisit,
332                    lastBuy,
333                    lastSold,
334                    memberSince,
335                    birthday
336                FROM customers
337                WHERE phone IS NOT NULL
338                AND phone != ''
339                AND optInText = 1";
340    }
341
342    /**
343     * Apply common filters to query conditions
344     *
345     * Adds standard filters like rating, customer type, etc. to the conditions array.
346     *
347     * @param array $conditions Query WHERE conditions array
348     * @param array $params Query parameters array (passed by reference)
349     * @param array $config Trigger configuration
350     * @return array Updated conditions array
351     */
352    protected function applyCommonFilters($conditions, &$params, $config)
353    {
354        // Minimum rating filter
355        if (isset($config['min_rating']) && $config['min_rating'] > 0) {
356            $conditions[] = 'rating >= :min_rating';
357            $params[':min_rating'] = (int)$config['min_rating'];
358        }
359
360        // Customer type filter
361        if (isset($config['customer_type'])) {
362            switch ($config['customer_type']) {
363                case 'buyers':
364                    $conditions[] = 'lastBuy IS NOT NULL';
365                    break;
366                case 'sellers':
367                    $conditions[] = 'lastSold IS NOT NULL';
368                    break;
369                case 'new':
370                    $conditions[] = 'memberSince >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)';
371                    break;
372                case 'returning':
373                    $conditions[] = 'lastVisit IS NOT NULL';
374                    $conditions[] = 'memberSince < DATE_SUB(CURDATE(), INTERVAL 30 DAY)';
375                    break;
376            }
377        }
378
379        return $conditions;
380    }
381
382    /**
383     * Execute customer query with safety limits
384     *
385     * Executes a prepared statement with safety limits to prevent runaway queries.
386     *
387     * @param string $sql Complete SQL query
388     * @param array $params Query parameters
389     * @return array Array of customer records
390     * @throws Exception If query execution fails
391     */
392    protected function executeCustomerQuery($sql, $params = [])
393    {
394        try {
395            $db = $this->getStoreDb();
396
397            // Add LIMIT to query if not already present
398            if (stripos($sql, 'LIMIT') === false) {
399                $sql .= ' LIMIT :max_customers';
400                $params[':max_customers'] = $this->maxCustomersPerQuery;
401            }
402
403            $stmt = $db->prepare($sql);
404
405            // Bind parameters
406            foreach ($params as $key => $value) {
407                if ($key === ':max_customers') {
408                    $stmt->bindValue($key, $value, PDO::PARAM_INT);
409                } else {
410                    $stmt->bindValue($key, $value);
411                }
412            }
413
414            $stmt->execute();
415            return $stmt->fetchAll(PDO::FETCH_ASSOC);
416
417        } catch (Exception $e) {
418            $this->logError('Query execution failed: ' . $e->getMessage());
419            throw $e;
420        }
421    }
422
423    /**
424     * Log error message
425     *
426     * Logs an error message using the configured logger or error_log.
427     *
428     * @param string $message Error message to log
429     * @param array $context Additional context data
430     * @return void
431     */
432    protected function logError($message, $context = [])
433    {
434        $logMessage = sprintf(
435            '[%s] %s: %s',
436            $this->getTriggerType(),
437            $message,
438            !empty($context) ? json_encode($context) : ''
439        );
440
441        if ($this->logger !== null && method_exists($this->logger, 'error')) {
442            $this->logger->error($logMessage);
443        } else {
444            error_log($logMessage);
445        }
446    }
447
448    /**
449     * Log info message
450     *
451     * Logs an informational message using the configured logger or error_log.
452     *
453     * @param string $message Info message to log
454     * @param array $context Additional context data
455     * @return void
456     */
457    protected function logInfo($message, $context = [])
458    {
459        $logMessage = sprintf(
460            '[%s] %s: %s',
461            $this->getTriggerType(),
462            $message,
463            !empty($context) ? json_encode($context) : ''
464        );
465
466        if ($this->logger !== null && method_exists($this->logger, 'info')) {
467            $this->logger->info($logMessage);
468        } else {
469            error_log($logMessage);
470        }
471    }
472
473    /**
474     * Abstract method: Find customers matching trigger criteria
475     *
476     * Must be implemented by concrete trigger classes to define their specific
477     * customer selection logic.
478     *
479     * @param string $typeNum Store identifier
480     * @param array $triggerConfig Trigger configuration
481     * @return array Array of customer data arrays
482     */
483    abstract public function findCustomers($typeNum, $triggerConfig);
484
485    /**
486     * Abstract method: Get trigger type identifier
487     *
488     * Must be implemented by concrete trigger classes to return their unique identifier.
489     *
490     * @return string Trigger type identifier
491     */
492    abstract public function getTriggerType();
493}