Jake Vanderwerf
2026-05-31 d7e7d248cbe41cd7a9ef9c2fb022b6c4831f99a3
inc/integrations/GoogleMyBusiness.php
@@ -1,8 +1,7 @@
<?php
namespace JVBase\integrations;
use JVBase\meta\MetaManager;
use JVBase\managers\CacheManager;
use JVBase\meta\Meta;
use WP_Error;
if (!defined('ABSPATH')) {
   exit;
@@ -10,9 +9,12 @@
class GoogleMyBusiness extends Integrations
{
   private string $access_token;
   private ?string $access_token = null;
   protected string $readMask = 'name,title,storefrontAddress,metadata,openInfo,storeCode,categories,phoneNumbers,labels,specialHours';
   private ?string $location = null;
   private ?string $refresh_token = null;
   private ?string $client_id = null;
   private ?string $client_secret = null;
   private ?string $account_id = null;
   public function __construct(?int $userID = null)
@@ -35,6 +37,7 @@
      ];
      $this->apiEndpoints = [
         '/accounts/[^/]+/locations/[^/]+/reviews',
         '/accounts/[^/]+/locations/[^/]+/foodMenus',
         '/v4/accounts/[^/]+/locations/[^/]+/media',
         '/v4/accounts/[^/]+/locations/[^/]+/localPosts',
@@ -81,18 +84,18 @@
            'label'  => 'OAuth Client Secret',
            'required'  => true,
         ],
         'access_token' => [
            'type'   => 'text',
            'subtype'   => 'password',
            'label'  => 'Access Token',
            'hint'   => 'Generated automagically after OAuth authorization.'
         ],
         'refresh_token'   => [
            'type'   => 'text',
            'subtype'   => 'password',
            'label'  => 'Refresh Token',
            'hint'   => 'Generated automagically after OAuth authorization.'
         ]
//       'access_token' => [
//          'type'   => 'text',
//          'subtype'   => 'password',
//          'label'  => 'Access Token',
//          'hint'   => 'Generated automagically after OAuth authorization.'
//       ],
//       'refresh_token'   => [
//          'type'   => 'text',
//          'subtype'   => 'password',
//          'label'  => 'Refresh Token',
//          'hint'   => 'Generated automagically after OAuth authorization.'
//       ]
      ];
      $this->advanced = [
@@ -124,6 +127,10 @@
            'check_oauth_status' => 'Check OAuth Status'
         ]
      );
      if (JVB_TESTING) {
         $this->cache->flush();
      }
   }
   protected function initialize(): void
