From 2fd4aed9361e8ca3bf9a94bf1f703e5fc4c59b78 Mon Sep 17 00:00:00 2001 From: Thomas Fellows Date: Mon, 31 Aug 2026 14:18:26 +0900 Subject: [PATCH 01/12] Add Google Business Profile integration --- .env.example | 6 + app/Actions/Post/CreatePost.php | 5 +- app/Actions/Post/SyncPostPlatforms.php | 35 ++- .../ReconcileGoogleBusinessProfilePosts.php | 32 +++ app/Console/Commands/RecoverStuckPosts.php | 8 +- app/Enums/PostPlatform/ContentType.php | 36 +++ app/Enums/PostPlatform/Status.php | 3 + app/Enums/SocialAccount/Platform.php | 13 +- .../GoogleBusinessProfilePublishException.php | 47 ++++ .../Controllers/App/AnalyticsController.php | 7 +- .../Auth/GoogleBusinessProfileController.php | 210 +++++++++++++++ .../Requests/Api/Post/StorePostRequest.php | 52 ++++ .../Requests/Api/Post/UpdatePostRequest.php | 17 +- .../Requests/App/Post/UpdatePostRequest.php | 13 + .../Resources/Api/PostPlatformResource.php | 3 + .../Resources/Api/SocialAccountResource.php | 8 + .../Resources/App/SocialAccountResource.php | 8 + app/Jobs/PublishToSocialPlatform.php | 44 ++- .../ReconcileGoogleBusinessProfilePost.php | 130 +++++++++ app/Mcp/Tools/Post/CreatePostTool.php | 37 ++- app/Mcp/Tools/Post/UpdatePostTool.php | 9 + app/Models/GoogleBusinessProfileLocation.php | 56 ++++ app/Models/PostPlatform.php | 32 +++ app/Models/SocialAccount.php | 5 + app/Services/Media/MediaOptimizer.php | 6 + app/Services/Post/PostMetricsFetcher.php | 2 + app/Services/Social/ConnectionVerifier.php | 45 ++++ .../GoogleBusinessProfileAnalytics.php | 127 +++++++++ .../GoogleBusinessProfileApi.php | 175 ++++++++++++ .../GoogleBusinessProfilePublisher.php | 170 ++++++++++++ app/Support/PostPlatformMetaRules.php | 159 +++++++++++ config/services.php | 8 + config/trypost.php | 4 + .../GoogleBusinessProfileLocationFactory.php | 41 +++ database/factories/PostPlatformFactory.php | 8 + database/factories/SocialAccountFactory.php | 9 + ...oogle_business_profile_locations_table.php | 40 +++ ...profile_fields_to_post_platforms_table.php | 31 +++ lang/en/accounts.php | 13 + lang/en/posts.php | 20 ++ .../accounts/google-business-profile.svg | 13 + .../js/components/ChannelConfigurator.vue | 12 + .../editor/GoogleBusinessProfileSettings.vue | 120 +++++++++ .../components/posts/editor/ScheduleTab.vue | 3 + .../previews/GoogleBusinessProfilePreview.vue | 49 ++++ .../posts/previews/PlatformPreview.vue | 3 + resources/js/composables/usePlatformLogo.ts | 8 + resources/js/composables/usePostStatus.ts | 6 + .../GoogleBusinessProfileLocationSelect.vue | 95 +++++++ resources/js/pages/analytics/Index.vue | 7 + resources/js/pages/posts/Show.vue | 2 +- resources/js/types/content-type.ts | 4 + resources/js/types/platform.ts | 1 + resources/js/types/post.ts | 5 + routes/app.php | 6 + routes/console.php | 2 + .../GoogleBusinessProfileIntegrationTest.php | 251 ++++++++++++++++++ 57 files changed, 2248 insertions(+), 13 deletions(-) create mode 100644 app/Console/Commands/ReconcileGoogleBusinessProfilePosts.php create mode 100644 app/Exceptions/Social/GoogleBusinessProfilePublishException.php create mode 100644 app/Http/Controllers/Auth/GoogleBusinessProfileController.php create mode 100644 app/Jobs/ReconcileGoogleBusinessProfilePost.php create mode 100644 app/Models/GoogleBusinessProfileLocation.php create mode 100644 app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileAnalytics.php create mode 100644 app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileApi.php create mode 100644 app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfilePublisher.php create mode 100644 database/factories/GoogleBusinessProfileLocationFactory.php create mode 100644 database/migrations/2026_08_25_120000_create_google_business_profile_locations_table.php create mode 100644 database/migrations/2026_08_25_120001_add_google_business_profile_fields_to_post_platforms_table.php create mode 100644 public/images/accounts/google-business-profile.svg create mode 100644 resources/js/components/posts/editor/GoogleBusinessProfileSettings.vue create mode 100644 resources/js/components/posts/previews/GoogleBusinessProfilePreview.vue create mode 100644 resources/js/pages/accounts/GoogleBusinessProfileLocationSelect.vue create mode 100644 tests/Feature/Services/Social/GoogleBusinessProfileIntegrationTest.php diff --git a/.env.example b/.env.example index 8b76d274e..b27efe238 100644 --- a/.env.example +++ b/.env.example @@ -157,6 +157,12 @@ GOOGLE_AUTH_ENABLED=false GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback" + +# Google Business Profile (https://developers.google.com/my-business) +GOOGLE_BUSINESS_PROFILE_CLIENT_ID= +GOOGLE_BUSINESS_PROFILE_CLIENT_SECRET= +GOOGLE_BUSINESS_PROFILE_CLIENT_REDIRECT="${APP_URL}/accounts/google-business-profile/callback" +GOOGLE_BUSINESS_PROFILE_ENABLED=true GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback" # GitHub (https://github.com/settings/developers) diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index a0f439e89..e3422e905 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -35,7 +35,7 @@ class CreatePost * date?: ?string, * scheduled_at?: ?string, * created_via?: ?CreatedVia, - * platforms?: array}>, + * platforms?: array}>, * label_ids?: array * } $data */ @@ -62,6 +62,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P } $updates = ['enabled' => true]; + $googleBusinessProfileLocationId = data_get($platformData, 'google_business_profile_location_id'); if ($contentType = data_get($platformData, 'content_type')) { $updates['content_type'] = $contentType; @@ -71,6 +72,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P if (is_array($meta) && $meta !== []) { $existing = $post->postPlatforms() ->where('social_account_id', $accountId) + ->when($googleBusinessProfileLocationId, fn ($query) => $query->where('google_business_profile_location_id', $googleBusinessProfileLocationId)) ->first(); if ($existing) { @@ -83,6 +85,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P $post->postPlatforms() ->where('social_account_id', $accountId) + ->when($googleBusinessProfileLocationId, fn ($query) => $query->where('google_business_profile_location_id', $googleBusinessProfileLocationId)) ->update($updates); } diff --git a/app/Actions/Post/SyncPostPlatforms.php b/app/Actions/Post/SyncPostPlatforms.php index b0709fdbf..759bbb961 100644 --- a/app/Actions/Post/SyncPostPlatforms.php +++ b/app/Actions/Post/SyncPostPlatforms.php @@ -6,6 +6,7 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\PostPlatform\Status as PostPlatformStatus; +use App\Enums\SocialAccount\Platform; use App\Models\Post; class SyncPostPlatforms @@ -20,14 +21,44 @@ public static function execute(Post $post): void { $workspace = $post->workspace; - $existingAccountIds = $post->postPlatforms()->pluck('social_account_id')->filter(); + $existingAccountIds = $post->postPlatforms() + ->whereNull('google_business_profile_location_id') + ->pluck('social_account_id') + ->filter(); + + $existingGoogleLocationIds = $post->postPlatforms() + ->whereNotNull('google_business_profile_location_id') + ->pluck('google_business_profile_location_id') + ->filter(); $missingAccounts = $workspace->socialAccounts() ->active() - ->whereNotIn('id', $existingAccountIds) + ->with(['googleBusinessProfileLocations' => fn ($query) => $query->where('is_selected', true)]) ->get(); foreach ($missingAccounts as $account) { + if ($account->platform === Platform::GoogleBusinessProfile) { + foreach ($account->googleBusinessProfileLocations->whereNotIn('id', $existingGoogleLocationIds) as $location) { + $post->postPlatforms()->create([ + 'social_account_id' => $account->id, + 'google_business_profile_location_id' => $location->id, + 'platform' => $account->platform->value, + 'platform_name' => $location->title, + 'platform_username' => $location->store_code, + 'platform_avatar' => $account->getRawOriginal('avatar_url'), + 'content_type' => ContentType::defaultFor($account->platform), + 'status' => PostPlatformStatus::Pending, + 'enabled' => false, + ]); + } + + continue; + } + + if ($existingAccountIds->contains($account->id)) { + continue; + } + $post->postPlatforms()->create([ 'social_account_id' => $account->id, 'platform' => $account->platform->value, diff --git a/app/Console/Commands/ReconcileGoogleBusinessProfilePosts.php b/app/Console/Commands/ReconcileGoogleBusinessProfilePosts.php new file mode 100644 index 000000000..e044c0abf --- /dev/null +++ b/app/Console/Commands/ReconcileGoogleBusinessProfilePosts.php @@ -0,0 +1,32 @@ +where('platform', Platform::GoogleBusinessProfile) + ->whereIn('status', [Status::Submitted, Status::PendingReview]) + ->whereNotNull('platform_post_id') + ->oldest('last_reconciled_at') + ->chunkById(100, function ($postPlatforms): void { + foreach ($postPlatforms as $postPlatform) { + ReconcileGoogleBusinessProfilePost::dispatch($postPlatform); + } + }); + } +} diff --git a/app/Console/Commands/RecoverStuckPosts.php b/app/Console/Commands/RecoverStuckPosts.php index a8f74498d..513f9c5f1 100644 --- a/app/Console/Commands/RecoverStuckPosts.php +++ b/app/Console/Commands/RecoverStuckPosts.php @@ -59,7 +59,13 @@ public function handle(): void // fresh updated_at — do not finalize the post while that work is still live. $stillActive = $post->postPlatforms() ->enabled() - ->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying]) + ->whereIn('status', [ + PlatformStatus::Publishing, + PlatformStatus::Pending, + PlatformStatus::Retrying, + PlatformStatus::Submitted, + PlatformStatus::PendingReview, + ]) ->exists(); if ($stillActive) { diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 0d56e74cd..89b99282e 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -33,6 +33,12 @@ enum ContentType: string // YouTube case YouTubeShort = 'youtube_short'; + // Google Business Profile local posts + case GoogleBusinessProfileStandard = 'google_business_profile_standard'; + case GoogleBusinessProfileEvent = 'google_business_profile_event'; + case GoogleBusinessProfileOffer = 'google_business_profile_offer'; + case GoogleBusinessProfileAlert = 'google_business_profile_alert'; + // X (Twitter) case XPost = 'x_post'; @@ -75,6 +81,10 @@ public function label(): string self::TikTokVideo => 'Video', self::TikTokPhoto => 'Photo carousel', self::YouTubeShort => 'Short', + self::GoogleBusinessProfileStandard => 'Update', + self::GoogleBusinessProfileEvent => 'Event', + self::GoogleBusinessProfileOffer => 'Offer', + self::GoogleBusinessProfileAlert => 'Alert', self::XPost => 'Post', self::ThreadsPost => 'Post', self::PinterestPin => 'Pin', @@ -101,6 +111,10 @@ public function platform(): SocialPlatform self::FacebookPost, self::FacebookReel, self::FacebookStory => SocialPlatform::Facebook, self::TikTokVideo, self::TikTokPhoto => SocialPlatform::TikTok, self::YouTubeShort => SocialPlatform::YouTube, + self::GoogleBusinessProfileStandard, + self::GoogleBusinessProfileEvent, + self::GoogleBusinessProfileOffer, + self::GoogleBusinessProfileAlert => SocialPlatform::GoogleBusinessProfile, self::XPost => SocialPlatform::X, self::ThreadsPost => SocialPlatform::Threads, self::PinterestPin, self::PinterestVideoPin, self::PinterestCarousel => SocialPlatform::Pinterest, @@ -131,6 +145,10 @@ public function aiImageDimensions(): array self::XPost, self::BlueskyPost, self::MastodonPost => ['width' => 1080, 'height' => 1080], + self::GoogleBusinessProfileStandard, + self::GoogleBusinessProfileEvent, + self::GoogleBusinessProfileOffer, + self::GoogleBusinessProfileAlert => ['width' => 1200, 'height' => 900], // Stories 9:16 (Instagram + Facebook) self::InstagramStory, @@ -169,6 +187,10 @@ public function maxMediaCount(): int self::TikTokVideo => 1, self::TikTokPhoto => 35, self::YouTubeShort => 1, + self::GoogleBusinessProfileStandard, + self::GoogleBusinessProfileEvent, + self::GoogleBusinessProfileOffer, + self::GoogleBusinessProfileAlert => 1, self::XPost => 4, self::ThreadsPost => 10, self::PinterestPin, self::PinterestVideoPin => 1, @@ -450,6 +472,10 @@ public function supportsVideo(): bool self::TikTokVideo => true, self::TikTokPhoto => false, self::YouTubeShort => true, + self::GoogleBusinessProfileStandard, + self::GoogleBusinessProfileEvent, + self::GoogleBusinessProfileOffer, + self::GoogleBusinessProfileAlert => true, self::XPost => true, self::ThreadsPost => true, self::PinterestVideoPin => true, @@ -469,6 +495,10 @@ public function supportsImage(): bool self::TikTokVideo => false, self::TikTokPhoto => true, self::YouTubeShort => false, + self::GoogleBusinessProfileStandard, + self::GoogleBusinessProfileEvent, + self::GoogleBusinessProfileOffer, + self::GoogleBusinessProfileAlert => true, self::PinterestVideoPin => false, default => true, }; @@ -526,6 +556,10 @@ public function requiresMedia(): bool self::TelegramPost => false, self::FacebookPost => false, self::DiscordMessage => false, + self::GoogleBusinessProfileStandard, + self::GoogleBusinessProfileEvent, + self::GoogleBusinessProfileOffer, + self::GoogleBusinessProfileAlert => false, default => true, }; } @@ -550,6 +584,7 @@ public static function aiSupported(): array self::FacebookPost, self::PinterestPin, self::PinterestCarousel, + self::GoogleBusinessProfileStandard, ]; } @@ -602,6 +637,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::Facebook => self::FacebookPost, SocialPlatform::TikTok => self::TikTokVideo, SocialPlatform::YouTube => self::YouTubeShort, + SocialPlatform::GoogleBusinessProfile => self::GoogleBusinessProfileStandard, SocialPlatform::X => self::XPost, SocialPlatform::Threads => self::ThreadsPost, SocialPlatform::Pinterest => self::PinterestPin, diff --git a/app/Enums/PostPlatform/Status.php b/app/Enums/PostPlatform/Status.php index 354c683bb..8bec83221 100644 --- a/app/Enums/PostPlatform/Status.php +++ b/app/Enums/PostPlatform/Status.php @@ -8,7 +8,10 @@ enum Status: string { case Pending = 'pending'; case Publishing = 'publishing'; + case Submitted = 'submitted'; + case PendingReview = 'pending_review'; case Retrying = 'retrying'; case Published = 'published'; case Failed = 'failed'; + case Rejected = 'rejected'; } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 1d9d207c8..8dc4c96c7 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -13,6 +13,7 @@ enum Platform: string case X = 'x'; case TikTok = 'tiktok'; case YouTube = 'youtube'; + case GoogleBusinessProfile = 'google-business-profile'; case Facebook = 'facebook'; case Instagram = 'instagram'; case InstagramFacebook = 'instagram-facebook'; @@ -60,6 +61,7 @@ public function label(): string self::X => 'X', self::TikTok => 'TikTok', self::YouTube => 'YouTube Shorts', + self::GoogleBusinessProfile => 'Google Business Profile', self::Facebook => 'Facebook Page', self::Instagram => 'Instagram', self::InstagramFacebook => 'Instagram (Facebook Business)', @@ -79,6 +81,7 @@ public function color(): string self::X => '#000000', self::TikTok => '#000000', self::YouTube => '#FF0000', + self::GoogleBusinessProfile => '#4285F4', self::Facebook => '#1877F2', self::Instagram => '#E4405F', self::InstagramFacebook => '#E4405F', @@ -98,6 +101,7 @@ public function allowedMediaTypes(): array self::X => [MediaType::Image, MediaType::Video], self::TikTok => [MediaType::Video], self::YouTube => [MediaType::Video], + self::GoogleBusinessProfile => [MediaType::Image, MediaType::Video], self::Facebook => [MediaType::Image, MediaType::Video], self::Instagram, self::InstagramFacebook => [MediaType::Image, MediaType::Video], self::Threads => [MediaType::Image, MediaType::Video], @@ -116,6 +120,7 @@ public function maxImages(): int self::X => 4, self::TikTok => 0, self::YouTube => 0, + self::GoogleBusinessProfile => 1, self::Facebook => 10, self::Instagram, self::InstagramFacebook => 10, self::Threads => 10, @@ -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::GoogleBusinessProfile, self::Telegram => null, }; } @@ -189,6 +194,7 @@ public function maxContentLength(): int self::X => 280, self::TikTok => 2200, self::YouTube => 100, + self::GoogleBusinessProfile => 1500, self::Facebook => 10000, self::Instagram, self::InstagramFacebook => 2200, self::Threads => 500, @@ -238,6 +244,7 @@ public function recommendedAiContentLength(): int // YouTube Shorts — fits within the 100-char title (with " #Shorts" // suffix taking 8 chars) so the same string works as title + desc self::YouTube => 80, + self::GoogleBusinessProfile => 300, // Telegram channel posts — short announcements read best self::Telegram => 400, // Discord — conversational community posts read best when concise @@ -256,6 +263,7 @@ public function requiredPublishScopes(): array self::Facebook => ['pages_manage_posts'], self::TikTok => ['video.publish'], self::YouTube => ['https://www.googleapis.com/auth/youtube.upload'], + self::GoogleBusinessProfile => ['https://www.googleapis.com/auth/business.manage'], self::LinkedIn => ['w_member_social'], self::LinkedInPage => ['w_organization_social'], self::X => ['tweet.write'], @@ -275,6 +283,7 @@ public function supportsTextOnly(): bool self::X => true, self::TikTok => false, self::YouTube => false, + self::GoogleBusinessProfile => true, self::Facebook => true, self::Instagram, self::InstagramFacebook => false, self::Threads => true, @@ -323,7 +332,7 @@ public function hasTokenRefreshFlow(): bool { return match ($this) { self::LinkedIn, self::LinkedInPage, self::X, self::Bluesky, - self::YouTube, self::TikTok, self::Pinterest, + self::YouTube, self::GoogleBusinessProfile, self::TikTok, self::Pinterest, self::Threads, self::Instagram => true, default => false, }; diff --git a/app/Exceptions/Social/GoogleBusinessProfilePublishException.php b/app/Exceptions/Social/GoogleBusinessProfilePublishException.php new file mode 100644 index 000000000..f4bcb7c14 --- /dev/null +++ b/app/Exceptions/Social/GoogleBusinessProfilePublishException.php @@ -0,0 +1,47 @@ +json(), 'error.message', 'Google Business Profile rejected the post.'); + $code = (string) data_get($response->json(), 'error.status', $response->status()); + + if ($response->status() === 401) { + throw new TokenExpiredException($message, platformErrorCode: $code); + } + + if ($response->status() === 429 || $response->serverError()) { + throw new PlatformUnavailableException( + "Google Business Profile API temporarily failed ({$response->status()}).", + $response->status(), + ); + } + + $category = match (true) { + $response->status() === 403 => ErrorCategory::Permission, + default => ErrorCategory::ContentPolicy, + }; + + return new static( + userMessage: $message, + category: $category, + platformErrorCode: $code, + rawResponse: $response->body(), + ); + } + + public function platform(): string + { + return 'google-business-profile'; + } +} diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index 8376afe8f..4658f01f5 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -6,9 +6,12 @@ use App\Enums\SocialAccount\Platform; use App\Exceptions\PlatformUnavailableException; +use App\Exceptions\Social\GoogleBusinessProfilePublishException; +use App\Exceptions\TokenExpiredException; use App\Http\Controllers\Controller; use App\Models\SocialAccount; use App\Services\Social\FacebookAnalytics; +use App\Services\Social\GoogleBusinessProfile\GoogleBusinessProfileAnalytics; use App\Services\Social\InstagramAnalytics; use App\Services\Social\LinkedInPageAnalytics; use App\Services\Social\PinterestAnalytics; @@ -37,6 +40,7 @@ class AnalyticsController extends Controller Platform::LinkedInPage, Platform::Pinterest, Platform::YouTube, + Platform::GoogleBusinessProfile, Platform::Telegram, ]; @@ -98,10 +102,11 @@ private function metricsFor(SocialAccount $account, ?Carbon $since, ?Carbon $unt Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->getMetrics($account, $since, $until), Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until), Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until), + Platform::GoogleBusinessProfile => app(GoogleBusinessProfileAnalytics::class)->getMetrics($account, $since, $until), Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account), default => [], }; - } catch (PlatformUnavailableException|ConnectionException $e) { + } catch (GoogleBusinessProfilePublishException|PlatformUnavailableException|TokenExpiredException|ConnectionException $e) { report($e); return []; diff --git a/app/Http/Controllers/Auth/GoogleBusinessProfileController.php b/app/Http/Controllers/Auth/GoogleBusinessProfileController.php new file mode 100644 index 000000000..fa3dcc45d --- /dev/null +++ b/app/Http/Controllers/Auth/GoogleBusinessProfileController.php @@ -0,0 +1,210 @@ +ensurePlatformEnabled(); + $workspace = $request->user()->currentWorkspace; + $this->authorize('manageAccounts', $workspace); + + session([ + 'social_connect_workspace' => $workspace->id, + 'social_reconnect_id' => null, + 'google_business_profile_account_id' => null, + ]); + + return Inertia::location($this->provider() + ->scopes($this->scopes) + ->with([ + 'access_type' => 'offline', + 'prompt' => 'consent', + 'include_granted_scopes' => 'true', + ]) + ->redirect() + ->getTargetUrl()); + } + + public function callback(Request $request, GoogleBusinessProfileApi $api): InertiaResponse|RedirectResponse + { + if ($request->filled('error')) { + return $this->popupCallback(false, __('accounts.popup_callback.failed_to_authenticate'), $this->platform->value); + } + + $workspace = $this->workspaceFromSession($request); + if (! $workspace) { + return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); + } + + try { + $googleUser = $this->provider()->user(); + $accounts = $api->accounts($googleUser->token); + $locations = collect($accounts) + ->flatMap(fn (array $account): array => collect($api->locations($googleUser->token, (string) data_get($account, 'name'))) + ->map(fn (array $location): array => [...$location, 'googleAccountName' => data_get($account, 'name')]) + ->all()) + ->values(); + + if ($locations->isEmpty()) { + return $this->popupCallback(false, __('accounts.popup_callback.no_google_business_profile_locations'), $this->platform->value); + } + + $socialAccount = DB::transaction(function () use ($workspace, $googleUser, $locations): SocialAccount { + $socialAccount = $workspace->socialAccounts()->firstOrNew([ + 'platform' => $this->platform->value, + 'platform_user_id' => $googleUser->getId(), + ]); + $socialAccount->fill([ + 'username' => $googleUser->getEmail(), + 'display_name' => $googleUser->getName() ?: $googleUser->getEmail(), + 'access_token' => $googleUser->token, + 'token_expires_at' => $googleUser->expiresIn ? now()->addSeconds($googleUser->expiresIn) : null, + 'scopes' => $this->scopes, + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + 'meta' => ['google_user_id' => $googleUser->getId()], + ]); + + // Google may omit the refresh token on a repeat authorization. + // Keep the previously issued token instead of making the account + // unable to refresh as soon as the new access token expires. + if (filled($googleUser->refreshToken)) { + $socialAccount->refresh_token = $googleUser->refreshToken; + } + $socialAccount->save(); + + $seen = []; + foreach ($locations as $location) { + $name = (string) data_get($location, 'name'); + $seen[] = $name; + $socialAccount->googleBusinessProfileLocations()->updateOrCreate( + ['google_location_name' => $name], + [ + 'google_account_name' => data_get($location, 'googleAccountName'), + 'title' => data_get($location, 'title'), + 'store_code' => data_get($location, 'storeCode'), + 'timezone' => data_get($location, 'metadata.timezone'), + 'maps_uri' => data_get($location, 'metadata.mapsUri'), + 'website_uri' => data_get($location, 'websiteUri'), + 'phone_number' => data_get($location, 'phoneNumbers.primaryPhone'), + 'storefront_address' => data_get($location, 'storefrontAddress'), + 'metadata' => data_get($location, 'metadata'), + 'is_verified' => (bool) data_get($location, 'metadata.hasVoiceOfMerchant', false), + 'last_synced_at' => now(), + ], + ); + } + + $socialAccount->googleBusinessProfileLocations() + ->whereNotIn('google_location_name', $seen) + ->update(['is_selected' => false]); + + return $socialAccount; + }); + + session(['google_business_profile_account_id' => $socialAccount->id]); + + return redirect()->route('app.social.google-business-profile.select-locations'); + } catch (NetworkAlreadyConnectedException) { + return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + } catch (\Throwable $e) { + Log::error('Google Business Profile OAuth error', ['error' => $e->getMessage()]); + + return $this->popupCallback(false, __('accounts.popup_callback.error_connecting_google_business_profile'), $this->platform->value); + } + } + + public function selectLocations(Request $request): InertiaResponse + { + $account = $this->accountFromSession($request); + if (! $account) { + return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + } + + return Inertia::render('accounts/GoogleBusinessProfileLocationSelect', [ + 'locations' => $account->googleBusinessProfileLocations() + ->orderBy('title') + ->get(['id', 'title', 'store_code', 'storefront_address', 'maps_uri', 'is_selected']), + ]); + } + + public function select(Request $request): InertiaResponse + { + $account = $this->accountFromSession($request); + if (! $account) { + return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + } + + $validated = $request->validate([ + 'location_ids' => ['required', 'array', 'min:1'], + 'location_ids.*' => [ + 'uuid', + Rule::exists('google_business_profile_locations', 'id')->where('social_account_id', $account->id), + ], + ]); + + DB::transaction(function () use ($account, $validated): void { + $account->googleBusinessProfileLocations()->update(['is_selected' => false]); + $account->googleBusinessProfileLocations() + ->whereIn('id', $validated['location_ids']) + ->update(['is_selected' => true]); + }); + + session()->forget(['google_business_profile_account_id', 'social_connect_workspace', 'social_reconnect_id']); + + return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + } + + private function provider(): GoogleProvider + { + /** @var GoogleProvider $provider */ + $provider = Socialite::buildProvider(GoogleProvider::class, config('services.google-business-profile')); + + return $provider; + } + + private function workspaceFromSession(Request $request): ?Workspace + { + $workspace = Workspace::find(session('social_connect_workspace')); + + return $workspace && $request->user()->can('manageAccounts', $workspace) ? $workspace : null; + } + + private function accountFromSession(Request $request): ?SocialAccount + { + $workspace = $this->workspaceFromSession($request); + if (! $workspace) { + return null; + } + + return $workspace->socialAccounts() + ->where('platform', $this->platform) + ->find(session('google_business_profile_account_id')); + } +} diff --git a/app/Http/Requests/Api/Post/StorePostRequest.php b/app/Http/Requests/Api/Post/StorePostRequest.php index 8f19cb3ba..ff0613eea 100644 --- a/app/Http/Requests/Api/Post/StorePostRequest.php +++ b/app/Http/Requests/Api/Post/StorePostRequest.php @@ -14,6 +14,7 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class StorePostRequest extends FormRequest { @@ -45,6 +46,12 @@ public function rules(): array ->where('workspace_id', $workspaceId) ->where('is_active', true), ], + 'platforms.*.google_business_profile_location_id' => [ + 'sometimes', + 'nullable', + 'uuid', + Rule::exists('google_business_profile_locations', 'id'), + ], 'platforms.*.content_type' => [ 'required', 'string', @@ -77,6 +84,51 @@ public function attributes(): array return PostPlatformMetaRules::attributes(); } + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $platforms = (array) $this->input('platforms', []); + $accounts = SocialAccount::query() + ->where('workspace_id', $this->user()->currentWorkspace->id) + ->whereIn('id', collect($platforms)->pluck('social_account_id')->filter()) + ->get() + ->keyBy('id'); + + foreach ($platforms as $index => $platform) { + $account = $accounts->get(data_get($platform, 'social_account_id')); + if ($account?->platform !== Platform::GoogleBusinessProfile) { + if (filled(data_get($platform, 'google_business_profile_location_id'))) { + $validator->errors()->add( + "platforms.{$index}.google_business_profile_location_id", + 'A Google Business Profile location can only be used with a Google Business Profile connection.', + ); + } + + continue; + } + + $locationId = data_get($platform, 'google_business_profile_location_id'); + $valid = filled($locationId) && $account->googleBusinessProfileLocations() + ->whereKey($locationId) + ->where('is_selected', true) + ->exists(); + + if (! $valid) { + $validator->errors()->add( + "platforms.{$index}.google_business_profile_location_id", + 'Choose a selected Google Business Profile location managed by this connection.', + ); + } + } + + PostPlatformMetaRules::addGoogleBusinessProfileErrors( + $validator, + $platforms, + fn ($platform) => ContentType::tryFrom((string) data_get($platform, 'content_type')), + ); + }); + } + /** * @return Collection */ diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 2796b80f4..70f5c2723 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -83,7 +83,22 @@ public function attributes(): array public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator): void { - if (! in_array($this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true)) { + $submittedPlatforms = (array) $this->input('platforms', []); + $isPublishing = in_array($this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true); + + if ($this->has('platforms') || $isPublishing) { + $effectivePayloads = PostPlatformMetaRules::effectivePayloadsForUpdate( + $this->route('post'), + $this->has('platforms') ? $submittedPlatforms : null, + ); + PostPlatformMetaRules::addGoogleBusinessProfileErrors( + $validator, + $effectivePayloads, + fn ($platform) => ContentType::tryFrom((string) data_get($platform, 'content_type')), + ); + } + + if (! $isPublishing) { return; } diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 83647284d..a7b07d4c6 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -80,6 +80,19 @@ public function attributes(): array public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator): void { + $submittedPlatforms = (array) $this->input('platforms', []); + if ($this->has('platforms') || $this->isPublishingOrScheduling()) { + $effectivePayloads = PostPlatformMetaRules::effectivePayloadsForUpdate( + $this->route('post'), + $this->has('platforms') ? $submittedPlatforms : null, + ); + PostPlatformMetaRules::addGoogleBusinessProfileErrors( + $validator, + $effectivePayloads, + fn ($platform) => ContentType::tryFrom((string) data_get($platform, 'content_type')), + ); + } + if (! $this->isPublishingOrScheduling()) { return; } diff --git a/app/Http/Resources/Api/PostPlatformResource.php b/app/Http/Resources/Api/PostPlatformResource.php index 3995c87c6..d45abc0e7 100644 --- a/app/Http/Resources/Api/PostPlatformResource.php +++ b/app/Http/Resources/Api/PostPlatformResource.php @@ -23,6 +23,9 @@ public function toArray(Request $request): array 'enabled' => $this->enabled, 'platform_url' => $this->platform_url, 'published_at' => $this->published_at?->format('Y-m-d H:i:s'), + 'submitted_at' => $this->submitted_at?->format('Y-m-d H:i:s'), + 'last_reconciled_at' => $this->last_reconciled_at?->format('Y-m-d H:i:s'), + 'google_business_profile_location_id' => $this->google_business_profile_location_id, 'error_message' => $this->error_message, // Display fields fall back to snapshots (platform_name/username/avatar) // so deleted accounts still render correctly in the post history. diff --git a/app/Http/Resources/Api/SocialAccountResource.php b/app/Http/Resources/Api/SocialAccountResource.php index 5fd2880fc..82ed432cb 100644 --- a/app/Http/Resources/Api/SocialAccountResource.php +++ b/app/Http/Resources/Api/SocialAccountResource.php @@ -4,6 +4,7 @@ namespace App\Http\Resources\Api; +use App\Enums\SocialAccount\Platform; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -21,6 +22,13 @@ public function toArray(Request $request): array 'username' => $this->username, 'is_active' => $this->is_active, 'status' => $this->status?->value, + 'google_business_profile_locations' => $this->when( + $this->platform === Platform::GoogleBusinessProfile, + fn () => $this->googleBusinessProfileLocations() + ->where('is_selected', true) + ->orderBy('title') + ->get(['id', 'title', 'store_code', 'maps_uri', 'is_verified']), + ), ]; } } diff --git a/app/Http/Resources/App/SocialAccountResource.php b/app/Http/Resources/App/SocialAccountResource.php index ee476b448..74b5f6748 100644 --- a/app/Http/Resources/App/SocialAccountResource.php +++ b/app/Http/Resources/App/SocialAccountResource.php @@ -4,6 +4,7 @@ namespace App\Http\Resources\App; +use App\Enums\SocialAccount\Platform; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -30,6 +31,13 @@ public function toArray(Request $request): array 'error_message' => $this->error_message, 'last_used_at' => $this->last_used_at, 'created_at' => $this->created_at, + 'google_business_profile_locations' => $this->when( + $this->platform === Platform::GoogleBusinessProfile, + fn () => $this->googleBusinessProfileLocations() + ->where('is_selected', true) + ->orderBy('title') + ->get(['id', 'title', 'store_code', 'maps_uri', 'is_verified']), + ), ]; } } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 106a9f92e..5782dddcc 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -22,6 +22,7 @@ use App\Services\Social\ConnectionVerifier; use App\Services\Social\Discord\DiscordPublisher; use App\Services\Social\FacebookPublisher; +use App\Services\Social\GoogleBusinessProfile\GoogleBusinessProfilePublisher; use App\Services\Social\InstagramPublisher; use App\Services\Social\LinkedInPagePublisher; use App\Services\Social\LinkedInPublisher; @@ -124,7 +125,11 @@ public function handle(): void try { $publisher = $this->getPublisher(); $result = $publisher->publish($this->postPlatform); - $this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url')); + if ($this->postPlatform->platform === SocialPlatform::GoogleBusinessProfile) { + $this->recordGoogleBusinessProfileSubmission($result); + } else { + $this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url')); + } break; } catch (PlatformUnavailableException $e) { $this->rescheduleForRetry($e); @@ -318,6 +323,7 @@ private function isTerminal(): bool return in_array($this->postPlatform->status, [ PostPlatformStatus::Published, PostPlatformStatus::Failed, + PostPlatformStatus::Rejected, ], true); } @@ -337,7 +343,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|GoogleBusinessProfilePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -345,6 +351,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::X => app(XPublisher::class), SocialPlatform::TikTok => app(TikTokPublisher::class), SocialPlatform::YouTube => app(YouTubePublisher::class), + SocialPlatform::GoogleBusinessProfile => app(GoogleBusinessProfilePublisher::class), SocialPlatform::Facebook => app(FacebookPublisher::class), SocialPlatform::Instagram, SocialPlatform::InstagramFacebook => app(InstagramPublisher::class), SocialPlatform::Threads => app(ThreadsPublisher::class), @@ -363,7 +370,7 @@ private function updatePostStatus(): void $total = $enabledPlatforms->count(); $publishedCount = $enabledPlatforms->where('status', PostPlatformStatus::Published)->count(); - $failedCount = $enabledPlatforms->where('status', PostPlatformStatus::Failed)->count(); + $failedCount = $enabledPlatforms->whereIn('status', [PostPlatformStatus::Failed, PostPlatformStatus::Rejected])->count(); $finishedCount = $publishedCount + $failedCount; // Only update post status when all platforms have finished @@ -387,6 +394,37 @@ private function updatePostStatus(): void $this->notify($post, PostPlatformStatus::Failed); } + /** @param array $result */ + private function recordGoogleBusinessProfileSubmission(array $result): void + { + $state = (string) data_get($result, 'provider_state', 'PROCESSING'); + + if (in_array($state, ['LIVE', 'RECURRING'], true)) { + $this->postPlatform->markAsPublished((string) data_get($result, 'id'), data_get($result, 'url')); + + return; + } + + if ($state === 'REJECTED') { + $this->postPlatform->markAsRejected('Google rejected this post during review.', [ + 'provider_state' => $state, + 'reconciled_at' => now()->toIso8601String(), + ]); + + return; + } + + $status = $state === 'PROCESSING' + ? PostPlatformStatus::PendingReview + : PostPlatformStatus::Submitted; + + $this->postPlatform->markAsSubmitted( + (string) data_get($result, 'id'), + data_get($result, 'url'), + $status, + ); + } + public function failed(?Throwable $exception): void { Log::error('PublishToSocialPlatform job failed permanently', [ diff --git a/app/Jobs/ReconcileGoogleBusinessProfilePost.php b/app/Jobs/ReconcileGoogleBusinessProfilePost.php new file mode 100644 index 000000000..ef25b1951 --- /dev/null +++ b/app/Jobs/ReconcileGoogleBusinessProfilePost.php @@ -0,0 +1,130 @@ + */ + public array $backoff = [60, 300, 900, 1800]; + + public function __construct(public PostPlatform $postPlatform) + { + $this->onQueue($postPlatform->platform->queue()); + } + + public function handle(GoogleBusinessProfileApi $api, ConnectionVerifier $verifier): void + { + $this->postPlatform->refresh()->load(['socialAccount', 'post.postPlatforms']); + + if (! in_array($this->postPlatform->status, [Status::Submitted, Status::PendingReview], true) + || blank($this->postPlatform->platform_post_id)) { + return; + } + + $account = $this->postPlatform->socialAccount; + if ($account->needsProactiveTokenRefresh()) { + $verifier->refreshToken($account); + } + + $remote = $api->localPost($account, $this->postPlatform->platform_post_id); + $state = (string) data_get($remote, 'state', 'PROCESSING'); + + match (true) { + in_array($state, ['LIVE', 'RECURRING'], true) => $this->postPlatform->markAsPublished( + $this->postPlatform->platform_post_id, + data_get($remote, 'searchUrl', $this->postPlatform->platform_url), + ), + $state === 'REJECTED' => $this->postPlatform->markAsRejected( + 'Google rejected this post during review.', + ['provider_state' => $state, 'remote' => $this->safeRemoteContext($remote)], + ), + default => $this->postPlatform->update([ + 'status' => $state === 'PROCESSING' ? Status::PendingReview : Status::Submitted, + 'platform_url' => data_get($remote, 'searchUrl', $this->postPlatform->platform_url), + 'last_reconciled_at' => now(), + 'error_context' => ['provider_state' => $state], + ]), + }; + + if (in_array($state, ['LIVE', 'RECURRING'], true)) { + $this->postPlatform->update(['last_reconciled_at' => now()]); + } + + $this->reconcilePostStatus(); + PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); + } + + public function failed(?Throwable $exception): void + { + $this->postPlatform->refresh(); + + if (! in_array($this->postPlatform->status, [Status::Submitted, Status::PendingReview], true)) { + return; + } + + $this->postPlatform->update([ + 'status' => Status::Failed, + 'error_message' => 'Google Business Profile post status could not be confirmed after several attempts.', + 'error_context' => [ + 'category' => 'reconciliation_failed', + 'failed_at' => now()->toIso8601String(), + ], + 'last_reconciled_at' => now(), + ]); + + $this->reconcilePostStatus(); + PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); + + if ($exception) { + report($exception); + } + } + + /** @param array $remote + * @return array + */ + private function safeRemoteContext(array $remote): array + { + return array_filter([ + 'name' => data_get($remote, 'name'), + 'state' => data_get($remote, 'state'), + 'topic_type' => data_get($remote, 'topicType'), + 'update_time' => data_get($remote, 'updateTime'), + ]); + } + + private function reconcilePostStatus(): void + { + $post = $this->postPlatform->post->fresh('postPlatforms'); + $enabled = $post->postPlatforms->where('enabled', true); + $published = $enabled->where('status', Status::Published)->count(); + $failed = $enabled->whereIn('status', [Status::Failed, Status::Rejected])->count(); + + if (($published + $failed) < $enabled->count()) { + return; + } + + match (true) { + $published === $enabled->count() => $post->markAsPublished(), + $published > 0 => $post->markAsPartiallyPublished(), + default => $post->markAsFailed(), + }; + } +} diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index a90e40305..5b3990d28 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -7,13 +7,16 @@ use App\Actions\Post\CreatePost; use App\Enums\Post\CreatedVia; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; use App\Http\Resources\Api\PostResource; use App\Mcp\Concerns\AuthorizesMcpTool; +use App\Models\SocialAccount; use App\Models\Workspace; use App\Rules\ContentTypeMatchesPlatform; use App\Support\PostPlatformMetaRules; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -51,6 +54,7 @@ public function handle(Request $request): Response|ResponseFactory ->where('workspace_id', $workspace->id) ->where('is_active', true), ], + 'platforms.*.google_business_profile_location_id' => ['sometimes', 'nullable', 'uuid', Rule::exists('google_business_profile_locations', 'id')], 'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPlatform], ...PostPlatformMetaRules::rules(), ], @@ -60,6 +64,36 @@ public function handle(Request $request): Response|ResponseFactory $validated['created_via'] = CreatedVia::Mcp; + $accounts = SocialAccount::query() + ->where('workspace_id', $workspace->id) + ->whereIn('id', collect($validated['platforms'] ?? [])->pluck('social_account_id')->filter()) + ->get() + ->keyBy('id'); + $locationErrors = []; + foreach (($validated['platforms'] ?? []) as $index => $platform) { + $account = $accounts->get(data_get($platform, 'social_account_id')); + if ($account?->platform !== Platform::GoogleBusinessProfile) { + if (filled(data_get($platform, 'google_business_profile_location_id'))) { + $locationErrors["platforms.{$index}.google_business_profile_location_id"] = 'A Google Business Profile location can only be used with a Google Business Profile connection.'; + } + + continue; + } + + $locationId = data_get($platform, 'google_business_profile_location_id'); + if (blank($locationId) || ! $account->googleBusinessProfileLocations()->whereKey($locationId)->where('is_selected', true)->exists()) { + $locationErrors["platforms.{$index}.google_business_profile_location_id"] = 'Choose a selected Google Business Profile location managed by this connection.'; + } + } + if ($locationErrors !== []) { + throw ValidationException::withMessages($locationErrors); + } + + PostPlatformMetaRules::assertGoogleBusinessProfilePayloads( + $validated['platforms'] ?? [], + fn ($platform) => ContentType::tryFrom((string) data_get($platform, 'content_type')), + ); + $post = CreatePost::execute($workspace, $request->user(), $validated); $post->load(['postPlatforms.socialAccount', 'labels']); @@ -78,8 +112,9 @@ public function schema(JsonSchema $schema): array 'platforms' => $schema->array() ->items($schema->object(fn ($p) => [ 'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'), + 'google_business_profile_location_id' => $p->string()->description('Required for Google Business Profile: UUID of the selected managed location returned by the social-account listing.'), 'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'), - 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), + 'meta' => $p->object()->description('Per-platform metadata. Google Business Profile: CTA fields (cta_action_type, cta_url); Event/Offer require event_title, event_start_at, event_end_at; Offer supports offer_coupon_code, offer_redeem_url, offer_terms; Alert requires alert_type=COVID_19; recurrence supports daily, weekly, or monthly fields. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), ])) ->description('Platforms to publish on. Accounts not listed remain available but disabled.'), ]; diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 3f0ae0e62..f7f54ef51 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -78,6 +78,15 @@ public function handle(Request $request): Response|ResponseFactory // media, so a misconfigured post can't be scheduled even without resubmitting // content_type. Mirrors the public API's withValidator check. if ($status === Status::Scheduled->value) { + $effectivePayloads = PostPlatformMetaRules::effectivePayloadsForUpdate( + $post, + array_key_exists('platforms', $validated) ? $validated['platforms'] : null, + ); + PostPlatformMetaRules::assertGoogleBusinessProfilePayloads( + $effectivePayloads, + fn ($platform) => ContentType::tryFrom((string) data_get($platform, 'content_type')), + ); + $errors = ContentTypeCompatibleWithMedia::errorsFor( ContentTypeCompatibleWithMedia::entriesForUpdate($post, data_get($validated, 'platforms')), (array) ($post->media ?? []), diff --git a/app/Models/GoogleBusinessProfileLocation.php b/app/Models/GoogleBusinessProfileLocation.php new file mode 100644 index 000000000..be23cbd67 --- /dev/null +++ b/app/Models/GoogleBusinessProfileLocation.php @@ -0,0 +1,56 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'social_account_id', + 'google_account_name', + 'google_location_name', + 'title', + 'store_code', + 'timezone', + 'maps_uri', + 'website_uri', + 'phone_number', + 'storefront_address', + 'metadata', + 'is_selected', + 'is_verified', + 'last_synced_at', + ]; + + protected function casts(): array + { + return [ + 'storefront_address' => 'array', + 'metadata' => 'array', + 'is_selected' => 'boolean', + 'is_verified' => 'boolean', + 'last_synced_at' => 'datetime', + ]; + } + + public function socialAccount(): BelongsTo + { + return $this->belongsTo(SocialAccount::class); + } + + public function postPlatforms(): HasMany + { + return $this->hasMany(PostPlatform::class); + } +} diff --git a/app/Models/PostPlatform.php b/app/Models/PostPlatform.php index 35f0f0dda..ab27f7d53 100644 --- a/app/Models/PostPlatform.php +++ b/app/Models/PostPlatform.php @@ -23,6 +23,7 @@ class PostPlatform extends Model protected $fillable = [ 'post_id', 'social_account_id', + 'google_business_profile_location_id', 'enabled', 'platform', 'platform_name', @@ -35,6 +36,8 @@ class PostPlatform extends Model 'error_message', 'error_context', 'published_at', + 'submitted_at', + 'last_reconciled_at', 'meta', 'connection_warning_sent_at', ]; @@ -47,6 +50,8 @@ protected function casts(): array 'content_type' => ContentType::class, 'status' => Status::class, 'published_at' => 'datetime', + 'submitted_at' => 'datetime', + 'last_reconciled_at' => 'datetime', 'meta' => 'array', 'error_context' => 'array', 'connection_warning_sent_at' => 'datetime', @@ -63,6 +68,11 @@ public function socialAccount(): BelongsTo return $this->belongsTo(SocialAccount::class); } + public function googleBusinessProfileLocation(): BelongsTo + { + return $this->belongsTo(GoogleBusinessProfileLocation::class); + } + /** * Only platforms still enabled for publishing — disabled ones are * excluded from PublishPost, so anything else that mirrors publish @@ -122,6 +132,28 @@ public function markAsPublished(string $platformPostId, ?string $platformUrl = n $this->socialAccount?->update(['last_used_at' => $now]); } + public function markAsSubmitted(string $platformPostId, ?string $platformUrl = null, Status $status = Status::Submitted): void + { + $this->update([ + 'status' => $status, + 'platform_post_id' => $platformPostId, + 'platform_url' => $platformUrl, + 'submitted_at' => now(), + 'error_message' => null, + 'error_context' => null, + ]); + } + + public function markAsRejected(string $message, ?array $context = null): void + { + $this->update([ + 'status' => Status::Rejected, + 'error_message' => $message, + 'error_context' => $context, + 'last_reconciled_at' => now(), + ]); + } + public function markAsFailed(string $errorMessage, ?array $errorContext = null): void { $this->update([ diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 6059bb3ed..e90a1b23e 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -223,6 +223,11 @@ public function postPlatforms(): HasMany return $this->hasMany(PostPlatform::class); } + public function googleBusinessProfileLocations(): HasMany + { + return $this->hasMany(GoogleBusinessProfileLocation::class); + } + protected function isTokenExpired(): Attribute { return Attribute::make( diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index 3370a4a0c..db0bc25b1 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -342,6 +342,12 @@ private function getImageConfig(Platform $platform): array 'format' => 'image/jpeg', 'quality' => 100, ], + Platform::GoogleBusinessProfile => [ + 'max_width' => 1200, + 'max_size' => 10 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 100, + ], Platform::Telegram => [ 'max_width' => 2048, 'max_size' => 10 * 1024 * 1024, diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index b68e3305b..9b1aac7d5 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -10,6 +10,7 @@ use App\Services\Social\BlueskyAnalytics; use App\Services\Social\Discord\DiscordAnalytics; use App\Services\Social\FacebookAnalytics; +use App\Services\Social\GoogleBusinessProfile\GoogleBusinessProfileAnalytics; use App\Services\Social\InstagramAnalytics; use App\Services\Social\LinkedInPageAnalytics; use App\Services\Social\MastodonAnalytics; @@ -73,6 +74,7 @@ public function forPlatform(PostPlatform $postPlatform): array Platform::Threads => app(ThreadsAnalytics::class)->fetchPostMetrics($postPlatform), Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->fetchPostMetrics($postPlatform), Platform::YouTube => app(YouTubeAnalytics::class)->fetchPostMetrics($postPlatform), + Platform::GoogleBusinessProfile => app(GoogleBusinessProfileAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Pinterest => app(PinterestAnalytics::class)->fetchPostMetrics($postPlatform), default => ['unsupported' => true, 'reason' => 'platform_not_supported'], }); diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 7c38c91f0..c582b423a 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -179,6 +179,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool Platform::Threads => $this->verifyThreads($account), Platform::TikTok => $this->verifyTikTok($account), Platform::YouTube => $this->verifyYouTube($account), + Platform::GoogleBusinessProfile => $this->verifyGoogleBusinessProfile($account), Platform::Pinterest => $this->verifyPinterest($account), Platform::Bluesky => $this->verifyBluesky($account), Platform::Mastodon => $this->verifyMastodon($account), @@ -231,6 +232,7 @@ public function refreshToken(SocialAccount $account): bool Platform::X => $this->refreshXToken($account), Platform::Bluesky => $this->refreshBlueskyToken($account), Platform::YouTube => $this->refreshYouTubeToken($account), + Platform::GoogleBusinessProfile => $this->refreshGoogleBusinessProfileToken($account), Platform::TikTok => $this->refreshTikTokToken($account), Platform::Pinterest => $this->refreshPinterestToken($account), Platform::Threads => $this->refreshThreadsToken($account), @@ -364,6 +366,30 @@ private function refreshYouTubeToken(SocialAccount $account): void $account->refresh(); } + private function refreshGoogleBusinessProfileToken(SocialAccount $account): void + { + if (! $account->refresh_token) { + throw new TokenExpiredException('No refresh token available for Google Business Profile account'); + } + + $response = TokenRefreshClient::for(Platform::GoogleBusinessProfile)->send(fn () => $this->refreshHttp()->asForm() + ->post(config('trypost.platforms.google-business-profile.oauth_api').'/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.google-business-profile.client_id'), + 'client_secret' => config('services.google-business-profile.client_secret'), + ])); + + $data = $response->json(); + + $account->update([ + 'access_token' => $this->tokenFrom($data, $account->platform), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + + $account->refresh(); + } + private function refreshTikTokToken(SocialAccount $account): void { if (! $account->refresh_token) { @@ -626,6 +652,25 @@ private function verifyYouTube(SocialAccount $account): bool ); } + private function verifyGoogleBusinessProfile(SocialAccount $account): bool + { + $response = Http::withToken($account->access_token) + ->get('https://mybusinessaccountmanagement.googleapis.com/v1/accounts', ['pageSize' => 1]); + + if ($response->status() === 401) { + 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(), + ); + } + private function verifyPinterest(SocialAccount $account): bool { $response = Http::withToken($account->access_token) diff --git a/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileAnalytics.php b/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileAnalytics.php new file mode 100644 index 000000000..4021cde30 --- /dev/null +++ b/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileAnalytics.php @@ -0,0 +1,127 @@ + */ + public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array + { + $this->refreshTokenIfNeeded($account); + + $since ??= now()->subDays(30); + $until ??= now(); + $totals = array_fill_keys(self::DAILY_METRICS, 0); + /** @var array $keywords */ + $keywords = []; + + foreach ($account->googleBusinessProfileLocations()->where('is_selected', true)->get() as $location) { + $response = $this->api->performance($location, [ + 'dailyMetrics' => self::DAILY_METRICS, + 'dailyRange.start_date.year' => $since->year, + 'dailyRange.start_date.month' => $since->month, + 'dailyRange.start_date.day' => $since->day, + 'dailyRange.end_date.year' => $until->year, + 'dailyRange.end_date.month' => $until->month, + 'dailyRange.end_date.day' => $until->day, + ]); + + foreach ((array) data_get($response, 'multiDailyMetricTimeSeries', []) as $seriesGroup) { + foreach ((array) data_get($seriesGroup, 'dailyMetricTimeSeries', []) as $series) { + $metric = (string) data_get($series, 'dailyMetric'); + $totals[$metric] = ($totals[$metric] ?? 0) + collect(data_get($series, 'timeSeries.datedValues', [])) + ->sum(fn (array $point): int => (int) data_get($point, 'value', 0)); + } + } + + foreach ($this->api->searchKeywords($location, [ + 'monthlyRange.start_month.year' => $since->year, + 'monthlyRange.start_month.month' => $since->month, + 'monthlyRange.end_month.year' => $until->year, + 'monthlyRange.end_month.month' => $until->month, + ]) as $keyword) { + $term = (string) data_get($keyword, 'searchKeyword'); + $value = data_get($keyword, 'insightsValue.value'); + $threshold = data_get($keyword, 'insightsValue.threshold'); + $keywords[$term] ??= ['value' => 0, 'estimated' => false]; + $keywords[$term]['value'] += (int) ($value ?? $threshold ?? 0); + $keywords[$term]['estimated'] = $keywords[$term]['estimated'] || $threshold !== null; + } + } + + uasort($keywords, fn (array $left, array $right): int => $right['value'] <=> $left['value']); + + return [ + ['label' => 'Profile impressions', 'value' => $totals['BUSINESS_IMPRESSIONS_DESKTOP_MAPS'] + $totals['BUSINESS_IMPRESSIONS_DESKTOP_SEARCH'] + $totals['BUSINESS_IMPRESSIONS_MOBILE_MAPS'] + $totals['BUSINESS_IMPRESSIONS_MOBILE_SEARCH']], + ['label' => 'Calls', 'value' => $totals['CALL_CLICKS']], + ['label' => 'Website clicks', 'value' => $totals['WEBSITE_CLICKS']], + ['label' => 'Direction requests', 'value' => $totals['BUSINESS_DIRECTION_REQUESTS']], + ['label' => 'Conversations', 'value' => $totals['BUSINESS_CONVERSATIONS']], + ['label' => 'Bookings', 'value' => $totals['BUSINESS_BOOKINGS']], + ['label' => 'Food orders', 'value' => $totals['BUSINESS_FOOD_ORDERS']], + ['label' => 'Menu clicks', 'value' => $totals['BUSINESS_FOOD_MENU_CLICKS']], + ...collect($keywords)->take(5)->map( + fn (array $keyword, string $term): array => [ + 'label' => "Search: {$term}", + 'value' => $keyword['estimated'] ? '<'.$keyword['value'] : $keyword['value'], + ], + )->values()->all(), + ]; + } + + /** @return array */ + public function fetchPostMetrics(PostPlatform $postPlatform): array + { + $account = $postPlatform->socialAccount; + $location = $postPlatform->googleBusinessProfileLocation; + if (! $account || ! $location || ! $postPlatform->platform_post_id) { + return ['unsupported' => true, 'reason' => 'missing_account_or_location']; + } + + $this->refreshTokenIfNeeded($account); + + $response = $this->api->localPostInsights( + $location, + $postPlatform->platform_post_id, + ); + $metricValues = (array) data_get($response, 'localPostMetrics.0.metricValues', []); + $metrics = collect($metricValues)->mapWithKeys(fn (array $metric): array => [ + data_get($metric, 'metric') => (int) data_get($metric, 'totalValue.value', 0), + ]); + + return [ + ['label' => 'Views in Google Search', 'value' => (int) $metrics->get('LOCAL_POST_VIEWS_SEARCH', 0)], + ['label' => 'Call-to-action clicks', 'value' => (int) $metrics->get('LOCAL_POST_ACTIONS_CALL_TO_ACTION', 0)], + ]; + } + + private function refreshTokenIfNeeded(SocialAccount $account): void + { + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + } +} diff --git a/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileApi.php b/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileApi.php new file mode 100644 index 000000000..ba120628f --- /dev/null +++ b/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfileApi.php @@ -0,0 +1,175 @@ +> */ + public function accounts(string $accessToken): array + { + return $this->collectPages( + fn (?string $pageToken): Response => $this->http($accessToken)->get(self::ACCOUNT_MANAGEMENT_API.'/accounts', array_filter([ + 'pageSize' => 20, + 'pageToken' => $pageToken, + ])), + 'accounts', + ); + } + + /** @return list> */ + public function locations(string $accessToken, string $accountName): array + { + return $this->collectPages( + fn (?string $pageToken): Response => $this->http($accessToken)->get(self::BUSINESS_INFORMATION_API."/{$accountName}/locations", array_filter([ + 'pageSize' => 100, + 'pageToken' => $pageToken, + 'readMask' => 'name,title,storeCode,storefrontAddress,websiteUri,phoneNumbers,metadata', + ])), + 'locations', + ); + } + + /** @param array $payload + * @return array + */ + public function createLocalPost(GoogleBusinessProfileLocation $location, array $payload): array + { + $parent = $location->google_account_name.'/'.$location->google_location_name; + $response = $this->request(fn () => $this->http($location->socialAccount->access_token) + ->post(self::LOCAL_POSTS_API."/{$parent}/localPosts", $payload)); + + if ($response->failed()) { + throw GoogleBusinessProfilePublishException::fromApiResponse($response); + } + + return $response->json() ?? []; + } + + /** @return array */ + public function localPost(SocialAccount $account, string $localPostName): array + { + $response = $this->request(fn () => $this->http($account->access_token) + ->get(self::LOCAL_POSTS_API.'/'.ltrim($localPostName, '/'))); + + if ($response->failed()) { + throw GoogleBusinessProfilePublishException::fromApiResponse($response); + } + + return $response->json() ?? []; + } + + /** @return array */ + public function localPostInsights(GoogleBusinessProfileLocation $location, string $localPostName): array + { + $parent = $location->google_account_name.'/'.$location->google_location_name; + $response = $this->request(fn () => $this->http($location->socialAccount->access_token) + ->post(self::LOCAL_POSTS_API."/{$parent}/localPosts:reportInsights", [ + 'localPostNames' => [$localPostName], + 'basicRequest' => [ + 'metricRequests' => [ + ['metric' => 'LOCAL_POST_VIEWS_SEARCH', 'options' => ['AGGREGATED_TOTAL']], + ['metric' => 'LOCAL_POST_ACTIONS_CALL_TO_ACTION', 'options' => ['AGGREGATED_TOTAL']], + ], + ], + ])); + + if ($response->failed()) { + throw GoogleBusinessProfilePublishException::fromApiResponse($response); + } + + return $response->json() ?? []; + } + + /** @param array $query + * @return array + */ + public function performance(GoogleBusinessProfileLocation $location, array $query): array + { + $metrics = (array) ($query['dailyMetrics'] ?? []); + unset($query['dailyMetrics']); + $queryString = collect($metrics) + ->map(fn (string $metric): string => 'dailyMetrics='.rawurlencode($metric)) + ->push(http_build_query($query)) + ->filter() + ->implode('&'); + + $response = $this->request(fn () => $this->http($location->socialAccount->access_token) + ->get(self::PERFORMANCE_API.'/'.ltrim($location->google_location_name, '/').':fetchMultiDailyMetricsTimeSeries?'.$queryString)); + + if ($response->failed()) { + throw GoogleBusinessProfilePublishException::fromApiResponse($response); + } + + return $response->json() ?? []; + } + + /** @return list> */ + public function searchKeywords(GoogleBusinessProfileLocation $location, array $query): array + { + return $this->collectPages( + fn (?string $pageToken): Response => $this->http($location->socialAccount->access_token) + ->get(self::PERFORMANCE_API.'/'.ltrim($location->google_location_name, '/').'/searchkeywords/impressions/monthly', [ + ...$query, + 'pageSize' => 100, + 'pageToken' => $pageToken, + ]), + 'searchKeywordsCounts', + ); + } + + private function http(string $accessToken): PendingRequest + { + return Http::acceptJson()->asJson()->withToken($accessToken)->timeout(30)->connectTimeout(10); + } + + /** @param callable(?string): Response $request + * @return list> + */ + private function collectPages(callable $request, string $key): array + { + $items = []; + $pageToken = null; + + do { + $response = $this->request(fn () => $request($pageToken)); + + if ($response->failed()) { + throw GoogleBusinessProfilePublishException::fromApiResponse($response); + } + + $items = [...$items, ...($response->json($key) ?? [])]; + $pageToken = $response->json('nextPageToken'); + } while (filled($pageToken)); + + return $items; + } + + /** @param callable(): Response $request */ + private function request(callable $request): Response + { + try { + return $request(); + } catch (ConnectionException $e) { + throw new PlatformUnavailableException('Google Business Profile API is unavailable: '.$e->getMessage()); + } + } +} diff --git a/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfilePublisher.php b/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfilePublisher.php new file mode 100644 index 000000000..9645c7d37 --- /dev/null +++ b/app/Services/Social/GoogleBusinessProfile/GoogleBusinessProfilePublisher.php @@ -0,0 +1,170 @@ + */ + public function publish(PostPlatform $postPlatform): array + { + $location = $postPlatform->googleBusinessProfileLocation; + + if (! $location || $location->social_account_id !== $postPlatform->social_account_id) { + throw new GoogleBusinessProfilePublishException( + userMessage: 'Choose a valid Google Business Profile location before publishing.', + category: ErrorCategory::Permission, + ); + } + + $account = $postPlatform->socialAccount; + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + + $result = $this->api->createLocalPost($location, $this->payload($postPlatform)); + + return [ + 'id' => data_get($result, 'name'), + 'url' => data_get($result, 'searchUrl'), + 'provider_state' => data_get($result, 'state', 'PROCESSING'), + ]; + } + + /** @return array */ + public function payload(PostPlatform $postPlatform): array + { + $meta = $postPlatform->meta ?? []; + $payload = array_filter([ + 'languageCode' => data_get($meta, 'language_code'), + 'summary' => $postPlatform->post->content, + 'topicType' => $this->topicType($postPlatform->content_type), + 'callToAction' => $this->callToAction($meta), + 'event' => $this->event($postPlatform->content_type, $meta), + 'offer' => $postPlatform->content_type === ContentType::GoogleBusinessProfileOffer ? array_filter([ + 'couponCode' => data_get($meta, 'offer_coupon_code'), + 'redeemOnlineUrl' => data_get($meta, 'offer_redeem_url'), + 'termsConditions' => data_get($meta, 'offer_terms'), + ], fn (mixed $value): bool => filled($value)) : null, + 'alertType' => $postPlatform->content_type === ContentType::GoogleBusinessProfileAlert + ? data_get($meta, 'alert_type') + : null, + ], fn (mixed $value): bool => $value !== null && $value !== [] && $value !== ''); + + if ($media = $postPlatform->post->mediaItems->first()) { + $payload['media'] = [['sourceUrl' => $media->url]]; + } + + return $payload; + } + + /** @param array $meta + * @return array|null + */ + private function callToAction(array $meta): ?array + { + $type = data_get($meta, 'cta_action_type'); + if (blank($type)) { + return null; + } + + return array_filter([ + 'actionType' => $type, + 'url' => $type === 'CALL' ? null : data_get($meta, 'cta_url'), + ], fn (mixed $value): bool => filled($value)); + } + + /** @param array $meta + * @return array|null + */ + private function event(ContentType $contentType, array $meta): ?array + { + if (! in_array($contentType, [ContentType::GoogleBusinessProfileEvent, ContentType::GoogleBusinessProfileOffer], true)) { + return null; + } + + $start = CarbonImmutable::parse((string) data_get($meta, 'event_start_at')); + $end = CarbonImmutable::parse((string) data_get($meta, 'event_end_at')); + + return array_filter([ + 'title' => data_get($meta, 'event_title'), + 'schedule' => [ + 'startDate' => $this->date($start), + 'startTime' => $this->time($start), + 'endDate' => $this->date($end), + 'endTime' => $this->time($end), + ], + 'recurrenceInfo' => $this->recurrence($meta), + ], fn (mixed $value): bool => $value !== null && $value !== []); + } + + /** @param array $meta + * @return array|null + */ + private function recurrence(array $meta): ?array + { + $pattern = data_get($meta, 'recurrence_pattern'); + if (blank($pattern)) { + return null; + } + + $key = match ($pattern) { + 'daily' => 'dailyPattern', + 'weekly' => 'weeklyPattern', + 'monthly' => 'monthlyPattern', + default => null, + }; + if ($key === null) { + return null; + } + + $patternPayload = match ($pattern) { + 'daily' => [], + 'weekly' => ['daysOfWeek' => data_get($meta, 'recurrence_days_of_week', [])], + 'monthly' => array_filter([ + 'dayOfMonth' => data_get($meta, 'recurrence_day_of_month'), + 'dayOfWeekOccurrence' => data_get($meta, 'recurrence_day_of_week_occurrence'), + ], fn (mixed $value): bool => filled($value)), + }; + + return array_filter([ + 'seriesEndTime' => filled(data_get($meta, 'recurrence_series_end_at')) + ? CarbonImmutable::parse((string) data_get($meta, 'recurrence_series_end_at'))->toRfc3339String() + : null, + $key => (object) $patternPayload, + ], fn (mixed $value): bool => $value !== null); + } + + /** @return array{year: int, month: int, day: int} */ + private function date(CarbonImmutable $date): array + { + return ['year' => $date->year, 'month' => $date->month, 'day' => $date->day]; + } + + /** @return array{hours: int, minutes: int, seconds: int, nanos: int} */ + private function time(CarbonImmutable $date): array + { + return ['hours' => $date->hour, 'minutes' => $date->minute, 'seconds' => $date->second, 'nanos' => 0]; + } + + private function topicType(ContentType $contentType): string + { + return match ($contentType) { + ContentType::GoogleBusinessProfileStandard => 'STANDARD', + ContentType::GoogleBusinessProfileEvent => 'EVENT', + ContentType::GoogleBusinessProfileOffer => 'OFFER', + ContentType::GoogleBusinessProfileAlert => 'ALERT', + default => throw new \LogicException('Unsupported Google Business Profile content type.'), + }; + } +} diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index 15cc71046..2449cb8a8 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -5,6 +5,7 @@ namespace App\Support; use App\Enums\PostPlatform\AspectRatio; +use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Models\Post; use Illuminate\Validation\Rule; @@ -31,6 +32,45 @@ class PostPlatformMetaRules 'SELF_ONLY', ]; + public const GOOGLE_BUSINESS_PROFILE_CTA_TYPES = ['BOOK', 'ORDER', 'SHOP', 'LEARN_MORE', 'SIGN_UP', 'CALL']; + + public const GOOGLE_BUSINESS_PROFILE_RECURRENCE_PATTERNS = ['daily', 'weekly', 'monthly']; + + public const GOOGLE_BUSINESS_PROFILE_DAYS_OF_WEEK = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY']; + + public const GOOGLE_BUSINESS_PROFILE_WEEK_OCCURRENCES = ['FIRST', 'SECOND', 'THIRD', 'FOURTH', 'FIFTH', 'LAST']; + + /** + * Resolve an update exactly as UpdatePost will: submitted rows become the + * enabled set and merge their meta patches; omitted rows keep stored state. + * + * @param array|null $submittedPlatforms + * @return list}> + */ + public static function effectivePayloadsForUpdate(Post $post, ?array $submittedPlatforms): array + { + $stored = $post->postPlatforms()->get()->keyBy('id'); + + if ($submittedPlatforms === null) { + return $stored->where('enabled', true)->map(fn ($postPlatform): array => [ + 'content_type' => $postPlatform->content_type->value, + 'meta' => $postPlatform->meta ?? [], + ])->values()->all(); + } + + return collect($submittedPlatforms)->map(function ($platform) use ($stored): array { + $postPlatform = $stored->get(data_get($platform, 'id')); + + return [ + 'content_type' => (string) (data_get($platform, 'content_type') ?? $postPlatform?->content_type?->value ?? ''), + 'meta' => array_filter( + array_merge($postPlatform?->meta ?? [], (array) data_get($platform, 'meta', [])), + fn (mixed $value): bool => $value !== null, + ), + ]; + })->values()->all(); + } + /** * Validation rules for `platforms.*.meta` and all its per-platform sub-keys. * Spread into a FormRequest/MCP tool rule set as the complete meta contract. @@ -64,6 +104,24 @@ public static function rules(): array 'platforms.*.meta.title' => ['sometimes', 'nullable', 'string', 'max:100'], 'platforms.*.meta.link' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + // Google Business Profile local posts + 'platforms.*.meta.language_code' => ['sometimes', 'nullable', 'string', 'max:35'], + 'platforms.*.meta.cta_action_type' => ['sometimes', 'nullable', 'string', Rule::in(self::GOOGLE_BUSINESS_PROFILE_CTA_TYPES)], + 'platforms.*.meta.cta_url' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + 'platforms.*.meta.event_title' => ['sometimes', 'nullable', 'string', 'max:255'], + 'platforms.*.meta.event_start_at' => ['sometimes', 'nullable', 'date'], + 'platforms.*.meta.event_end_at' => ['sometimes', 'nullable', 'date'], + 'platforms.*.meta.offer_coupon_code' => ['sometimes', 'nullable', 'string', 'max:255'], + 'platforms.*.meta.offer_redeem_url' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + 'platforms.*.meta.offer_terms' => ['sometimes', 'nullable', 'string', 'max:5000'], + 'platforms.*.meta.alert_type' => ['sometimes', 'nullable', 'string', Rule::in(['COVID_19'])], + 'platforms.*.meta.recurrence_pattern' => ['sometimes', 'nullable', 'string', Rule::in(self::GOOGLE_BUSINESS_PROFILE_RECURRENCE_PATTERNS)], + 'platforms.*.meta.recurrence_series_end_at' => ['sometimes', 'nullable', 'date'], + 'platforms.*.meta.recurrence_days_of_week' => ['sometimes', 'nullable', 'array', 'min:1'], + 'platforms.*.meta.recurrence_days_of_week.*' => ['string', Rule::in(self::GOOGLE_BUSINESS_PROFILE_DAYS_OF_WEEK)], + 'platforms.*.meta.recurrence_day_of_month' => ['sometimes', 'nullable', 'integer', 'between:1,31'], + 'platforms.*.meta.recurrence_day_of_week_occurrence' => ['sometimes', 'nullable', 'string', Rule::in(self::GOOGLE_BUSINESS_PROFILE_WEEK_OCCURRENCES)], + // Discord 'platforms.*.meta.channel_id' => ['sometimes', 'nullable', 'string'], 'platforms.*.meta.channel_name' => ['sometimes', 'nullable', 'string'], @@ -147,6 +205,12 @@ public static function assertStoredPostPublishable(Post $post): void [$field, $message] = $violation; $errors["platforms.{$index}.meta.{$field}"] = $message; } + + $errors = [...$errors, ...self::googleBusinessProfileErrorsFor( + $postPlatform->content_type, + $postPlatform->meta ?? [], + "platforms.{$index}.meta", + )]; } if ($errors !== []) { @@ -154,6 +218,101 @@ public static function assertStoredPostPublishable(Post $post): void } } + /** + * @param array $platforms + * @param callable(mixed, int): ?ContentType $resolveContentType + */ + public static function addGoogleBusinessProfileErrors(Validator $validator, array $platforms, callable $resolveContentType): void + { + foreach ($platforms as $index => $platform) { + foreach (self::googleBusinessProfileErrorsFor( + $resolveContentType($platform, $index), + (array) data_get($platform, 'meta', []), + "platforms.{$index}.meta", + ) as $field => $message) { + $validator->errors()->add($field, $message); + } + } + } + + /** + * @param array $platforms + * @param callable(mixed, int): ?ContentType $resolveContentType + * + * @throws ValidationException + */ + public static function assertGoogleBusinessProfilePayloads(array $platforms, callable $resolveContentType): void + { + $errors = []; + foreach ($platforms as $index => $platform) { + $errors = [...$errors, ...self::googleBusinessProfileErrorsFor( + $resolveContentType($platform, $index), + (array) data_get($platform, 'meta', []), + "platforms.{$index}.meta", + )]; + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } + + /** @param array $meta + * @return array + */ + private static function googleBusinessProfileErrorsFor(?ContentType $contentType, array $meta, string $prefix): array + { + $gbpTypes = [ + ContentType::GoogleBusinessProfileStandard, + ContentType::GoogleBusinessProfileEvent, + ContentType::GoogleBusinessProfileOffer, + ContentType::GoogleBusinessProfileAlert, + ]; + if (! in_array($contentType, $gbpTypes, true)) { + return []; + } + + $errors = []; + if (filled(data_get($meta, 'cta_action_type')) + && data_get($meta, 'cta_action_type') !== 'CALL' + && blank(data_get($meta, 'cta_url'))) { + $errors["{$prefix}.cta_url"] = 'A destination URL is required for this call to action.'; + } + + if (in_array($contentType, [ContentType::GoogleBusinessProfileEvent, ContentType::GoogleBusinessProfileOffer], true)) { + foreach (['event_title' => 'Event title', 'event_start_at' => 'Event start', 'event_end_at' => 'Event end'] as $field => $label) { + if (blank(data_get($meta, $field))) { + $errors["{$prefix}.{$field}"] = "{$label} is required for this Google Business Profile post type."; + } + } + + if (filled(data_get($meta, 'event_start_at')) && filled(data_get($meta, 'event_end_at')) + && strtotime((string) data_get($meta, 'event_end_at')) <= strtotime((string) data_get($meta, 'event_start_at'))) { + $errors["{$prefix}.event_end_at"] = 'Event end must be after event start.'; + } + } + + if ($contentType === ContentType::GoogleBusinessProfileAlert && blank(data_get($meta, 'alert_type'))) { + $errors["{$prefix}.alert_type"] = 'Alert type is required for a Google Business Profile alert.'; + } + + if (data_get($meta, 'recurrence_pattern') === 'weekly' && blank(data_get($meta, 'recurrence_days_of_week'))) { + $errors["{$prefix}.recurrence_days_of_week"] = 'Choose at least one weekday for weekly recurrence.'; + } + + if (data_get($meta, 'recurrence_pattern') === 'monthly') { + $monthlyOptions = collect([ + data_get($meta, 'recurrence_day_of_month'), + data_get($meta, 'recurrence_day_of_week_occurrence'), + ])->filter(fn (mixed $value): bool => filled($value))->count(); + if ($monthlyOptions !== 1) { + $errors["{$prefix}.recurrence_day_of_month"] = 'Choose either a day of month or a weekday occurrence for monthly recurrence.'; + } + } + + return $errors; + } + /** * The missing required meta field for a platform about to publish, or null when * nothing is missing. Single source of "what each platform requires to publish". diff --git a/config/services.php b/config/services.php index 4b5d5690f..d2ff38bdf 100644 --- a/config/services.php +++ b/config/services.php @@ -68,6 +68,14 @@ 'redirect' => env('GOOGLE_CLIENT_REDIRECT'), ], + // Google OAuth used for Business Profile management. A separate client is + // supported so YouTube and GBP can have independent consent screens. + 'google-business-profile' => [ + 'client_id' => env('GOOGLE_BUSINESS_PROFILE_CLIENT_ID'), + 'client_secret' => env('GOOGLE_BUSINESS_PROFILE_CLIENT_SECRET'), + 'redirect' => env('GOOGLE_BUSINESS_PROFILE_CLIENT_REDIRECT'), + ], + // Google OAuth (used for login/signup) 'google-auth' => [ 'client_id' => env('GOOGLE_CLIENT_ID'), diff --git a/config/trypost.php b/config/trypost.php index f9d038e4d..76b35d21f 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -182,6 +182,10 @@ 'analytics_api' => env('YOUTUBE_ANALYTICS_API', 'https://youtubeanalytics.googleapis.com/v2'), 'oauth_api' => env('YOUTUBE_OAUTH_API', 'https://oauth2.googleapis.com'), ], + 'google-business-profile' => [ + 'enabled' => env('GOOGLE_BUSINESS_PROFILE_ENABLED', true), + 'oauth_api' => env('GOOGLE_BUSINESS_PROFILE_OAUTH_API', 'https://oauth2.googleapis.com'), + ], 'facebook' => [ 'enabled' => env('FACEBOOK_ENABLED', true), 'graph_api' => env('FACEBOOK_GRAPH_API', 'https://graph.facebook.com/v25.0'), diff --git a/database/factories/GoogleBusinessProfileLocationFactory.php b/database/factories/GoogleBusinessProfileLocationFactory.php new file mode 100644 index 000000000..e4f190eb4 --- /dev/null +++ b/database/factories/GoogleBusinessProfileLocationFactory.php @@ -0,0 +1,41 @@ + */ +class GoogleBusinessProfileLocationFactory extends Factory +{ + protected $model = GoogleBusinessProfileLocation::class; + + public function definition(): array + { + $locationId = fake()->unique()->numerify('###############'); + + return [ + 'social_account_id' => SocialAccount::factory()->state([ + 'platform' => Platform::GoogleBusinessProfile, + 'scopes' => ['https://www.googleapis.com/auth/business.manage'], + ]), + 'google_account_name' => 'accounts/'.fake()->numerify('##########'), + 'google_location_name' => 'locations/'.$locationId, + 'title' => fake()->company(), + 'store_code' => fake()->optional()->bothify('STORE-###'), + 'timezone' => fake()->timezone(), + 'maps_uri' => 'https://maps.google.com/?cid='.$locationId, + 'website_uri' => fake()->url(), + 'phone_number' => fake()->phoneNumber(), + 'storefront_address' => ['locality' => fake()->city(), 'regionCode' => 'US'], + 'metadata' => [], + 'is_selected' => true, + 'is_verified' => true, + 'last_synced_at' => now(), + ]; + } +} diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index cb39e970f..90066bb5d 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -123,6 +123,14 @@ public function youtube(): static ]); } + public function googleBusinessProfile(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::GoogleBusinessProfile, + 'content_type' => ContentType::GoogleBusinessProfileStandard, + ]); + } + public function pinterest(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index 927aba12a..a3c31df25 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -77,6 +77,15 @@ public function youtube(): static ]); } + public function googleBusinessProfile(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::GoogleBusinessProfile, + 'scopes' => Platform::GoogleBusinessProfile->requiredPublishScopes(), + 'meta' => ['google_user_id' => 'google-user-123'], + ]); + } + public function facebook(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_08_25_120000_create_google_business_profile_locations_table.php b/database/migrations/2026_08_25_120000_create_google_business_profile_locations_table.php new file mode 100644 index 000000000..2f8178ccf --- /dev/null +++ b/database/migrations/2026_08_25_120000_create_google_business_profile_locations_table.php @@ -0,0 +1,40 @@ +uuid('id')->primary(); + $table->foreignUuid('social_account_id')->constrained()->cascadeOnDelete(); + $table->string('google_account_name'); + $table->string('google_location_name'); + $table->string('title'); + $table->string('store_code')->nullable(); + $table->string('timezone')->nullable(); + $table->text('maps_uri')->nullable(); + $table->text('website_uri')->nullable(); + $table->string('phone_number')->nullable(); + $table->json('storefront_address')->nullable(); + $table->json('metadata')->nullable(); + $table->boolean('is_selected')->default(false); + $table->boolean('is_verified')->default(false); + $table->timestamp('last_synced_at')->nullable(); + $table->timestamps(); + + $table->unique(['social_account_id', 'google_location_name'], 'gbp_locations_account_location_unique'); + $table->index(['social_account_id', 'is_selected']); + }); + } + + public function down(): void + { + Schema::dropIfExists('google_business_profile_locations'); + } +}; diff --git a/database/migrations/2026_08_25_120001_add_google_business_profile_fields_to_post_platforms_table.php b/database/migrations/2026_08_25_120001_add_google_business_profile_fields_to_post_platforms_table.php new file mode 100644 index 000000000..d7e9443c7 --- /dev/null +++ b/database/migrations/2026_08_25_120001_add_google_business_profile_fields_to_post_platforms_table.php @@ -0,0 +1,31 @@ +foreignUuid('google_business_profile_location_id') + ->nullable() + ->after('social_account_id') + ->constrained('google_business_profile_locations') + ->nullOnDelete(); + $table->timestamp('submitted_at')->nullable()->after('published_at'); + $table->timestamp('last_reconciled_at')->nullable()->after('submitted_at'); + }); + } + + public function down(): void + { + Schema::table('post_platforms', function (Blueprint $table) { + $table->dropConstrainedForeignId('google_business_profile_location_id'); + $table->dropColumn(['submitted_at', 'last_reconciled_at']); + }); + } +}; diff --git a/lang/en/accounts.php b/lang/en/accounts.php index ce62dbfdb..6cb92aac0 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -21,6 +21,7 @@ 'x' => 'Connect your X (Twitter) account', 'tiktok' => 'Connect your TikTok account', 'youtube' => 'Connect a YouTube channel', + 'google-business-profile' => 'Connect one or more Google Business Profile locations', 'facebook' => 'Connect a Facebook page', 'instagram' => 'Connect via Instagram Login or Facebook Pages', 'instagram-facebook' => 'Connect Instagram via Facebook page', @@ -90,6 +91,16 @@ 'choose' => 'Choose', ], + 'google_business_profile' => [ + 'title' => 'Select Google Business Profile locations', + 'description' => 'Choose every location you want to publish to and analyze in TryPost.', + 'no_locations' => 'No locations found', + 'no_locations_description' => 'This Google account does not manage any Business Profile locations.', + 'store_code' => 'Store code: :code', + 'save' => 'Connect selected locations', + 'saving' => 'Connecting...', + ], + 'instagram_facebook' => [ 'title' => 'Select Instagram Account', 'description' => 'Choose which Instagram account you want to connect', @@ -146,6 +157,8 @@ 'wrong_account' => 'That is a different account. Authorize the one you are reconnecting.', 'all_connected' => 'Every account on this login is already connected.', 'busy' => 'Another connection is still finishing. Please try again in a moment.', + 'no_google_business_profile_locations' => 'No Google Business Profile locations were found for this Google account.', + 'error_connecting_google_business_profile' => 'Could not connect Google Business Profile. Please try again.', 'error_connecting_page' => 'Error connecting page. Please try again.', 'error_connecting_channel' => 'Error connecting channel. Please try again.', 'session_expired' => 'Session expired. Please try again.', diff --git a/lang/en/posts.php b/lang/en/posts.php index ffdf6cecf..d76af7a76 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -420,7 +420,10 @@ 'published' => 'Published', 'publishing' => 'Publishing...', 'retrying' => 'Retrying...', + 'submitted' => 'Submitted to Google', + 'pending_review' => 'Pending Google review', 'failed' => 'Failed', + 'rejected' => 'Rejected', ], 'delete_modal' => [ @@ -514,6 +517,22 @@ 'label' => 'Short', 'description' => 'Vertical video up to 3 minutes', ], + 'google_business_profile_standard' => [ + 'label' => 'Update', + 'description' => 'Business update with optional media and call to action', + ], + 'google_business_profile_event' => [ + 'label' => 'Event', + 'description' => 'Timed or recurring event with optional media and call to action', + ], + 'google_business_profile_offer' => [ + 'label' => 'Offer', + 'description' => 'Timed or recurring offer with coupon and redemption details', + ], + 'google_business_profile_alert' => [ + 'label' => 'Alert', + 'description' => 'High-priority alert where Google makes authoring available', + ], 'x_post' => [ 'label' => 'Post', 'description' => 'Tweet with text and media', @@ -558,6 +577,7 @@ 'x' => 'X', 'tiktok' => 'TikTok', 'youtube' => 'YouTube Shorts', + 'google-business-profile' => 'Google Business Profile', 'facebook' => 'Facebook Page', 'instagram' => 'Instagram', 'threads' => 'Threads', diff --git a/public/images/accounts/google-business-profile.svg b/public/images/accounts/google-business-profile.svg new file mode 100644 index 000000000..05317bc4d --- /dev/null +++ b/public/images/accounts/google-business-profile.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index ce3d00e87..ca8cdfac8 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -4,6 +4,7 @@ import { computed } from 'vue'; import DiscordSettings from '@/components/posts/editor/DiscordSettings.vue'; import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue'; +import GoogleBusinessProfileSettings from '@/components/posts/editor/GoogleBusinessProfileSettings.vue'; import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue'; import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue'; import PinterestSettings from '@/components/posts/editor/PinterestSettings.vue'; @@ -84,6 +85,7 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel ! + ! props.channels.filter((channel) => isSel @update:content-type="emit('update:contentType', channel.id, $event)" @update:meta="emit('update:meta', channel.id, $event)" /> + +import { IconChevronDown, IconChevronUp } from '@tabler/icons-vue'; +import { computed, ref } from 'vue'; + +import { Input } from '@/components/ui/input'; +import { getPlatformLogo } from '@/composables/usePlatformLogo'; +import { ContentType } from '@/types/content-type'; + +interface Props { + socialAccount: { display_label: string } | null; + contentType: string; + meta: Record; + disabled?: boolean; + previewOnly?: boolean; +} + +const props = withDefaults(defineProps(), { disabled: false, previewOnly: false }); +const emit = defineEmits<{ + 'update:contentType': [value: string]; + 'update:meta': [value: Record]; +}>(); + +const open = ref(true); +const variants = [ + { value: ContentType.GoogleBusinessProfileStandard, label: 'Update' }, + { value: ContentType.GoogleBusinessProfileEvent, label: 'Event' }, + { value: ContentType.GoogleBusinessProfileOffer, label: 'Offer' }, + { value: ContentType.GoogleBusinessProfileAlert, label: 'Alert' }, +]; +const ctaTypes = [ + ['', 'No button'], ['BOOK', 'Book'], ['ORDER', 'Order'], ['SHOP', 'Shop'], + ['LEARN_MORE', 'Learn more'], ['SIGN_UP', 'Sign up'], ['CALL', 'Call'], +]; +const weekdays = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY']; +const isEvent = computed(() => [ContentType.GoogleBusinessProfileEvent, ContentType.GoogleBusinessProfileOffer].includes(props.contentType as any)); +const isOffer = computed(() => props.contentType === ContentType.GoogleBusinessProfileOffer); +const isAlert = computed(() => props.contentType === ContentType.GoogleBusinessProfileAlert); + +const update = (key: string, value: any) => emit('update:meta', { ...props.meta, [key]: value === '' ? null : value }); +const toggleWeekday = (day: string) => { + const current = (props.meta.recurrence_days_of_week ?? []) as string[]; + update('recurrence_days_of_week', current.includes(day) ? current.filter((item) => item !== day) : [...current, day]); +}; + + +