Jake Vanderwerf
2026-08-07 a8ff85042ffbfe94b678fd818c02deb613bfd116
inc/integrations/services/Umami.php
@@ -1,5 +1,7 @@
<?php
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
if (!defined('ABSPATH')) {
@@ -8,796 +10,9 @@
class Umami extends Integrations
{
   private string $website_id;
   private string $api_url;
   private string $api_token;
   // Tracking configuration
   private array $valid_events = [
      'view_feed',
      'view_taxonomy',
      'view_profile',
      'view_shop',
      'view_content',
      'toggle_favourite',
      'click_profile',
      'click_content',
      'click_taxonomy',
      'click_shop',
      'user_session'
   ];
   // Database tables
   private string $events_table;
   private string $metrics_table;
   protected static array $instances = [];
   public static function getInstance(?int $userID = null):self
   {
      $key = is_null($userID) ? 'base' : $userID;
      if (!array_key_exists($key, self::$instances)) {
         self::$instances[$key] = new static($userID);
      }
      return self::$instances[$key];
   }
   protected function __construct(?int $userID = null)
   {
      $this->service_name = 'umami';
      $this->title = 'Umami.js';
      $this->icon = 'chart-line';
      $this->apiBase = '';  // Set dynamically based on credentials
      $this->apiEndpoints = [
         'api/websites',
         'api/websites/{websiteId}/stats',
         'api/websites/{websiteId}/pageviews',
         'api/websites/{websiteId}/events'
      ];
      $this->fields = [
         'website_id' => [
            'label'     => 'Website ID',
            'type'      => 'text',
            'placeholder'=> 'Enter Umami Website ID',
            'hint'      => 'Found in your Umami dashboard under Settings → Websites.',
            'required'  => true
         ],
      ];
      $this->advanced = [
         'api_url' => [
            'label'     => 'API URL',
            'type'      => 'text',
            'placeholder'=> 'https://analytics.yourdomain.com',
            'hint'      => 'Your Umami server URL. Leave blank if using umami.is cloud service.'
         ],
         'api_token' => [
            'label'     => 'API Token (Optional)',
            'type'      => 'text',
            'subtype'   => 'password',
            'placeholder'=> 'Enter API Token for analytics data',
            'hint'      => 'Required only if you want to retrieve analytics data for dashboards.'
         ]
      ];
      $this->instructions = [
         'Login to your <a href="https://cloud.umami.is/settings/websites" target="_blank">umami.is</a> account, select your website, and copy the website ID'
      ];
      parent::__construct();
      $this->actions = array_merge(
         $this->actions,
         [
            'refresh_data' =>    'handleRefreshData'
         ]
      );
      global $wpdb;
      $this->events_table = $wpdb->prefix . BASE . 'umami_events';
      $this->metrics_table = $wpdb->prefix . BASE . 'performance_metrics';
   }
   /**
    * Initialize service-specific properties
    */
   use Admin, Instance;
   protected function initialize(): void
   {
      $this->website_id = $this->credentials['website_id'] ?? '';
      $this->api_url = $this->credentials['api_url'] ?? '';
      $this->api_token = $this->credentials['api_token'] ?? '';
      $this->ttl = intval($this->credentials['cache_duration'] ?? 60) * 60;
      // Set dynamic API base if provided
      if (!empty($this->api_url)) {
         $this->apiBase = rtrim($this->api_url, '/');
      }
   }
   /**
    * Register additional WordPress hooks
    */
   protected function registerAdditionalHooks(): void
   {
      // Add tracking script to frontend
      add_action('wp_head', [$this, 'renderTrackingScript'], 5);
      // Add user session tracking
      add_action('wp_footer', [$this, 'trackUserSession'], 100);
      // Schedule data collection if API is configured
      if (!empty($this->api_token)) {
         add_action('jvb_daily_umami_collection', [$this, 'collectDailyData']);
         if (!wp_next_scheduled('jvb_daily_umami_collection')) {
            wp_schedule_event(time(), 'daily', 'jvb_daily_umami_collection');
         }
      }
   }
   /***************************************************************************
    * TRACKING SCRIPT METHODS
    ***************************************************************************/
   /**
    * Render tracking script in head
    */
   public function renderTrackingScript(): void
   {
      // Skip on local environments
      if (JVB_TESTING) {
         return;
      }
      if (!$this->isSetUp() || is_admin()) {
         return;
      }
      $script_url = $this->getTrackingScriptUrl();
      $website_id = $this->getWebsiteId();
      if (!$website_id || !$script_url) {
         return;
      }
      // Preconnect for performance
      $domain = parse_url($script_url, PHP_URL_HOST);
      echo '<link rel="preconnect" href="https://' . esc_attr($domain) . '"/>' . "\n";
      // Add tracking script
      $attributes = [
         'defer',
         'src="' . esc_url($script_url) . '"',
         'data-website-id="' . esc_attr($website_id) . '"'
      ];
      // Add optional configuration
      if (!empty($this->credentials['respect_dnt']) && $this->credentials['respect_dnt']) {
         $attributes[] = 'data-do-not-track="true"';
      }
      if (!empty($this->credentials['domains'])) {
         $attributes[] = 'data-domains="' . esc_attr($this->credentials['domains']) . '"';
      }
      echo '<script ' . implode(' ', $attributes) . '></script>' . "\n";
   }
   /**
    * Track user session information
    */
   public function trackUserSession(): void
   {
      if (!$this->isSetUp() || is_admin()) {
         return;
      }
      $attributes = $this->buildTrackingAttributes('user_session', 'pageview', [
         'logged_in' => is_user_logged_in() ? 'true' : 'false',
         'user_type' => $this->getCurrentUserType()
      ]);
      if (empty($attributes)) {
         return;
      }
      echo '<div ' . $this->attributesToString($attributes) . ' style="display:none"></div>' . "\n";
      // Add page-specific tracking
      $this->trackPageView();
   }
   /**
    * Track specific page views
    */
   private function trackPageView(): void
   {
      $tracker_args = [];
      $event = '';
      $type = '';
      if (is_singular([BASE . 'artist', BASE . 'partner'])) {
         // Profile view
         global $post;
         $event = 'view_profile';
         $type = get_post_type();
         $tracker_args = [
            'id' => get_the_ID(),
            'from' => $this->getTrafficSource(),
            'owner_id' => $post->post_author
         ];
         // Check for specific content in query
         foreach (['tattoo', 'piercing', 'artwork'] as $content_type) {
            if (isset($_GET[$content_type])) {
               $tracker_args['item'] = sanitize_text_field($_GET[$content_type]);
               break;
            }
         }
      } elseif (is_tax(BASE . 'shop')) {
         // Shop view
         $event = 'view_shop';
         $type = BASE . 'shop';
         $tracker_args = [
            'id' => get_queried_object_id(),
            'from' => $this->getTrafficSource()
         ];
      } elseif (is_tax()) {
         // Taxonomy view
         $obj = get_queried_object();
         $event = 'view_taxonomy';
         $type = $obj->taxonomy;
         $tracker_args = [
            'id' => $obj->term_id,
            'from' => $this->getTrafficSource()
         ];
      }
      if ($event && $type) {
         $attributes = $this->buildTrackingAttributes($event, $type, $tracker_args);
         echo '<div ' . $this->attributesToString($attributes) . ' style="display:none"></div>' . "\n";
      }
   }
   /***************************************************************************
    * TRACKING ATTRIBUTE BUILDER METHODS
    ***************************************************************************/
   /**
    * Build tracking attributes for events
    */
   public function buildTrackingAttributes(string $event, string $type, array $args = []): array
   {
      if (!in_array($event, $this->valid_events)) {
         return [];
      }
      // Normalize type
      $normalized_type = str_replace(BASE, '', $type);
      // Build base attributes
      $attributes = [
         'data-umami-event' => esc_attr($event),
         'data-umami-event-type' => esc_attr($normalized_type)
      ];
      // Add ID if provided
      if (!empty($args['id'])) {
         $attributes['data-umami-event-id'] = esc_attr($args['id']);
      }
      // Add source information
      if (!empty($args['source_id'])) {
         $attributes['data-umami-event-source'] = esc_attr($args['source_id']);
         if (!empty($args['source_type'])) {
            $attributes['data-umami-event-source-type'] = esc_attr(
               str_replace(BASE, '', $args['source_type'])
            );
         }
      }
      // Add owner information
      if (!empty($args['owner_id'])) {
         $attributes['data-umami-event-owner'] = esc_attr($args['owner_id']);
         if (!empty($args['owner_type'])) {
            $attributes['data-umami-event-owner-type'] = esc_attr(
               str_replace(BASE, '', $args['owner_type'])
            );
         }
      }
      // Add traffic source
      if (!empty($args['from'])) {
         $attributes['data-umami-event-from'] = esc_attr($args['from']);
      }
      // Add item reference
      if (!empty($args['item'])) {
         $attributes['data-umami-event-item'] = esc_attr($args['item']);
      }
      // Add metadata
      foreach ($args as $key => $value) {
         if (in_array($key, ['id', 'source_id', 'source_type', 'owner_id', 'owner_type', 'from', 'item'])) {
            continue;
         }
         $clean_key = 'data-umami-event-' . str_replace('_', '-', sanitize_key($key));
         $attributes[$clean_key] = esc_attr($value);
      }
      return $attributes;
   }
   /**
    * Convert attributes array to HTML string
    */
   public function attributesToString(array $attributes): string
   {
      $attr_strings = [];
      foreach ($attributes as $key => $value) {
         $attr_strings[] = $key . '="' . $value . '"';
      }
      return implode(' ', $attr_strings);
   }
   /**
    * Public tracking methods for use in templates
    */
   public function trackContentClick(int $id, string $type, array $args = []): string
   {
      $args['id'] = $id;
      // Auto-detect owner for content types
      if (empty($args['owner_id']) && in_array($type, [BASE . 'tattoo', BASE . 'artwork', BASE . 'piercing'])) {
         $post = get_post($id);
         if ($post && !empty($post->post_author)) {
            $args['owner_id'] = $post->post_author;
            $args['owner_type'] = 'user';
         }
      }
      return $this->attributesToString(
         $this->buildTrackingAttributes('click_content', $type, $args)
      );
   }
   public function trackProfileClick(int $id, string $type, array $args = []): string
   {
      $args['id'] = $id;
      return $this->attributesToString(
         $this->buildTrackingAttributes('click_profile', $type, $args)
      );
   }
   public function trackTaxonomyClick(int $id, string $taxonomy, array $args = []): string
   {
      $args['id'] = $id;
      return $this->attributesToString(
         $this->buildTrackingAttributes('click_taxonomy', $taxonomy, $args)
      );
   }
   public function trackContentTaxonomyClick(int $id, string $taxonomy, array $args = []): string
   {
      $args['id'] = $id;
      return $this->attributesToString(
         $this->buildTrackingAttributes('click_' . $taxonomy, $taxonomy, $args)
      );
   }
   public function trackFavouriteToggle(int $id, string $type, bool $is_favourite, array $args = []): string
   {
      $args['id'] = $id;
      $args['action'] = $is_favourite ? 'add' : 'remove';
      return $this->attributesToString(
         $this->buildTrackingAttributes('toggle_favourite', $type, $args)
      );
   }
   public function trackFeedView(int $id, string $type, array $args = []): array
   {
      $args['id'] = $id;
      return $this->buildTrackingAttributes('view_feed', $type, $args);
   }
   /***************************************************************************
    * API DATA COLLECTION METHODS
    ***************************************************************************/
   /**
    * Get analytics data from Umami API
    */
   public function getAnalytics(string $start_date, string $end_date, bool $use_cache = true): ?array
   {
      if (empty($this->api_url) || empty($this->api_token)) {
         return null;
      }
      // Check cache first
      if ($use_cache && $this->cache) {
         $cache_key = md5("analytics_{$start_date}_{$end_date}");
         $cached = $this->cache->get($cache_key);
         if ($cached !== false) {
            return $cached;
         }
      }
      try {
         $url = rtrim($this->api_url, '/') . "/api/websites/{$this->website_id}/stats";
         $params = [
            'startAt' => strtotime($start_date) * 1000,
            'endAt' => strtotime($end_date) * 1000
         ];
         $response = wp_remote_get($url . '?' . http_build_query($params), [
            'headers' => [
               'Authorization' => 'Bearer ' . $this->api_token,
               'Accept' => 'application/json'
            ],
            'timeout' => 30
         ]);
         if (is_wp_error($response)) {
            $this->logError('API request failed', ['error' => $response->get_error_message()]);
            return null;
         }
         $body = wp_remote_retrieve_body($response);
         $data = json_decode($body, true);
         if (json_last_error() !== JSON_ERROR_NONE) {
            $this->logError('Invalid JSON response from API');
            return null;
         }
         // Cache the result
         if ($use_cache && $this->cache) {
            $this->cache->set($cache_key, $data, $this->ttl);
         }
         return $data;
      } catch (Exception $e) {
         $this->logError('Exception during API call', ['error' => $e->getMessage()]);
         return null;
      }
   }
   /**
    * Get page views for a specific URL
    */
   public function getPageViews(string $url, string $start_date, string $end_date): ?array
   {
      if (empty($this->api_url) || empty($this->api_token)) {
         return null;
      }
      $api_endpoint = rtrim($this->api_url, '/') . "/api/websites/{$this->website_id}/pageviews";
      $params = [
         'startAt' => strtotime($start_date) * 1000,
         'endAt' => strtotime($end_date) * 1000,
         'url' => $url,
         'unit' => 'day'
      ];
      $response = wp_remote_get($api_endpoint . '?' . http_build_query($params), [
         'headers' => [
            'Authorization' => 'Bearer ' . $this->api_token,
            'Accept' => 'application/json'
         ],
         'timeout' => 30
      ]);
      if (is_wp_error($response)) {
         return null;
      }
      return json_decode(wp_remote_retrieve_body($response), true);
   }
   /**
    * Get custom events data
    */
   public function getEvents(string $event_name = '', string $start_date = '', string $end_date = ''): ?array
   {
      if (empty($this->api_url) || empty($this->api_token)) {
         return null;
      }
      $api_endpoint = rtrim($this->api_url, '/') . "/api/websites/{$this->website_id}/events";
      $params = [];
      if ($start_date && $end_date) {
         $params['startAt'] = strtotime($start_date) * 1000;
         $params['endAt'] = strtotime($end_date) * 1000;
      }
      if ($event_name) {
         $params['event'] = $event_name;
      }
      $url = $api_endpoint;
      if (!empty($params)) {
         $url .= '?' . http_build_query($params);
      }
      $response = wp_remote_get($url, [
         'headers' => [
            'Authorization' => 'Bearer ' . $this->api_token,
            'Accept' => 'application/json'
         ],
         'timeout' => 30
      ]);
      if (is_wp_error($response)) {
         return null;
      }
      return json_decode(wp_remote_retrieve_body($response), true);
   }
   /**
    * Collect daily analytics data and store in database
    */
   public function collectDailyData(): void
   {
      if (!$this->isSetUp() || empty($this->api_token)) {
         return;
      }
      $yesterday = date('Y-m-d', strtotime('-1 day'));
      $data = $this->getAnalytics($yesterday, $yesterday, false);
      if (!$data) {
         $this->logError('Failed to collect daily data', ['date' => $yesterday]);
         return;
      }
      global $wpdb;
      // Store basic metrics
      $wpdb->insert($this->metrics_table, [
         'date' => $yesterday,
         'pageviews' => $data['pageviews']['value'] ?? 0,
         'visitors' => $data['visitors']['value'] ?? 0,
         'visits' => $data['visits']['value'] ?? 0,
         'bounces' => $data['bounces']['value'] ?? 0,
         'total_time' => $data['totaltime']['value'] ?? 0,
         'created_at' => current_time('mysql')
      ]);
      // Collect and store events
      $events = $this->getEvents('', $yesterday, $yesterday);
      if ($events) {
         foreach ($events as $event) {
            $wpdb->insert($this->events_table, [
               'date' => $yesterday,
               'event_name' => $event['x'] ?? '',
               'event_count' => $event['y'] ?? 0,
               'created_at' => current_time('mysql')
            ]);
         }
      }
      $this->logDebug('Successfully collected daily data', ['date' => $yesterday]);
   }
   /***************************************************************************
    * HELPER METHODS
    ***************************************************************************/
   /**
    * Get current user type for tracking
    */
   private function getCurrentUserType(): string
   {
      if (!is_user_logged_in()) {
         return 'guest';
      }
      $user = wp_get_current_user();
      $roles = $user->roles;
      if (empty($roles)) {
         return 'user';
      }
      $role = array_values($roles)[0];
      return str_replace(BASE, '', $role);
   }
   /**
    * Determine traffic source
    */
   private function getTrafficSource(): string
   {
      if (isset($_GET['utm_source'])) {
         return sanitize_text_field($_GET['utm_source']);
      }
      if (isset($_SERVER['HTTP_REFERER'])) {
         $referer = $_SERVER['HTTP_REFERER'];
         $site_url = get_site_url();
         if (strpos($referer, $site_url) === false) {
            $domain = parse_url($referer, PHP_URL_HOST);
            return $domain ?: 'external';
         }
         return 'internal';
      }
      return 'direct';
   }
   /**
    * Get website ID
    */
   public function getWebsiteId(): string
   {
      $this->ensureInitialized();
      return $this->website_id;
   }
   /**
    * Get tracking script URL
    */
   public function getTrackingScriptUrl(): string
   {
      if (!empty($this->api_url)) {
         return rtrim($this->api_url, '/') . '/script.js';
      }
      // Default to Umami cloud
      return 'https://cloud.umami.is/script.js';
   }
   /***************************************************************************
    * REQUIRED ABSTRACT METHOD IMPLEMENTATIONS
    ***************************************************************************/
   /**
    * Get request headers for API calls
    */
   protected function getRequestHeaders(): array
   {
      $headers = [
         'Accept' => 'application/json',
         'Content-Type' => 'application/json'
      ];
      if (!empty($this->api_token)) {
         $headers['Authorization'] = 'Bearer ' . $this->api_token;
      }
      return $headers;
   }
   protected function handleRefreshData():array
   {
      // Collect today's data
      $today = date('Y-m-d');
      $data = $this->getAnalytics($today, $today, false);
      if ($data) {
         // Clear cache for today
         $cache_key = md5("analytics_{$today}_{$today}");
         $this->cache->forget($cache_key);
         return [
            'success' => true,
            'message' => 'Data refreshed successfully',
            'data' => $data
         ];
      }
      return [
         'success'   => false,
         'message'   => 'Something went wrong...'
      ];
   }
   /**
    * Process queued operations
    */
   public function processOperation(array|\WP_Error $result, object $operation, array $data): array|\WP_Error
   {
      $prefix = strtolower($this->service_name) . '_';
      switch ($operation->type) {
         case $prefix . 'collect_analytics':
            $this->collectDailyData();
            return ['success' => true, 'message' => 'Analytics collected'];
         default:
            return $result;
      }
   }
   /**
    * Test API connection
    */
   protected function performConnectionTest(): bool
   {
      // Basic tracking only needs website ID
      if (empty($this->website_id)) {
         return false;
      }
      // If API credentials provided, test them
      if (!empty($this->api_url) && !empty($this->api_token)) {
         try {
            $response = wp_remote_get(rtrim($this->api_url, '/') . '/api/websites', [
               'headers' => [
                  'Authorization' => 'Bearer ' . $this->api_token,
                  'Accept' => 'application/json'
               ],
               'timeout' => 10
            ]);
            if (is_wp_error($response)) {
               return false;
            }
            return wp_remote_retrieve_response_code($response) === 200;
         } catch (Exception $e) {
            $this->logError('Connection test failed', ['error' => $e->getMessage()]);
            return false;
         }
      }
      // Website ID exists, basic tracking will work
      return true;
   }
   /**
    * Get service description
    */
   public function getServiceDescription(): string
   {
      return "Privacy-focused analytics to understand your website traffic.";
   }
   /**
    * Create database tables for metrics storage
    */
   public static function createTables(): void
   {
      global $wpdb;
      $charset_collate = $wpdb->get_charset_collate();
      // Events table
      $events_table = $wpdb->prefix . BASE . 'umami_events';
      $events_sql = "CREATE TABLE IF NOT EXISTS $events_table (
            id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
            date date NOT NULL,
            event_name varchar(255) NOT NULL,
            event_count int(11) NOT NULL DEFAULT 0,
            event_data longtext,
            created_at datetime NOT NULL,
            PRIMARY KEY (id),
            KEY date_event (date, event_name)
        ) $charset_collate;";
      // Metrics table
      $metrics_table = $wpdb->prefix . BASE . 'performance_metrics';
      $metrics_sql = "CREATE TABLE IF NOT EXISTS $metrics_table (
            id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
            date date NOT NULL,
            pageviews int(11) NOT NULL DEFAULT 0,
            visitors int(11) NOT NULL DEFAULT 0,
            visits int(11) NOT NULL DEFAULT 0,
            bounces int(11) NOT NULL DEFAULT 0,
            total_time int(11) NOT NULL DEFAULT 0,
            created_at datetime NOT NULL,
            PRIMARY KEY (id),
            UNIQUE KEY date_unique (date)
        ) $charset_collate;";
      require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
      dbDelta($events_sql);
      dbDelta($metrics_sql);
      // TODO: Implement initialize() method.
   }
}