Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 226
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventPhaseProcessor
0.00% covered (danger)
0.00%
0 / 226
0.00% covered (danger)
0.00%
0 / 13
2352
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
 processStore
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
30
 processEvent
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
42
 getPhaseTransitions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getErrors
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 updateEventPhase
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 activatePendingIntegrations
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 activateIntegrationsFallback
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 markEventCompleted
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 logAudit
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 logError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getUpcomingTransitions
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
380
 checkForUpdates
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace BuyerKiosk\EventManagement\Services;
4
5use PDO;
6use PDOException;
7use DateTime;
8use Exception;
9use BuyerKiosk\EventManagement\Models\Event;
10use BuyerKiosk\EventManagement\Models\EventIntegration;
11
12/**
13 * EventPhaseProcessor - On-demand event phase processing service
14 *
15 * This service processes events and updates their phases based on the current date.
16 * It is designed to be called on-demand (e.g., at store open) rather than as a
17 * scheduled cron job.
18 *
19 * Phase Transitions:
20 * - upcoming -> build_up: Start preparing for the event
21 * - build_up -> active: Event is now live, activate pending integrations
22 * - active -> wind_down: Event has ended, begin cleanup
23 * - wind_down -> completed: Event fully concluded, mark as completed
24 *
25 * @package BuyerKiosk\EventManagement\Services
26 */
27class EventPhaseProcessor
28{
29    /**
30     * @var PDO Database connection for the store
31     */
32    private PDO $db;
33
34    /**
35     * @var int|null Employee ID for audit logging
36     */
37    private ?int $employeeId;
38
39    /**
40     * @var IntegrationService|null Integration service for activation
41     */
42    private ?IntegrationService $integrationService;
43
44    /**
45     * @var array Phase transitions that occurred during processing
46     */
47    private array $phaseTransitions = [];
48
49    /**
50     * @var array Processing errors
51     */
52    private array $errors = [];
53
54    /**
55     * Constructor
56     *
57     * @param PDO $db Store database connection
58     * @param IntegrationService|null $integrationService Optional integration service for activations
59     * @param int|null $employeeId Employee ID for audit logging
60     */
61    public function __construct(
62        PDO $db,
63        ?IntegrationService $integrationService = null,
64        ?int $employeeId = null
65    ) {
66        $this->db = $db;
67        $this->integrationService = $integrationService;
68        $this->employeeId = $employeeId;
69    }
70
71    /**
72     * Process all events for a store and update their phases
73     *
74     * Selects all events with status 'scheduled' or 'active' and recalculates
75     * their phase based on the current date. When phases change, updates the
76     * database and triggers appropriate actions.
77     *
78     * @param DateTime|null $now Optional: override current date for testing
79     * @return array {
80     *     eventsProcessed: int,
81     *     phasesChanged: int,
82     *     integrationsActivated: int,
83     *     eventsCompleted: int,
84     *     transitions: array,
85     *     errors: array
86     * }
87     */
88    public function processStore(?DateTime $now = null): array
89    {
90        $this->phaseTransitions = [];
91        $this->errors = [];
92
93        $now = $now ?? new DateTime();
94
95        // Select events that need phase evaluation
96        $sql = "SELECT * FROM events
97                WHERE status IN (:scheduled, :active)
98                ORDER BY startDate ASC";
99
100        $stmt = $this->db->prepare($sql);
101        $stmt->execute([
102            ':scheduled' => Event::STATUS_SCHEDULED,
103            ':active' => Event::STATUS_ACTIVE,
104        ]);
105
106        $eventsProcessed = 0;
107        $phasesChanged = 0;
108        $integrationsActivated = 0;
109        $eventsCompleted = 0;
110
111        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
112            try {
113                $event = Event::fromRow($row);
114                $result = $this->processEvent($event, $now);
115
116                $eventsProcessed++;
117
118                if ($result['phaseChanged']) {
119                    $phasesChanged++;
120                }
121
122                $integrationsActivated += $result['integrationsActivated'];
123
124                if ($result['markedCompleted']) {
125                    $eventsCompleted++;
126                }
127            } catch (Exception $e) {
128                $this->errors[] = [
129                    'eventId' => $row['id'] ?? null,
130                    'eventName' => $row['name'] ?? 'Unknown',
131                    'error' => $e->getMessage(),
132                ];
133                $this->logError("Failed to process event {$row['id']}", [
134                    'error' => $e->getMessage(),
135                ]);
136            }
137        }
138
139        return [
140            'eventsProcessed' => $eventsProcessed,
141            'phasesChanged' => $phasesChanged,
142            'integrationsActivated' => $integrationsActivated,
143            'eventsCompleted' => $eventsCompleted,
144            'transitions' => $this->phaseTransitions,
145            'errors' => $this->errors,
146        ];
147    }
148
149    /**
150     * Process a single event and update its phase if needed
151     *
152     * Calculates the current phase for the event and if it differs from
153     * the stored phase, performs the appropriate transitions:
154     * - Updates the phase in the database
155     * - If transitioning to 'active', activates pending integrations
156     * - If transitioning to 'completed', marks the event as completed
157     * - Logs all transitions to the audit log
158     *
159     * @param Event $event The event to process
160     * @param DateTime|null $now Optional: override current date for testing
161     * @return array {
162     *     phaseChanged: bool,
163     *     oldPhase: string,
164     *     newPhase: string,
165     *     integrationsActivated: int,
166     *     markedCompleted: bool
167     * }
168     */
169    public function processEvent(Event $event, ?DateTime $now = null): array
170    {
171        $now = $now ?? new DateTime();
172
173        $oldPhase = $event->phase;
174        $newPhase = $event->calculatePhase($now);
175
176        $result = [
177            'phaseChanged' => false,
178            'oldPhase' => $oldPhase,
179            'newPhase' => $newPhase,
180            'integrationsActivated' => 0,
181            'markedCompleted' => false,
182        ];
183
184        // No change needed
185        if ($oldPhase === $newPhase) {
186            return $result;
187        }
188
189        $result['phaseChanged'] = true;
190
191        // Begin phase transition
192        $this->db->beginTransaction();
193
194        try {
195            // Update the phase in the database
196            $this->updateEventPhase($event->id, $newPhase);
197
198            // Record the transition
199            $this->phaseTransitions[] = [
200                'eventId' => $event->id,
201                'eventName' => $event->name,
202                'oldPhase' => $oldPhase,
203                'newPhase' => $newPhase,
204                'timestamp' => $now->format('Y-m-d H:i:s'),
205            ];
206
207            // Handle specific phase transitions
208            if ($newPhase === Event::PHASE_ACTIVE && $oldPhase !== Event::PHASE_ACTIVE) {
209                // Transitioning to active - activate pending integrations
210                $result['integrationsActivated'] = $this->activatePendingIntegrations($event);
211            }
212
213            if ($newPhase === Event::PHASE_COMPLETED) {
214                // Transitioning to completed - mark event as completed
215                $this->markEventCompleted($event->id);
216                $result['markedCompleted'] = true;
217            }
218
219            // Log the phase transition to audit
220            $this->logAudit($event->id, 'phase_transition', [
221                'oldPhase' => $oldPhase,
222                'newPhase' => $newPhase,
223                'integrationsActivated' => $result['integrationsActivated'],
224                'markedCompleted' => $result['markedCompleted'],
225            ]);
226
227            $this->db->commit();
228        } catch (Exception $e) {
229            $this->db->rollBack();
230            throw $e;
231        }
232
233        return $result;
234    }
235
236    /**
237     * Get all phase transitions that occurred during the last processing run
238     *
239     * @return array Array of transition records with keys:
240     *     eventId, eventName, oldPhase, newPhase, timestamp
241     */
242    public function getPhaseTransitions(): array
243    {
244        return $this->phaseTransitions;
245    }
246
247    /**
248     * Get all errors that occurred during the last processing run
249     *
250     * @return array Array of error records with keys:
251     *     eventId, eventName, error
252     */
253    public function getErrors(): array
254    {
255        return $this->errors;
256    }
257
258    /**
259     * Update the phase of an event in the database
260     *
261     * @param int $eventId Event ID
262     * @param string $newPhase New phase value
263     */
264    private function updateEventPhase(int $eventId, string $newPhase): void
265    {
266        $sql = "UPDATE events SET phase = :phase, updated_at = NOW() WHERE id = :id";
267        $stmt = $this->db->prepare($sql);
268        $stmt->execute([
269            ':id' => $eventId,
270            ':phase' => $newPhase,
271        ]);
272    }
273
274    /**
275     * Activate pending integrations for an event transitioning to active phase
276     *
277     * @param Event $event The event being activated
278     * @return int Number of integrations activated
279     */
280    private function activatePendingIntegrations(Event $event): int
281    {
282        // If no integration service is available, just update the integration statuses
283        if ($this->integrationService === null) {
284            return $this->activateIntegrationsFallback($event->id);
285        }
286
287        // Use the full integration service to activate pending integrations
288        $result = $this->integrationService->activatePending($event);
289
290        // Log any failures
291        if (!empty($result['failed'])) {
292            foreach ($result['failed'] as $failure) {
293                $this->errors[] = [
294                    'eventId' => $event->id,
295                    'eventName' => $event->name,
296                    'error' => "Integration activation failed: {$failure['error']}",
297                    'integrationId' => $failure['integrationId'] ?? null,
298                    'integrationType' => $failure['type'] ?? null,
299                ];
300            }
301        }
302
303        return $result['activated'];
304    }
305
306    /**
307     * Fallback integration activation when IntegrationService is not available
308     *
309     * Simply updates the status of pending integrations to 'active' without
310     * calling the underlying adapters.
311     *
312     * @param int $eventId Event ID
313     * @return int Number of integrations updated
314     */
315    private function activateIntegrationsFallback(int $eventId): int
316    {
317        $sql = "UPDATE event_integrations
318                SET status = :newStatus
319                WHERE event_id = :eventId AND status = :pendingStatus";
320
321        $stmt = $this->db->prepare($sql);
322        $stmt->execute([
323            ':eventId' => $eventId,
324            ':newStatus' => EventIntegration::STATUS_ACTIVE,
325            ':pendingStatus' => EventIntegration::STATUS_PENDING,
326        ]);
327
328        return $stmt->rowCount();
329    }
330
331    /**
332     * Mark an event as completed when it reaches the completed phase
333     *
334     * Updates the event status to 'completed' and stores the previous status.
335     *
336     * @param int $eventId Event ID
337     */
338    private function markEventCompleted(int $eventId): void
339    {
340        $sql = "UPDATE events SET
341                    status = :completedStatus,
342                    previous_status = status,
343                    updated_at = NOW()
344                WHERE id = :id AND status = :activeStatus";
345
346        $stmt = $this->db->prepare($sql);
347        $stmt->execute([
348            ':id' => $eventId,
349            ':completedStatus' => Event::STATUS_COMPLETED,
350            ':activeStatus' => Event::STATUS_ACTIVE,
351        ]);
352
353        // Also update any remaining active integrations to completed
354        $sql = "UPDATE event_integrations
355                SET status = :completedStatus
356                WHERE event_id = :eventId AND status = :activeStatus";
357
358        $stmt = $this->db->prepare($sql);
359        $stmt->execute([
360            ':eventId' => $eventId,
361            ':completedStatus' => EventIntegration::STATUS_COMPLETED,
362            ':activeStatus' => EventIntegration::STATUS_ACTIVE,
363        ]);
364    }
365
366    /**
367     * Log an audit entry for event phase changes
368     *
369     * @param int $eventId Event ID
370     * @param string $action Action performed
371     * @param array|null $details Additional details
372     */
373    private function logAudit(int $eventId, string $action, ?array $details = null): void
374    {
375        try {
376            $sql = "INSERT INTO event_audit_log (event_id, action, details, employee_id, created_at)
377                    VALUES (:eventId, :action, :details, :employeeId, NOW())";
378            $stmt = $this->db->prepare($sql);
379            $stmt->execute([
380                ':eventId' => $eventId,
381                ':action' => $action,
382                ':details' => $details ? json_encode($details) : null,
383                ':employeeId' => $this->employeeId,
384            ]);
385        } catch (PDOException $e) {
386            // Audit log failure should not break the main operation
387            $this->logError("Failed to log audit entry for event {$eventId}", [
388                'error' => $e->getMessage(),
389            ]);
390        }
391    }
392
393    /**
394     * Log an error to the system error log
395     *
396     * @param string $message Error message
397     * @param array $context Additional context
398     */
399    private function logError(string $message, array $context = []): void
400    {
401        $logMessage = "[EventManagement:EventPhaseProcessor] {$message}";
402        if (!empty($context)) {
403            $logMessage .= ' ' . json_encode($context);
404        }
405        error_log($logMessage);
406    }
407
408    /**
409     * Get events that are approaching a phase transition
410     *
411     * Useful for dashboard warnings or notifications about upcoming transitions.
412     *
413     * @param int $daysAhead Number of days to look ahead
414     * @param DateTime|null $now Optional: override current date for testing
415     * @return array Array of events with their upcoming transition info
416     */
417    public function getUpcomingTransitions(int $daysAhead = 7, ?DateTime $now = null): array
418    {
419        $now = $now ?? new DateTime();
420        $futureDate = (clone $now)->modify("+{$daysAhead} days");
421
422        $sql = "SELECT * FROM events
423                WHERE status IN (:scheduled, :active)
424                AND (
425                    -- Upcoming to build_up transition
426                    (DATE_SUB(startDate, INTERVAL buildUpDays DAY) BETWEEN :now AND :future)
427                    -- Build_up to active transition
428                    OR (startDate BETWEEN :now AND :future)
429                    -- Active to wind_down transition
430                    OR (endDate BETWEEN :now AND :future)
431                    -- Wind_down to completed transition
432                    OR (DATE_ADD(endDate, INTERVAL windDownDays DAY) BETWEEN :now AND :future)
433                )
434                ORDER BY startDate ASC";
435
436        $stmt = $this->db->prepare($sql);
437        $stmt->execute([
438            ':scheduled' => Event::STATUS_SCHEDULED,
439            ':active' => Event::STATUS_ACTIVE,
440            ':now' => $now->format('Y-m-d'),
441            ':future' => $futureDate->format('Y-m-d'),
442        ]);
443
444        $upcomingTransitions = [];
445
446        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
447            $event = Event::fromRow($row);
448            $dateRange = $event->getDateRange();
449
450            // Determine which transition is upcoming
451            $transitions = [];
452
453            // Check build_up start
454            if ($dateRange['buildUpStart'] !== null &&
455                $dateRange['buildUpStart'] >= $now &&
456                $dateRange['buildUpStart'] <= $futureDate &&
457                $event->phase === Event::PHASE_UPCOMING) {
458                $transitions[] = [
459                    'type' => 'build_up',
460                    'date' => $dateRange['buildUpStart']->format('Y-m-d'),
461                    'daysUntil' => $now->diff($dateRange['buildUpStart'])->days,
462                ];
463            }
464
465            // Check active start
466            if ($event->startDate !== null &&
467                $event->startDate >= $now &&
468                $event->startDate <= $futureDate &&
469                in_array($event->phase, [Event::PHASE_UPCOMING, Event::PHASE_BUILD_UP])) {
470                $transitions[] = [
471                    'type' => 'active',
472                    'date' => $event->startDate->format('Y-m-d'),
473                    'daysUntil' => $now->diff($event->startDate)->days,
474                ];
475            }
476
477            // Check wind_down start
478            if ($event->endDate !== null &&
479                $event->endDate >= $now &&
480                $event->endDate <= $futureDate &&
481                $event->phase === Event::PHASE_ACTIVE) {
482                $transitions[] = [
483                    'type' => 'wind_down',
484                    'date' => $event->endDate->format('Y-m-d'),
485                    'daysUntil' => $now->diff($event->endDate)->days,
486                ];
487            }
488
489            // Check completed
490            if ($dateRange['windDownEnd'] !== null &&
491                $dateRange['windDownEnd'] >= $now &&
492                $dateRange['windDownEnd'] <= $futureDate &&
493                $event->phase === Event::PHASE_WIND_DOWN) {
494                $transitions[] = [
495                    'type' => 'completed',
496                    'date' => $dateRange['windDownEnd']->format('Y-m-d'),
497                    'daysUntil' => $now->diff($dateRange['windDownEnd'])->days,
498                ];
499            }
500
501            if (!empty($transitions)) {
502                $upcomingTransitions[] = [
503                    'event' => $event->toArray(),
504                    'transitions' => $transitions,
505                ];
506            }
507        }
508
509        return $upcomingTransitions;
510    }
511
512    /**
513     * Check if any events need phase updates without processing them
514     *
515     * Useful for determining if processStore() needs to be called.
516     *
517     * @param DateTime|null $now Optional: override current date for testing
518     * @return array {
519     *     needsUpdate: bool,
520     *     count: int,
521     *     events: array of event IDs that need updating
522     * }
523     */
524    public function checkForUpdates(?DateTime $now = null): array
525    {
526        $now = $now ?? new DateTime();
527
528        $sql = "SELECT * FROM events
529                WHERE status IN (:scheduled, :active)
530                ORDER BY startDate ASC";
531
532        $stmt = $this->db->prepare($sql);
533        $stmt->execute([
534            ':scheduled' => Event::STATUS_SCHEDULED,
535            ':active' => Event::STATUS_ACTIVE,
536        ]);
537
538        $needsUpdate = [];
539
540        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
541            $event = Event::fromRow($row);
542            $calculatedPhase = $event->calculatePhase($now);
543
544            if ($event->phase !== $calculatedPhase) {
545                $needsUpdate[] = [
546                    'eventId' => $event->id,
547                    'eventName' => $event->name,
548                    'currentPhase' => $event->phase,
549                    'calculatedPhase' => $calculatedPhase,
550                ];
551            }
552        }
553
554        return [
555            'needsUpdate' => !empty($needsUpdate),
556            'count' => count($needsUpdate),
557            'events' => $needsUpdate,
558        ];
559    }
560}