@@ -131,12 +138,12 @@
      if (empty($this->credentials)) {
         $this->loadCredentials();
      }
      $this->access_token = $this->credentials['access_token'] ?? '';
      $this->refresh_token = $this->credentials['refresh_token'] ?? '';
      $this->client_id = $this->credentials['client_id'] ?? '';
      $this->client_secret = $this->credentials['client_secret'] ?? '';
      $this->location = $this->credentials['location'] ?? null;
      $this->account_id = $this->credentials['account'] ?? null;
      $this->access_token = (array_key_exists('access_token', $this->credentials)) ? $this->credentials['access_token'] : null;
      $this->refresh_token = (array_key_exists('refresh_token', $this->credentials)) ? $this->credentials['refresh_token'] : null;
      $this->client_id = (array_key_exists('client_id', $this->credentials)) ? $this->credentials['client_id'] : null;
      $this->client_secret = (array_key_exists('client_secret', $this->credentials)) ? $this->credentials['client_secret'] : null;
      $this->location = (array_key_exists('location', $this->credentials)) ? $this->credentials['location'] : null;
      $this->account_id = (array_key_exists('account', $this->credentials)) ? $this->credentials['account'] : null;
      if ($this->account_id) {
         $this->apiEndpoints[] = "/v1/{$this->account_id}/locations";
@@ -157,6 +164,16 @@
      }
   }
   /**
    * Check if response contains an error - Google-specific
    */
   protected function isErrorResponse(array $response): bool
   {
      // Google APIs return errors in this format:
      // {"error": {"code": 401, "message": "...", "status": "UNAUTHENTICATED"}}
      return isset($response['error']) && isset($response['error']['code']);
   }
   protected function getRequestHeaders(): array
   {
      return [
@@ -187,7 +204,6 @@
      $type = $settings['content_type']??'post'; //can be 'post', 'offer', 'event', 'hours', 'info',
      $initial = $settings['initial']?? false;
      $syncOnUpdate = $settings['update']??false;
      error_log('Handling GMB Save Post with settings: '.print_r($settings, true));
      if ($update && !$syncOnUpdate) {
         return;
      }
@@ -195,7 +211,6 @@
         return;
      }
      error_log('Continuing on...');
      $options = $data = [];
      switch ($type) {
         case 'menu_item':
@@ -207,8 +222,7 @@
      }
      $data['post_id'] = $postID;
      $operation = ($update) ? 'update_' : 'create_';
      error_log('[GMB]Queuing sync to service:'.print_r($data, true));
      error_log('Options: '.print_r($options, true));
      $this->queueOperation(
         $operation.$type,
         $data,
@@ -268,7 +282,7 @@
      }
      $postID = $data['post_id'];
      $meta = new MetaManager($postID, 'post');
      $meta = Meta::forPost($postID);
      $fields = [
         'start_date',
         'end_date',
@@ -304,7 +318,7 @@
         $result =  $this->updatePost($fields["_{$this->service_name}_item_id"], $data);
      } else {
         $result = $this->createPost($data);
         $meta->updateValue("_{$this->service_name}_item_id", $result['name']);
         $meta->set("_{$this->service_name}_item_id", $result['name']);
      }
      return [
@@ -325,7 +339,7 @@
      }
      $postID = $data['post_id'];
      $meta = new MetaManager($postID, 'post');
      $meta = Meta::forPost($postID);
      $fields = [
         'post_excerpt',
         'post_title',
@@ -357,7 +371,7 @@
         $result =  $this->updatePost($fields["_{$this->service_name}_item_id"], $data);
      } else {
         $result = $this->createPost($data);
         $meta->updateValue("_{$this->service_name}_item_id", $result['name']);
         $meta->set("_{$this->service_name}_item_id", $result['name']);
      }
      return [
@@ -379,7 +393,7 @@
      }
      $postID = $data['post_id'];
      $meta = new MetaManager($postID, 'post');
      $meta = Meta::forPost($postID);
      $fields = [
         'post_excerpt',
         'post_title',
@@ -408,7 +422,7 @@
         $result =  $this->updatePost($fields["_{$this->service_name}_item_id"], $data);
      } else {
         $result = $this->createPost($data);
         $meta->updateValue("_{$this->service_name}_item_id", $result['name']);
         $meta->set("_{$this->service_name}_item_id", $result['name']);
      }
      return [
@@ -457,8 +471,6 @@
         // Build the complete menu structure
         $menu_data = $gmb->collectMenu($menu_items);
         error_log('Menu Data: '.print_r($menu_data, true));
         // Send to Google My Business API
         $result = $this->updateFoodMenus($menu_data);
         return [
@@ -496,7 +508,6 @@
            $locations = $this->getLocations($account['name']);
            foreach ($locations as $location) {
               error_log('Fetched Location: '.print_r($location, true));
               if ($location['storeCode'] === $this->credentials['location']) {
                  // Auto-migrate: update stored location to use full resource name
                  $this->setSelectedLocation($location['name']);
@@ -683,7 +694,6 @@
         // Filter to only allowed fields
         $patch_data = array_intersect_key($updates,$allowed_fields);
         error_log('Updates: '.print_r($updates, true));
         $location_name = $this->getSelectedLocationResourceName();
         if (empty($patch_data)) {
@@ -1028,7 +1038,6 @@
      if(!$this->isSetUp()) {
         return [];
      }
      error_log('[GMB] updateBusinessHours called with hours: ' . print_r($hours, true));
      $location_name = $this->credentials['location'];
      if (empty($location_name)) {
@@ -1051,11 +1060,7 @@
            ]
         ];
         error_log('[GMB] Complete update data: ' . print_r($update_data, true));
         $endpoint = "/v1/{$location_name}?updateMask=regularHours";
         error_log('[GMB] API endpoint: ' . $endpoint);
         error_log('[GMB] Using API base: ' . 'base');
         // Make the API request
         $response = $this->makeRequest(
@@ -1065,18 +1070,15 @@
            'base'
         );
         error_log('[GMB] API response: ' . print_r($response, true));
         $success = $response !== null;
         error_log('[GMB] updateBusinessHours result: ' . ($success ? 'SUCCESS' : 'FAILED'));
         // Additional validation - check if the response contains the updated hours
         if ($success && $response) {
            if (isset($response['regularHours'])) {
               error_log('[GMB] SUCCESS: Updated hours confirmed in response: ' . print_r($response['regularHours'], true));
//             error_log('[GMB] SUCCESS: Updated hours confirmed in response: ' . print_r($response['regularHours'], true));
            } else {
               error_log('[GMB] WARNING: No regularHours in response, but API call succeeded');
               error_log('[GMB] Full response keys: ' . implode(', ', array_keys($response)));
//             error_log('[GMB] WARNING: No regularHours in response, but API call succeeded');
//             error_log('[GMB] Full response keys: ' . implode(', ', array_keys($response)));
            }
         }
@@ -1086,9 +1088,6 @@
         ];
      } catch (\Exception $e) {
         error_log('[GMB] Exception in updateBusinessHours: ' . $e->getMessage());
         error_log('[GMB] Exception trace: ' . $e->getTraceAsString());
         $this->logError($e->getMessage(), [
            'method' => 'updateBusinessHours'
         ]);
@@ -1101,8 +1100,6 @@
   private function validateAndFormatHours(array $hours): array
   {
      error_log('[GMB] Validating hours format...');
      $valid_days = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'];
      $periods = [];
@@ -1132,8 +1129,6 @@
            continue;
         }
         error_log('[GMB] Processing ' . $formatted_day . ' - times data: ' . print_r($times, true));
         // Check if day is open with flexible comparison
         $is_open = false;
         if (isset($times['open'])) {
@@ -1165,10 +1160,7 @@
         ];
         $periods[] = $period;
         error_log('[GMB] Valid period for ' . $formatted_day . ': ' . print_r($period, true));
      }
      error_log('[GMB] Total valid periods: ' . count($periods));
      return $periods;
   }
@@ -1247,8 +1239,6 @@
            ]
         ];
         error_log('[GMB] Updating special hours with corrected structure: ' . json_encode($update_data, JSON_PRETTY_PRINT));
         // Try the PATCH request
         $response = $this->makeRequest(
            'PATCH',
@@ -1263,8 +1253,6 @@
         ];
      } catch (\Exception $e) {
         error_log('[GMB] setSpecialHours Exception: ' . $e->getMessage());
         $this->logError($e->getMessage(), [
            'method' => 'setSpecialHours'
         ]);
@@ -1416,9 +1404,6 @@
      $ttl = 7 * 24 * 60 * 60; // week in seconds
      $response = $this->getRequest('/v1/accounts', [], 'accounts', $ttl, $force)??[];
      // Log the raw response for debugging
      error_log('[GMB] Raw accounts response: ' . print_r($response, true));
      if (isset($response['accounts']) && is_array($response['accounts'])) {
         return $response['accounts'];
      }
@@ -1431,6 +1416,127 @@
   }
   /**
    * Get reviews for the current location
    * @param int $page_size Number of reviews to fetch (max 50)
    * @return array|null
    */
   public function getReviews(int $page_size = 5): ?array
   {
      $this->ensureInitialized();
      if (!$this->location) {
         throw new \Exception('No location selected');
      }
      if (!$this->account_id) {
         throw new \Exception('No account configured');
      }
      $location = $this->getSelectedLocationResourceName();
      $account = $this->account_id;
      // Check cache first (weekly refresh = 604800 seconds)
      $cache_key = ['reviews', $location, $page_size];
      $cached = $this->cache->get($cache_key);
      if ($cached !== false) {
         return $cached;
      }
      try {
         // Reviews endpoint from My Business Account Management API
         $response = $this->getRequest(
            "/{$account}/{$location}/reviews",
            [
               'orderBy' => 'updateTime desc'
            ],
            'v4'
         );
         $reviews = $response ?? [];
         // Cache for 1 week (604800 seconds)
         $this->cache->set($cache_key, $reviews, WEEK_IN_SECONDS);
         return $reviews;
      } catch (\Exception $e) {
         $this->logError($e->getMessage(), [
            'method' => 'getReviews'
         ]);
         return null;
      }
   }
   /**
    * Get the URL to view all Google reviews for the current location
    * @return string|null The reviews viewing URL or null if not available
    */
   public function getReviewsViewUrl(): ?string
   {
      $this->ensureInitialized();
      try {
         $location = $this->getLocation();
         if (empty($location)) {
            return null;
         }
         // Prefer maps URL as it shows all reviews directly
         if (!empty($location['metadata']['mapsUrl'])) {
            return $location['metadata']['mapsUrl'];
         }
         // Fallback: construct from Place ID
         if (!empty($location['metadata']['placeId'])) {
            return 'https://search.google.com/local/reviews?placeid=' .
               urlencode($location['metadata']['placeId']);
         }
         return null;
      } catch (\Exception $e) {
         $this->logError('Failed to get reviews view URL: ' . $e->getMessage(), [
            'method' => 'getReviewsViewUrl'
         ]);
         return null;
      }
   }
   /**
    * Get the URL to leave a review for the current location
    * @return string|null The review URL or null if not available
    */
   public function getReviewUrl(): ?string
   {
      $this->ensureInitialized();
      try {
         $location = $this->getLocation();
         if (empty($location)) {
            return null;
         }
         // Try to use Place ID for write review
         if (!empty($location['metadata']['placeId'])) {
            return 'https://search.google.com/local/writereview?placeid=' .
               urlencode($location['metadata']['placeId']);
         }
         // Fallback to maps URL
         if (!empty($location['metadata']['mapsUrl'])) {
            return $location['metadata']['mapsUrl'] . '/reviews';
         }
         return null;
      } catch (\Exception $e) {
         $this->logError('Failed to get review URL: ' . $e->getMessage(), [
            'method' => 'getReviewUrl'
         ]);
         return null;
      }
   }
   /**
    * Get locations for an account (with persistent storage)
    * Allowed Fields: https://developers.google.com/my-business/content/location-data#list_of_all_supported_filter_fields
    */
