Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
DaysSinceSoldTrigger
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 6
756
0.00% covered (danger)
0.00%
0 / 1
 findCustomers
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
132
 getTriggerType
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 / 17
0.00% covered (danger)
0.00%
0 / 1
72
 buildMessage
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 addCustomReplacements
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 calculateTargetDate
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\SellerMarketing;
4
5use BuyerKiosk\SellerMarketing\Triggers\TriggerInterface;
6use Exception;
7use InvalidArgumentException;
8use PDO;
9
10/**
11 * DaysSinceSoldTrigger
12 *
13 * Concrete trigger implementation for finding customers based on days since last sold.
14 * This trigger identifies customers who last sold exactly N days ago, with optional
15 * rating and rating inclusion filters. Useful for re-engagement campaigns targeting
16 * sellers who haven't been active recently.
17 *
18 * Configuration Options:
19 * - days (required, int): Number of days since last sold (exact match)
20 * - min_rating (optional, float): Minimum customer rating (0-5)
21 * - include_no_ratings (optional, bool): Whether to include customers with no rating
22 * - customer_type (optional, string): Filter by customer type (all, new, returning, etc.)
23 *
24 * Example Configuration:
25 * [
26 *     'days' => 30,
27 *     'min_rating' => 3.5,
28 *     'include_no_ratings' => true
29 * ]
30 *
31 * @package BuyerKiosk\SellerMarketing
32 */
33class DaysSinceSoldTrigger extends BaseTrigger implements TriggerInterface
34{
35    /**
36     * Find customers who last sold exactly N days ago
37     *
38     * Queries the store database for customers whose lastSold date was exactly
39     * the configured number of days ago. Applies optional rating filters and
40     * respects opt-in preferences.
41     *
42     * @param string $typeNum Store identifier (e.g., 'ou00', 'pc00')
43     * @param array $triggerConfig Configuration array with required key 'days'
44     * @return array Array of customer data with keys: customerID, firstName, lastName,
45     *               phone, email, rating, lastSold, loyaltyPoints, memberSince
46     * @throws InvalidArgumentException If required configuration is missing
47     * @throws Exception If database query fails
48     */
49    public function findCustomers($typeNum, $triggerConfig)
50    {
51        // Validate configuration
52        if (!$this->validateConfig($triggerConfig)) {
53            throw new InvalidArgumentException('Invalid trigger configuration provided');
54        }
55
56        if (!isset($triggerConfig['days']) || !is_numeric($triggerConfig['days'])) {
57            throw new InvalidArgumentException('Configuration must include "days" as a numeric value');
58        }
59
60        $days = (int)$triggerConfig['days'];
61        if ($days < 0) {
62            throw new InvalidArgumentException('Days value must be non-negative');
63        }
64
65        // Set store context
66        $this->setTypeNum($typeNum);
67
68        // Build the customer query
69        $conditions = [];
70        $params = [];
71
72        // Base query with phone number and opt-in validation
73        $sql = $this->buildBaseCustomerQuery();
74
75        // Add the days since last sold condition
76        // Calculate the target date: exactly N days ago from today
77        $targetDate = $this->calculateTargetDate($days);
78        $conditions[] = "DATE(lastSold) = :target_date";
79        $params[':target_date'] = $targetDate;
80
81        // Apply common filters (rating, customer type, etc.)
82        $conditions[] = 'lastSold IS NOT NULL'; // Ensure they have a last sold date
83        $conditions = $this->applyCommonFilters($conditions, $params, $triggerConfig);
84
85        // Handle include_no_ratings option
86        if (isset($triggerConfig['include_no_ratings']) && $triggerConfig['include_no_ratings']) {
87            // Already included in base query via rating >= :min_rating check
88            // This flag allows customers with NULL rating when min_rating is set
89            if (isset($triggerConfig['min_rating']) && $triggerConfig['min_rating'] > 0) {
90                // Modify the conditions to allow NULL ratings
91                $key = array_search('rating >= :min_rating', $conditions);
92                if ($key !== false) {
93                    unset($conditions[$key]);
94                    $conditions[] = '(rating >= :min_rating OR rating IS NULL)';
95                }
96            }
97        }
98
99        // Combine all conditions
100        $whereClause = ' AND ' . implode(' AND ', $conditions);
101        $sql .= $whereClause;
102
103        // Execute query with safety limits
104        try {
105            $customers = $this->executeCustomerQuery($sql, $params);
106
107            $this->logInfo('Found customers matching days_since_sold trigger', [
108                'typeNum' => $typeNum,
109                'days' => $days,
110                'count' => count($customers)
111            ]);
112
113            return $customers;
114
115        } catch (Exception $e) {
116            $this->logError('Failed to find customers for days_since_sold trigger', [
117                'typeNum' => $typeNum,
118                'days' => $days,
119                'error' => $e->getMessage()
120            ]);
121            throw $e;
122        }
123    }
124
125    /**
126     * Get the trigger type identifier
127     *
128     * Returns the unique identifier for this trigger type used to map configurations
129     * to this handler class.
130     *
131     * @return string Trigger type identifier
132     */
133    public function getTriggerType()
134    {
135        return 'days_since_sold';
136    }
137
138    /**
139     * Validate trigger configuration
140     *
141     * Validates that the trigger configuration contains all required fields and
142     * that values are within acceptable ranges. Extends parent validation to
143     * include trigger-specific checks.
144     *
145     * @param array $config Trigger configuration to validate
146     * @return bool True if configuration is valid, false otherwise
147     */
148    public function validateConfig($config)
149    {
150        // Call parent validation for common fields
151        if (!parent::validateConfig($config)) {
152            return false;
153        }
154
155        // Validate required 'days' field
156        if (!isset($config['days'])) {
157            $this->logError('Required configuration field "days" is missing');
158            return false;
159        }
160
161        if (!is_numeric($config['days'])) {
162            $this->logError('Configuration field "days" must be numeric');
163            return false;
164        }
165
166        $days = (int)$config['days'];
167        if ($days < 0 || $days > 365) {
168            $this->logError('Days must be between 0 and 365');
169            return false;
170        }
171
172        // Validate optional 'include_no_ratings' field
173        if (isset($config['include_no_ratings'])) {
174            if (!is_bool($config['include_no_ratings'])) {
175                $this->logError('Configuration field "include_no_ratings" must be a boolean');
176                return false;
177            }
178        }
179
180        return true;
181    }
182
183    /**
184     * Build personalized message with trigger-specific variables
185     *
186     * Extends the base buildMessage method to add variables specific to the
187     * days_since_sold trigger, such as %days% for the number of days.
188     *
189     * @param string $messageTemplate Message template with variable placeholders
190     * @param array $customerData Customer data array with lastSold date
191     * @param array $storeData Store information array
192     * @return string Final message with all variables replaced
193     * @throws InvalidArgumentException If required data is missing
194     */
195    public function buildMessage($messageTemplate, $customerData, $storeData)
196    {
197        // Call parent implementation for common variables
198        $message = parent::buildMessage($messageTemplate, $customerData, $storeData);
199
200        // Calculate days since last sold for variable replacement
201        $daysSinceSold = 0;
202        if (!empty($customerData['lastSold'])) {
203            try {
204                $daysSinceSold = $this->calculateDaysDifference($customerData['lastSold']);
205            } catch (Exception $e) {
206                $this->logError('Failed to calculate days since sold', [
207                    'lastSold' => $customerData['lastSold'],
208                    'error' => $e->getMessage()
209                ]);
210            }
211        }
212
213        // Add trigger-specific variable replacement
214        $message = str_replace('%days%', (string)$daysSinceSold, $message);
215
216        return $message;
217    }
218
219    /**
220     * Add custom variable replacements for days_since_sold trigger
221     *
222     * Adds %days% variable to the replacement map with the number of days
223     * since the customer last sold.
224     *
225     * @param array $replacements Current replacement map
226     * @param array $customerData Customer data array
227     * @param array $storeData Store information array
228     * @return array Updated replacement map with %days% variable
229     */
230    protected function addCustomReplacements($replacements, $customerData, $storeData)
231    {
232        // Calculate days since last sold
233        if (!empty($customerData['lastSold'])) {
234            try {
235                $daysSinceSold = $this->calculateDaysDifference($customerData['lastSold']);
236                $replacements['%days%'] = (string)$daysSinceSold;
237            } catch (Exception $e) {
238                $this->logError('Failed to calculate days for variable replacement', [
239                    'error' => $e->getMessage()
240                ]);
241                $replacements['%days%'] = '0';
242            }
243        } else {
244            $replacements['%days%'] = '0';
245        }
246
247        return $replacements;
248    }
249
250    /**
251     * Calculate the target date for N days ago
252     *
253     * Calculates the date that was exactly N days in the past from today,
254     * taking into account timezone settings. Used to find customers who
255     * last sold on that specific date.
256     *
257     * @param int $days Number of days in the past
258     * @return string Date string in Y-m-d format
259     */
260    private function calculateTargetDate($days)
261    {
262        $currentDate = $this->getCurrentDate();
263        $timestamp = strtotime("-{$days} days", strtotime($currentDate));
264        return date('Y-m-d', $timestamp);
265    }
266}