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'); + } +}