@@ -1445,7 +1551,6 @@
      if (empty($account_name) || !str_starts_with($account_name, 'accounts/')) {
         return [];
      }
      error_log('[GMB] getLocations() called for: ' . $account_name . ' (force: ' . ($force ? 'true' : 'false') . ')');
      $params = ['readMask' => $this->readMask];
      $ttl = 7 * 24 * 60 * 60; // Week in seconds
@@ -1475,8 +1580,6 @@
    */
   public function refreshStoredData(): array
   {
      error_log('[GMB] Manually refreshing accounts and locations data');
      try {
         // Fetch fresh accounts data from API
         $accounts = $this->getAccounts(true);
@@ -1536,7 +1639,6 @@
      ];
      $endpoint = "/v1/{$location_name}?".http_build_query($params);
      error_log('[GMB] Fetching location: ' . $location_name);
      $location = $this->getRequest($endpoint, [], 'base', 'moderate', $force);
      return $location??null;
@@ -1636,9 +1738,7 @@
      $text = preg_replace('/\s+/', ' ', $text);
      // Trim
      $text = trim($text);
      return $text;
      return trim($text);
   }
   /**
    * Format date for GMB API
@@ -1679,13 +1779,10 @@
         // Validate hour and minute ranges
         if ($hour >= 0 && $hour <= 23 && $minute >= 0 && $minute <= 59) {
            $result = [
            return [
               'hours' => $hour,
               'minutes' => $minute
            ];
            error_log('[GMB] Converted time "' . $time . '" to Google format: ' . print_r($result, true));
            return $result;
         }
      }
@@ -1975,9 +2072,7 @@
         [],
         'posts'
      );
      $result = $response['foodMenus'] ?? [];
      return $result;
      return $response['foodMenus'] ?? [];
   }
   /**
@@ -2099,8 +2194,6 @@
   public function handleGetAllLocations():WP_Error|array
   {
      try {
         error_log('[GMB] AJAX getAllLocations called');
         // Check if we should force refresh
         $force = isset($_POST['force']) && $_POST['force'] === 'true';
@@ -2114,7 +2207,6 @@
            $all_locations = array_merge($all_locations, $locations);
         }
         error_log('[GMB] AJAX returning ' . count($all_locations) . ' locations');
         return [
            'success'   => true,
            'locations' => $all_locations,
@@ -2122,7 +2214,6 @@
         ];
      } catch (\Exception $e) {
         error_log('[GMB] AJAX getAllLocations exception: ' . $e->getMessage());
         return new WP_Error('failure', 'Something went wrong: '.$e->getMessage());
      }
   }
@@ -2136,15 +2227,12 @@
         // Handle both AJAX and REST API calls
         $selected_account = null;
         error_log('handle Update Location: '.print_r($data, true));
         if (is_array($data)) {
            $selected_account = $data['account'] ?? $data['location'] ?? null;
         } elseif (isset($_POST['account'])) {
            $selected_account = sanitize_text_field($_POST['account']);
         }
         error_log('[GMB] updateLocation received: ' . print_r($selected_account, true));
         if (empty($selected_account)) {
            throw new \Exception('No account selected');
         }
@@ -2187,9 +2275,7 @@
   {
      try {
         // Use the static method to clear the entire cache group
         CacheManager::invalidateGroup('integrations_'.$this->cacheName);
         error_log('[GMB] Cleared all stored data for cache group: ' . $this->cacheName);
         $this->cache->flush();
         return true;
      } catch (\Exception $e) {
@@ -2261,12 +2347,11 @@
            'dailyRange.endDate.day' => date('j', strtotime($end_date))
         ];
         $response = $this->getRequest(
            "/v1/{$location_name}:fetchMultiDailyMetricsTimeSeries?" . http_build_query($params),
            $params,
            'performance'
         );
      return $response;
      return $this->getRequest(
         "/v1/{$location_name}:fetchMultiDailyMetricsTimeSeries?" . http_build_query($params),
         $params,
         'performance'
      );
   }
   /**
@@ -2385,7 +2470,7 @@
   protected function collectMenu(array $menu_items): array
   {
      $defaultMeta = new MetaManager($this->userID, 'integrations');
      $defaultMeta = Meta::forOptions($this->userID.'_integrations');
      $defaults = ['menu_name', 'menu_description', 'default_section', 'cuisines', 'source_url', 'language', 'default_currency'];
      $defaults = $defaultMeta->getAll($defaults);
@@ -2446,7 +2531,7 @@
   protected function buildMenuItem(\WP_Post $item, array $defaults):array
   {
      $meta = new MetaManager($item->ID, 'post');
      $meta = Meta::forPost($item->ID);
      $fields = $this->mappedMenuFields($item->post_type);
      $values = $meta->getAll(array_values($fields));
@@ -2575,8 +2660,8 @@
   protected function getMenuSectionsOrder(array $sections_map):array
   {
      $optionsMeta = new MetaManager(null, 'options');
      $sectionOrder = $optionsMeta->getValue('menu_section_order');
      $optionsMeta = Meta::forOptions('options');
      $sectionOrder = $optionsMeta->get('menu_section_order');
      // Build final GMB menu structure
      $ordered = [];
@@ -2612,8 +2697,8 @@
      // Collect cuisines from individual items if specified
      foreach ($menu_items as $item) {
         $meta = new MetaManager($item->ID, 'post');
         $item_cuisines = $meta->getValue('cuisines');
         $meta = Meta::forPost($item->ID);
         $item_cuisines = $meta->get('cuisines');
         if (!empty($item_cuisines)) {
            $item_cuisines = is_array($item_cuisines) ?
@@ -2884,4 +2969,6 @@
         ]
      ];
   }
}