origin -> verification -> persistence. * * @package Enrollment_Verifier_Logger */ defined( 'ABSPATH' ) || exit; /** * Passive orchestration. Never throws. */ class EVLFL_Event_Manager { const DUPLICATE_WINDOW = 5; // Seconds. /** * Confidence rank (higher is stronger). * * @var array */ private static $rank = array( 'unknown' => 0, 'inferred' => 1, 'detected' => 2, 'confirmed' => 3, ); /** * Detector. * * @var EVLFL_Event_Detector */ private $detector; /** * Source detector. * * @var EVLFL_Source_Detector */ private $sources; /** * Verifier. * * @var EVLFL_Event_Verifier */ private $verifier; /** * Repository. * * @var EVLFL_Logger */ private $logger; /** * Constructor. * * @param EVLFL_Event_Detector $detector Detector. * @param EVLFL_Source_Detector $sources Source detector. * @param EVLFL_Event_Verifier $verifier Verifier. * @param EVLFL_Logger $logger Repository. */ public function __construct( $detector, $sources, $verifier, $logger ) { $this->detector = $detector; $this->sources = $sources; $this->verifier = $verifier; $this->logger = $logger; } /** * Detect + normalize (capture phase, at hook time). * * @param array $raw Raw integration data. * @return array|null */ public function capture( array $raw ) { try { return $this->detector->detect( $raw ); } catch ( \Throwable $e ) { $this->debug( $e ); return null; } } /** * Resolve, verify and persist a captured event. * * @param array $pending Result of capture(). * @param array $extra_ctx Extra context known later (e.g. expired_hook_fired). * @return int Row ID, or 0 when skipped/failed. */ public function process( array $pending, array $extra_ctx = array() ) { try { $event = $pending['event']; $context = array_merge( $pending['context'], $extra_ctx ); $state = $pending['state']; $origin = $this->sources->resolve( $event, $context ); $event = array_merge( $event, $origin ); $verdict = $this->verifier->verify( $event, $context, $state + array( 'now' => time() ) ); $event = array_merge( $event, $verdict ); // Overall confidence is the weaker of origin and verification confidence. $a = isset( self::$rank[ $origin['confidence'] ] ) ? self::$rank[ $origin['confidence'] ] : 0; $b = isset( self::$rank[ $verdict['confidence'] ] ) ? self::$rank[ $verdict['confidence'] ] : 0; $event['confidence'] = array_search( min( $a, $b ), self::$rank, true ); $event['metadata'] = array( 'request_type' => isset( $context['request_type'] ) ? $context['request_type'] : 'unknown', 'hook' => isset( $context['hook'] ) ? $context['hook'] : '', 'state' => array( 'expires_on' => isset( $state['expires_on'] ) ? $state['expires_on'] : null, 'has_access' => array_key_exists( 'has_access', $state ) ? $state['has_access'] : null, ), 'detection' => array( 'current_user_id' => isset( $context['current_user_id'] ) ? (int) $context['current_user_id'] : 0, 'expired_hook_fired' => ! empty( $context['expired_hook_fired'] ), ), ); if ( ! empty( $context['woocommerce_hook'] ) ) { $event['metadata']['detection']['woocommerce_hook'] = (string) $context['woocommerce_hook']; } if ( ! empty( $context['reenrolled_in_request'] ) ) { $event['metadata']['detection']['reenrolled_in_request'] = true; } if ( ! empty( $context['unenrolled_earlier_in_request'] ) ) { $event['metadata']['detection']['unenrolled_earlier_in_request'] = true; } if ( isset( $context['integration_metadata'] ) ) { $event['metadata']['integration'] = $context['integration_metadata']; } $event = EVLFL_Event::normalize( $event ); if ( null === $event || ! EVLFL_Settings::should_log( $event ) || $this->is_duplicate( $event ) ) { return 0; } $id = $this->logger->insert( $event ); if ( $id ) { do_action( 'evlfl_event_logged', $id, $event ); } return $id; } catch ( \Throwable $e ) { $this->debug( $e ); return 0; } } /** * A duplicate is the same action as the latest event for the pair, within the window. * Alternating actions (enroll, unenroll, enroll) are never suppressed. * * @param array $event Normalized event. * @return bool */ private function is_duplicate( array $event ) { $latest = $this->logger->get_latest_for_pair( $event['user_id'], $event['course_id'] ); if ( ! $latest || $latest['action'] !== $event['action'] ) { return false; } $diff = strtotime( $event['event_time'] . ' UTC' ) - strtotime( $latest['event_time'] . ' UTC' ); return $diff >= 0 && $diff <= self::DUPLICATE_WINDOW; } /** * Debug-only error log (no personal data). * * @param \Throwable $e Error. * @return void */ private function debug( $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'EVL error: ' . get_class( $e ) . ': ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } }