From f6491f075ee2f2742715d96c6c29c0814f343dfb Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 17:28:57 +0000 Subject: [PATCH] feat: extract business providers from app/Plug/Business GoogleMyBusiness provider with Core\Plug\Business namespace alignment. Co-Authored-By: Claude Opus 4.6 --- composer.json | 17 +++ src/GoogleMyBusiness/Auth.php | 156 +++++++++++++++++++++++++++ src/GoogleMyBusiness/Delete.php | 53 ++++++++++ src/GoogleMyBusiness/Locations.php | 117 ++++++++++++++++++++ src/GoogleMyBusiness/Post.php | 164 +++++++++++++++++++++++++++++ src/GoogleMyBusiness/Read.php | 146 +++++++++++++++++++++++++ 6 files changed, 653 insertions(+) create mode 100644 composer.json create mode 100644 src/GoogleMyBusiness/Auth.php create mode 100644 src/GoogleMyBusiness/Delete.php create mode 100644 src/GoogleMyBusiness/Locations.php create mode 100644 src/GoogleMyBusiness/Post.php create mode 100644 src/GoogleMyBusiness/Read.php diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..fbfe46d --- /dev/null +++ b/composer.json @@ -0,0 +1,17 @@ +{ + "name": "core/php-plug-business", + "description": "Business platform integrations for the Plug framework", + "type": "library", + "license": "EUPL-1.2", + "require": { + "php": "^8.2", + "core/php": "^1.0" + }, + "autoload": { + "psr-4": { + "Core\\Plug\\Business\\": "src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/GoogleMyBusiness/Auth.php b/src/GoogleMyBusiness/Auth.php new file mode 100644 index 0000000..5def568 --- /dev/null +++ b/src/GoogleMyBusiness/Auth.php @@ -0,0 +1,156 @@ +clientId = $clientId; + $this->clientSecret = $clientSecret; + $this->redirectUrl = $redirectUrl; + } + + public static function identifier(): string + { + return 'googlemybusiness'; + } + + public static function name(): string + { + return 'Google My Business'; + } + + /** + * Get OAuth URL. + */ + public function getAuthUrl(): string + { + $params = [ + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUrl, + 'scope' => implode(' ', $this->scope), + 'response_type' => 'code', + 'access_type' => 'offline', + 'prompt' => 'consent', + 'include_granted_scopes' => 'true', + ]; + + return self::AUTH_URL.'?'.http_build_query($params); + } + + /** + * Exchange code for access token. + * + * @param array $params ['code' => string] + */ + public function requestAccessToken(array $params): array + { + if (! isset($params['code'])) { + return ['error' => 'Authorization code is required']; + } + + $response = $this->http() + ->asForm() + ->post(self::TOKEN_URL, [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'redirect_uri' => $this->redirectUrl, + 'grant_type' => 'authorization_code', + 'code' => $params['code'], + ]); + + if (! $response->successful()) { + $error = $response->json(); + + return ['error' => $error['error_description'] ?? $error['error'] ?? 'Token exchange failed']; + } + + $data = $response->json(); + + return [ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'] ?? '', + 'expires_in' => now('UTC')->addSeconds($data['expires_in'] ?? 3600)->timestamp, + 'token_type' => $data['token_type'] ?? 'Bearer', + 'scope' => $data['scope'] ?? '', + ]; + } + + /** + * Refresh the access token. + */ + public function refresh(): Response + { + $refreshToken = $this->token['refresh_token'] ?? null; + + if (! $refreshToken) { + return $this->error('No refresh token available'); + } + + $response = $this->http() + ->asForm() + ->post(self::TOKEN_URL, [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + ]); + + return $this->fromHttp($response, function ($data) use ($refreshToken) { + return [ + 'access_token' => $data['access_token'], + 'refresh_token' => $refreshToken, // Google doesn't return new refresh token + 'expires_in' => now('UTC')->addSeconds($data['expires_in'] ?? 3600)->timestamp, + 'token_type' => $data['token_type'] ?? 'Bearer', + ]; + }); + } + + public function getAccount(): Response + { + return (new Read)->withToken($this->token)->me(); + } + + /** + * Google Business Profile URL. + */ + public static function externalAccountUrl(string $placeId): string + { + if ($placeId) { + return "https://www.google.com/maps/place/?q=place_id:{$placeId}"; + } + + return 'https://business.google.com'; + } +} diff --git a/src/GoogleMyBusiness/Delete.php b/src/GoogleMyBusiness/Delete.php new file mode 100644 index 0000000..fd08a77 --- /dev/null +++ b/src/GoogleMyBusiness/Delete.php @@ -0,0 +1,53 @@ +accessToken(); + if (! $accessToken) { + return $this->error('Access token is required'); + } + + $response = $this->http() + ->withToken($accessToken) + ->delete(self::API_URL."/{$id}"); + + // GMB returns 200 with empty body on success + if ($response->successful()) { + return $this->ok([ + 'deleted' => true, + 'id' => $id, + ]); + } + + return $this->fromHttp($response, fn ($data) => [ + 'deleted' => false, + 'error' => $data['error']['message'] ?? 'Failed to delete post', + ]); + } +} diff --git a/src/GoogleMyBusiness/Locations.php b/src/GoogleMyBusiness/Locations.php new file mode 100644 index 0000000..c50daf8 --- /dev/null +++ b/src/GoogleMyBusiness/Locations.php @@ -0,0 +1,117 @@ +accountName = $accountName; + + return $this; + } + + /** + * List business locations. + */ + public function listEntities(): Response + { + $accessToken = $this->accessToken(); + if (! $accessToken) { + return $this->error('Access token is required'); + } + + if (! $this->accountName) { + return $this->error('Account name is required'); + } + + $response = $this->http() + ->withToken($accessToken) + ->get(self::BUSINESS_API_URL."/{$this->accountName}/locations", [ + 'readMask' => 'name,title,storefrontAddress,metadata,phoneNumbers,websiteUri', + ]); + + return $this->fromHttp($response, function ($data) { + return [ + 'locations' => array_map(fn ($location) => [ + 'id' => $location['name'] ?? '', + 'title' => $location['title'] ?? '', + 'address' => $this->formatAddress($location['storefrontAddress'] ?? []), + 'place_id' => $location['metadata']['placeId'] ?? '', + 'maps_url' => $location['metadata']['mapsUri'] ?? '', + 'phone' => $location['phoneNumbers']['primaryPhone'] ?? null, + 'website' => $location['websiteUri'] ?? null, + ], $data['locations'] ?? []), + ]; + }); + } + + /** + * Get a specific location. + */ + public function get(string $locationName): Response + { + $accessToken = $this->accessToken(); + if (! $accessToken) { + return $this->error('Access token is required'); + } + + $response = $this->http() + ->withToken($accessToken) + ->get(self::BUSINESS_API_URL."/{$locationName}", [ + 'readMask' => 'name,title,storefrontAddress,metadata,phoneNumbers,websiteUri,regularHours,specialHours', + ]); + + return $this->fromHttp($response, function ($data) { + return [ + 'id' => $data['name'] ?? '', + 'title' => $data['title'] ?? '', + 'address' => $this->formatAddress($data['storefrontAddress'] ?? []), + 'place_id' => $data['metadata']['placeId'] ?? '', + 'maps_url' => $data['metadata']['mapsUri'] ?? '', + 'phone' => $data['phoneNumbers']['primaryPhone'] ?? null, + 'website' => $data['websiteUri'] ?? null, + 'regular_hours' => $data['regularHours'] ?? null, + 'special_hours' => $data['specialHours'] ?? null, + ]; + }); + } + + /** + * Format address array to string. + */ + private function formatAddress(array $address): string + { + $parts = array_filter([ + $address['addressLines'][0] ?? '', + $address['locality'] ?? '', + $address['administrativeArea'] ?? '', + $address['postalCode'] ?? '', + $address['regionCode'] ?? '', + ]); + + return implode(', ', $parts); + } +} diff --git a/src/GoogleMyBusiness/Post.php b/src/GoogleMyBusiness/Post.php new file mode 100644 index 0000000..39aa1fa --- /dev/null +++ b/src/GoogleMyBusiness/Post.php @@ -0,0 +1,164 @@ +accessToken(); + if (! $accessToken) { + return $this->error('Access token is required'); + } + + $location = $params['location'] ?? ''; + if (! $location) { + return $this->error('Location is required for Google My Business posts'); + } + + $postType = $params['post_type'] ?? 'STANDARD'; + $payload = $this->buildPayload($text, $media, $postType, $params); + + $response = $this->http() + ->withToken($accessToken) + ->post(self::API_URL."/{$location}/localPosts", $payload); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['name'] ?? '', + 'state' => $data['state'] ?? 'LIVE', + 'url' => $data['searchUrl'] ?? null, + ]); + } + + /** + * Build post payload based on type. + */ + private function buildPayload(string $text, Collection $media, string $postType, array $params): array + { + $payload = [ + 'languageCode' => $params['language'] ?? 'en', + 'summary' => $text, + 'topicType' => $postType, + ]; + + // Add media (GMB only supports photos) + if ($media->isNotEmpty()) { + $mediaItem = $media->first(); + $url = is_array($mediaItem) ? ($mediaItem['url'] ?? '') : $mediaItem; + + if ($url) { + $payload['media'] = [ + 'mediaFormat' => 'PHOTO', + 'sourceUrl' => $url, + ]; + } + } + + // Call to action + if (! empty($params['cta_type']) && ! empty($params['cta_url'])) { + $payload['callToAction'] = [ + 'actionType' => $params['cta_type'], // BOOK, ORDER, SHOP, LEARN_MORE, SIGN_UP, CALL + 'url' => $params['cta_url'], + ]; + } + + // Event details + if ($postType === 'EVENT' && ! empty($params['event_title'])) { + $payload['event'] = [ + 'title' => $params['event_title'], + 'schedule' => [], + ]; + + if (! empty($params['event_start'])) { + $payload['event']['schedule']['startDate'] = $this->formatDate($params['event_start']); + $payload['event']['schedule']['startTime'] = $this->formatTime($params['event_start']); + } + + if (! empty($params['event_end'])) { + $payload['event']['schedule']['endDate'] = $this->formatDate($params['event_end']); + $payload['event']['schedule']['endTime'] = $this->formatTime($params['event_end']); + } + } + + // Offer details + if ($postType === 'OFFER') { + $payload['offer'] = []; + + if (! empty($params['coupon_code'])) { + $payload['offer']['couponCode'] = $params['coupon_code']; + } + + if (! empty($params['redeem_url'])) { + $payload['offer']['redeemOnlineUrl'] = $params['redeem_url']; + } + + if (! empty($params['terms'])) { + $payload['offer']['termsConditions'] = $params['terms']; + } + } + + return $payload; + } + + /** + * Format datetime to GMB date array. + */ + private function formatDate(string $datetime): array + { + $date = new \DateTime($datetime); + + return [ + 'year' => (int) $date->format('Y'), + 'month' => (int) $date->format('m'), + 'day' => (int) $date->format('d'), + ]; + } + + /** + * Format datetime to GMB time array. + */ + private function formatTime(string $datetime): array + { + $date = new \DateTime($datetime); + + return [ + 'hours' => (int) $date->format('H'), + 'minutes' => (int) $date->format('i'), + 'seconds' => 0, + 'nanos' => 0, + ]; + } + + /** + * GMB posts don't have public URLs. + */ + public static function externalPostUrl(string $locationName, string $postId): string + { + return ''; + } +} diff --git a/src/GoogleMyBusiness/Read.php b/src/GoogleMyBusiness/Read.php new file mode 100644 index 0000000..02f8e96 --- /dev/null +++ b/src/GoogleMyBusiness/Read.php @@ -0,0 +1,146 @@ +accessToken(); + if (! $accessToken) { + return $this->error('Access token is required'); + } + + $response = $this->http() + ->withToken($accessToken) + ->get(self::API_URL."/{$id}"); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['name'] ?? '', + 'summary' => $data['summary'] ?? '', + 'state' => $data['state'] ?? '', + 'topic_type' => $data['topicType'] ?? '', + 'create_time' => $data['createTime'] ?? null, + 'update_time' => $data['updateTime'] ?? null, + 'media' => $data['media'] ?? null, + 'call_to_action' => $data['callToAction'] ?? null, + ]); + } + + /** + * Get current account info. + */ + public function me(): Response + { + $accessToken = $this->accessToken(); + if (! $accessToken) { + return $this->error('Access token is required'); + } + + $response = $this->http() + ->withToken($accessToken) + ->get(self::API_URL.'/accounts'); + + return $this->fromHttp($response, function ($data) { + $accounts = $data['accounts'] ?? []; + + if (empty($accounts)) { + return [ + 'id' => null, + 'name' => null, + 'error' => 'No business accounts found', + ]; + } + + // Return first account as primary + $account = $accounts[0]; + + return [ + 'id' => $account['name'] ?? '', + 'name' => $account['accountName'] ?? 'Google Business Profile', + 'type' => $account['type'] ?? '', + 'role' => $account['role'] ?? '', + 'state' => $account['state']['status'] ?? '', + ]; + }); + } + + /** + * List accounts. + */ + public function list(array $params = []): Response + { + $accessToken = $this->accessToken(); + if (! $accessToken) { + return $this->error('Access token is required'); + } + + $response = $this->http() + ->withToken($accessToken) + ->get(self::API_URL.'/accounts'); + + return $this->fromHttp($response, function ($data) { + return [ + 'accounts' => array_map(fn ($account) => [ + 'id' => $account['name'] ?? '', + 'name' => $account['accountName'] ?? '', + 'type' => $account['type'] ?? '', + 'role' => $account['role'] ?? '', + ], $data['accounts'] ?? []), + ]; + }); + } + + /** + * List local posts for a location. + */ + public function posts(string $locationName, array $params = []): Response + { + $accessToken = $this->accessToken(); + if (! $accessToken) { + return $this->error('Access token is required'); + } + + $response = $this->http() + ->withToken($accessToken) + ->get(self::API_URL."/{$locationName}/localPosts", [ + 'pageSize' => $params['per_page'] ?? 10, + 'pageToken' => $params['page_token'] ?? null, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'posts' => array_map(fn ($post) => [ + 'id' => $post['name'] ?? '', + 'summary' => $post['summary'] ?? '', + 'state' => $post['state'] ?? '', + 'topic_type' => $post['topicType'] ?? '', + 'create_time' => $post['createTime'] ?? null, + ], $data['localPosts'] ?? []), + 'next_page_token' => $data['nextPageToken'] ?? null, + ]); + } +}