From 063ea37c50cabe374b1efa1c9c0eb6fe2b6917a0 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 02:58:16 +0200 Subject: [PATCH] feat(google-business): manage a connected Business Profile location A googleBusiness() resource covering the profile, attributes, food menus, services, photos, place action links, verification, performance and search keywords, plus assign, which hands the location to another workspace. Query lists now repeat the bare parameter; http_build_query would have indexed daily_metrics as daily_metrics[0], a name the API does not read. --- README.md | 19 ++ src/Client.php | 9 + src/Http/HttpClient.php | 18 +- src/Resource/GoogleBusinessResource.php | 317 ++++++++++++++++++++++++ tests/GoogleBusinessTest.php | 117 +++++++++ 5 files changed, 474 insertions(+), 6 deletions(-) create mode 100644 src/Resource/GoogleBusinessResource.php create mode 100644 tests/GoogleBusinessTest.php diff --git a/README.md b/README.md index 446aa27..bb734bb 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,25 @@ $client->accounts()->addDiscordMemberRole('acc_2', $role->id, $members[0]->id); $client->accounts()->sendDiscordDm('acc_2', $members[0]->id, 'Welcome aboard'); ``` +## Google Business Profile + +Manage a connected Business Profile location: the profile, attributes, food menus, services, photos, action links, verification and performance. + +```php +$location = $client->googleBusiness()->getLocation('acc_1'); +$client->googleBusiness()->updateLocation('acc_1', title: 'Corner Bakery', websiteUri: 'https://yourbrand.com'); + +// Photos come from your media library, JPEG or PNG. +$client->googleBusiness()->addMedia('acc_1', $mediaId, 'INTERIOR'); + +$client->googleBusiness()->createPlaceAction('acc_1', 'https://yourbrand.com/book', 'APPOINTMENT'); + +$metrics = $client->googleBusiness()->getPerformance('acc_1', '2026-09-01', '2026-09-30'); +$terms = $client->googleBusiness()->getSearchKeywords('acc_1', '2026-08-01', '2026-09-01'); +``` + +Responses relay Google's own shape as plain arrays. Reads need the `accounts` scope, writes `publish` as well. Every call fails with a 503 `configuration_error` until Google grants the deployment Business Profile API access. + ## Account groups ```php diff --git a/src/Client.php b/src/Client.php index 2853e8e..232d8b2 100644 --- a/src/Client.php +++ b/src/Client.php @@ -12,6 +12,7 @@ use Fopost\Sdk\Resource\AiResource; use Fopost\Sdk\Resource\BroadcastsResource; use Fopost\Sdk\Resource\ContactsResource; +use Fopost\Sdk\Resource\GoogleBusinessResource; use Fopost\Sdk\Resource\InboxResource; use Fopost\Sdk\Resource\ActivityResource; use Fopost\Sdk\Resource\KnowledgeResource; @@ -55,6 +56,7 @@ final class Client private readonly AdsResource $ads; private readonly MediaResource $media; private readonly ValidateResource $validate; + private readonly GoogleBusinessResource $googleBusiness; public function __construct( ?string $apiKey = null, @@ -87,6 +89,7 @@ public function __construct( $this->ads = new AdsResource($this->http); $this->media = new MediaResource($this->http); $this->validate = new ValidateResource($this->http); + $this->googleBusiness = new GoogleBusinessResource($this->http); } public function posts(): PostsResource @@ -164,6 +167,12 @@ public function validate(): ValidateResource return $this->validate; } + /** Manage a connected Google Business Profile location. */ + public function googleBusiness(): GoogleBusinessResource + { + return $this->googleBusiness; + } + public function baseUrl(): string { return $this->http->baseUrl(); diff --git a/src/Http/HttpClient.php b/src/Http/HttpClient.php index 680ddff..66749e6 100644 --- a/src/Http/HttpClient.php +++ b/src/Http/HttpClient.php @@ -90,19 +90,25 @@ public function url(string $path, ?array $params = null): string { $url = str_contains($path, '://') ? $path : $this->baseUrl . '/' . ltrim($path, '/'); - $clean = []; + $pairs = []; foreach ($params ?? [] as $key => $value) { if ($value === null) { continue; } - if (is_bool($value)) { - $value = $value ? 'true' : 'false'; + // A list repeats the bare parameter; http_build_query would index + // it as `key[0]=`, which the API reads as a different name. + foreach (is_array($value) ? $value : [$value] as $item) { + if (is_bool($item)) { + $item = $item ? 'true' : 'false'; + } + // urlencode, not rawurlencode: http_build_query spelt a space + // as '+', and the existing callers' URLs are asserted that way. + $pairs[] = urlencode((string) $key) . '=' . urlencode((string) $item); } - $clean[$key] = $value; } - if ($clean !== []) { - $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($clean); + if ($pairs !== []) { + $url .= (str_contains($url, '?') ? '&' : '?') . implode('&', $pairs); } return $url; diff --git a/src/Resource/GoogleBusinessResource.php b/src/Resource/GoogleBusinessResource.php new file mode 100644 index 0000000..9a770d3 --- /dev/null +++ b/src/Resource/GoogleBusinessResource.php @@ -0,0 +1,317 @@ +googleBusiness(): manage a connected Google Business Profile location. + * + * Google grants Business Profile API access per project. Until that grant lands + * on a deployment every call here fails with a 503 configuration_error. + * + * Responses relay Google's own shape, field for field, so they come back as + * plain arrays rather than models we would have to keep chasing. + */ +final class GoogleBusinessResource extends Resource +{ + /** The set fetched when a caller names no metrics. */ + public const DEFAULT_DAILY_METRICS = [ + 'BUSINESS_IMPRESSIONS_DESKTOP_MAPS', + 'BUSINESS_IMPRESSIONS_DESKTOP_SEARCH', + 'BUSINESS_IMPRESSIONS_MOBILE_MAPS', + 'BUSINESS_IMPRESSIONS_MOBILE_SEARCH', + 'CALL_CLICKS', + 'WEBSITE_CLICKS', + 'BUSINESS_DIRECTION_REQUESTS', + ]; + + /** @return array */ + public function getLocation(string $accountId): array + { + return self::asArray(self::unwrap($this->http->get("/accounts/{$accountId}/gbp/location"))); + } + + /** + * Patch the profile; an argument left out keeps its value, null clears it. + * + * @param array|Undefined $additionalPhones + * @param array>|Undefined $regularHours + * @return array + */ + public function updateLocation( + string $accountId, + string|Undefined $title = Undefined::Value, + string|null|Undefined $description = Undefined::Value, + string|null|Undefined $websiteUri = Undefined::Value, + string|null|Undefined $primaryPhone = Undefined::Value, + array|Undefined $additionalPhones = Undefined::Value, + string|null|Undefined $storeCode = Undefined::Value, + array|Undefined $regularHours = Undefined::Value, + ): array { + $body = []; + $fields = [ + 'title' => $title, + 'description' => $description, + 'website_uri' => $websiteUri, + 'primary_phone' => $primaryPhone, + 'additional_phones' => $additionalPhones, + 'store_code' => $storeCode, + 'regular_hours' => $regularHours, + ]; + foreach ($fields as $key => $value) { + if (!Undefined::is($value)) { + $body[$key] = $value; + } + } + + return self::asArray(self::unwrap( + $this->http->request('PATCH', "/accounts/{$accountId}/gbp/location", $body === [] ? (object) [] : $body), + )); + } + + /** + * The attribute values set on the location, or what Google offers it. + * + * @return array + */ + public function getAttributes( + string $accountId, + ?bool $available = null, + ?string $categoryName = null, + ?string $regionCode = null, + ?string $languageCode = null, + ): array { + return self::asArray(self::unwrap($this->http->get("/accounts/{$accountId}/gbp/attributes", [ + 'available' => $available, + 'category_name' => $categoryName, + 'region_code' => $regionCode, + 'language_code' => $languageCode, + ]))); + } + + /** + * Only the named attributes change; every other one is left alone. + * + * @param array> $attributes + * @return array + */ + public function updateAttributes(string $accountId, array $attributes): array + { + return self::asArray(self::unwrap( + $this->http->request('PATCH', "/accounts/{$accountId}/gbp/attributes", ['attributes' => $attributes]), + )); + } + + /** @return array */ + public function getMenus(string $accountId): array + { + return self::asArray(self::unwrap($this->http->get("/accounts/{$accountId}/gbp/menus"))); + } + + /** + * Google has no per-section patch, so the whole menu set is replaced. + * + * @param array> $menus + * @return array + */ + public function replaceMenus(string $accountId, array $menus): array + { + return self::asArray(self::unwrap($this->http->put("/accounts/{$accountId}/gbp/menus", ['menus' => $menus]))); + } + + /** @return array */ + public function getServices(string $accountId): array + { + return self::asArray(self::unwrap($this->http->get("/accounts/{$accountId}/gbp/services"))); + } + + /** + * @param array> $serviceItems + * @return array + */ + public function replaceServices(string $accountId, array $serviceItems): array + { + return self::asArray(self::unwrap( + $this->http->put("/accounts/{$accountId}/gbp/services", ['service_items' => $serviceItems]), + )); + } + + /** @return array */ + public function listMedia(string $accountId, ?int $pageSize = null, ?string $pageToken = null): array + { + return self::asArray(self::unwrap($this->http->get("/accounts/{$accountId}/gbp/media", [ + 'page_size' => $pageSize, + 'page_token' => $pageToken, + ]))); + } + + /** + * Add a photo from the media library; JPEG or PNG, same workspace. + * + * @return array + */ + public function addMedia( + string $accountId, + string $mediaId, + string $category = 'ADDITIONAL', + ?string $description = null, + ): array { + $body = ['media_id' => $mediaId, 'category' => $category]; + if ($description !== null) { + $body['description'] = $description; + } + + return self::asArray(self::unwrap($this->http->post("/accounts/{$accountId}/gbp/media", $body))); + } + + /** @return array */ + public function deleteMedia(string $accountId, string $mediaKey): array + { + return self::asArray(self::unwrap($this->http->delete("/accounts/{$accountId}/gbp/media/{$mediaKey}"))); + } + + /** @return array */ + public function listPlaceActions(string $accountId): array + { + return self::asArray(self::unwrap($this->http->get("/accounts/{$accountId}/gbp/place-actions"))); + } + + /** @return array */ + public function createPlaceAction( + string $accountId, + string $uri, + string $placeActionType, + ?bool $isPreferred = null, + ): array { + $body = ['uri' => $uri, 'place_action_type' => $placeActionType]; + if ($isPreferred !== null) { + $body['is_preferred'] = $isPreferred; + } + + return self::asArray(self::unwrap($this->http->post("/accounts/{$accountId}/gbp/place-actions", $body))); + } + + /** @return array */ + public function updatePlaceAction( + string $accountId, + string $linkId, + string|Undefined $uri = Undefined::Value, + bool|Undefined $isPreferred = Undefined::Value, + ): array { + $body = []; + foreach (['uri' => $uri, 'is_preferred' => $isPreferred] as $key => $value) { + if (!Undefined::is($value)) { + $body[$key] = $value; + } + } + + return self::asArray(self::unwrap($this->http->request( + 'PATCH', + "/accounts/{$accountId}/gbp/place-actions/{$linkId}", + $body === [] ? (object) [] : $body, + ))); + } + + /** @return array */ + public function deletePlaceAction(string $accountId, string $linkId): array + { + return self::asArray(self::unwrap( + $this->http->delete("/accounts/{$accountId}/gbp/place-actions/{$linkId}"), + )); + } + + /** + * The ways Google will let this location be verified. + * + * @return array + */ + public function getVerificationOptions(string $accountId, ?string $languageCode = null): array + { + return self::asArray(self::unwrap($this->http->get("/accounts/{$accountId}/gbp/verification", [ + 'language_code' => $languageCode, + ]))); + } + + /** + * The response names the pending verification to complete with the PIN. + * + * @return array + */ + public function startVerification( + string $accountId, + string $method, + ?string $languageCode = null, + ?string $phoneNumber = null, + ?string $emailAddress = null, + ?string $mailerContactName = null, + ): array { + $body = self::compact([ + 'method' => $method, + 'language_code' => $languageCode, + 'phone_number' => $phoneNumber, + 'email_address' => $emailAddress, + 'mailer_contact_name' => $mailerContactName, + ]); + + return self::asArray(self::unwrap($this->http->post("/accounts/{$accountId}/gbp/verification/start", $body))); + } + + /** @return array */ + public function completeVerification(string $accountId, string $verificationName, string $pin): array + { + return self::asArray(self::unwrap($this->http->post("/accounts/{$accountId}/gbp/verification/complete", [ + 'verification_name' => $verificationName, + 'pin' => $pin, + ]))); + } + + /** + * Daily impressions, calls, direction requests and clicks for the range. + * + * @param array|null $dailyMetrics + * @return array + */ + public function getPerformance( + string $accountId, + string $startDate, + string $endDate, + ?array $dailyMetrics = null, + ): array { + return self::asArray(self::unwrap($this->http->get("/accounts/{$accountId}/gbp/performance", [ + 'start_date' => $startDate, + 'end_date' => $endDate, + 'daily_metrics' => $dailyMetrics, + ]))); + } + + /** + * The search terms people used to find the listing, by month. + * + * @return array + */ + public function getSearchKeywords( + string $accountId, + string $startDate, + string $endDate, + ?string $pageToken = null, + ): array { + return self::asArray(self::unwrap($this->http->get("/accounts/{$accountId}/gbp/performance", [ + 'keywords' => true, + 'start_date' => $startDate, + 'end_date' => $endDate, + 'page_token' => $pageToken, + ]))); + } + + /** Hand the location to another workspace; the caller must own both. */ + public function assign(string $accountId, string $workspaceId): AccountMove + { + return AccountMove::fromArray(self::unwrap( + $this->http->post("/accounts/{$accountId}/gbp/assign", ['workspace_id' => $workspaceId]), + )); + } +} diff --git a/tests/GoogleBusinessTest.php b/tests/GoogleBusinessTest.php new file mode 100644 index 0000000..812a7e1 --- /dev/null +++ b/tests/GoogleBusinessTest.php @@ -0,0 +1,117 @@ +client(); + $gb = $client->googleBusiness(); + + $calls = [ + [fn () => $gb->getLocation('a_1'), 'GET', self::BASE . '/location'], + [fn () => $gb->updateLocation('a_1', title: 'Corner Bakery'), 'PATCH', self::BASE . '/location'], + [fn () => $gb->getAttributes('a_1'), 'GET', self::BASE . '/attributes'], + [fn () => $gb->updateAttributes('a_1', []), 'PATCH', self::BASE . '/attributes'], + [fn () => $gb->getMenus('a_1'), 'GET', self::BASE . '/menus'], + [fn () => $gb->replaceMenus('a_1', []), 'PUT', self::BASE . '/menus'], + [fn () => $gb->getServices('a_1'), 'GET', self::BASE . '/services'], + [fn () => $gb->replaceServices('a_1', []), 'PUT', self::BASE . '/services'], + [fn () => $gb->listMedia('a_1'), 'GET', self::BASE . '/media'], + [fn () => $gb->addMedia('a_1', 'm_1'), 'POST', self::BASE . '/media'], + [fn () => $gb->deleteMedia('a_1', 'CAoSL'), 'DELETE', self::BASE . '/media/CAoSL'], + [fn () => $gb->listPlaceActions('a_1'), 'GET', self::BASE . '/place-actions'], + [ + fn () => $gb->createPlaceAction('a_1', 'https://example.com/book', 'APPOINTMENT'), + 'POST', + self::BASE . '/place-actions', + ], + [ + fn () => $gb->updatePlaceAction('a_1', 'links-1', isPreferred: true), + 'PATCH', + self::BASE . '/place-actions/links-1', + ], + [fn () => $gb->deletePlaceAction('a_1', 'links-1'), 'DELETE', self::BASE . '/place-actions/links-1'], + [fn () => $gb->getVerificationOptions('a_1'), 'GET', self::BASE . '/verification'], + [fn () => $gb->startVerification('a_1', 'SMS'), 'POST', self::BASE . '/verification/start'], + [fn () => $gb->completeVerification('a_1', 'v1', '123456'), 'POST', self::BASE . '/verification/complete'], + ]; + + foreach ($calls as [$call, $method, $url]) { + $this->transport->push(200, ['data' => ['ok' => true]]); + $call(); + $this->assertSame($method, $this->transport->last()['method']); + $this->assertSame($url, $this->transport->last()['url']); + } + } + + public function testPatchesCarryOnlyTheFieldsTheCallerSet(): void + { + $this->transport->push(200, ['data' => []]); + $this->client()->googleBusiness()->updateLocation('a_1', description: null, storeCode: 'S-12'); + + $this->assertSame(['description' => null, 'store_code' => 'S-12'], $this->transport->lastJson()); + } + + public function testAPhotoIsNamedByItsLibraryId(): void + { + $this->transport->push(200, ['data' => []]); + $this->client()->googleBusiness()->addMedia('a_1', 'm_1', 'INTERIOR', 'Front counter'); + + $this->assertSame( + ['media_id' => 'm_1', 'category' => 'INTERIOR', 'description' => 'Front counter'], + $this->transport->lastJson(), + ); + } + + public function testPerformanceRepeatsTheMetricParameter(): void + { + $this->transport->push(200, ['data' => []]); + $this->client()->googleBusiness()->getPerformance( + 'a_1', + '2026-09-01', + '2026-09-07', + ['CALL_CLICKS', 'WEBSITE_CLICKS'], + ); + + $this->assertSame( + self::BASE . '/performance?start_date=2026-09-01&end_date=2026-09-07' + . '&daily_metrics=CALL_CLICKS&daily_metrics=WEBSITE_CLICKS', + $this->transport->last()['url'], + ); + } + + public function testSearchKeywordsAsksTheSameRouteForTheMonthlyTerms(): void + { + $this->transport->push(200, ['data' => []]); + $this->client()->googleBusiness()->getSearchKeywords('a_1', '2026-08-01', '2026-09-01'); + + $this->assertStringContainsString('keywords=true', $this->transport->last()['url']); + } + + public function testAssignHandsTheLocationToAnotherWorkspace(): void + { + $this->transport->push(200, ['data' => ['id' => 'a_1', 'workspace_id' => 'w_2']]); + $moved = $this->client()->googleBusiness()->assign('a_1', 'w_2'); + + $this->assertSame(self::BASE . '/assign', $this->transport->last()['url']); + $this->assertSame(['workspace_id' => 'w_2'], $this->transport->lastJson()); + $this->assertSame('a_1', $moved->id); + } + + public function testAPendingApiGrantSurfacesAs503(): void + { + $this->transport->push(503, ['error' => 'configuration_error', 'message' => 'Not available yet']); + + $this->expectException(ApiException::class); + $this->expectExceptionCode(503); + $this->client()->googleBusiness()->getLocation('a_1'); + } +}