From fa9de5a3770d55564c0d9e46176bb065f5870ebd Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 10 Aug 2026 22:47:03 -0300 Subject: [PATCH 01/47] feat: add Google Business Profile platform enum and config --- .env.example | 7 +++++ app/Enums/SocialAccount/Platform.php | 18 ++++++++++-- config/services.php | 9 ++++++ config/trypost.php | 13 +++++++++ tests/Unit/Enums/PlatformTest.php | 43 ++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 2ca8be9ce..3787dac7d 100644 --- a/.env.example +++ b/.env.example @@ -149,6 +149,12 @@ GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback" GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback" +# Google Business Profile (https://console.cloud.google.com) +# Dedicated OAuth app, isolated from YouTube +GOOGLE_BUSINESS_CLIENT_ID= +GOOGLE_BUSINESS_CLIENT_SECRET= +GOOGLE_BUSINESS_CLIENT_REDIRECT="${APP_URL}/accounts/google-business/callback" + # GitHub (https://github.com/settings/developers) # Used for GitHub login/signup GITHUB_AUTH_ENABLED=false @@ -228,6 +234,7 @@ NIGHTWATCH_TOKEN= # MASTODON_ENABLED=true # BLUESKY_ENABLED=true # TELEGRAM_ENABLED=true +# GOOGLE_BUSINESS_ENABLED=true # Media Services UNSPLASH_ACCESS_KEY= diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index e665ed453..12fdc04e2 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -22,6 +22,7 @@ enum Platform: string case Mastodon = 'mastodon'; case Telegram = 'telegram'; case Discord = 'discord'; + case GoogleBusiness = 'google_business'; /** * The social network this platform belongs to. Variants that represent the @@ -69,6 +70,7 @@ public function label(): string self::Mastodon => 'Mastodon', self::Telegram => 'Telegram', self::Discord => 'Discord', + self::GoogleBusiness => 'Google Business Profile', }; } @@ -88,6 +90,7 @@ public function color(): string self::Mastodon => '#6364FF', self::Telegram => '#26A5E4', self::Discord => '#5865F2', + self::GoogleBusiness => '#4285F4', }; } @@ -106,6 +109,7 @@ public function allowedMediaTypes(): array self::Mastodon => [MediaType::Image, MediaType::Video], self::Telegram => [MediaType::Image, MediaType::Video], self::Discord => [MediaType::Image, MediaType::Video], + self::GoogleBusiness => [MediaType::Image], }; } @@ -124,6 +128,7 @@ public function maxImages(): int self::Mastodon => 4, self::Telegram => 10, self::Discord => 10, + self::GoogleBusiness => 1, }; } @@ -147,7 +152,7 @@ public function altTextMaxLength(): ?int self::Threads => 1000, self::Pinterest => 500, self::Discord => 1024, - self::TikTok, self::YouTube, self::Telegram => null, + self::TikTok, self::YouTube, self::Telegram, self::GoogleBusiness => null, }; } @@ -181,6 +186,7 @@ public function supportsAltText(): bool * - Mastodon: 500 default; instances may be higher (we stay conservative) * - Telegram: 4096 for a text message (media captions are capped at 1024, * handled in the publisher by sending long text as its own message) + * - Google Business Profile Local Post `summary`: 1500 */ public function maxContentLength(): int { @@ -197,6 +203,7 @@ public function maxContentLength(): int self::Mastodon => 500, self::Telegram => 4096, self::Discord => 2000, + self::GoogleBusiness => 1500, }; } @@ -242,6 +249,9 @@ public function recommendedAiContentLength(): int self::Telegram => 400, // Discord — conversational community posts read best when concise self::Discord => 280, + // Google Business Profile — image does most of the work, keep the + // summary tight and scannable + self::GoogleBusiness => 300, }; } @@ -265,6 +275,7 @@ public function requiredPublishScopes(): array self::Mastodon => ['write:statuses'], self::Telegram => [], self::Discord => [], + self::GoogleBusiness => ['https://www.googleapis.com/auth/business.manage'], }; } @@ -283,6 +294,7 @@ public function supportsTextOnly(): bool self::Mastodon => true, self::Telegram => true, self::Discord => true, + self::GoogleBusiness => true, }; } @@ -324,7 +336,7 @@ public function hasTokenRefreshFlow(): bool return match ($this) { self::LinkedIn, self::LinkedInPage, self::X, self::Bluesky, self::YouTube, self::TikTok, self::Pinterest, - self::Threads, self::Instagram => true, + self::Threads, self::Instagram, self::GoogleBusiness => true, default => false, }; } @@ -352,6 +364,7 @@ public static function accessTokenExtendingPlatformValues(): array * * - X: a 2-hour access token. * - Instagram / Threads: Meta's 60-day long-lived token. + * - Google Business Profile: standard Google OAuth2 1-hour access token. * * Networks that always return expires_in (LinkedIn, TikTok, YouTube, * Pinterest), whose refresh sets a fixed lifetime directly (Bluesky), or @@ -362,6 +375,7 @@ public function defaultTokenTtlSeconds(): ?int { return match ($this) { self::X => 7200, + self::GoogleBusiness => 3600, self::Instagram, self::Threads => 5184000, default => null, }; diff --git a/config/services.php b/config/services.php index 4b5d5690f..94f3e58b0 100644 --- a/config/services.php +++ b/config/services.php @@ -117,6 +117,15 @@ 'redirect' => env('DISCORD_CLIENT_REDIRECT'), ], + // Google Business Profile — dedicated OAuth app, isolated from 'google' + // (YouTube) so adding the sensitive business.manage scope never triggers + // Google to re-review the YouTube app's already-verified scope set. + 'google-business' => [ + 'client_id' => env('GOOGLE_BUSINESS_CLIENT_ID'), + 'client_secret' => env('GOOGLE_BUSINESS_CLIENT_SECRET'), + 'redirect' => env('GOOGLE_BUSINESS_CLIENT_REDIRECT'), + ], + 'gtm' => [ 'id' => env('GTM_ID'), ], diff --git a/config/trypost.php b/config/trypost.php index acb68a9a6..9127d8cea 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -218,6 +218,19 @@ 'permissions' => env('DISCORD_PERMISSIONS', '248832'), 'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('DISCORD_SCOPES', 'bot,identify,guilds'))))), ], + 'google_business' => [ + 'enabled' => env('GOOGLE_BUSINESS_ENABLED', true), + // Account Management API — lists the Business accounts a user administers. + 'account_management_api' => env('GOOGLE_BUSINESS_ACCOUNT_MANAGEMENT_API', 'https://mybusinessaccountmanagement.googleapis.com/v1'), + // Business Information API — lists locations under an account. + 'business_information_api' => env('GOOGLE_BUSINESS_BUSINESS_INFORMATION_API', 'https://mybusinessbusinessinformation.googleapis.com/v1'), + // Legacy but still-active v4 API — the only home for Local Post create/update/delete. + 'local_posts_api' => env('GOOGLE_BUSINESS_LOCAL_POSTS_API', 'https://mybusiness.googleapis.com/v4'), + // Business Profile Performance API — location-level analytics. + 'performance_api' => env('GOOGLE_BUSINESS_PERFORMANCE_API', 'https://businessprofileperformance.googleapis.com/v1'), + // OAuth token endpoint, same host Google uses for every OAuth2 client. + 'oauth_api' => env('GOOGLE_BUSINESS_OAUTH_API', 'https://oauth2.googleapis.com'), + ], ], ]; diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index 803756c5c..961e3f46f 100644 --- a/tests/Unit/Enums/PlatformTest.php +++ b/tests/Unit/Enums/PlatformTest.php @@ -187,3 +187,46 @@ Platform::InstagramFacebook->value, ]); }); + +test('google business has correct label and color', function () { + expect(Platform::GoogleBusiness->label())->toBe('Google Business Profile'); + expect(Platform::GoogleBusiness->color())->toBe('#4285F4'); +}); + +test('google business has correct media rules', function () { + expect(Platform::GoogleBusiness->allowedMediaTypes())->toBe([MediaType::Image]); + expect(Platform::GoogleBusiness->maxImages())->toBe(1); + expect(Platform::GoogleBusiness->supportsAltText())->toBeFalse(); + expect(Platform::GoogleBusiness->maxContentLength())->toBe(1500); +}); + +test('google business supports text-only posts and requires no content by default', function () { + expect(Platform::GoogleBusiness->supportsTextOnly())->toBeTrue(); + expect(Platform::GoogleBusiness->requiresContent())->toBeFalse(); +}); + +test('google business has a real token refresh flow with a 1 hour default ttl', function () { + expect(Platform::GoogleBusiness->hasTokenRefreshFlow())->toBeTrue(); + expect(Platform::GoogleBusiness->defaultTokenTtlSeconds())->toBe(3600); +}); + +test('google business requires the business.manage scope to publish', function () { + expect(Platform::GoogleBusiness->requiredPublishScopes())->toBe([ + 'https://www.googleapis.com/auth/business.manage', + ]); +}); + +test('google business queue name is scoped to the platform', function () { + expect(Platform::GoogleBusiness->queue())->toBe('social-google_business'); +}); + +test('google business is enabled by default and connectable', function () { + expect(Platform::GoogleBusiness->isEnabled())->toBeTrue(); + expect(Platform::GoogleBusiness->isConnectable())->toBeTrue(); +}); + +test('google business can be disabled via config', function () { + config(['trypost.platforms.google_business.enabled' => false]); + + expect(Platform::GoogleBusiness->isEnabled())->toBeFalse(); +}); From 0f5cdbf7cfe6153b9b25e1806d9fde19c412054c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 10 Aug 2026 22:55:28 -0300 Subject: [PATCH 02/47] feat: add GoogleBusinessPost content type --- app/Enums/PostPlatform/ContentType.php | 9 +++++++++ lang/ar/posts.php | 4 ++++ lang/de/posts.php | 4 ++++ lang/el/posts.php | 4 ++++ lang/en/posts.php | 4 ++++ lang/es/posts.php | 4 ++++ lang/fr/posts.php | 4 ++++ lang/it/posts.php | 4 ++++ lang/ja/posts.php | 4 ++++ lang/ko/posts.php | 4 ++++ lang/nl/posts.php | 4 ++++ lang/pl/posts.php | 4 ++++ lang/pt-BR/posts.php | 4 ++++ lang/ru/posts.php | 4 ++++ lang/tr/posts.php | 4 ++++ lang/uk/posts.php | 4 ++++ lang/zh/posts.php | 4 ++++ tests/Unit/Enums/ContentTypeTest.php | 15 +++++++++++++++ 18 files changed, 88 insertions(+) diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 0d56e74cd..c6ea21c5d 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -56,6 +56,9 @@ enum ContentType: string // Discord case DiscordMessage = 'discord_message'; + // Google Business Profile + case GoogleBusinessPost = 'google_business_post'; + /** * AI generation format for an Instagram carousel. Not a content type — * carousel posts are persisted as InstagramFeed. @@ -84,6 +87,7 @@ public function label(): string self::MastodonPost => 'Post', self::TelegramPost => 'Post', self::DiscordMessage => 'Message', + self::GoogleBusinessPost => 'Post', }; } @@ -108,6 +112,7 @@ public function platform(): SocialPlatform self::MastodonPost => SocialPlatform::Mastodon, self::TelegramPost => SocialPlatform::Telegram, self::DiscordMessage => SocialPlatform::Discord, + self::GoogleBusinessPost => SocialPlatform::GoogleBusiness, }; } @@ -177,6 +182,7 @@ public function maxMediaCount(): int self::MastodonPost => 4, self::TelegramPost => 10, self::DiscordMessage => 10, + self::GoogleBusinessPost => 1, }; } @@ -458,6 +464,7 @@ public function supportsVideo(): bool self::MastodonPost => true, self::TelegramPost => true, self::DiscordMessage => true, + self::GoogleBusinessPost => false, }; } @@ -526,6 +533,7 @@ public function requiresMedia(): bool self::TelegramPost => false, self::FacebookPost => false, self::DiscordMessage => false, + self::GoogleBusinessPost => false, default => true, }; } @@ -609,6 +617,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::Mastodon => self::MastodonPost, SocialPlatform::Telegram => self::TelegramPost, SocialPlatform::Discord => self::DiscordMessage, + SocialPlatform::GoogleBusiness => self::GoogleBusinessPost, }; } } diff --git a/lang/ar/posts.php b/lang/ar/posts.php index c61611e59..3b507f9da 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -550,6 +550,10 @@ 'label' => 'رسالة', 'description' => 'رسالة إلى قناة Discord مع وسائط وتضمينات اختيارية', ], + 'google_business_post' => [ + 'label' => 'منشور', + 'description' => 'يظهر على ملفك التجاري في البحث والخرائط', + ], ], 'platforms' => [ diff --git a/lang/de/posts.php b/lang/de/posts.php index bd79bfa3e..52b722d7b 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -552,6 +552,10 @@ 'label' => 'Nachricht', 'description' => 'Nachricht an einen Discord-Kanal mit optionalen Medien & Embeds', ], + 'google_business_post' => [ + 'label' => 'Beitrag', + 'description' => 'Wird in deinem Geschäftsprofil in Suche und Karten angezeigt', + ], ], 'platforms' => [ diff --git a/lang/el/posts.php b/lang/el/posts.php index 04d8992e1..d8dda04b4 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -550,6 +550,10 @@ 'label' => 'Μήνυμα', 'description' => 'Μήνυμα σε κανάλι Discord με προαιρετικά πολυμέσα και embeds', ], + 'google_business_post' => [ + 'label' => 'Δημοσίευση', + 'description' => 'Εμφανίζεται στο Επιχειρηματικό σας Προφίλ στην Αναζήτηση και τους Χάρτες', + ], ], 'platforms' => [ diff --git a/lang/en/posts.php b/lang/en/posts.php index 4ce09e420..671a3f219 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -550,6 +550,10 @@ 'label' => 'Message', 'description' => 'Message to a Discord channel with optional media & embeds', ], + 'google_business_post' => [ + 'label' => 'Post', + 'description' => 'Appears on your Business Profile in Search and Maps', + ], ], 'platforms' => [ diff --git a/lang/es/posts.php b/lang/es/posts.php index 957d4d1a3..92e4e1093 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -550,6 +550,10 @@ 'label' => 'Mensaje', 'description' => 'Mensaje a un canal de Discord con multimedia y embeds opcionales', ], + 'google_business_post' => [ + 'label' => 'Publicación', + 'description' => 'Aparece en tu Perfil Empresarial en Búsqueda y Mapas', + ], ], 'platforms' => [ diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 1983643a7..69c09ab55 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -550,6 +550,10 @@ 'label' => 'Message', 'description' => 'Message vers un salon Discord avec médias et embeds facultatifs', ], + 'google_business_post' => [ + 'label' => 'Publication', + 'description' => 'Apparaît dans votre Profil Entreprise dans la Recherche et Cartes', + ], ], 'platforms' => [ diff --git a/lang/it/posts.php b/lang/it/posts.php index 6460e6cb2..60eb1cd2d 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -550,6 +550,10 @@ 'label' => 'Messaggio', 'description' => 'Messaggio a un canale Discord con media ed embed facoltativi', ], + 'google_business_post' => [ + 'label' => 'Pubblicazione', + 'description' => 'Appare nel tuo Profilo Aziendale in Ricerca e Mappe', + ], ], 'platforms' => [ diff --git a/lang/ja/posts.php b/lang/ja/posts.php index cdae132c2..0d42cef6b 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -550,6 +550,10 @@ 'label' => 'メッセージ', 'description' => 'メディアと埋め込み(任意)付きの Discord チャンネルへのメッセージ', ], + 'google_business_post' => [ + 'label' => '投稿', + 'description' => 'ビジネス プロフィールに検索とマップで表示されます', + ], ], 'platforms' => [ diff --git a/lang/ko/posts.php b/lang/ko/posts.php index ee4a20fa7..65670101d 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -550,6 +550,10 @@ 'label' => '메시지', 'description' => '선택적 미디어 및 임베드가 있는 Discord 채널 메시지', ], + 'google_business_post' => [ + 'label' => '게시물', + 'description' => '비즈니스 프로필에 검색 및 지도에 표시됩니다', + ], ], 'platforms' => [ diff --git a/lang/nl/posts.php b/lang/nl/posts.php index d9ef79626..1c70e3972 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -550,6 +550,10 @@ 'label' => 'Bericht', 'description' => 'Bericht naar een Discord-kanaal met optionele media en embeds', ], + 'google_business_post' => [ + 'label' => 'Bericht', + 'description' => 'Wordt weergegeven in je Bedrijfsprofiel in Zoeken en Kaarten', + ], ], 'platforms' => [ diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 3f9732a8e..79779b76b 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -550,6 +550,10 @@ 'label' => 'Wiadomość', 'description' => 'Wiadomość na kanale Discord z opcjonalnymi multimediami i osadzeniami', ], + 'google_business_post' => [ + 'label' => 'Post', + 'description' => 'Pojawia się w twoim Profilu Biznesowym w Wyszukiwaniu i Mapach', + ], ], 'platforms' => [ diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index a13828d37..34a7029d1 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -550,6 +550,10 @@ 'label' => 'Mensagem', 'description' => 'Mensagem para um canal do Discord com mídia e embeds opcionais', ], + 'google_business_post' => [ + 'label' => 'Publicação', + 'description' => 'Aparece no seu Perfil Empresarial em Pesquisa e Mapas', + ], ], 'platforms' => [ diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 9787313b2..d826a3ded 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -550,6 +550,10 @@ 'label' => 'Сообщение', 'description' => 'Сообщение в канал Discord с опциональным медиа и встраиваниями', ], + 'google_business_post' => [ + 'label' => 'Пост', + 'description' => 'Отображается в вашем Профиле компании в Поиске и Картах', + ], ], 'platforms' => [ diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 5cb220c56..0a39c1774 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -552,6 +552,10 @@ 'label' => 'Mesaj', 'description' => 'İsteğe bağlı medya ve yerleştirmeler içeren Discord kanalına mesaj', ], + 'google_business_post' => [ + 'label' => 'Gönderi', + 'description' => 'İşletme Profilinde Arama ve Haritalar\'da görünür', + ], ], 'platforms' => [ diff --git a/lang/uk/posts.php b/lang/uk/posts.php index 8dc88b8c1..7ff1b27bf 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -550,6 +550,10 @@ 'label' => 'Повідомлення', 'description' => 'Повідомлення в канал Discord із необов’язковим медіа та вбудовуваннями', ], + 'google_business_post' => [ + 'label' => 'Публікація', + 'description' => 'Відображається у вашому Профілі компанії в Пошуку та Картах', + ], ], 'platforms' => [ diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 449ed49e7..39482adbd 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -550,6 +550,10 @@ 'label' => '消息', 'description' => '发送到 Discord 频道的消息,可附带媒体和嵌入内容', ], + 'google_business_post' => [ + 'label' => '帖子', + 'description' => '在搜索和地图中显示在您的商业资料中', + ], ], 'platforms' => [ diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index aa87067af..af04169a1 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -351,3 +351,18 @@ expect(ContentType::forPlatform(Platform::LinkedIn))->toHaveCount(1)->toContain(ContentType::LinkedInPost); expect(ContentType::forPlatform(Platform::LinkedInPage))->toHaveCount(1)->toContain(ContentType::LinkedInPagePost); }); + +test('google business post maps to the google business platform', function () { + expect(ContentType::GoogleBusinessPost->platform())->toBe(Platform::GoogleBusiness); +}); + +test('google business post media rules allow at most one image, no video', function () { + expect(ContentType::GoogleBusinessPost->maxMediaCount())->toBe(1); + expect(ContentType::GoogleBusinessPost->supportsVideo())->toBeFalse(); + expect(ContentType::GoogleBusinessPost->supportsImage())->toBeTrue(); + expect(ContentType::GoogleBusinessPost->requiresMedia())->toBeFalse(); +}); + +test('google business post is the default content type for the platform', function () { + expect(ContentType::defaultFor(Platform::GoogleBusiness))->toBe(ContentType::GoogleBusinessPost); +}); From ddba0b1a2ebd38bce429b9887b2e0026785b8842 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 10 Aug 2026 23:03:37 -0300 Subject: [PATCH 03/47] feat: add Google Business Profile post meta validation rules --- app/Support/PostPlatformMetaRules.php | 27 ++++++++++++++ database/factories/PostPlatformFactory.php | 8 +++++ database/factories/SocialAccountFactory.php | 14 ++++++++ lang/ar/posts.php | 35 ++++++++++++++++++ lang/de/posts.php | 35 ++++++++++++++++++ lang/el/posts.php | 35 ++++++++++++++++++ lang/en/posts.php | 35 ++++++++++++++++++ lang/es/posts.php | 35 ++++++++++++++++++ lang/fr/posts.php | 35 ++++++++++++++++++ lang/it/posts.php | 35 ++++++++++++++++++ lang/ja/posts.php | 35 ++++++++++++++++++ lang/ko/posts.php | 35 ++++++++++++++++++ lang/nl/posts.php | 35 ++++++++++++++++++ lang/pl/posts.php | 35 ++++++++++++++++++ lang/pt-BR/posts.php | 35 ++++++++++++++++++ lang/ru/posts.php | 35 ++++++++++++++++++ lang/tr/posts.php | 35 ++++++++++++++++++ lang/uk/posts.php | 35 ++++++++++++++++++ lang/zh/posts.php | 35 ++++++++++++++++++ tests/Unit/PostPlatformMetaRulesTest.php | 39 +++++++++++++++++++++ 20 files changed, 648 insertions(+) diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index 15cc71046..e43be4f23 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -76,6 +76,22 @@ public static function rules(): array 'platforms.*.meta.embeds.*.url' => ['sometimes', 'nullable', 'url'], 'platforms.*.meta.embeds.*.image' => ['sometimes', 'nullable', 'url'], 'platforms.*.meta.embeds.*.color' => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9A-Fa-f]{6}$/'], + + // Google Business Profile + 'platforms.*.meta.topic_type' => ['sometimes', 'nullable', 'string', Rule::in(['STANDARD', 'EVENT', 'OFFER'])], + 'platforms.*.meta.call_to_action' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.call_to_action.action_type' => ['sometimes', 'nullable', 'string', Rule::in(['NONE', 'BOOK', 'ORDER', 'SHOP', 'LEARN_MORE', 'SIGN_UP', 'GET_OFFER', 'CALL'])], + 'platforms.*.meta.call_to_action.url' => ['required_unless:platforms.*.meta.call_to_action.action_type,NONE,CALL', 'nullable', 'url:http,https', 'max:2048'], + 'platforms.*.meta.event' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.event.title' => ['sometimes', 'nullable', 'string', 'max:100'], + 'platforms.*.meta.event.start_date' => ['sometimes', 'nullable', 'date'], + 'platforms.*.meta.event.end_date' => ['sometimes', 'nullable', 'date', 'after_or_equal:platforms.*.meta.event.start_date'], + 'platforms.*.meta.event.start_time' => ['sometimes', 'nullable', 'date_format:H:i'], + 'platforms.*.meta.event.end_time' => ['sometimes', 'nullable', 'date_format:H:i'], + 'platforms.*.meta.offer' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.offer.coupon_code' => ['sometimes', 'nullable', 'string', 'max:58'], + 'platforms.*.meta.offer.redeem_online_url' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + 'platforms.*.meta.offer.terms_conditions' => ['sometimes', 'nullable', 'string', 'max:5000'], ]; } @@ -103,6 +119,8 @@ public static function attributes(): array return [ 'platforms.*.meta.title' => __('posts.form.pinterest.title'), 'platforms.*.meta.link' => __('posts.form.pinterest.link'), + 'platforms.*.meta.event.title' => __('posts.form.google_business.event_title'), + 'platforms.*.meta.call_to_action.url' => __('posts.form.google_business.cta_url'), ]; } @@ -166,6 +184,15 @@ private static function requiredMetaViolation(?Platform $platform, mixed $meta): $platform === Platform::TikTok && blank(data_get($meta, 'privacy_level')) => ['privacy_level', trans('posts.form.tiktok.privacy_required')], $platform === Platform::Pinterest && blank(data_get($meta, 'board_id')) => ['board_id', trans('posts.form.pinterest.board_required')], $platform === Platform::Discord && blank(data_get($meta, 'channel_id')) => ['channel_id', trans('posts.form.discord.channel_required')], + $platform === Platform::GoogleBusiness + && data_get($meta, 'topic_type', 'STANDARD') === 'EVENT' + && blank(data_get($meta, 'event.title')) => ['event.title', trans('posts.form.google_business.event_title_required')], + $platform === Platform::GoogleBusiness + && data_get($meta, 'topic_type', 'STANDARD') === 'EVENT' + && blank(data_get($meta, 'event.start_date')) => ['event.start_date', trans('posts.form.google_business.event_start_date_required')], + $platform === Platform::GoogleBusiness + && data_get($meta, 'topic_type', 'STANDARD') === 'EVENT' + && blank(data_get($meta, 'event.end_date')) => ['event.end_date', trans('posts.form.google_business.event_end_date_required')], default => null, }; } diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index cb39e970f..7312e6ea8 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -131,6 +131,14 @@ public function pinterest(): static ]); } + public function googleBusiness(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::GoogleBusiness, + 'content_type' => ContentType::GoogleBusinessPost, + ]); + } + public function pinterestVideoPin(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index 927aba12a..3087f9fb9 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -109,6 +109,20 @@ public function pinterest(): static ]); } + public function googleBusiness(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::GoogleBusiness, + 'scopes' => Platform::GoogleBusiness->requiredPublishScopes(), + 'meta' => [ + 'location_id' => 'accounts/123456789/locations/987654321', + 'account_name' => 'accounts/123456789', + 'location_name' => 'locations/987654321', + 'google_user_id' => 'google-user-123', + ], + ]); + } + public function bluesky(): static { return $this->state(fn (array $attributes) => [ diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 3b507f9da..10d84dcec 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -186,6 +186,41 @@ 'embed_image' => 'رابط الصورة', 'embed_color' => 'اللون', ], + 'google_business' => [ + 'settings' => 'إعدادات ملف Google Business Profile', + 'posting_to' => 'النشر إلى', + 'topic_type_label' => 'نوع المنشور', + 'topic_type' => [ + 'standard' => 'تحديث', + 'event' => 'حدث', + 'offer' => 'عرض', + ], + 'cta_label' => 'زر', + 'cta_none' => 'بدون زر', + 'cta' => [ + 'book' => 'احجز', + 'order' => 'اطلب عبر الإنترنت', + 'shop' => 'اشتر', + 'learn_more' => 'تعرف على المزيد', + 'sign_up' => 'اشترك', + 'get_offer' => 'احصل على العرض', + 'call' => 'اتصل الآن', + ], + 'cta_url' => 'رابط الزر', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'عنوان الحدث', + 'event_title_placeholder' => 'عرض الصيف', + 'event_title_required' => 'أدخل عنوان الحدث.', + 'event_start_date' => 'تاريخ البدء', + 'event_start_date_required' => 'أدخل تاريخ البدء.', + 'event_end_date' => 'تاريخ الانتهاء', + 'event_end_date_required' => 'أدخل تاريخ الانتهاء.', + 'event_start_time' => 'وقت البدء', + 'event_end_time' => 'وقت الانتهاء', + 'offer_coupon_code' => 'رمز القسيمة', + 'offer_redeem_url' => 'رابط الاسترداد عبر الإنترنت', + 'offer_terms' => 'الشروط والأحكام', + ], 'warnings' => [ 'no_variant' => 'اختر نوع منشور للمتابعة.', 'requires_media' => 'يتطلب هذا النوع من المنشورات صورة أو فيديو واحدًا على الأقل.', diff --git a/lang/de/posts.php b/lang/de/posts.php index 52b722d7b..4b2cd36f0 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -188,6 +188,41 @@ 'embed_image' => 'Bild-URL', 'embed_color' => 'Farbe', ], + 'google_business' => [ + 'settings' => 'Google Business Profile-Einstellungen', + 'posting_to' => 'Veröffentlichen auf', + 'topic_type_label' => 'Beitragstyp', + 'topic_type' => [ + 'standard' => 'Aktualisierung', + 'event' => 'Veranstaltung', + 'offer' => 'Angebot', + ], + 'cta_label' => 'Schaltfläche', + 'cta_none' => 'Keine Schaltfläche', + 'cta' => [ + 'book' => 'Buchen', + 'order' => 'Online bestellen', + 'shop' => 'Kaufen', + 'learn_more' => 'Mehr erfahren', + 'sign_up' => 'Anmelden', + 'get_offer' => 'Angebot nutzen', + 'call' => 'Jetzt anrufen', + ], + 'cta_url' => 'Schaltflächenlink', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'Veranstaltungstitel', + 'event_title_placeholder' => 'Sommerverkauf', + 'event_title_required' => 'Gib einen Veranstaltungstitel ein.', + 'event_start_date' => 'Startdatum', + 'event_start_date_required' => 'Gib ein Startdatum ein.', + 'event_end_date' => 'Enddatum', + 'event_end_date_required' => 'Gib ein Enddatum ein.', + 'event_start_time' => 'Startzeit', + 'event_end_time' => 'Endzeit', + 'offer_coupon_code' => 'Gutscheincode', + 'offer_redeem_url' => 'Online-Einlösungslink', + 'offer_terms' => 'Geschäftsbedingungen', + ], 'warnings' => [ 'no_variant' => 'Wähle einen Beitragstyp, um fortzufahren.', 'requires_media' => 'Dieser Beitragstyp erfordert mindestens ein Bild oder Video.', diff --git a/lang/el/posts.php b/lang/el/posts.php index d8dda04b4..33de913ab 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -186,6 +186,41 @@ 'embed_image' => 'URL εικόνας', 'embed_color' => 'Χρώμα', ], + 'google_business' => [ + 'settings' => 'Ρυθμίσεις Google Business Profile', + 'posting_to' => 'Δημοσίευση σε', + 'topic_type_label' => 'Τύπος δημοσίευσης', + 'topic_type' => [ + 'standard' => 'Ενημέρωση', + 'event' => 'Εκδήλωση', + 'offer' => 'Προσφορά', + ], + 'cta_label' => 'Κουμπί', + 'cta_none' => 'Χωρίς κουμπί', + 'cta' => [ + 'book' => 'Κράτηση', + 'order' => 'Παραγγελία online', + 'shop' => 'Αγορά', + 'learn_more' => 'Μάθετε περισσότερα', + 'sign_up' => 'Εγγραφή', + 'get_offer' => 'Λάβετε προσφορά', + 'call' => 'Καλέστε τώρα', + ], + 'cta_url' => 'Σύνδεσμος κουμπιού', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'Τίτλος εκδήλωσης', + 'event_title_placeholder' => 'Θερινή έκπτωση', + 'event_title_required' => 'Εισάγετε τίτλο εκδήλωσης.', + 'event_start_date' => 'Ημερομηνία έναρξης', + 'event_start_date_required' => 'Εισάγετε ημερομηνία έναρξης.', + 'event_end_date' => 'Ημερομηνία λήξης', + 'event_end_date_required' => 'Εισάγετε ημερομηνία λήξης.', + 'event_start_time' => 'Ώρα έναρξης', + 'event_end_time' => 'Ώρα λήξης', + 'offer_coupon_code' => 'Κωδικός κουπονιού', + 'offer_redeem_url' => 'Σύνδεσμος εξαργύρωσης online', + 'offer_terms' => 'Όροι και προϋποθέσεις', + ], 'warnings' => [ 'no_variant' => 'Επιλέξτε έναν τύπο δημοσίευσης για να συνεχίσετε.', 'requires_media' => 'Αυτός ο τύπος δημοσίευσης απαιτεί τουλάχιστον μία εικόνα ή βίντεο.', diff --git a/lang/en/posts.php b/lang/en/posts.php index 671a3f219..a8ca58a7f 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -186,6 +186,41 @@ 'embed_image' => 'Image URL', 'embed_color' => 'Color', ], + 'google_business' => [ + 'settings' => 'Google Business Profile Settings', + 'posting_to' => 'Posting to', + 'topic_type_label' => 'Post type', + 'topic_type' => [ + 'standard' => 'Update', + 'event' => 'Event', + 'offer' => 'Offer', + ], + 'cta_label' => 'Button', + 'cta_none' => 'No button', + 'cta' => [ + 'book' => 'Book', + 'order' => 'Order online', + 'shop' => 'Buy', + 'learn_more' => 'Learn more', + 'sign_up' => 'Sign up', + 'get_offer' => 'Redeem offer', + 'call' => 'Call now', + ], + 'cta_url' => 'Button link', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'Event title', + 'event_title_placeholder' => 'Summer sale', + 'event_title_required' => 'Enter an event title.', + 'event_start_date' => 'Start date', + 'event_start_date_required' => 'Enter an event start date.', + 'event_end_date' => 'End date', + 'event_end_date_required' => 'Enter an event end date.', + 'event_start_time' => 'Start time', + 'event_end_time' => 'End time', + 'offer_coupon_code' => 'Coupon code', + 'offer_redeem_url' => 'Redeem online link', + 'offer_terms' => 'Terms & conditions', + ], 'warnings' => [ 'no_variant' => 'Pick a post type to continue.', 'requires_media' => 'This post type requires at least one image or video.', diff --git a/lang/es/posts.php b/lang/es/posts.php index 92e4e1093..1eb0e3d05 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -186,6 +186,41 @@ 'embed_image' => 'URL de la imagen', 'embed_color' => 'Color', ], + 'google_business' => [ + 'settings' => 'Configuración de Google Business Profile', + 'posting_to' => 'Publicando en', + 'topic_type_label' => 'Tipo de publicación', + 'topic_type' => [ + 'standard' => 'Actualización', + 'event' => 'Evento', + 'offer' => 'Oferta', + ], + 'cta_label' => 'Botón', + 'cta_none' => 'Sin botón', + 'cta' => [ + 'book' => 'Reservar', + 'order' => 'Pedir online', + 'shop' => 'Comprar', + 'learn_more' => 'Más información', + 'sign_up' => 'Registrarse', + 'get_offer' => 'Canjear oferta', + 'call' => 'Llamar ahora', + ], + 'cta_url' => 'Enlace del botón', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'Título del evento', + 'event_title_placeholder' => 'Venta de verano', + 'event_title_required' => 'Ingresa un título de evento.', + 'event_start_date' => 'Fecha de inicio', + 'event_start_date_required' => 'Ingresa una fecha de inicio.', + 'event_end_date' => 'Fecha de finalización', + 'event_end_date_required' => 'Ingresa una fecha de finalización.', + 'event_start_time' => 'Hora de inicio', + 'event_end_time' => 'Hora de finalización', + 'offer_coupon_code' => 'Código de cupón', + 'offer_redeem_url' => 'Enlace para canjear online', + 'offer_terms' => 'Términos y condiciones', + ], 'warnings' => [ 'no_variant' => 'Elige un tipo de publicación para continuar.', 'requires_media' => 'Este tipo requiere al menos una imagen o video.', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 69c09ab55..6c276b900 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -186,6 +186,41 @@ 'embed_image' => 'URL de l\'image', 'embed_color' => 'Couleur', ], + 'google_business' => [ + 'settings' => 'Paramètres de Google Business Profile', + 'posting_to' => 'Publier sur', + 'topic_type_label' => 'Type de publication', + 'topic_type' => [ + 'standard' => 'Mise à jour', + 'event' => 'Événement', + 'offer' => 'Offre', + ], + 'cta_label' => 'Bouton', + 'cta_none' => 'Pas de bouton', + 'cta' => [ + 'book' => 'Réserver', + 'order' => 'Commander en ligne', + 'shop' => 'Acheter', + 'learn_more' => 'En savoir plus', + 'sign_up' => 'S\'inscrire', + 'get_offer' => 'Utiliser l\'offre', + 'call' => 'Appelez maintenant', + ], + 'cta_url' => 'Lien du bouton', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'Titre de l\'événement', + 'event_title_placeholder' => 'Vente d\'été', + 'event_title_required' => 'Entrez un titre d\'événement.', + 'event_start_date' => 'Date de début', + 'event_start_date_required' => 'Entrez une date de début.', + 'event_end_date' => 'Date de fin', + 'event_end_date_required' => 'Entrez une date de fin.', + 'event_start_time' => 'Heure de début', + 'event_end_time' => 'Heure de fin', + 'offer_coupon_code' => 'Code de coupon', + 'offer_redeem_url' => 'Lien de rachat en ligne', + 'offer_terms' => 'Conditions et termes', + ], 'warnings' => [ 'no_variant' => 'Choisissez un type de publication pour continuer.', 'requires_media' => 'Ce type de publication nécessite au moins une image ou une vidéo.', diff --git a/lang/it/posts.php b/lang/it/posts.php index 60eb1cd2d..0b4012afc 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -186,6 +186,41 @@ 'embed_image' => 'URL immagine', 'embed_color' => 'Colore', ], + 'google_business' => [ + 'settings' => 'Impostazioni Google Business Profile', + 'posting_to' => 'Pubblicazione su', + 'topic_type_label' => 'Tipo di post', + 'topic_type' => [ + 'standard' => 'Aggiornamento', + 'event' => 'Evento', + 'offer' => 'Offerta', + ], + 'cta_label' => 'Pulsante', + 'cta_none' => 'Nessun pulsante', + 'cta' => [ + 'book' => 'Prenota', + 'order' => 'Ordina online', + 'shop' => 'Acquista', + 'learn_more' => 'Scopri di più', + 'sign_up' => 'Iscriviti', + 'get_offer' => 'Riscatta offerta', + 'call' => 'Chiama ora', + ], + 'cta_url' => 'Link del pulsante', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'Titolo evento', + 'event_title_placeholder' => 'Saldi estivi', + 'event_title_required' => 'Inserisci un titolo evento.', + 'event_start_date' => 'Data inizio', + 'event_start_date_required' => 'Inserisci una data inizio.', + 'event_end_date' => 'Data fine', + 'event_end_date_required' => 'Inserisci una data fine.', + 'event_start_time' => 'Ora inizio', + 'event_end_time' => 'Ora fine', + 'offer_coupon_code' => 'Codice coupon', + 'offer_redeem_url' => 'Link riscatto online', + 'offer_terms' => 'Termini e condizioni', + ], 'warnings' => [ 'no_variant' => 'Scegli un tipo di post per continuare.', 'requires_media' => 'Questo tipo di post richiede almeno un\'immagine o un video.', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index 0d42cef6b..654191ee1 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -186,6 +186,41 @@ 'embed_image' => '画像 URL', 'embed_color' => '色', ], + 'google_business' => [ + 'settings' => 'Google Business Profile 設定', + 'posting_to' => '投稿先', + 'topic_type_label' => '投稿タイプ', + 'topic_type' => [ + 'standard' => '更新', + 'event' => 'イベント', + 'offer' => 'オファー', + ], + 'cta_label' => 'ボタン', + 'cta_none' => 'ボタンなし', + 'cta' => [ + 'book' => '予約', + 'order' => 'オンラインで注文', + 'shop' => '購入', + 'learn_more' => 'もっと詳しく', + 'sign_up' => '登録', + 'get_offer' => 'オファーを利用', + 'call' => '今すぐ電話', + ], + 'cta_url' => 'ボタンリンク', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'イベントタイトル', + 'event_title_placeholder' => '夏のセール', + 'event_title_required' => 'イベントタイトルを入力してください。', + 'event_start_date' => '開始日', + 'event_start_date_required' => '開始日を入力してください。', + 'event_end_date' => '終了日', + 'event_end_date_required' => '終了日を入力してください。', + 'event_start_time' => '開始時刻', + 'event_end_time' => '終了時刻', + 'offer_coupon_code' => 'クーポンコード', + 'offer_redeem_url' => 'オンライン引き換えリンク', + 'offer_terms' => '利用規約', + ], 'warnings' => [ 'no_variant' => '続けるには投稿タイプを選択してください。', 'requires_media' => 'この投稿タイプには少なくとも 1 つの画像または動画が必要です。', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 65670101d..d6648119e 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -186,6 +186,41 @@ 'embed_image' => '이미지 URL', 'embed_color' => '색상', ], + 'google_business' => [ + 'settings' => 'Google Business Profile 설정', + 'posting_to' => '게시 대상', + 'topic_type_label' => '게시물 유형', + 'topic_type' => [ + 'standard' => '업데이트', + 'event' => '이벤트', + 'offer' => '오퍼', + ], + 'cta_label' => '버튼', + 'cta_none' => '버튼 없음', + 'cta' => [ + 'book' => '예약', + 'order' => '온라인 주문', + 'shop' => '구매', + 'learn_more' => '자세히 알아보기', + 'sign_up' => '가입', + 'get_offer' => '오퍼 받기', + 'call' => '지금 전화', + ], + 'cta_url' => '버튼 링크', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => '이벤트 제목', + 'event_title_placeholder' => '여름 세일', + 'event_title_required' => '이벤트 제목을 입력하세요.', + 'event_start_date' => '시작 날짜', + 'event_start_date_required' => '시작 날짜를 입력하세요.', + 'event_end_date' => '종료 날짜', + 'event_end_date_required' => '종료 날짜를 입력하세요.', + 'event_start_time' => '시작 시간', + 'event_end_time' => '종료 시간', + 'offer_coupon_code' => '쿠폰 코드', + 'offer_redeem_url' => '온라인 사용 링크', + 'offer_terms' => '약관', + ], 'warnings' => [ 'no_variant' => '계속하려면 게시물 유형을 선택하세요.', 'requires_media' => '이 게시물 유형에는 이미지 또는 동영상이 하나 이상 필요합니다.', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 1c70e3972..5fbd00266 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -186,6 +186,41 @@ 'embed_image' => 'Afbeeldings-URL', 'embed_color' => 'Kleur', ], + 'google_business' => [ + 'settings' => 'Google Business Profile-instellingen', + 'posting_to' => 'Posten naar', + 'topic_type_label' => 'Posttype', + 'topic_type' => [ + 'standard' => 'Update', + 'event' => 'Evenement', + 'offer' => 'Aanbod', + ], + 'cta_label' => 'Knop', + 'cta_none' => 'Geen knop', + 'cta' => [ + 'book' => 'Boeken', + 'order' => 'Online bestellen', + 'shop' => 'Kopen', + 'learn_more' => 'Meer informatie', + 'sign_up' => 'Aanmelden', + 'get_offer' => 'Aanbod gebruiken', + 'call' => 'Nu bellen', + ], + 'cta_url' => 'Koppelinformatie voor knop', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'Evenementtitel', + 'event_title_placeholder' => 'Zomeruitverkoop', + 'event_title_required' => 'Voer een evenementtitel in.', + 'event_start_date' => 'Startdatum', + 'event_start_date_required' => 'Voer een startdatum in.', + 'event_end_date' => 'Einddatum', + 'event_end_date_required' => 'Voer een einddatum in.', + 'event_start_time' => 'Starttijd', + 'event_end_time' => 'Eindtijd', + 'offer_coupon_code' => 'Couponcode', + 'offer_redeem_url' => 'Online inwisselingskoppeling', + 'offer_terms' => 'Voorwaarden', + ], 'warnings' => [ 'no_variant' => 'Kies een posttype om door te gaan.', 'requires_media' => 'Dit posttype vereist ten minste één afbeelding of video.', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 79779b76b..c7736b9b0 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -186,6 +186,41 @@ 'embed_image' => 'Adres URL obrazu', 'embed_color' => 'Kolor', ], + 'google_business' => [ + 'settings' => 'Ustawienia Google Business Profile', + 'posting_to' => 'Publikowanie na', + 'topic_type_label' => 'Typ posta', + 'topic_type' => [ + 'standard' => 'Aktualizacja', + 'event' => 'Wydarzenie', + 'offer' => 'Oferta', + ], + 'cta_label' => 'Przycisk', + 'cta_none' => 'Bez przycisku', + 'cta' => [ + 'book' => 'Zarezerwuj', + 'order' => 'Zamów online', + 'shop' => 'Kup', + 'learn_more' => 'Dowiedz się więcej', + 'sign_up' => 'Zarejestruj się', + 'get_offer' => 'Uzyskaj ofertę', + 'call' => 'Zadzwoń teraz', + ], + 'cta_url' => 'Link przycisku', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'Tytuł wydarzenia', + 'event_title_placeholder' => 'Letnia wyprzedaż', + 'event_title_required' => 'Wpisz tytuł wydarzenia.', + 'event_start_date' => 'Data rozpoczęcia', + 'event_start_date_required' => 'Wpisz datę rozpoczęcia.', + 'event_end_date' => 'Data zakończenia', + 'event_end_date_required' => 'Wpisz datę zakończenia.', + 'event_start_time' => 'Czas rozpoczęcia', + 'event_end_time' => 'Czas zakończenia', + 'offer_coupon_code' => 'Kod kuponu', + 'offer_redeem_url' => 'Link do realizacji online', + 'offer_terms' => 'Warunki i postanowienia', + ], 'warnings' => [ 'no_variant' => 'Wybierz typ posta, aby kontynuować.', 'requires_media' => 'Ten typ posta wymaga co najmniej jednego obrazu lub filmu.', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 34a7029d1..8ffbc3a04 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -186,6 +186,41 @@ 'embed_image' => 'URL da imagem', 'embed_color' => 'Cor', ], + 'google_business' => [ + 'settings' => 'Configurações do Google Business Profile', + 'posting_to' => 'Publicando em', + 'topic_type_label' => 'Tipo de publicação', + 'topic_type' => [ + 'standard' => 'Atualização', + 'event' => 'Evento', + 'offer' => 'Oferta', + ], + 'cta_label' => 'Botão', + 'cta_none' => 'Sem botão', + 'cta' => [ + 'book' => 'Reservar', + 'order' => 'Pedir online', + 'shop' => 'Comprar', + 'learn_more' => 'Saiba mais', + 'sign_up' => 'Inscreva-se', + 'get_offer' => 'Usar oferta', + 'call' => 'Ligar agora', + ], + 'cta_url' => 'Link do botão', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'Título do evento', + 'event_title_placeholder' => 'Venda de verão', + 'event_title_required' => 'Insira um título de evento.', + 'event_start_date' => 'Data de início', + 'event_start_date_required' => 'Insira uma data de início.', + 'event_end_date' => 'Data de término', + 'event_end_date_required' => 'Insira uma data de término.', + 'event_start_time' => 'Hora de início', + 'event_end_time' => 'Hora de término', + 'offer_coupon_code' => 'Código do cupom', + 'offer_redeem_url' => 'Link para resgatar online', + 'offer_terms' => 'Termos e condições', + ], 'warnings' => [ 'no_variant' => 'Escolha um tipo de publicação para continuar.', 'requires_media' => 'Este tipo exige pelo menos uma imagem ou vídeo.', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index d826a3ded..f7103b2be 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -186,6 +186,41 @@ 'embed_image' => 'URL изображения', 'embed_color' => 'Цвет', ], + 'google_business' => [ + 'settings' => 'Настройки Google Business Profile', + 'posting_to' => 'Публикация в', + 'topic_type_label' => 'Тип поста', + 'topic_type' => [ + 'standard' => 'Обновление', + 'event' => 'Событие', + 'offer' => 'Предложение', + ], + 'cta_label' => 'Кнопка', + 'cta_none' => 'Без кнопки', + 'cta' => [ + 'book' => 'Забронировать', + 'order' => 'Заказать онлайн', + 'shop' => 'Купить', + 'learn_more' => 'Узнать больше', + 'sign_up' => 'Зарегистрироваться', + 'get_offer' => 'Получить предложение', + 'call' => 'Позвонить сейчас', + ], + 'cta_url' => 'Ссылка кнопки', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'Название события', + 'event_title_placeholder' => 'Летняя распродажа', + 'event_title_required' => 'Введите название события.', + 'event_start_date' => 'Дата начала', + 'event_start_date_required' => 'Введите дату начала.', + 'event_end_date' => 'Дата окончания', + 'event_end_date_required' => 'Введите дату окончания.', + 'event_start_time' => 'Время начала', + 'event_end_time' => 'Время окончания', + 'offer_coupon_code' => 'Код купона', + 'offer_redeem_url' => 'Ссылка на удаление в Интернете', + 'offer_terms' => 'Условия использования', + ], 'warnings' => [ 'no_variant' => 'Выберите тип поста, чтобы продолжить.', 'requires_media' => 'Этот тип поста требует хотя бы одно изображение или видео.', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 0a39c1774..fa1c3f0b3 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -188,6 +188,41 @@ 'embed_image' => 'Görsel URL\'si', 'embed_color' => 'Renk', ], + 'google_business' => [ + 'settings' => 'Google Business Profile Ayarları', + 'posting_to' => 'Şuraya paylaşılıyor', + 'topic_type_label' => 'Gönderi türü', + 'topic_type' => [ + 'standard' => 'Güncelleme', + 'event' => 'Etkinlik', + 'offer' => 'Teklif', + ], + 'cta_label' => 'Düğme', + 'cta_none' => 'Düğme yok', + 'cta' => [ + 'book' => 'Rezervasyon', + 'order' => 'Çevrimiçi sipariş', + 'shop' => 'Satın al', + 'learn_more' => 'Daha fazla bilgi', + 'sign_up' => 'Kaydol', + 'get_offer' => 'Teklifi al', + 'call' => 'Hemen ara', + ], + 'cta_url' => 'Düğme bağlantısı', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'Etkinlik başlığı', + 'event_title_placeholder' => 'Yaz indirimi', + 'event_title_required' => 'Bir etkinlik başlığı girin.', + 'event_start_date' => 'Başlangıç tarihi', + 'event_start_date_required' => 'Başlangıç tarihini girin.', + 'event_end_date' => 'Bitiş tarihi', + 'event_end_date_required' => 'Bitiş tarihini girin.', + 'event_start_time' => 'Başlangıç saati', + 'event_end_time' => 'Bitiş saati', + 'offer_coupon_code' => 'Kupon kodu', + 'offer_redeem_url' => 'Çevrimiçi kullanım bağlantısı', + 'offer_terms' => 'Şartlar ve koşullar', + ], 'warnings' => [ 'no_variant' => 'Devam etmek için bir gönderi türü seçin.', 'requires_media' => 'Bu gönderi türü en az bir görsel veya video gerektirir.', diff --git a/lang/uk/posts.php b/lang/uk/posts.php index 7ff1b27bf..a0727d89b 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -186,6 +186,41 @@ 'embed_image' => 'URL зображення', 'embed_color' => 'Колір', ], + 'google_business' => [ + 'settings' => 'Налаштування Google Business Profile', + 'posting_to' => 'Публікація в', + 'topic_type_label' => 'Тип посту', + 'topic_type' => [ + 'standard' => 'Оновлення', + 'event' => 'Подія', + 'offer' => 'Пропозиція', + ], + 'cta_label' => 'Кнопка', + 'cta_none' => 'Без кнопки', + 'cta' => [ + 'book' => 'Забронювати', + 'order' => 'Замовити онлайн', + 'shop' => 'Купити', + 'learn_more' => 'Дізнатись більше', + 'sign_up' => 'Зареєструватися', + 'get_offer' => 'Отримати пропозицію', + 'call' => 'Позвонити зараз', + ], + 'cta_url' => 'Посилання кнопки', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => 'Назва події', + 'event_title_placeholder' => 'Літній розпродаж', + 'event_title_required' => 'Введіть назву події.', + 'event_start_date' => 'Дата початку', + 'event_start_date_required' => 'Введіть дату початку.', + 'event_end_date' => 'Дата завершення', + 'event_end_date_required' => 'Введіть дату завершення.', + 'event_start_time' => 'Час початку', + 'event_end_time' => 'Час завершення', + 'offer_coupon_code' => 'Код купона', + 'offer_redeem_url' => 'Посилання на реалізацію онлайн', + 'offer_terms' => 'Умови та положення', + ], 'warnings' => [ 'no_variant' => 'Виберіть тип поста, щоб продовжити.', 'requires_media' => 'Цей тип поста потребує принаймні одного зображення або відео.', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 39482adbd..c3ca66317 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -186,6 +186,41 @@ 'embed_image' => '图片 URL', 'embed_color' => '颜色', ], + 'google_business' => [ + 'settings' => 'Google Business Profile 设置', + 'posting_to' => '发布到', + 'topic_type_label' => '帖子类型', + 'topic_type' => [ + 'standard' => '更新', + 'event' => '活动', + 'offer' => '优惠', + ], + 'cta_label' => '按钮', + 'cta_none' => '无按钮', + 'cta' => [ + 'book' => '预订', + 'order' => '在线订购', + 'shop' => '购买', + 'learn_more' => '了解详情', + 'sign_up' => '注册', + 'get_offer' => '获取优惠', + 'call' => '立即致电', + ], + 'cta_url' => '按钮链接', + 'cta_url_placeholder' => 'https://example.com', + 'event_title' => '活动标题', + 'event_title_placeholder' => '夏季促销', + 'event_title_required' => '请输入活动标题。', + 'event_start_date' => '开始日期', + 'event_start_date_required' => '请输入开始日期。', + 'event_end_date' => '结束日期', + 'event_end_date_required' => '请输入结束日期。', + 'event_start_time' => '开始时间', + 'event_end_time' => '结束时间', + 'offer_coupon_code' => '优惠券代码', + 'offer_redeem_url' => '在线兑换链接', + 'offer_terms' => '条款和条件', + ], 'warnings' => [ 'no_variant' => '请选择一个帖子类型以继续。', 'requires_media' => '此帖子类型至少需要一张图片或一个视频。', diff --git a/tests/Unit/PostPlatformMetaRulesTest.php b/tests/Unit/PostPlatformMetaRulesTest.php index f3af6ec52..f3869f592 100644 --- a/tests/Unit/PostPlatformMetaRulesTest.php +++ b/tests/Unit/PostPlatformMetaRulesTest.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use App\Enums\SocialAccount\Platform; use App\Support\PostPlatformMetaRules; +use ReflectionMethod; test('custom meta messages only cover pinterest title and link', function () { expect(PostPlatformMetaRules::messages())->toBe([ @@ -16,6 +18,8 @@ expect(PostPlatformMetaRules::attributes())->toBe([ 'platforms.*.meta.title' => __('posts.form.pinterest.title'), 'platforms.*.meta.link' => __('posts.form.pinterest.link'), + 'platforms.*.meta.event.title' => __('posts.form.google_business.event_title'), + 'platforms.*.meta.call_to_action.url' => __('posts.form.google_business.cta_url'), ]); }); @@ -31,3 +35,38 @@ 'platforms.*.meta.link', ]); }); + +test('google business event topic type requires event title, start date, and end date to publish', function () { + $violation = (new ReflectionMethod(PostPlatformMetaRules::class, 'requiredMetaViolation')) + ->invoke(null, Platform::GoogleBusiness, ['topic_type' => 'EVENT']); + + expect($violation)->not->toBeNull(); + expect($violation[0])->toBe('event.title'); +}); + +test('google business standard topic type has no required meta', function () { + $violation = (new ReflectionMethod(PostPlatformMetaRules::class, 'requiredMetaViolation')) + ->invoke(null, Platform::GoogleBusiness, ['topic_type' => 'STANDARD']); + + expect($violation)->toBeNull(); +}); + +test('google business event topic type with all fields present has no violation', function () { + $violation = (new ReflectionMethod(PostPlatformMetaRules::class, 'requiredMetaViolation')) + ->invoke(null, Platform::GoogleBusiness, [ + 'topic_type' => 'EVENT', + 'event' => ['title' => 'Sale', 'start_date' => '2026-09-01', 'end_date' => '2026-09-02'], + ]); + + expect($violation)->toBeNull(); +}); + +test('google business meta rules validate topic_type and call_to_action shape', function () { + $rules = PostPlatformMetaRules::rules(); + + expect($rules)->toHaveKey('platforms.*.meta.topic_type'); + expect($rules)->toHaveKey('platforms.*.meta.call_to_action.action_type'); + expect($rules)->toHaveKey('platforms.*.meta.call_to_action.url'); + expect($rules)->toHaveKey('platforms.*.meta.event.title'); + expect($rules)->toHaveKey('platforms.*.meta.offer.coupon_code'); +}); From 6687f833a31505f672a0b05bd8969531d36c1ace Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 10 Aug 2026 23:07:53 -0300 Subject: [PATCH 04/47] feat: add GoogleBusinessPublishException error mapping --- .../Social/GoogleBusinessPublishException.php | 96 +++++++++++++++++++ .../GoogleBusinessPublishExceptionTest.php | 72 ++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 app/Exceptions/Social/GoogleBusinessPublishException.php create mode 100644 tests/Unit/Exceptions/Social/GoogleBusinessPublishExceptionTest.php diff --git a/app/Exceptions/Social/GoogleBusinessPublishException.php b/app/Exceptions/Social/GoogleBusinessPublishException.php new file mode 100644 index 000000000..001c4f409 --- /dev/null +++ b/app/Exceptions/Social/GoogleBusinessPublishException.php @@ -0,0 +1,96 @@ +status(); + $reason = (string) data_get($response->json(), 'error.status', ''); + $message = (string) data_get($response->json(), 'error.message', ''); + $rawResponse = $response->body(); + + if (self::isConfirmedDeadToken($response)) { + throw new TokenExpiredException( + message: $message !== '' ? $message : 'Google Business Profile access token has expired or been revoked', + platformErrorCode: $reason !== '' ? $reason : (string) $status, + ); + } + + if ($reason === 'PERMISSION_DENIED') { + return new static( + userMessage: 'Permission denied. Please reconnect and confirm access to this business location.', + category: ErrorCategory::Permission, + platformErrorCode: $reason, + rawResponse: $rawResponse, + ); + } + + if ($reason === 'NOT_FOUND') { + return new static( + userMessage: 'Business location not found. It may have been deleted — please reconnect.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: $reason, + rawResponse: $rawResponse, + ); + } + + if ($reason === 'INVALID_ARGUMENT') { + return new static( + userMessage: $message !== '' ? $message : 'Invalid post content. Please check your post details.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: $reason, + rawResponse: $rawResponse, + ); + } + + if ($reason === 'RESOURCE_EXHAUSTED' || $status === 429) { + return new static( + userMessage: 'Rate limit exceeded. Please try again later.', + category: ErrorCategory::RateLimit, + platformErrorCode: $reason !== '' ? $reason : (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status >= 500) { + return new static( + userMessage: 'Google Business Profile server error. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $message !== '' ? $message : $rawResponse, + category: ErrorCategory::Unknown, + platformErrorCode: $reason !== '' ? $reason : (string) $status, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'google_business'; + } + + /** + * Whether this response confirms the account's own access_token is dead + * (not merely a transient or content-specific failure). Shared with + * ConnectionVerifier so both the publish and verify paths agree on what + * a dead Google Business Profile token looks like. + */ + public static function isConfirmedDeadToken(Response $response): bool + { + return $response->status() === 401 + || data_get($response->json(), 'error.status') === 'UNAUTHENTICATED'; + } +} diff --git a/tests/Unit/Exceptions/Social/GoogleBusinessPublishExceptionTest.php b/tests/Unit/Exceptions/Social/GoogleBusinessPublishExceptionTest.php new file mode 100644 index 000000000..7c833039f --- /dev/null +++ b/tests/Unit/Exceptions/Social/GoogleBusinessPublishExceptionTest.php @@ -0,0 +1,72 @@ + ['status' => 'UNAUTHENTICATED', 'message' => 'bad token']], 401); + $fakeResponse = Http::fake(['*' => $response])->post('https://mybusinessaccountmanagement.googleapis.com/test'); + + GoogleBusinessPublishException::fromApiResponse($fakeResponse); +})->throws(TokenExpiredException::class); + +test('permission denied maps to the permission category', function () { + $response = Http::response(['error' => ['status' => 'PERMISSION_DENIED', 'message' => 'no access']], 403); + $fakeResponse = Http::fake(['*' => $response])->post('https://mybusinessaccountmanagement.googleapis.com/test'); + + $exception = GoogleBusinessPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Permission); +}); + +test('not found maps to the content policy category', function () { + $response = Http::response(['error' => ['status' => 'NOT_FOUND', 'message' => 'location gone']], 404); + $fakeResponse = Http::fake(['*' => $response])->post('https://mybusinessaccountmanagement.googleapis.com/test'); + + $exception = GoogleBusinessPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ContentPolicy); +}); + +test('resource exhausted maps to the rate limit category', function () { + $response = Http::response(['error' => ['status' => 'RESOURCE_EXHAUSTED', 'message' => 'slow down']], 429); + $fakeResponse = Http::fake(['*' => $response])->post('https://mybusinessaccountmanagement.googleapis.com/test'); + + $exception = GoogleBusinessPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::RateLimit); +}); + +test('500 status maps to the server error category', function () { + $response = Http::response(['error' => ['status' => 'INTERNAL', 'message' => 'oops']], 500); + $fakeResponse = Http::fake(['*' => $response])->post('https://mybusinessaccountmanagement.googleapis.com/test'); + + $exception = GoogleBusinessPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ServerError); +}); + +test('isConfirmedDeadToken is true for 401 status', function () { + $response = Http::response([], 401); + $fakeResponse = Http::fake(['*' => $response])->post('https://mybusinessaccountmanagement.googleapis.com/test'); + + expect(GoogleBusinessPublishException::isConfirmedDeadToken($fakeResponse))->toBeTrue(); +}); + +test('isConfirmedDeadToken is true for UNAUTHENTICATED status', function () { + $response = Http::response(['error' => ['status' => 'UNAUTHENTICATED']], 400); + $fakeResponse = Http::fake(['*' => $response])->post('https://mybusinessaccountmanagement.googleapis.com/test'); + + expect(GoogleBusinessPublishException::isConfirmedDeadToken($fakeResponse))->toBeTrue(); +}); + +test('isConfirmedDeadToken is false for PERMISSION_DENIED status', function () { + $response = Http::response(['error' => ['status' => 'PERMISSION_DENIED']], 403); + $fakeResponse = Http::fake(['*' => $response])->post('https://mybusinessaccountmanagement.googleapis.com/test'); + + expect(GoogleBusinessPublishException::isConfirmedDeadToken($fakeResponse))->toBeFalse(); +}); From 823ffc8ba65a04c3ac59daa51ea24b51f3673502 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 10 Aug 2026 23:11:00 -0300 Subject: [PATCH 05/47] test: add missing INVALID_ARGUMENT error mapping test --- .../Social/GoogleBusinessPublishExceptionTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/Unit/Exceptions/Social/GoogleBusinessPublishExceptionTest.php b/tests/Unit/Exceptions/Social/GoogleBusinessPublishExceptionTest.php index 7c833039f..8c860dcc6 100644 --- a/tests/Unit/Exceptions/Social/GoogleBusinessPublishExceptionTest.php +++ b/tests/Unit/Exceptions/Social/GoogleBusinessPublishExceptionTest.php @@ -32,6 +32,16 @@ expect($exception->category)->toBe(ErrorCategory::ContentPolicy); }); +test('invalid argument maps to the content policy category', function () { + $response = Http::response(['error' => ['status' => 'INVALID_ARGUMENT', 'message' => 'Invalid post format']], 400); + $fakeResponse = Http::fake(['*' => $response])->post('https://mybusinessaccountmanagement.googleapis.com/test'); + + $exception = GoogleBusinessPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ContentPolicy) + ->and($exception->userMessage)->toBe('Invalid post format'); +}); + test('resource exhausted maps to the rate limit category', function () { $response = Http::response(['error' => ['status' => 'RESOURCE_EXHAUSTED', 'message' => 'slow down']], 429); $fakeResponse = Http::fake(['*' => $response])->post('https://mybusinessaccountmanagement.googleapis.com/test'); From 89081debfb49e63a2e3c96f90ac88b327dd886e7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 10 Aug 2026 23:18:00 -0300 Subject: [PATCH 06/47] feat: add GoogleBusinessPublisher for Local Post publishing --- .../Social/GoogleBusinessPublisher.php | 311 ++++++++++++++++++ .../Social/GoogleBusinessPublisherTest.php | 188 +++++++++++ 2 files changed, 499 insertions(+) create mode 100644 app/Services/Social/GoogleBusinessPublisher.php create mode 100644 tests/Feature/Services/Social/GoogleBusinessPublisherTest.php diff --git a/app/Services/Social/GoogleBusinessPublisher.php b/app/Services/Social/GoogleBusinessPublisher.php new file mode 100644 index 000000000..230eb74fc --- /dev/null +++ b/app/Services/Social/GoogleBusinessPublisher.php @@ -0,0 +1,311 @@ +accountManagementUrl = config('trypost.platforms.google_business.account_management_api'); + $this->businessInformationUrl = config('trypost.platforms.google_business.business_information_api'); + $this->localPostsUrl = config('trypost.platforms.google_business.local_posts_api'); + } + + public function publish(PostPlatform $postPlatform): array + { + $this->validateContentLength($postPlatform); + + $account = $postPlatform->socialAccount; + + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + + $content = $postPlatform->post->content + ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, Platform::GoogleBusiness) + : ''; + + $locationId = (string) data_get($account->meta, 'location_id'); + + if (blank($locationId)) { + throw new GoogleBusinessPublishException( + userMessage: 'This Google Business Profile account has no location configured. Please reconnect it.', + category: ErrorCategory::Permission, + ); + } + + $payload = $this->buildPayload($postPlatform, $content); + + $response = $this->socialHttp()->withToken($account->access_token) + ->post("{$this->localPostsUrl}/{$locationId}/localPosts", $payload); + + if ($response->failed()) { + Log::error('Google Business Profile post creation failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + $postName = (string) data_get($response->json(), 'name'); + + return [ + 'id' => $postName, + 'url' => 'https://business.google.com/locations/'.$this->shortLocationId($locationId), + ]; + } + + /** + * @return list + */ + public function fetchLocations(string $accessToken): array + { + $locations = []; + + foreach ($this->fetchAccounts($accessToken) as $accountName) { + array_push($locations, ...$this->fetchLocationsForAccount($accessToken, $accountName)); + } + + return $locations; + } + + /** + * @return list + */ + public function getLocations(SocialAccount $account): array + { + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + + return $this->fetchLocations($account->access_token); + } + + /** + * @return array{id: string, account_name: string, location_name: string, title: string, address: ?string} + */ + private function buildPayload(PostPlatform $postPlatform, string $content): array + { + $languageCode = $postPlatform->post->workspace->content_language ?? ContentLanguage::DEFAULT->value; + $topicType = (string) data_get($postPlatform->meta, 'topic_type', 'STANDARD'); + + $payload = [ + 'languageCode' => $languageCode, + 'summary' => $content, + 'topicType' => $topicType, + ]; + + $callToActionType = data_get($postPlatform->meta, 'call_to_action.action_type'); + + if (filled($callToActionType) && $callToActionType !== 'NONE') { + $callToAction = ['actionType' => $callToActionType]; + + if ($callToActionType !== 'CALL') { + $callToAction['url'] = data_get($postPlatform->meta, 'call_to_action.url'); + } + + $payload['callToAction'] = $callToAction; + } + + $media = $postPlatform->post->mediaItems->first(fn ($item) => $item->isImage()); + + if ($media) { + $payload['media'] = [[ + 'mediaFormat' => 'PHOTO', + 'sourceUrl' => $media->url, + ]]; + } + + if ($topicType === 'EVENT') { + $payload['event'] = $this->buildEvent($postPlatform); + } + + if ($topicType === 'OFFER') { + $offer = $this->buildOffer($postPlatform); + + if ($offer !== []) { + $payload['offer'] = $offer; + } + } + + return $payload; + } + + private function buildEvent(PostPlatform $postPlatform): array + { + $schedule = [ + 'startDate' => $this->formatDate((string) data_get($postPlatform->meta, 'event.start_date')), + 'endDate' => $this->formatDate((string) data_get($postPlatform->meta, 'event.end_date')), + ]; + + if (filled(data_get($postPlatform->meta, 'event.start_time'))) { + $schedule['startTime'] = $this->formatTime((string) data_get($postPlatform->meta, 'event.start_time')); + } + + if (filled(data_get($postPlatform->meta, 'event.end_time'))) { + $schedule['endTime'] = $this->formatTime((string) data_get($postPlatform->meta, 'event.end_time')); + } + + return [ + 'title' => (string) data_get($postPlatform->meta, 'event.title'), + 'schedule' => $schedule, + ]; + } + + private function buildOffer(PostPlatform $postPlatform): array + { + return array_filter([ + 'couponCode' => data_get($postPlatform->meta, 'offer.coupon_code'), + 'redeemOnlineUrl' => data_get($postPlatform->meta, 'offer.redeem_online_url'), + 'termsConditions' => data_get($postPlatform->meta, 'offer.terms_conditions'), + ], fn ($value) => filled($value)); + } + + /** + * @return array{year: int, month: int, day: int} + */ + private function formatDate(string $date): array + { + $carbon = CarbonImmutable::parse($date); + + return ['year' => (int) $carbon->format('Y'), 'month' => (int) $carbon->format('n'), 'day' => (int) $carbon->format('j')]; + } + + /** + * @return array{hours: int, minutes: int, seconds: int, nanos: int} + */ + private function formatTime(string $time): array + { + $carbon = CarbonImmutable::parse($time); + + return ['hours' => (int) $carbon->format('G'), 'minutes' => (int) $carbon->format('i'), 'seconds' => 0, 'nanos' => 0]; + } + + /** + * Google's `locations/{id}` full resource name → the bare numeric id used + * in the dashboard link, matching postiz's synthesized URL shape. + */ + private function shortLocationId(string $locationId): string + { + $segments = explode('/', $locationId); + + return end($segments); + } + + /** + * @return list Full "accounts/{id}" resource names. + */ + private function fetchAccounts(string $accessToken): array + { + $accounts = []; + $pageToken = null; + + do { + $response = $this->socialHttp()->withToken($accessToken) + ->get("{$this->accountManagementUrl}/accounts", array_filter([ + 'pageSize' => 100, + 'pageToken' => $pageToken, + ])); + + if ($response->failed()) { + Log::error('Google Business Profile accounts fetch failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + $data = $response->json() ?? []; + + foreach (data_get($data, 'accounts', []) as $account) { + $accounts[] = (string) data_get($account, 'name'); + } + + $pageToken = data_get($data, 'nextPageToken'); + } while (filled($pageToken)); + + return $accounts; + } + + /** + * @return list + */ + private function fetchLocationsForAccount(string $accessToken, string $accountName): array + { + $locations = []; + $pageToken = null; + + do { + $response = $this->socialHttp()->withToken($accessToken) + ->get("{$this->businessInformationUrl}/{$accountName}/locations", array_filter([ + 'readMask' => 'name,title,storefrontAddress,metadata', + 'pageSize' => 100, + 'pageToken' => $pageToken, + ])); + + if ($response->failed()) { + Log::error('Google Business Profile locations fetch failed', [ + 'account' => $accountName, + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + $data = $response->json() ?? []; + + foreach (data_get($data, 'locations', []) as $location) { + $locations[] = [ + 'id' => (string) data_get($location, 'name'), + 'account_name' => $accountName, + 'location_name' => (string) data_get($location, 'name'), + 'title' => (string) data_get($location, 'title'), + 'address' => $this->formatAddress(data_get($location, 'storefrontAddress')), + ]; + } + + $pageToken = data_get($data, 'nextPageToken'); + } while (filled($pageToken)); + + return $locations; + } + + private function formatAddress(?array $storefrontAddress): ?string + { + if (! $storefrontAddress) { + return null; + } + + $lines = (array) data_get($storefrontAddress, 'addressLines', []); + $locality = data_get($storefrontAddress, 'locality'); + $parts = array_filter([implode(' ', $lines), $locality]); + + return $parts === [] ? null : implode(', ', $parts); + } + + private function handleApiError(Response $response): never + { + throw GoogleBusinessPublishException::fromApiResponse($response); + } +} diff --git a/tests/Feature/Services/Social/GoogleBusinessPublisherTest.php b/tests/Feature/Services/Social/GoogleBusinessPublisherTest.php new file mode 100644 index 000000000..4cbeaa8b9 --- /dev/null +++ b/tests/Feature/Services/Social/GoogleBusinessPublisherTest.php @@ -0,0 +1,188 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id, 'content_language' => 'en']); + + $this->socialAccount = SocialAccount::factory()->googleBusiness()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'accounts/123456789/locations/987654321', + 'token_expires_at' => now()->addHour(), + ]); + + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => 'Check out our new arrivals!', + ]); + + $this->postPlatform = PostPlatform::factory()->googleBusiness()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $this->socialAccount->id, + 'platform' => Platform::GoogleBusiness, + 'content_type' => ContentType::GoogleBusinessPost, + 'meta' => ['topic_type' => 'STANDARD'], + ]); + + $this->publisher = new GoogleBusinessPublisher; +}); + +test('publishes a standard post with the workspace content language', function () { + Http::fake([ + config('trypost.platforms.google_business.local_posts_api').'/*' => Http::response([ + 'name' => 'accounts/123456789/locations/987654321/localPosts/999', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('accounts/123456789/locations/987654321/localPosts/999'); + expect($result['url'])->toBe('https://business.google.com/locations/987654321'); + + Http::assertSent(function ($request) { + return $request->url() === config('trypost.platforms.google_business.local_posts_api').'/accounts/123456789/locations/987654321/localPosts' + && $request['languageCode'] === 'en' + && $request['summary'] === 'Check out our new arrivals!' + && $request['topicType'] === 'STANDARD' + && ! isset($request['media']); + }); +}); + +test('includes a call to action when configured', function () { + Http::fake([ + config('trypost.platforms.google_business.local_posts_api').'/*' => Http::response(['name' => 'x'], 200), + ]); + + $this->postPlatform->update([ + 'meta' => ['topic_type' => 'STANDARD', 'call_to_action' => ['action_type' => 'BOOK', 'url' => 'https://example.com/book']], + ]); + + $this->publisher->publish($this->postPlatform->fresh()); + + Http::assertSent(fn ($request) => data_get($request->data(), 'callToAction') === ['actionType' => 'BOOK', 'url' => 'https://example.com/book']); +}); + +test('call omits the url even when one is stored', function () { + Http::fake([ + config('trypost.platforms.google_business.local_posts_api').'/*' => Http::response(['name' => 'x'], 200), + ]); + + $this->postPlatform->update([ + 'meta' => ['topic_type' => 'STANDARD', 'call_to_action' => ['action_type' => 'CALL', 'url' => null]], + ]); + + $this->publisher->publish($this->postPlatform->fresh()); + + Http::assertSent(fn ($request) => data_get($request->data(), 'callToAction') === ['actionType' => 'CALL']); +}); + +test('builds an event payload for EVENT topic type', function () { + Http::fake([ + config('trypost.platforms.google_business.local_posts_api').'/*' => Http::response(['name' => 'x'], 200), + ]); + + $this->postPlatform->update([ + 'meta' => [ + 'topic_type' => 'EVENT', + 'event' => ['title' => 'Grand Opening', 'start_date' => '2026-09-01', 'end_date' => '2026-09-02'], + ], + ]); + + $this->publisher->publish($this->postPlatform->fresh()); + + Http::assertSent(function ($request) { + $event = data_get($request->data(), 'event'); + + return $event['title'] === 'Grand Opening' + && $event['schedule']['startDate'] === ['year' => 2026, 'month' => 9, 'day' => 1] + && $event['schedule']['endDate'] === ['year' => 2026, 'month' => 9, 'day' => 2]; + }); +}); + +test('rejects video media for google business posts', function () { + $this->post->update([ + 'media' => [[ + 'id' => 'test-media-id', + 'path' => 'media/2026-01/video.mp4', + 'url' => 'https://example.com/media/2026-01/video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'video.mp4', + ]], + ]); + + Http::fake([ + config('trypost.platforms.google_business.local_posts_api').'/*' => Http::response(['name' => 'x'], 200), + ]); + + $this->publisher->publish($this->postPlatform->fresh()); + + // Only a PHOTO mediaFormat is ever sent — video attachments are silently + // excluded here because ContentType::GoogleBusinessPost::supportsVideo() + // is false, so the editor's own validation already blocks scheduling one; + // this assertion is the backend's defense-in-depth for that same rule. + Http::assertSent(fn ($request) => ! isset($request['media']) || data_get($request->data(), 'media.0.mediaFormat') !== 'VIDEO'); +}); + +test('throws a structured exception on API failure', function () { + Http::fake([ + config('trypost.platforms.google_business.local_posts_api').'/*' => Http::response([ + 'error' => ['status' => 'INVALID_ARGUMENT', 'message' => 'summary too long'], + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(GoogleBusinessPublishException::class, 'summary too long'); +}); + +test('fetchLocations flattens accounts and locations across pages', function () { + Http::fake([ + config('trypost.platforms.google_business.account_management_api').'/accounts*' => Http::response([ + 'accounts' => [['name' => 'accounts/111']], + ], 200), + config('trypost.platforms.google_business.business_information_api').'/accounts/111/locations*' => Http::response([ + 'locations' => [ + ['name' => 'accounts/111/locations/222', 'title' => 'Downtown Store', 'storefrontAddress' => ['addressLines' => ['123 Main St'], 'locality' => 'Springfield']], + ], + ], 200), + ]); + + $locations = $this->publisher->fetchLocations('fake-access-token'); + + expect($locations)->toHaveCount(1); + expect($locations[0])->toMatchArray([ + 'id' => 'accounts/111/locations/222', + 'account_name' => 'accounts/111', + 'location_name' => 'accounts/111/locations/222', + 'title' => 'Downtown Store', + 'address' => '123 Main St, Springfield', + ]); + + Http::assertSent(function ($request) { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return $request->method() === 'GET' + && str_starts_with($request->url(), config('trypost.platforms.google_business.account_management_api').'/accounts') + && data_get($query, 'pageSize') === '100'; + }); + + Http::assertSent(function ($request) { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return $request->method() === 'GET' + && str_starts_with($request->url(), config('trypost.platforms.google_business.business_information_api').'/accounts/111/locations') + && data_get($query, 'readMask') === 'name,title,storefrontAddress,metadata'; + }); +}); From cf0423ffcdc2dc18dbb8849ef466e30d886c81b0 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 10 Aug 2026 23:25:22 -0300 Subject: [PATCH 07/47] feat: add GoogleBusinessAnalytics location performance metrics Implements GoogleBusinessAnalytics service to fetch five Google Business Profile performance metrics (website clicks, call clicks, direction requests, desktop map impressions, mobile map impressions) via the Performance API. Adds i18n keys to all 16 locale files. TDD: Red test passing, 3 assertions validated. --- .../Social/GoogleBusinessAnalytics.php | 88 +++++++++++++++++++ lang/ar/analytics.php | 5 ++ lang/de/analytics.php | 5 ++ lang/el/analytics.php | 5 ++ lang/en/analytics.php | 5 ++ lang/es/analytics.php | 5 ++ lang/fr/analytics.php | 5 ++ lang/it/analytics.php | 5 ++ lang/ja/analytics.php | 5 ++ lang/ko/analytics.php | 5 ++ lang/nl/analytics.php | 5 ++ lang/pl/analytics.php | 5 ++ lang/pt-BR/analytics.php | 5 ++ lang/ru/analytics.php | 5 ++ lang/tr/analytics.php | 5 ++ lang/uk/analytics.php | 5 ++ lang/zh/analytics.php | 5 ++ .../Social/GoogleBusinessAnalyticsTest.php | 56 ++++++++++++ 18 files changed, 224 insertions(+) create mode 100644 app/Services/Social/GoogleBusinessAnalytics.php create mode 100644 tests/Feature/Services/Social/GoogleBusinessAnalyticsTest.php diff --git a/app/Services/Social/GoogleBusinessAnalytics.php b/app/Services/Social/GoogleBusinessAnalytics.php new file mode 100644 index 000000000..e46c7abb7 --- /dev/null +++ b/app/Services/Social/GoogleBusinessAnalytics.php @@ -0,0 +1,88 @@ + Google metric enum => translation key. */ + private const METRICS = [ + 'WEBSITE_CLICKS' => 'analytics.metrics.website_clicks', + 'CALL_CLICKS' => 'analytics.metrics.call_clicks', + 'BUSINESS_DIRECTION_REQUESTS' => 'analytics.metrics.direction_requests', + 'BUSINESS_IMPRESSIONS_DESKTOP_MAPS' => 'analytics.metrics.desktop_map_impressions', + 'BUSINESS_IMPRESSIONS_MOBILE_MAPS' => 'analytics.metrics.mobile_map_impressions', + ]; + + private string $baseUrl; + + public function __construct() + { + $this->baseUrl = config('trypost.platforms.google_business.performance_api'); + } + + public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array + { + $since ??= now()->subDays(7); + $until ??= now(); + + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + + $locationId = (string) data_get($account->meta, 'location_id'); + + if (blank($locationId)) { + return []; + } + + $response = $this->socialHttp()->withToken($account->access_token) + ->get("{$this->baseUrl}/{$locationId}:fetchMultiDailyMetricsTimeSeries", [ + 'dailyMetrics' => array_keys(self::METRICS), + 'dailyRange.start_date.year' => $since->format('Y'), + 'dailyRange.start_date.month' => $since->format('n'), + 'dailyRange.start_date.day' => $since->format('j'), + 'dailyRange.end_date.year' => $until->format('Y'), + 'dailyRange.end_date.month' => $until->format('n'), + 'dailyRange.end_date.day' => $until->format('j'), + ]); + + if ($response->failed()) { + Log::warning('Google Business Profile analytics fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return []; + } + + $series = data_get($response->json(), 'multiDailyMetricTimeSeries.0.dailyMetricTimeSeries', []); + + $totals = collect(self::METRICS)->mapWithKeys(fn ($labelKey, $metric) => [$metric => 0])->all(); + + foreach ($series as $entry) { + $metric = data_get($entry, 'dailyMetric'); + + if (! array_key_exists($metric, $totals)) { + continue; + } + + $values = collect(data_get($entry, 'timeSeries.datedValues', [])) + ->sum(fn ($value) => (int) data_get($value, 'value', 0)); + + $totals[$metric] = $values; + } + + return collect(self::METRICS) + ->map(fn (string $labelKey, string $metric) => ['label' => __($labelKey), 'value' => $totals[$metric]]) + ->values() + ->all(); + } +} diff --git a/lang/ar/analytics.php b/lang/ar/analytics.php index 025526c5f..a84ea400f 100644 --- a/lang/ar/analytics.php +++ b/lang/ar/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'مشاهدات الفيديو', 'videos' => 'مقاطع الفيديو', 'views' => 'المشاهدات', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/de/analytics.php b/lang/de/analytics.php index 57e97d26f..a6e74698a 100644 --- a/lang/de/analytics.php +++ b/lang/de/analytics.php @@ -53,5 +53,10 @@ 'video_views' => 'Videoaufrufe', 'videos' => 'Videos', 'views' => 'Aufrufe', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/el/analytics.php b/lang/el/analytics.php index 779ecc226..05bca1ef8 100644 --- a/lang/el/analytics.php +++ b/lang/el/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Προβολές βίντεο', 'videos' => 'Βίντεο', 'views' => 'Προβολές', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/en/analytics.php b/lang/en/analytics.php index 99c8190c2..49f8929d1 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Video Views', 'videos' => 'Videos', 'views' => 'Views', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/es/analytics.php b/lang/es/analytics.php index da4d2a406..dba9c49b8 100644 --- a/lang/es/analytics.php +++ b/lang/es/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Vistas de Vídeo', 'videos' => 'Vídeos', 'views' => 'Vistas', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/fr/analytics.php b/lang/fr/analytics.php index c26d20b15..e664ec5e5 100644 --- a/lang/fr/analytics.php +++ b/lang/fr/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Vues de la vidéo', 'videos' => 'Vidéos', 'views' => 'Vues', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/it/analytics.php b/lang/it/analytics.php index 2bce4a934..322694155 100644 --- a/lang/it/analytics.php +++ b/lang/it/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Visualizzazioni video', 'videos' => 'Video', 'views' => 'Visualizzazioni', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/ja/analytics.php b/lang/ja/analytics.php index 22f015157..80e51760c 100644 --- a/lang/ja/analytics.php +++ b/lang/ja/analytics.php @@ -51,5 +51,10 @@ 'video_views' => '動画再生数', 'videos' => '動画', 'views' => '再生数', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/ko/analytics.php b/lang/ko/analytics.php index 946ba066c..895ecc8aa 100644 --- a/lang/ko/analytics.php +++ b/lang/ko/analytics.php @@ -51,5 +51,10 @@ 'video_views' => '동영상 조회수', 'videos' => '동영상', 'views' => '조회수', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/nl/analytics.php b/lang/nl/analytics.php index e62378ec1..ba7f8aa33 100644 --- a/lang/nl/analytics.php +++ b/lang/nl/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Videoweergaven', 'videos' => 'Video\'s', 'views' => 'Weergaven', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/pl/analytics.php b/lang/pl/analytics.php index 19def1bd9..632257489 100644 --- a/lang/pl/analytics.php +++ b/lang/pl/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Wyświetlenia wideo', 'videos' => 'Filmy', 'views' => 'Wyświetlenia', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/pt-BR/analytics.php b/lang/pt-BR/analytics.php index bad4c550d..c33fcecc6 100644 --- a/lang/pt-BR/analytics.php +++ b/lang/pt-BR/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Visualizações de Vídeo', 'videos' => 'Vídeos', 'views' => 'Visualizações', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/ru/analytics.php b/lang/ru/analytics.php index a0c767540..144b36457 100644 --- a/lang/ru/analytics.php +++ b/lang/ru/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Просмотры видео', 'videos' => 'Видео', 'views' => 'Просмотры', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/tr/analytics.php b/lang/tr/analytics.php index daafd3b3b..d4b74244c 100644 --- a/lang/tr/analytics.php +++ b/lang/tr/analytics.php @@ -53,5 +53,10 @@ 'video_views' => 'Video Görüntülemeleri', 'videos' => 'Videolar', 'views' => 'Görüntülemeler', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/uk/analytics.php b/lang/uk/analytics.php index 1a4891b22..5fb3a810b 100644 --- a/lang/uk/analytics.php +++ b/lang/uk/analytics.php @@ -51,5 +51,10 @@ 'video_views' => 'Перегляди відео', 'videos' => 'Відео', 'views' => 'Перегляди', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/lang/zh/analytics.php b/lang/zh/analytics.php index d9e81278d..1a5f29b1e 100644 --- a/lang/zh/analytics.php +++ b/lang/zh/analytics.php @@ -51,5 +51,10 @@ 'video_views' => '视频观看量', 'videos' => '视频', 'views' => '观看量', + 'website_clicks' => 'Website clicks', + 'call_clicks' => 'Call clicks', + 'direction_requests' => 'Direction requests', + 'desktop_map_impressions' => 'Desktop map impressions', + 'mobile_map_impressions' => 'Mobile map impressions', ], ]; diff --git a/tests/Feature/Services/Social/GoogleBusinessAnalyticsTest.php b/tests/Feature/Services/Social/GoogleBusinessAnalyticsTest.php new file mode 100644 index 000000000..a5d086234 --- /dev/null +++ b/tests/Feature/Services/Social/GoogleBusinessAnalyticsTest.php @@ -0,0 +1,56 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->socialAccount = SocialAccount::factory()->googleBusiness()->create([ + 'workspace_id' => $this->workspace->id, + 'token_expires_at' => now()->addHour(), + ]); + $this->analytics = new GoogleBusinessAnalytics; +}); + +test('fetches the five performance metrics for the account location', function () { + Http::fake([ + config('trypost.platforms.google_business.performance_api').'/*' => Http::response([ + 'multiDailyMetricTimeSeries' => [ + [ + 'dailyMetricTimeSeries' => [ + [ + 'dailyMetric' => 'WEBSITE_CLICKS', + 'timeSeries' => ['datedValues' => [['value' => '10'], ['value' => '5']]], + ], + [ + 'dailyMetric' => 'CALL_CLICKS', + 'timeSeries' => ['datedValues' => [['value' => '3']]], + ], + ], + ], + ], + ], 200), + ]); + + $metrics = $this->analytics->getMetrics($this->socialAccount); + + $labels = array_column($metrics, 'label'); + expect($labels)->toHaveCount(5); + + $websiteClicks = collect($metrics)->firstWhere('label', __('analytics.metrics.website_clicks')); + expect($websiteClicks['value'])->toBe(15); +}); + +test('returns empty array on api failure', function () { + Http::fake([ + config('trypost.platforms.google_business.performance_api').'/*' => Http::response([], 500), + ]); + + expect($this->analytics->getMetrics($this->socialAccount))->toBe([]); +}); From 25c7279008e9751c8665ba36465f762d2ef750ca Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 10 Aug 2026 23:30:35 -0300 Subject: [PATCH 08/47] fix: translate Google Business Profile metrics keys to all locales Replace English translations in 15 non-English locale files with proper translations matching each locale's existing terminology and style: - Arabic: Arabic translations - German: German translations - Greek: Greek translations - Spanish: Spanish translations - French: French translations - Italian: Italian translations - Japanese: Japanese translations - Korean: Korean translations - Dutch: Dutch translations - Polish: Polish translations - Portuguese-BR: Portuguese translations - Russian: Russian translations - Turkish: Turkish translations - Ukrainian: Ukrainian translations - Chinese: Chinese (Simplified) translations Verified by: LocalizationParityTest (18 passed) + GoogleBusinessAnalytics (2 passed) --- lang/ar/analytics.php | 10 +++++----- lang/de/analytics.php | 10 +++++----- lang/el/analytics.php | 10 +++++----- lang/es/analytics.php | 10 +++++----- lang/fr/analytics.php | 10 +++++----- lang/it/analytics.php | 10 +++++----- lang/ja/analytics.php | 10 +++++----- lang/ko/analytics.php | 10 +++++----- lang/nl/analytics.php | 10 +++++----- lang/pl/analytics.php | 10 +++++----- lang/pt-BR/analytics.php | 10 +++++----- lang/ru/analytics.php | 10 +++++----- lang/tr/analytics.php | 10 +++++----- lang/uk/analytics.php | 10 +++++----- lang/zh/analytics.php | 10 +++++----- 15 files changed, 75 insertions(+), 75 deletions(-) diff --git a/lang/ar/analytics.php b/lang/ar/analytics.php index a84ea400f..ade67406c 100644 --- a/lang/ar/analytics.php +++ b/lang/ar/analytics.php @@ -51,10 +51,10 @@ 'video_views' => 'مشاهدات الفيديو', 'videos' => 'مقاطع الفيديو', 'views' => 'المشاهدات', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => 'نقرات الموقع الإلكتروني', + 'call_clicks' => 'نقرات الاتصال', + 'direction_requests' => 'طلبات الاتجاهات', + 'desktop_map_impressions' => 'مرات ظهور الخريطة على سطح المكتب', + 'mobile_map_impressions' => 'مرات ظهور الخريطة على الجوال', ], ]; diff --git a/lang/de/analytics.php b/lang/de/analytics.php index a6e74698a..c79c8e57b 100644 --- a/lang/de/analytics.php +++ b/lang/de/analytics.php @@ -53,10 +53,10 @@ 'video_views' => 'Videoaufrufe', 'videos' => 'Videos', 'views' => 'Aufrufe', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => 'Website-Klicks', + 'call_clicks' => 'Anruf-Klicks', + 'direction_requests' => 'Routenanfragen', + 'desktop_map_impressions' => 'Kartenimpressionen auf dem Desktop', + 'mobile_map_impressions' => 'Kartenimpressionen auf Mobilgeräten', ], ]; diff --git a/lang/el/analytics.php b/lang/el/analytics.php index 05bca1ef8..70e42093a 100644 --- a/lang/el/analytics.php +++ b/lang/el/analytics.php @@ -51,10 +51,10 @@ 'video_views' => 'Προβολές βίντεο', 'videos' => 'Βίντεο', 'views' => 'Προβολές', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => 'Κλικ ιστοσελίδας', + 'call_clicks' => 'Κλικ κλήσης', + 'direction_requests' => 'Αιτήματα κατεύθυνσης', + 'desktop_map_impressions' => 'Εμφανίσεις χάρτη σε υπολογιστή', + 'mobile_map_impressions' => 'Εμφανίσεις χάρτη σε κινητό', ], ]; diff --git a/lang/es/analytics.php b/lang/es/analytics.php index dba9c49b8..e1c887418 100644 --- a/lang/es/analytics.php +++ b/lang/es/analytics.php @@ -51,10 +51,10 @@ 'video_views' => 'Vistas de Vídeo', 'videos' => 'Vídeos', 'views' => 'Vistas', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => 'Clics de Sitio Web', + 'call_clicks' => 'Clics de Llamada', + 'direction_requests' => 'Solicitudes de Dirección', + 'desktop_map_impressions' => 'Impresiones de Mapa de Escritorio', + 'mobile_map_impressions' => 'Impresiones de Mapa Móvil', ], ]; diff --git a/lang/fr/analytics.php b/lang/fr/analytics.php index e664ec5e5..1eaa169e0 100644 --- a/lang/fr/analytics.php +++ b/lang/fr/analytics.php @@ -51,10 +51,10 @@ 'video_views' => 'Vues de la vidéo', 'videos' => 'Vidéos', 'views' => 'Vues', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => 'Clics sur le site web', + 'call_clicks' => 'Clics d\'appel', + 'direction_requests' => 'Demandes d\'itinéraire', + 'desktop_map_impressions' => 'Impressions de carte sur ordinateur', + 'mobile_map_impressions' => 'Impressions de carte sur mobile', ], ]; diff --git a/lang/it/analytics.php b/lang/it/analytics.php index 322694155..5333d62af 100644 --- a/lang/it/analytics.php +++ b/lang/it/analytics.php @@ -51,10 +51,10 @@ 'video_views' => 'Visualizzazioni video', 'videos' => 'Video', 'views' => 'Visualizzazioni', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => 'Clic sul sito web', + 'call_clicks' => 'Clic sulla chiamata', + 'direction_requests' => 'Richieste di indicazioni', + 'desktop_map_impressions' => 'Impressioni della mappa su desktop', + 'mobile_map_impressions' => 'Impressioni della mappa mobile', ], ]; diff --git a/lang/ja/analytics.php b/lang/ja/analytics.php index 80e51760c..ca4837853 100644 --- a/lang/ja/analytics.php +++ b/lang/ja/analytics.php @@ -51,10 +51,10 @@ 'video_views' => '動画再生数', 'videos' => '動画', 'views' => '再生数', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => 'ウェブサイトクリック', + 'call_clicks' => '通話クリック', + 'direction_requests' => '経路リクエスト', + 'desktop_map_impressions' => 'デスクトップ地図インプレッション', + 'mobile_map_impressions' => 'モバイル地図インプレッション', ], ]; diff --git a/lang/ko/analytics.php b/lang/ko/analytics.php index 895ecc8aa..fbe72aa2d 100644 --- a/lang/ko/analytics.php +++ b/lang/ko/analytics.php @@ -51,10 +51,10 @@ 'video_views' => '동영상 조회수', 'videos' => '동영상', 'views' => '조회수', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => '웹사이트 클릭', + 'call_clicks' => '통화 클릭', + 'direction_requests' => '길찾기 요청', + 'desktop_map_impressions' => '데스크톱 지도 노출수', + 'mobile_map_impressions' => '모바일 지도 노출수', ], ]; diff --git a/lang/nl/analytics.php b/lang/nl/analytics.php index ba7f8aa33..5225b21d6 100644 --- a/lang/nl/analytics.php +++ b/lang/nl/analytics.php @@ -51,10 +51,10 @@ 'video_views' => 'Videoweergaven', 'videos' => 'Video\'s', 'views' => 'Weergaven', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => 'Websiteklikken', + 'call_clicks' => 'Oproepklikken', + 'direction_requests' => 'Routeaanvragen', + 'desktop_map_impressions' => 'Kaartweergaven op desktop', + 'mobile_map_impressions' => 'Kaartweergaven op mobiel', ], ]; diff --git a/lang/pl/analytics.php b/lang/pl/analytics.php index 632257489..872c516d2 100644 --- a/lang/pl/analytics.php +++ b/lang/pl/analytics.php @@ -51,10 +51,10 @@ 'video_views' => 'Wyświetlenia wideo', 'videos' => 'Filmy', 'views' => 'Wyświetlenia', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => 'Kliknięcia witryny', + 'call_clicks' => 'Kliknięcia połączeń', + 'direction_requests' => 'Żądania tras', + 'desktop_map_impressions' => 'Wyświetlenia map na komputerze', + 'mobile_map_impressions' => 'Wyświetlenia map na urządzeniu mobilnym', ], ]; diff --git a/lang/pt-BR/analytics.php b/lang/pt-BR/analytics.php index c33fcecc6..c3a3e05d2 100644 --- a/lang/pt-BR/analytics.php +++ b/lang/pt-BR/analytics.php @@ -51,10 +51,10 @@ 'video_views' => 'Visualizações de Vídeo', 'videos' => 'Vídeos', 'views' => 'Visualizações', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => 'Cliques no Site', + 'call_clicks' => 'Cliques de Chamada', + 'direction_requests' => 'Solicitações de Direções', + 'desktop_map_impressions' => 'Impressões de Mapa no Desktop', + 'mobile_map_impressions' => 'Impressões de Mapa no Celular', ], ]; diff --git a/lang/ru/analytics.php b/lang/ru/analytics.php index 144b36457..08b4b4707 100644 --- a/lang/ru/analytics.php +++ b/lang/ru/analytics.php @@ -51,10 +51,10 @@ 'video_views' => 'Просмотры видео', 'videos' => 'Видео', 'views' => 'Просмотры', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => 'Клики по веб-сайту', + 'call_clicks' => 'Клики на звонок', + 'direction_requests' => 'Запросы маршрутов', + 'desktop_map_impressions' => 'Показы карты на ПК', + 'mobile_map_impressions' => 'Показы карты на мобильном', ], ]; diff --git a/lang/tr/analytics.php b/lang/tr/analytics.php index d4b74244c..7eb3d76ec 100644 --- a/lang/tr/analytics.php +++ b/lang/tr/analytics.php @@ -53,10 +53,10 @@ 'video_views' => 'Video Görüntülemeleri', 'videos' => 'Videolar', 'views' => 'Görüntülemeler', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => 'Web Sitesi Tıklamaları', + 'call_clicks' => 'Arama Tıklamaları', + 'direction_requests' => 'Rota Talepleri', + 'desktop_map_impressions' => 'Masaüstü Harita Gösterimleri', + 'mobile_map_impressions' => 'Mobil Harita Gösterimleri', ], ]; diff --git a/lang/uk/analytics.php b/lang/uk/analytics.php index 5fb3a810b..ef35d95b0 100644 --- a/lang/uk/analytics.php +++ b/lang/uk/analytics.php @@ -51,10 +51,10 @@ 'video_views' => 'Перегляди відео', 'videos' => 'Відео', 'views' => 'Перегляди', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => 'Кліки по веб-сайту', + 'call_clicks' => 'Кліки на дзвінок', + 'direction_requests' => 'Запити маршрутів', + 'desktop_map_impressions' => 'Покази карти на комп\'ютері', + 'mobile_map_impressions' => 'Покази карти на мобільному', ], ]; diff --git a/lang/zh/analytics.php b/lang/zh/analytics.php index 1a5f29b1e..8a150b21f 100644 --- a/lang/zh/analytics.php +++ b/lang/zh/analytics.php @@ -51,10 +51,10 @@ 'video_views' => '视频观看量', 'videos' => '视频', 'views' => '观看量', - 'website_clicks' => 'Website clicks', - 'call_clicks' => 'Call clicks', - 'direction_requests' => 'Direction requests', - 'desktop_map_impressions' => 'Desktop map impressions', - 'mobile_map_impressions' => 'Mobile map impressions', + 'website_clicks' => '网站点击', + 'call_clicks' => '通话点击', + 'direction_requests' => '方向请求', + 'desktop_map_impressions' => '桌面地图展示量', + 'mobile_map_impressions' => '移动地图展示量', ], ]; From 0336dffe5964e5e99b2637dc54eb31b5288686fa Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 10 Aug 2026 23:34:21 -0300 Subject: [PATCH 09/47] feat: verify and refresh Google Business Profile tokens --- app/Services/Social/ConnectionVerifier.php | 50 +++++++++++++++++++ .../Social/ConnectionVerifierTest.php | 38 ++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 954e83298..54d3d0da7 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -8,6 +8,7 @@ use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\BlueskyPublishException; use App\Exceptions\Social\DiscordPublishException; +use App\Exceptions\Social\GoogleBusinessPublishException; use App\Exceptions\Social\LinkedInPublishException; use App\Exceptions\Social\MastodonPublishException; use App\Exceptions\Social\PinterestPublishException; @@ -115,6 +116,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool Platform::Mastodon => $this->verifyMastodon($account), Platform::Telegram => $this->verifyTelegram($account), Platform::Discord => $this->verifyDiscord($account), + Platform::GoogleBusiness => $this->verifyGoogleBusiness($account), }; } @@ -148,6 +150,7 @@ public function refreshToken(SocialAccount $account): void Platform::Pinterest => $this->refreshPinterestToken($account), Platform::Threads => $this->refreshThreadsToken($account), Platform::Instagram => $this->refreshInstagramToken($account), + Platform::GoogleBusiness => $this->refreshGoogleBusinessToken($account), // Facebook / InstagramFacebook use Page tokens that don't expire. // Mastodon tokens don't expire either. default => null, @@ -375,6 +378,30 @@ private function refreshInstagramToken(SocialAccount $account): void $account->refresh(); } + private function refreshGoogleBusinessToken(SocialAccount $account): void + { + if (! $account->refresh_token) { + throw new TokenExpiredException('No refresh token available for Google Business Profile account'); + } + + $response = TokenRefreshClient::for(Platform::GoogleBusiness)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.google_business.oauth_api').'/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.google-business.client_id'), + 'client_secret' => config('services.google-business.client_secret'), + ])); + + $data = $response->json(); + + $account->update([ + 'access_token' => data_get($data, 'access_token'), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + + $account->refresh(); + } + private function verifyLinkedIn(SocialAccount $account): bool { $response = Http::withToken($account->access_token) @@ -641,4 +668,27 @@ private function verifyMastodon(SocialAccount $account): bool $response->status(), ); } + + private function verifyGoogleBusiness(SocialAccount $account): bool + { + $locationId = data_get($account->meta, 'location_id'); + + $response = Http::withToken($account->access_token) + ->get(config('trypost.platforms.google_business.business_information_api')."/{$locationId}", [ + 'readMask' => 'name', + ]); + + if (GoogleBusinessPublishException::isConfirmedDeadToken($response)) { + throw new TokenExpiredException('Google Business Profile access token is invalid or expired'); + } + + if ($response->successful()) { + return true; + } + + throw new PlatformUnavailableException( + "{$account->platform->label()} verify failed ({$response->status()}).", + $response->status(), + ); + } } diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 48a86d792..96730ceea 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -1200,3 +1200,41 @@ Http::assertSentCount(1); }); + +test('verify succeeds for a healthy google business token', function () { + $account = SocialAccount::factory()->googleBusiness()->create(); + + Http::fake([ + config('trypost.platforms.google_business.business_information_api').'/*' => Http::response(['name' => $account->meta['location_id']], 200), + ]); + + expect(app(ConnectionVerifier::class)->verify($account))->toBeTrue(); +}); + +test('verify throws token expired for a dead google business token', function () { + $account = SocialAccount::factory()->googleBusiness()->create(); + + Http::fake([ + config('trypost.platforms.google_business.business_information_api').'/*' => Http::response(['error' => ['status' => 'UNAUTHENTICATED']], 401), + config('trypost.platforms.google_business.oauth_api').'/token' => Http::response(['error' => 'invalid_grant'], 400), + ]); + + expect(fn () => app(ConnectionVerifier::class)->verify($account))->toThrow(TokenExpiredException::class); +}); + +test('refreshToken exchanges the refresh token for a new access token', function () { + $account = SocialAccount::factory()->googleBusiness()->create([ + 'refresh_token' => 'refresh-abc', + ]); + + Http::fake([ + config('trypost.platforms.google_business.oauth_api').'/token' => Http::response([ + 'access_token' => 'new-access-token', + 'expires_in' => 3600, + ], 200), + ]); + + app(ConnectionVerifier::class)->refreshToken($account); + + expect($account->fresh()->access_token)->toBe('new-access-token'); +}); From d3c80ff67f134eaae0c962f93afa59c8061c05df Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 10 Aug 2026 23:38:41 -0300 Subject: [PATCH 10/47] feat: dispatch Google Business Profile posts through the publish job --- app/Jobs/PublishToSocialPlatform.php | 4 +++- .../Jobs/PublishToSocialPlatformTest.php | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 27a3a90e3..3725ac49a 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -21,6 +21,7 @@ use App\Services\Social\ConnectionVerifier; use App\Services\Social\Discord\DiscordPublisher; use App\Services\Social\FacebookPublisher; +use App\Services\Social\GoogleBusinessPublisher; use App\Services\Social\InstagramPublisher; use App\Services\Social\LinkedInPagePublisher; use App\Services\Social\LinkedInPublisher; @@ -275,7 +276,7 @@ private function safeFailureMessage(\Throwable $e): string : 'An unexpected error occurred while publishing. Please try again.'; } - private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher|GoogleBusinessPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -291,6 +292,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::Mastodon => app(MastodonPublisher::class), SocialPlatform::Telegram => app(TelegramPublisher::class), SocialPlatform::Discord => app(DiscordPublisher::class), + SocialPlatform::GoogleBusiness => app(GoogleBusinessPublisher::class), }; } diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 97d516212..b20a4170b 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -1025,3 +1025,23 @@ expect($this->postPlatform->error_context['category'])->toBe('token_expired'); expect($this->postPlatform->error_context['platform_error_code'])->toBe('190'); }); + +test('dispatches google business posts to GoogleBusinessPublisher', function () { + $account = SocialAccount::factory()->googleBusiness()->create([ + 'workspace_id' => $this->workspace->id, + 'token_expires_at' => now()->addHour(), + ]); + $postPlatform = PostPlatform::factory()->googleBusiness()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'meta' => ['topic_type' => 'STANDARD'], + ]); + + Http::fake([ + config('trypost.platforms.google_business.local_posts_api').'/*' => Http::response(['name' => 'accounts/1/locations/2/localPosts/3'], 200), + ]); + + (new PublishToSocialPlatform($postPlatform))->handle(); + + expect($postPlatform->fresh()->status)->toBe(PlatformStatus::Published); +}); From 2aede14247528748e89187180f3bd078a87cc128 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 10 Aug 2026 23:50:01 -0300 Subject: [PATCH 11/47] feat: add Google Business Profile OAuth connect flow Adds GoogleBusinessController with connect/callback/select-location/select routes, mirroring FacebookController's auto-connect-on-single-location and picker-on-multiple pattern. Registers the 'google-business' Socialite driver (separate from 'google-auth' and YouTube's 'google') and adds the required i18n keys across all 16 locales. --- .../Auth/GoogleBusinessController.php | 251 ++++++++++++++ app/Providers/AppServiceProvider.php | 7 + lang/ar/accounts.php | 12 + lang/de/accounts.php | 12 + lang/el/accounts.php | 12 + lang/en/accounts.php | 12 + lang/es/accounts.php | 12 + lang/fr/accounts.php | 12 + lang/it/accounts.php | 12 + lang/ja/accounts.php | 12 + lang/ko/accounts.php | 12 + lang/nl/accounts.php | 12 + lang/pl/accounts.php | 12 + lang/pt-BR/accounts.php | 12 + lang/ru/accounts.php | 12 + lang/tr/accounts.php | 12 + lang/uk/accounts.php | 12 + lang/zh/accounts.php | 12 + routes/app.php | 6 + .../Social/GoogleBusinessControllerTest.php | 305 ++++++++++++++++++ 20 files changed, 761 insertions(+) create mode 100644 app/Http/Controllers/Auth/GoogleBusinessController.php create mode 100644 tests/Feature/Social/GoogleBusinessControllerTest.php diff --git a/app/Http/Controllers/Auth/GoogleBusinessController.php b/app/Http/Controllers/Auth/GoogleBusinessController.php new file mode 100644 index 000000000..451eee403 --- /dev/null +++ b/app/Http/Controllers/Auth/GoogleBusinessController.php @@ -0,0 +1,251 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + $this->authorize('manageAccounts', $workspace); + + session([ + 'social_connect_workspace' => $workspace->id, + 'social_reconnect_id' => null, + ]); + + return $this->redirectToGoogle(); + } + + public function callback(Request $request): InertiaResponse|RedirectResponse + { + $workspaceId = session('social_connect_workspace'); + + if (! $workspaceId) { + return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + } + + $workspace = Workspace::find($workspaceId); + + if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { + return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); + } + + try { + $socialUser = Socialite::driver($this->driver)->user(); + + $locations = $this->publisher->fetchLocations($socialUser->token); + + if (empty($locations)) { + return $this->popupCallback(false, __('accounts.popup_callback.no_google_business_locations'), $this->platform->value); + } + + if (count($locations) === 1) { + $this->connectLocation($workspace, $locations[0], $socialUser->token, $socialUser->refreshToken, $socialUser->expiresIn, $socialUser->getId()); + + return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + } + + session([ + 'google_business_oauth' => [ + 'access_token' => $socialUser->token, + 'refresh_token' => $socialUser->refreshToken, + 'expires_in' => $socialUser->expiresIn, + 'user_id' => $socialUser->getId(), + ], + ]); + + return redirect()->route('app.social.google-business.select-location'); + } catch (NetworkAlreadyConnectedException) { + return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + } catch (\Exception $e) { + Log::error('Google Business Profile OAuth Error', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); + } + } + + public function selectLocation(Request $request): InertiaResponse + { + $oauthData = session('google_business_oauth'); + $workspaceId = session('social_connect_workspace'); + + if (! $oauthData || ! $workspaceId) { + return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + } + + $workspace = Workspace::find($workspaceId); + + if (! $workspace) { + return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); + } + + $locations = $this->publisher->fetchLocations(data_get($oauthData, 'access_token')); + + if (empty($locations)) { + $this->forgetSocialConnectSession(); + session()->forget('google_business_oauth'); + + return $this->popupCallback(false, __('accounts.popup_callback.no_google_business_locations'), $this->platform->value); + } + + return Inertia::render('accounts/GoogleBusinessLocationSelect', [ + 'workspace' => $workspace, + 'locations' => $locations, + ]); + } + + public function select(Request $request): InertiaResponse + { + $request->validate([ + 'location_id' => 'required|string', + ]); + + $oauthData = session('google_business_oauth'); + $workspaceId = session('social_connect_workspace'); + + if (! $oauthData || ! $workspaceId) { + return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + } + + $workspace = Workspace::find($workspaceId); + + if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { + return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); + } + + try { + $locations = $this->publisher->fetchLocations(data_get($oauthData, 'access_token')); + $selectedLocation = collect($locations)->firstWhere('id', $request->location_id); + + if (! $selectedLocation) { + return $this->popupCallback(false, __('accounts.popup_callback.location_not_found'), $this->platform->value); + } + + $reconnectId = data_get($oauthData, 'reconnect_id'); + + if ($reconnectId) { + $existingAccount = $workspace->socialAccounts()->find($reconnectId); + + if ($existingAccount) { + $existingAccount->update([ + 'platform_user_id' => data_get($selectedLocation, 'id'), + 'username' => data_get($selectedLocation, 'title'), + 'display_name' => data_get($selectedLocation, 'title'), + 'access_token' => data_get($oauthData, 'access_token'), + 'refresh_token' => data_get($oauthData, 'refresh_token'), + 'token_expires_at' => data_get($oauthData, 'expires_in') ? now()->addSeconds(data_get($oauthData, 'expires_in')) : null, + 'scopes' => $this->scopes, + 'meta' => [ + 'location_id' => data_get($selectedLocation, 'id'), + 'account_name' => data_get($selectedLocation, 'account_name'), + 'location_name' => data_get($selectedLocation, 'location_name'), + 'google_user_id' => data_get($oauthData, 'user_id'), + ], + ]); + $existingAccount->markAsConnected(); + + session()->forget(['google_business_oauth', 'social_reconnect_id']); + + return $this->popupCallback(true, __('accounts.popup_callback.reconnected'), $this->platform->value); + } + } + + $this->connectLocation( + $workspace, + $selectedLocation, + data_get($oauthData, 'access_token'), + data_get($oauthData, 'refresh_token'), + data_get($oauthData, 'expires_in'), + data_get($oauthData, 'user_id'), + ); + + session()->forget(['google_business_oauth', 'social_reconnect_id']); + + return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + } catch (NetworkAlreadyConnectedException) { + return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + } catch (\Exception $e) { + Log::error('Google Business Profile location selection error', [ + 'error' => $e->getMessage(), + ]); + + return $this->popupCallback(false, __('accounts.popup_callback.error_connecting_location'), $this->platform->value); + } + } + + private function connectLocation(Workspace $workspace, array $location, string $accessToken, ?string $refreshToken, ?int $expiresIn, ?string $googleUserId): void + { + $workspace->socialAccounts()->updateOrCreate( + [ + 'platform' => $this->platform->value, + 'platform_user_id' => data_get($location, 'id'), + ], + [ + 'username' => data_get($location, 'title'), + 'display_name' => data_get($location, 'title'), + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null, + 'scopes' => $this->scopes, + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + 'meta' => [ + 'location_id' => data_get($location, 'id'), + 'account_name' => data_get($location, 'account_name'), + 'location_name' => data_get($location, 'location_name'), + 'google_user_id' => $googleUserId, + ], + ], + ); + } + + private function redirectToGoogle(): Response + { + return Inertia::location( + Socialite::driver($this->driver) + ->scopes($this->scopes) + ->with([ + 'access_type' => 'offline', + 'prompt' => 'consent', + 'include_granted_scopes' => 'true', + ]) + ->redirect() + ->getTargetUrl() + ); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2f18e9068..350b4b4e5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -188,6 +188,13 @@ protected function configureSocialite(): void return Socialite::buildProvider(GoogleProvider::class, $config); }); + // Google Business Profile — dedicated app, separate from 'google' (YouTube). + Socialite::extend('google-business', function ($app) { + $config = $app['config']['services.google-business']; + + return Socialite::buildProvider(GoogleProvider::class, $config); + }); + // Instagram Business Login Socialite::extend('instagram', function ($app) { $config = $app['config']['services.instagram']; diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 46971781f..a323eb9e2 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'اربط حسابك على Mastodon', 'telegram' => 'اربط قناة أو مجموعة على Telegram', 'discord' => 'اربط خادم Discord', + 'google_business' => 'اربط موقع Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram.', 'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.', 'not_linkedin_admin' => 'أنت لست مشرفًا على أي صفحة LinkedIn.', + 'no_google_business_locations' => 'لم يتم العثور على مواقع Google Business Profile. يرجى التحقق من نشاطك التجاري أولاً.', + 'location_not_found' => 'الموقع غير موجود.', + 'error_connecting_location' => 'خطأ في ربط الموقع. يرجى المحاولة مرة أخرى.', + ], + + 'google_business' => [ + 'title' => 'اختر موقع النشاط التجاري', + 'description' => 'اختر الموقع الذي تريد ربطه', + 'no_locations' => 'لم يتم العثور على مواقع', + 'no_locations_description' => 'أنت لست مديرًا لأي موقع تم التحقق منه في Google Business Profile.', + 'choose' => 'اختيار', ], ]; diff --git a/lang/de/accounts.php b/lang/de/accounts.php index efb4801bb..cd4fbae8f 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -31,6 +31,7 @@ 'mastodon' => 'Verbinde dein Mastodon-Konto', 'telegram' => 'Verbinde einen Telegram-Kanal oder eine Telegram-Gruppe', 'discord' => 'Verbinde einen Discord-Server', + 'google_business' => 'Verbinde einen Google Unternehmensprofil-Standort', ], 'disconnect_modal' => [ @@ -155,5 +156,16 @@ 'no_facebook_instagram_pages' => 'Keine Facebook-Seiten mit verknüpften Instagram-Konten gefunden.', 'no_youtube_channels' => 'Keine YouTube-Kanäle gefunden. Bitte erstelle zuerst einen Kanal.', 'not_linkedin_admin' => 'Du bist kein Administrator einer LinkedIn-Seite.', + 'no_google_business_locations' => 'Keine Google Unternehmensprofil-Standorte gefunden. Bestätige zuerst dein Unternehmen.', + 'location_not_found' => 'Standort nicht gefunden.', + 'error_connecting_location' => 'Fehler beim Verbinden des Standorts. Bitte versuche es erneut.', + ], + + 'google_business' => [ + 'title' => 'Standort auswählen', + 'description' => 'Wähle aus, welchen Standort du verbinden möchtest', + 'no_locations' => 'Keine Standorte gefunden', + 'no_locations_description' => 'Du bist kein Manager eines verifizierten Google Unternehmensprofil-Standorts.', + 'choose' => 'Auswählen', ], ]; diff --git a/lang/el/accounts.php b/lang/el/accounts.php index e94adbd11..2626966fe 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Συνδέστε τον λογαριασμό σας Mastodon', 'telegram' => 'Συνδέστε ένα κανάλι ή ομάδα Telegram', 'discord' => 'Συνδέστε έναν διακομιστή Discord', + 'google_business' => 'Συνδέστε μια τοποθεσία Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Δεν βρέθηκαν σελίδες Facebook με συνδεδεμένους λογαριασμούς Instagram.', 'no_youtube_channels' => 'Δεν βρέθηκαν κανάλια YouTube. Παρακαλούμε δημιουργήστε πρώτα ένα κανάλι.', 'not_linkedin_admin' => 'Δεν είστε διαχειριστής καμίας σελίδας LinkedIn.', + 'no_google_business_locations' => 'Δεν βρέθηκαν τοποθεσίες Google Business Profile. Επαληθεύστε πρώτα την επιχείρησή σας.', + 'location_not_found' => 'Η τοποθεσία δεν βρέθηκε.', + 'error_connecting_location' => 'Σφάλμα κατά τη σύνδεση της τοποθεσίας. Δοκιμάστε ξανά.', + ], + + 'google_business' => [ + 'title' => 'Επιλογή Τοποθεσίας Επιχείρησης', + 'description' => 'Επιλέξτε ποια τοποθεσία θέλετε να συνδέσετε', + 'no_locations' => 'Δεν βρέθηκαν τοποθεσίες', + 'no_locations_description' => 'Δεν είστε διαχειριστής καμίας επαληθευμένης τοποθεσίας Google Business Profile.', + 'choose' => 'Επιλογή', ], ]; diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 6ab145525..fecb6d455 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Connect your Mastodon account', 'telegram' => 'Connect a Telegram channel or group', 'discord' => 'Connect a Discord server', + 'google_business' => 'Connect a Google Business Profile location', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'No Facebook Pages with linked Instagram accounts found.', 'no_youtube_channels' => 'No YouTube channels found. Please create a channel first.', 'not_linkedin_admin' => 'You are not an administrator of any LinkedIn page.', + 'no_google_business_locations' => 'No Google Business Profile locations found. Verify your business first.', + 'location_not_found' => 'Location not found.', + 'error_connecting_location' => 'Error connecting location. Please try again.', + ], + + 'google_business' => [ + 'title' => 'Select Business Location', + 'description' => 'Choose which location you want to connect', + 'no_locations' => 'No locations found', + 'no_locations_description' => 'You are not a manager of any verified Google Business Profile location.', + 'choose' => 'Choose', ], ]; diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 38a93d016..1374e0fb4 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Conecta tu cuenta de Mastodon', 'telegram' => 'Conecta un canal o grupo de Telegram', 'discord' => 'Conecta un servidor de Discord', + 'google_business' => 'Conecta una ubicación de Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'No se encontraron páginas de Facebook con cuentas de Instagram vinculadas.', 'no_youtube_channels' => 'No se encontraron canales de YouTube. Crea un canal primero.', 'not_linkedin_admin' => 'No eres administrador de ninguna página de LinkedIn.', + 'no_google_business_locations' => 'No se encontraron ubicaciones de Google Business Profile. Verifica tu negocio primero.', + 'location_not_found' => 'Ubicación no encontrada.', + 'error_connecting_location' => 'Error al conectar la ubicación. Por favor, inténtalo de nuevo.', + ], + + 'google_business' => [ + 'title' => 'Seleccionar Ubicación del Negocio', + 'description' => 'Elige qué ubicación quieres conectar', + 'no_locations' => 'No se encontraron ubicaciones', + 'no_locations_description' => 'No eres administrador de ninguna ubicación verificada de Google Business Profile.', + 'choose' => 'Elegir', ], ]; diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index 8e37fc69b..1e464d374 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Connectez votre compte Mastodon', 'telegram' => 'Connectez un canal ou un groupe Telegram', 'discord' => 'Connectez un serveur Discord', + 'google_business' => 'Connectez un établissement Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Aucune page Facebook associée à un compte Instagram trouvée.', 'no_youtube_channels' => 'Aucune chaîne YouTube trouvée. Veuillez d\'abord créer une chaîne.', 'not_linkedin_admin' => 'Vous n\'êtes administrateur d\'aucune page LinkedIn.', + 'no_google_business_locations' => 'Aucun établissement Google Business Profile trouvé. Vérifiez d\'abord votre établissement.', + 'location_not_found' => 'Établissement introuvable.', + 'error_connecting_location' => 'Erreur lors de la connexion de l\'établissement. Veuillez réessayer.', + ], + + 'google_business' => [ + 'title' => 'Sélectionner l\'établissement', + 'description' => 'Choisissez l\'établissement que vous souhaitez connecter', + 'no_locations' => 'Aucun établissement trouvé', + 'no_locations_description' => 'Vous n\'êtes gestionnaire d\'aucun établissement Google Business Profile vérifié.', + 'choose' => 'Choisir', ], ]; diff --git a/lang/it/accounts.php b/lang/it/accounts.php index b362705a1..e1f112085 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Collega il tuo account Mastodon', 'telegram' => 'Collega un canale o gruppo Telegram', 'discord' => 'Collega un server Discord', + 'google_business' => 'Collega una sede di Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Nessuna pagina Facebook con account Instagram collegati trovata.', 'no_youtube_channels' => 'Nessun canale YouTube trovato. Crea prima un canale.', 'not_linkedin_admin' => 'Non sei amministratore di alcuna pagina LinkedIn.', + 'no_google_business_locations' => 'Nessuna sede di Google Business Profile trovata. Verifica prima la tua attività.', + 'location_not_found' => 'Sede non trovata.', + 'error_connecting_location' => 'Errore nella connessione della sede. Riprova.', + ], + + 'google_business' => [ + 'title' => 'Seleziona Sede Attività', + 'description' => 'Scegli quale sede vuoi collegare', + 'no_locations' => 'Nessuna sede trovata', + 'no_locations_description' => 'Non sei gestore di nessuna sede verificata di Google Business Profile.', + 'choose' => 'Scegli', ], ]; diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index 4ca6e41df..de35ba2ed 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Mastodon アカウントを接続', 'telegram' => 'Telegram チャンネルまたはグループを接続', 'discord' => 'Discord サーバーを接続', + 'google_business' => 'Google ビジネス プロフィールの店舗を接続', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Instagram アカウントが連携された Facebook ページが見つかりません。', 'no_youtube_channels' => 'YouTube チャンネルが見つかりません。先にチャンネルを作成してください。', 'not_linkedin_admin' => 'あなたは管理者となっている LinkedIn ページがありません。', + 'no_google_business_locations' => 'Google ビジネス プロフィールの店舗が見つかりません。まずビジネスを確認してください。', + 'location_not_found' => '店舗が見つかりません。', + 'error_connecting_location' => '店舗の接続中にエラーが発生しました。もう一度お試しください。', + ], + + 'google_business' => [ + 'title' => 'ビジネス店舗を選択', + 'description' => '接続する店舗を選択してください', + 'no_locations' => '店舗が見つかりません', + 'no_locations_description' => '確認済みの Google ビジネス プロフィール店舗の管理者ではありません。', + 'choose' => '選択', ], ]; diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 89ad4b751..3a264488c 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Mastodon 계정을 연결하세요', 'telegram' => 'Telegram 채널 또는 그룹을 연결하세요', 'discord' => 'Discord 서버를 연결하세요', + 'google_business' => 'Google 비즈니스 프로필 위치를 연결하세요', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Instagram 계정이 연결된 Facebook 페이지를 찾을 수 없습니다.', 'no_youtube_channels' => 'YouTube 채널을 찾을 수 없습니다. 먼저 채널을 만드세요.', 'not_linkedin_admin' => '관리자로 있는 LinkedIn 페이지가 없습니다.', + 'no_google_business_locations' => 'Google 비즈니스 프로필 위치를 찾을 수 없습니다. 먼저 비즈니스를 인증하세요.', + 'location_not_found' => '위치를 찾을 수 없습니다.', + 'error_connecting_location' => '위치 연결 중 오류가 발생했습니다. 다시 시도해 주세요.', + ], + + 'google_business' => [ + 'title' => '비즈니스 위치 선택', + 'description' => '연결할 위치를 선택하세요', + 'no_locations' => '위치를 찾을 수 없습니다', + 'no_locations_description' => '인증된 Google 비즈니스 프로필 위치의 관리자가 아닙니다.', + 'choose' => '선택', ], ]; diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index c5c8f45e3..292284a53 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Koppel je Mastodon-account', 'telegram' => 'Koppel een Telegram-kanaal of -groep', 'discord' => 'Koppel een Discord-server', + 'google_business' => 'Koppel een Google Bedrijfsprofiel-locatie', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Geen Facebook-pagina\'s met gekoppelde Instagram-accounts gevonden.', 'no_youtube_channels' => 'Geen YouTube-kanalen gevonden. Maak eerst een kanaal aan.', 'not_linkedin_admin' => 'Je bent geen beheerder van een LinkedIn-pagina.', + 'no_google_business_locations' => 'Geen Google Bedrijfsprofiel-locaties gevonden. Verifieer eerst je bedrijf.', + 'location_not_found' => 'Locatie niet gevonden.', + 'error_connecting_location' => 'Fout bij het koppelen van de locatie. Probeer het opnieuw.', + ], + + 'google_business' => [ + 'title' => 'Selecteer Bedrijfslocatie', + 'description' => 'Kies welke locatie je wilt koppelen', + 'no_locations' => 'Geen locaties gevonden', + 'no_locations_description' => 'Je bent geen beheerder van een geverifieerde Google Bedrijfsprofiel-locatie.', + 'choose' => 'Kiezen', ], ]; diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index 0828de36e..90ed8230c 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Połącz swoje konto Mastodon', 'telegram' => 'Połącz kanał lub grupę na Telegramie', 'discord' => 'Połącz serwer Discord', + 'google_business' => 'Połącz lokalizację Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Nie znaleziono stron na Facebooku z powiązanymi kontami Instagram.', 'no_youtube_channels' => 'Nie znaleziono kanałów YouTube. Najpierw utwórz kanał.', 'not_linkedin_admin' => 'Nie jesteś administratorem żadnej strony LinkedIn.', + 'no_google_business_locations' => 'Nie znaleziono lokalizacji Google Business Profile. Najpierw zweryfikuj swoją firmę.', + 'location_not_found' => 'Nie znaleziono lokalizacji.', + 'error_connecting_location' => 'Błąd podczas łączenia lokalizacji. Spróbuj ponownie.', + ], + + 'google_business' => [ + 'title' => 'Wybierz lokalizację firmy', + 'description' => 'Wybierz, którą lokalizację chcesz połączyć', + 'no_locations' => 'Nie znaleziono lokalizacji', + 'no_locations_description' => 'Nie jesteś menedżerem żadnej zweryfikowanej lokalizacji Google Business Profile.', + 'choose' => 'Wybierz', ], ]; diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 4531dcdd1..c1b159f13 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Conecte sua conta do Mastodon', 'telegram' => 'Conecte um canal ou grupo do Telegram', 'discord' => 'Conecte um servidor do Discord', + 'google_business' => 'Conecte um local do Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Nenhuma página do Facebook com conta do Instagram vinculada foi encontrada.', 'no_youtube_channels' => 'Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.', 'not_linkedin_admin' => 'Você não é administrador de nenhuma página do LinkedIn.', + 'no_google_business_locations' => 'Nenhum local do Google Business Profile encontrado. Verifique sua empresa primeiro.', + 'location_not_found' => 'Local não encontrado.', + 'error_connecting_location' => 'Erro ao conectar local. Por favor, tente novamente.', + ], + + 'google_business' => [ + 'title' => 'Selecionar Local Comercial', + 'description' => 'Escolha qual local você deseja conectar', + 'no_locations' => 'Nenhum local encontrado', + 'no_locations_description' => 'Você não é gerente de nenhum local verificado do Google Business Profile.', + 'choose' => 'Escolher', ], ]; diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index 384a0c430..c90ef729e 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Подключите аккаунт Mastodon', 'telegram' => 'Подключите канал или группу Telegram', 'discord' => 'Подключите сервер Discord', + 'google_business' => 'Подключите местоположение Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Не найдено страниц Facebook со связанными аккаунтами Instagram.', 'no_youtube_channels' => 'Каналы YouTube не найдены. Сначала создайте канал.', 'not_linkedin_admin' => 'Вы не являетесь администратором ни одной страницы LinkedIn.', + 'no_google_business_locations' => 'Местоположения Google Business Profile не найдены. Сначала подтвердите свою компанию.', + 'location_not_found' => 'Местоположение не найдено.', + 'error_connecting_location' => 'Ошибка при подключении местоположения. Пожалуйста, попробуйте снова.', + ], + + 'google_business' => [ + 'title' => 'Выберите местоположение компании', + 'description' => 'Выберите местоположение, которое хотите подключить', + 'no_locations' => 'Местоположения не найдены', + 'no_locations_description' => 'Вы не являетесь менеджером ни одного подтверждённого местоположения Google Business Profile.', + 'choose' => 'Выбрать', ], ]; diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index e65d7b887..ac46b5ebb 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -31,6 +31,7 @@ 'mastodon' => 'Mastodon hesabınızı bağlayın', 'telegram' => 'Bir Telegram kanalı veya grubu bağlayın', 'discord' => 'Bir Discord sunucusu bağlayın', + 'google_business' => 'Bir Google İşletme Profili konumu bağlayın', ], 'disconnect_modal' => [ @@ -155,5 +156,16 @@ 'no_facebook_instagram_pages' => 'Bağlı Instagram hesabı olan Facebook Sayfası bulunamadı.', 'no_youtube_channels' => 'YouTube kanalı bulunamadı. Lütfen önce bir kanal oluşturun.', 'not_linkedin_admin' => 'Hiçbir LinkedIn sayfasının yöneticisi değilsiniz.', + 'no_google_business_locations' => 'Google İşletme Profili konumu bulunamadı. Önce işletmenizi doğrulayın.', + 'location_not_found' => 'Konum bulunamadı.', + 'error_connecting_location' => 'Konum bağlanırken hata oluştu. Lütfen tekrar deneyin.', + ], + + 'google_business' => [ + 'title' => 'İşletme Konumu Seç', + 'description' => 'Bağlamak istediğiniz konumu seçin', + 'no_locations' => 'Konum bulunamadı', + 'no_locations_description' => 'Doğrulanmış herhangi bir Google İşletme Profili konumunun yöneticisi değilsiniz.', + 'choose' => 'Seç', ], ]; diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index ae1a521f4..b9409e0e9 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => 'Підключіть акаунт Mastodon', 'telegram' => 'Підключіть канал або групу Telegram', 'discord' => 'Підключіть сервер Discord', + 'google_business' => 'Підключіть місцезнаходження Google Business Profile', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => 'Не знайдено сторінок Facebook із підключеними акаунтами Instagram.', 'no_youtube_channels' => 'Каналів YouTube не знайдено. Спочатку створіть канал.', 'not_linkedin_admin' => 'Ви не є адміністратором жодної сторінки LinkedIn.', + 'no_google_business_locations' => 'Місцезнаходження Google Business Profile не знайдено. Спочатку підтвердьте свій бізнес.', + 'location_not_found' => 'Місцезнаходження не знайдено.', + 'error_connecting_location' => 'Помилка під час підключення місцезнаходження. Спробуйте ще раз.', + ], + + 'google_business' => [ + 'title' => 'Виберіть місцезнаходження бізнесу', + 'description' => 'Виберіть місцезнаходження, яке хочете підключити', + 'no_locations' => 'Місцезнаходження не знайдено', + 'no_locations_description' => 'Ви не є менеджером жодного підтвердженого місцезнаходження Google Business Profile.', + 'choose' => 'Вибрати', ], ]; diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index af05c84ea..6b3ad81b0 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -29,6 +29,7 @@ 'mastodon' => '连接你的 Mastodon 账号', 'telegram' => '连接一个 Telegram 频道或群组', 'discord' => '连接一个 Discord 服务器', + 'google_business' => '连接一个 Google 商家资料位置', ], 'disconnect_modal' => [ @@ -153,5 +154,16 @@ 'no_facebook_instagram_pages' => '未找到关联了 Instagram 账号的 Facebook 主页。', 'no_youtube_channels' => '未找到 YouTube 频道,请先创建一个频道。', 'not_linkedin_admin' => '你不是任何 LinkedIn 页面的管理员。', + 'no_google_business_locations' => '未找到 Google 商家资料位置。请先验证您的企业。', + 'location_not_found' => '未找到该位置。', + 'error_connecting_location' => '连接位置时出错。请重试。', + ], + + 'google_business' => [ + 'title' => '选择商家位置', + 'description' => '选择您要连接的位置', + 'no_locations' => '未找到位置', + 'no_locations_description' => '您不是任何已验证的 Google 商家资料位置的管理者。', + 'choose' => '选择', ], ]; diff --git a/routes/app.php b/routes/app.php index a6742a769..2b50f2f69 100644 --- a/routes/app.php +++ b/routes/app.php @@ -36,6 +36,7 @@ use App\Http\Controllers\Auth\BlueskyController; use App\Http\Controllers\Auth\DiscordController; use App\Http\Controllers\Auth\FacebookController; +use App\Http\Controllers\Auth\GoogleBusinessController; use App\Http\Controllers\Auth\InstagramController; use App\Http\Controllers\Auth\InstagramFacebookController; use App\Http\Controllers\Auth\LinkedInController; @@ -107,6 +108,7 @@ Route::post('connect/mastodon', [MastodonController::class, 'authorizeInstance'])->name('app.social.mastodon.authorize'); Route::post('connect/telegram', [TelegramController::class, 'connect'])->name('app.social.telegram.connect'); Route::get('connect/discord', [DiscordController::class, 'connect'])->name('app.social.discord.connect'); + Route::get('connect/google-business', [GoogleBusinessController::class, 'connect'])->name('app.social.google-business.connect'); Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect'); }); @@ -146,6 +148,10 @@ Route::get('accounts/mastodon/callback', [MastodonController::class, 'callback'])->name('app.social.mastodon.callback'); Route::get('accounts/discord/callback', [DiscordController::class, 'callback'])->name('app.social.discord.callback'); + + Route::get('accounts/google-business/callback', [GoogleBusinessController::class, 'callback'])->name('app.social.google-business.callback'); + Route::get('accounts/google-business/select', [GoogleBusinessController::class, 'selectLocation'])->name('app.social.google-business.select-location'); + Route::post('accounts/google-business/select', [GoogleBusinessController::class, 'select'])->name('app.social.google-business.select'); }); // Routes that require account access and a current workspace diff --git a/tests/Feature/Social/GoogleBusinessControllerTest.php b/tests/Feature/Social/GoogleBusinessControllerTest.php new file mode 100644 index 000000000..75ebaaa72 --- /dev/null +++ b/tests/Feature/Social/GoogleBusinessControllerTest.php @@ -0,0 +1,305 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); +}); + +test('connect redirects to the google-business oauth driver', function () { + $driverMock = Mockery::mock(); + $driverMock->shouldReceive('scopes')->andReturnSelf(); + $driverMock->shouldReceive('with')->andReturnSelf(); + $driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([ + 'getTargetUrl' => 'https://accounts.google.com/o/oauth2/auth?test=1', + ])); + + Socialite::shouldReceive('driver')->with('google-business')->andReturn($driverMock); + + $response = $this->actingAs($this->user) + ->withHeader('X-Inertia', 'true') + ->get(route('app.social.google-business.connect')); + + $response->assertStatus(409); // Inertia::location returns 409 with X-Inertia header + + expect(session('social_connect_workspace'))->toBe($this->workspace->id); +}); + +test('google business callback auto-connects when exactly one location exists', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('gid-1'); + $socialiteUser->token = 'access-token'; + $socialiteUser->refreshToken = 'refresh-token'; + $socialiteUser->expiresIn = 3600; + + Socialite::shouldReceive('driver')->with('google-business')->andReturn( + Mockery::mock()->shouldReceive('user')->andReturn($socialiteUser)->getMock() + ); + + $this->mock(GoogleBusinessPublisher::class, function ($mock) { + $mock->shouldReceive('fetchLocations')->once()->with('access-token')->andReturn([ + ['id' => 'accounts/1/locations/2', 'account_name' => 'accounts/1', 'location_name' => 'locations/2', 'title' => 'Downtown Store', 'address' => null], + ]); + }); + + $response = $this->actingAs($this->user)->get(route('app.social.google-business.callback')); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('accounts/PopupCallback') + ->where('success', true) + ); + + $this->assertDatabaseHas('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::GoogleBusiness->value, + 'platform_user_id' => 'accounts/1/locations/2', + 'display_name' => 'Downtown Store', + 'status' => Status::Connected->value, + ]); + + $account = $this->workspace->socialAccounts()->where('platform', Platform::GoogleBusiness)->first(); + expect($account->meta['location_id'])->toBe('accounts/1/locations/2') + ->and($account->meta['account_name'])->toBe('accounts/1') + ->and($account->meta['google_user_id'])->toBe('gid-1'); +}); + +test('google business callback shows the location picker when multiple locations exist', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('gid-1'); + $socialiteUser->token = 'access-token'; + $socialiteUser->refreshToken = 'refresh-token'; + $socialiteUser->expiresIn = 3600; + + Socialite::shouldReceive('driver')->with('google-business')->andReturn( + Mockery::mock()->shouldReceive('user')->andReturn($socialiteUser)->getMock() + ); + + $this->mock(GoogleBusinessPublisher::class, function ($mock) { + $mock->shouldReceive('fetchLocations')->once()->andReturn([ + ['id' => 'accounts/1/locations/2', 'account_name' => 'accounts/1', 'location_name' => 'locations/2', 'title' => 'Downtown Store', 'address' => null], + ['id' => 'accounts/1/locations/3', 'account_name' => 'accounts/1', 'location_name' => 'locations/3', 'title' => 'Uptown Store', 'address' => null], + ]); + }); + + $response = $this->actingAs($this->user)->get(route('app.social.google-business.callback')); + + $response->assertRedirect(route('app.social.google-business.select-location')); + + expect($this->workspace->socialAccounts()->where('platform', Platform::GoogleBusiness)->exists())->toBeFalse(); + expect(session('google_business_oauth'))->not->toBeNull(); + expect(data_get(session('google_business_oauth'), 'access_token'))->toBe('access-token'); +}); + +test('google business callback fails when no locations are found', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('gid-1'); + $socialiteUser->token = 'access-token'; + $socialiteUser->refreshToken = 'refresh-token'; + $socialiteUser->expiresIn = 3600; + + Socialite::shouldReceive('driver')->with('google-business')->andReturn( + Mockery::mock()->shouldReceive('user')->andReturn($socialiteUser)->getMock() + ); + + $this->mock(GoogleBusinessPublisher::class, function ($mock) { + $mock->shouldReceive('fetchLocations')->once()->andReturn([]); + }); + + $response = $this->actingAs($this->user)->get(route('app.social.google-business.callback')); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->where('success', false) + ->where('message', __('accounts.popup_callback.no_google_business_locations')) + ); + + expect($this->workspace->socialAccounts()->where('platform', Platform::GoogleBusiness)->exists())->toBeFalse(); +}); + +test('google business callback shows network_taken when the network is already connected', function () { + config()->set('trypost.self_hosted', false); + + SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::GoogleBusiness, + 'platform_user_id' => 'accounts/9/locations/9', + ]); + + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('gid-1'); + $socialiteUser->token = 'access-token'; + $socialiteUser->refreshToken = 'refresh-token'; + $socialiteUser->expiresIn = 3600; + + Socialite::shouldReceive('driver')->with('google-business')->andReturn( + Mockery::mock()->shouldReceive('user')->andReturn($socialiteUser)->getMock() + ); + + $this->mock(GoogleBusinessPublisher::class, function ($mock) { + $mock->shouldReceive('fetchLocations')->once()->andReturn([ + ['id' => 'accounts/1/locations/2', 'account_name' => 'accounts/1', 'location_name' => 'locations/2', 'title' => 'Downtown Store', 'address' => null], + ]); + }); + + $response = $this->actingAs($this->user)->get(route('app.social.google-business.callback')); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->where('success', false) + ->where('message', __('accounts.popup_callback.network_taken')) + ); + + expect($this->workspace->socialAccounts()->where('platform', Platform::GoogleBusiness)->count())->toBe(1); +}); + +test('google business callback fails with expired session', function () { + // No session data - simulating expired session + + $response = $this->actingAs($this->user)->get(route('app.social.google-business.callback')); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->where('success', false) + ->where('message', __('accounts.popup_callback.session_expired')) + ); +}); + +test('select creates the social account for the chosen location', function () { + session([ + 'social_connect_workspace' => $this->workspace->id, + 'google_business_oauth' => [ + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_in' => 3600, + 'user_id' => 'gid-1', + ], + ]); + + $this->mock(GoogleBusinessPublisher::class, function ($mock) { + $mock->shouldReceive('fetchLocations')->once()->with('access-token')->andReturn([ + ['id' => 'accounts/1/locations/2', 'account_name' => 'accounts/1', 'location_name' => 'locations/2', 'title' => 'Downtown Store', 'address' => null], + ['id' => 'accounts/1/locations/3', 'account_name' => 'accounts/1', 'location_name' => 'locations/3', 'title' => 'Uptown Store', 'address' => null], + ]); + }); + + $response = $this->actingAs($this->user) + ->post(route('app.social.google-business.select'), ['location_id' => 'accounts/1/locations/2']); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('accounts/PopupCallback') + ->where('success', true) + ); + + $account = $this->workspace->socialAccounts()->where('platform', Platform::GoogleBusiness)->first(); + expect($account)->not->toBeNull() + ->and($account->meta['location_id'])->toBe('accounts/1/locations/2') + ->and($account->status)->toBe(Status::Connected) + ->and(session('google_business_oauth'))->toBeNull(); +}); + +test('select fails with an unknown location id', function () { + session([ + 'social_connect_workspace' => $this->workspace->id, + 'google_business_oauth' => [ + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_in' => 3600, + 'user_id' => 'gid-1', + ], + ]); + + $this->mock(GoogleBusinessPublisher::class, function ($mock) { + $mock->shouldReceive('fetchLocations')->once()->andReturn([ + ['id' => 'accounts/1/locations/2', 'account_name' => 'accounts/1', 'location_name' => 'locations/2', 'title' => 'Downtown Store', 'address' => null], + ]); + }); + + $response = $this->actingAs($this->user) + ->post(route('app.social.google-business.select'), ['location_id' => 'accounts/1/locations/nope']); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->where('success', false) + ->where('message', __('accounts.popup_callback.location_not_found')) + ); + + expect($this->workspace->socialAccounts()->where('platform', Platform::GoogleBusiness)->exists())->toBeFalse(); +}); + +test('select fails with expired session', function () { + // No session data + + $response = $this->actingAs($this->user) + ->post(route('app.social.google-business.select'), ['location_id' => 'accounts/1/locations/2']); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->where('success', false) + ->where('message', __('accounts.popup_callback.session_expired')) + ); +}); + +test('select reconnects an existing account when a reconnect id is present', function () { + $existingAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::GoogleBusiness, + 'platform_user_id' => 'accounts/1/locations/2', + 'status' => Status::TokenExpired, + ]); + + session([ + 'social_connect_workspace' => $this->workspace->id, + 'google_business_oauth' => [ + 'access_token' => 'new-access-token', + 'refresh_token' => 'new-refresh-token', + 'expires_in' => 3600, + 'user_id' => 'gid-1', + 'reconnect_id' => $existingAccount->id, + ], + ]); + + $this->mock(GoogleBusinessPublisher::class, function ($mock) { + $mock->shouldReceive('fetchLocations')->once()->with('new-access-token')->andReturn([ + ['id' => 'accounts/1/locations/2', 'account_name' => 'accounts/1', 'location_name' => 'locations/2', 'title' => 'Downtown Store', 'address' => null], + ]); + }); + + $response = $this->actingAs($this->user) + ->post(route('app.social.google-business.select'), ['location_id' => 'accounts/1/locations/2']); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->where('success', true) + ->where('message', __('accounts.popup_callback.reconnected')) + ); + + expect($this->workspace->socialAccounts()->where('platform', Platform::GoogleBusiness)->count())->toBe(1); + + $existingAccount->refresh(); + expect($existingAccount->status)->toBe(Status::Connected) + ->and($existingAccount->access_token)->toBe('new-access-token'); +}); From a37b280d57580f82cb814ffb3ef5bdaef1fa2a12 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 10 Aug 2026 23:56:46 -0300 Subject: [PATCH 12/47] feat: add Google Business Profile location picker page --- .../accounts/GoogleBusinessLocationSelect.vue | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 resources/js/pages/accounts/GoogleBusinessLocationSelect.vue diff --git a/resources/js/pages/accounts/GoogleBusinessLocationSelect.vue b/resources/js/pages/accounts/GoogleBusinessLocationSelect.vue new file mode 100644 index 000000000..f2e383ee2 --- /dev/null +++ b/resources/js/pages/accounts/GoogleBusinessLocationSelect.vue @@ -0,0 +1,86 @@ + + + From 4c3709e0f1b0ec545489f8b281846b5b2c2a06d1 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 11 Aug 2026 00:01:59 -0300 Subject: [PATCH 13/47] feat: add Google Business Profile composer settings card --- public/images/accounts/google_business.png | Bin 0 -> 479 bytes .../posts/editor/GoogleBusinessSettings.vue | 222 ++++++++++++++++++ resources/js/composables/usePlatformLogo.ts | 3 + 3 files changed, 225 insertions(+) create mode 100644 public/images/accounts/google_business.png create mode 100644 resources/js/components/posts/editor/GoogleBusinessSettings.vue diff --git a/public/images/accounts/google_business.png b/public/images/accounts/google_business.png new file mode 100644 index 0000000000000000000000000000000000000000..0b45bc84e80108d2e0350bb1974f746a1a7767a5 GIT binary patch literal 479 zcmV<50U-W~P)6=~N>8P=Hbyam1oA~###ipF zkw?NTe?)^Yc?8`YF1Oq}=lvS`->_hs#-~6?nF3fEpQ2W(TEs(wsgWuEfv13^M5svQ zmlBLtMlI4pg3-vRL|RHPIhm|T4-(8bnXe)}NiZpyqzFRdv|D{V+a9mhuu++)h*csc zAh8NbnWZV3KqDaEMP!I5l9AeMlt@TwWx}kyiL{VltTNEjiV-#tA)I1i4UlTw_IUm1 z<=C@a94$`~ +import { IconChevronDown, IconChevronUp } from '@tabler/icons-vue'; +import { computed, ref } from 'vue'; + +import InputError from '@/components/InputError.vue'; +import { Avatar } from '@/components/ui/avatar'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { usePageErrors } from '@/composables/usePageErrors'; +import { getPlatformLogo } from '@/composables/usePlatformLogo'; + +interface SocialAccount { + id: string; + platform: string; + display_name: string; + username: string; + display_label: string; + avatar_url: string | null; +} + +interface Props { + socialAccount: SocialAccount | null; + meta: Record; + disabled?: boolean; + previewOnly?: boolean; +} + +const props = withDefaults(defineProps(), { + disabled: false, + previewOnly: false, +}); + +const emit = defineEmits<{ + 'update:meta': [value: Record]; +}>(); + +const open = ref(false); + +const topicTypes = [ + { value: 'STANDARD', labelKey: 'posts.form.google_business.topic_type.standard' }, + { value: 'EVENT', labelKey: 'posts.form.google_business.topic_type.event' }, + { value: 'OFFER', labelKey: 'posts.form.google_business.topic_type.offer' }, +] as const; + +const ctaOptions = [ + { value: 'NONE', labelKey: 'posts.form.google_business.cta_none' }, + { value: 'BOOK', labelKey: 'posts.form.google_business.cta.book' }, + { value: 'ORDER', labelKey: 'posts.form.google_business.cta.order' }, + { value: 'SHOP', labelKey: 'posts.form.google_business.cta.shop' }, + { value: 'LEARN_MORE', labelKey: 'posts.form.google_business.cta.learn_more' }, + { value: 'SIGN_UP', labelKey: 'posts.form.google_business.cta.sign_up' }, + { value: 'GET_OFFER', labelKey: 'posts.form.google_business.cta.get_offer' }, + { value: 'CALL', labelKey: 'posts.form.google_business.cta.call' }, +] as const; + +const topicType = computed({ + get: () => props.meta?.topic_type || 'STANDARD', + set: (value: string) => emit('update:meta', { ...props.meta, topic_type: value }), +}); + +const ctaActionType = computed({ + get: () => props.meta?.call_to_action?.action_type || 'NONE', + set: (value: string) => emit('update:meta', { + ...props.meta, + call_to_action: { ...props.meta?.call_to_action, action_type: value }, + }), +}); + +const showCtaUrl = computed(() => ctaActionType.value !== 'NONE' && ctaActionType.value !== 'CALL'); + +const ctaUrl = computed({ + get: () => props.meta?.call_to_action?.url || '', + set: (value: string) => emit('update:meta', { + ...props.meta, + call_to_action: { ...props.meta?.call_to_action, url: value.trim() === '' ? null : value }, + }), +}); + +const eventField = (key: 'title' | 'start_date' | 'end_date' | 'start_time' | 'end_time') => computed({ + get: () => props.meta?.event?.[key] || '', + set: (value: string) => emit('update:meta', { + ...props.meta, + event: { ...props.meta?.event, [key]: value.trim() === '' ? null : value }, + }), +}); + +const eventTitle = eventField('title'); +const eventStartDate = eventField('start_date'); +const eventEndDate = eventField('end_date'); +const eventStartTime = eventField('start_time'); +const eventEndTime = eventField('end_time'); + +const offerField = (key: 'coupon_code' | 'redeem_online_url' | 'terms_conditions') => computed({ + get: () => props.meta?.offer?.[key] || '', + set: (value: string) => emit('update:meta', { + ...props.meta, + offer: { ...props.meta?.offer, [key]: value.trim() === '' ? null : value }, + }), +}); + +const offerCouponCode = offerField('coupon_code'); +const offerRedeemUrl = offerField('redeem_online_url'); +const offerTerms = offerField('terms_conditions'); + +// Surface backend validation errors keyed by platform index +// (`platforms.0.meta.*`). Suffix match avoids threading the index through props. +const errors = usePageErrors(); +const findError = (suffix: string) => computed( + () => Object.entries(errors.value).find(([key]) => key.endsWith(suffix))?.[1], +); +const eventTitleError = findError('.meta.event.title'); +const eventStartDateError = findError('.meta.event.start_date'); +const eventEndDateError = findError('.meta.event.end_date'); +const ctaUrlError = findError('.meta.call_to_action.url'); + + +