Add Google Business Profile as a supported social platform - #270
Open
paulocastellano wants to merge 56 commits into
Open
Add Google Business Profile as a supported social platform#270paulocastellano wants to merge 56 commits into
paulocastellano wants to merge 56 commits into
Conversation
Implements GoogleBusinessAnalytics service to fetch five Google Business Profile performance metrics (website clicks, call clicks, direction requests, desktop map impressions, mobile map impressions) via the Performance API. Adds i18n keys to all 16 locale files. TDD: Red test passing, 3 assertions validated.
Replace English translations in 15 non-English locale files with proper translations matching each locale's existing terminology and style: - Arabic: Arabic translations - German: German translations - Greek: Greek translations - Spanish: Spanish translations - French: French translations - Italian: Italian translations - Japanese: Japanese translations - Korean: Korean translations - Dutch: Dutch translations - Polish: Polish translations - Portuguese-BR: Portuguese translations - Russian: Russian translations - Turkish: Turkish translations - Ukrainian: Ukrainian translations - Chinese: Chinese (Simplified) translations Verified by: LocalizationParityTest (18 passed) + GoogleBusinessAnalytics (2 passed)
Adds GoogleBusinessController with connect/callback/select-location/select routes, mirroring FacebookController's auto-connect-on-single-location and picker-on-multiple pattern. Registers the 'google-business' Socialite driver (separate from 'google-auth' and YouTube's 'google') and adds the required i18n keys across all 16 locales.
…form's post update platforms.*.meta.call_to_action.url used required_unless, an implicit Laravel validation rule that still runs when the field (and its whole call_to_action block) is absent. Since no other platform sends call_to_action at all, the missing action_type was treated as "not NONE/CALL" and made the URL required on every post update (Pinterest, Discord, TikTok, etc.) across web, the REST API, and MCP. Follow the existing board_id/channel_id pattern: keep the rules() entry unconditional and enforce the CTA-required-unless-NONE/CALL check only in requiredMetaViolation(), scoped to Platform::GoogleBusiness. Adds the cta_url_required translation key (all 16 locales) and test coverage for both the regression and the still-enforced Google Business requirement.
…ry points Closes the CLAUDE.md-mandated round-trip coverage gap for PostPlatformMetaRules' topic_type/call_to_action/event/offer fields, proving they survive the full validate-then-persist path through all three post entry points, plus a token-refresh-window regression test for the Google Business rotating refresh_token model.
…sTest The file has no namespace declaration, so ReflectionMethod is already in the global namespace. Importing it via 'use' is a no-op and raises an error under the test environment's error-reporting configuration.
…nd OAuth popup routes Platform.GoogleBusiness was undefined, so the composer settings card never mounted and the Connect button silently no-opped.
…s API
The v1 Business Information API returns Location.name in its short
locations/{id} form, but the v4 localPosts endpoint requires the full
accounts/*/locations/* parent. fetchLocationsForAccount now composes the
full name for meta.location_id and keeps the short name in
meta.location_name, which is what the v1 verify call expects.
…ller The controller had no SUPPORTED_PLATFORMS entry or match arm, so the service was unreachable. The Performance API call also used the full location resource name (it wants the short one) and encoded dailyMetrics as bracket-indexed array params instead of repeated scalars.
…-side Mirrors PostPlatformMetaRules::requiredMetaViolation() so the composer shows an inline tooltip instead of letting the user hit a raw 422.
Google's v4 Local Posts API requires the `event` field for both the EVENT and OFFER topic types, so Offer posts were being rejected with INVALID_ARGUMENT because no `event` object was ever attached. Both topic types now reuse the same `meta.event.*` fields (title + date range), matching the API shape and Google's own Business Profile app flow, where creating an Offer also asks for a title and date range before the coupon details. The backend publish requirement, the editor sub-form, and the frontend compliance gate are all gated on the shared topic-type list.
…ation
findError() matched backend error keys by suffix only, so a post targeting
two Google Business Profile locations showed one location's error under
both settings panels.
ChannelConfigurator now passes each settings panel its position in the
selected-channel list, and GoogleBusinessSettings matches the full
`platforms.{index}.meta.*` key. That position is the submitted index: the
editor builds the `platforms` payload by filtering the same post_platforms
list by the same selection, which is the mapping ScheduleTab already relies
on for content-type errors.
Pinterest and Discord share the same suffix-match limitation; fixing them
is left to a separate change.
callback() never read session('social_reconnect_id'), so the reconnect
branch in select() could never fire even once a reconnect trigger exists.
Captures it and stores it in the google_business_oauth session payload, the
same way InstagramFacebookController already does.
…or and preview The settings panel's option list and the preview's label lookup each hand-wrote the same action types, so adding or renaming one meant editing both. Both now read resources/js/lib/googleBusiness.ts.
It had no callers: the connect flow has no SocialAccount yet, so the controller always calls fetchLocations() with the raw access token.
- selectLocation() now checks manageAccounts like callback()/select() do - the locations fetched during callback() are cached in the OAuth session payload, so a multi-location connect hits Google once instead of three times - select()'s inline validation moves to a dedicated FormRequest
Also disables the topic-type pills in preview-only mode, like every other input in the panel.
… analytics services
… model and publisher
…type The connect grid had no google_business entry in its platform theme map, so the tile fell back to an empty image, and the automation Generate node had no case for the platform, leaving its content type blank.
data_get()'s default only applies when the key is missing, so a stored 'topic_type' => null published an empty topicType. Same bug class as the CTA action type fixed earlier. Also hoists the repeated EVENT/OFFER guard out of the match(true) arms and points the publisher at the shared topic-type constant instead of a literal copy of the list.
…oday
CarbonImmutable::parse('') resolves to now(), so a blank start or end date
reaching publish() silently sent today's date to Google. buildEvent() now
throws the same way the missing-location guard does.
…path The location-not-found exit left live access and refresh tokens in the session, unlike every other exit in select(). Also extracts the shared location attribute builder so the reconnect branch stops duplicating connectLocation().
…nd MCP entry points
…atform meta validation Captures two gotchas discovered while building the Google Business Profile integration, so the next platform addition finds them instead of re-deriving the same bugs.
paulocastellano
marked this pull request as draft
August 11, 2026 14:59
Resolve four conflicts and adapt Google Business Profile to what landed on main since this branch forked: - AnalyticsController: keep main's metricsFor() wrapper, which swallows an unreachable platform instead of 500ing the analytics page, and add the Google Business arm inside it. - ConnectionVerifier: main made the refresh match exhaustive behind hasTokenRefreshFlow(), so the Google Business arm drops its default. - NetworkConnectGrid: the per-platform tile theme moved to usePlatformLogo::getPlatformTheme, so google_business moves with it. - usePostCompliance: keep both the X link defuser and the Google Business topic types. Two things main's newer tests caught: - refreshGoogleBusinessToken wrote data_get($data, 'access_token') straight into the column, so a 200 with no token nulled a working access_token and disconnected the account. It now goes through tokenFrom(), like every sibling, and uses refreshHttp() for the shared timeouts. - The rotating-window test seated two Google Business accounts in one workspace, which the one-account-per-network rule now refuses. Each window gets its own workspace.
Every other network's controller now goes through SocialAccount::connectIdentity(), which main added with the one-per-network cache lock, the identity check on reconnect, and the unpublished-target realignment. Google Business was the only one still calling updateOrCreate() directly, so it took none of them. The identity check is the one that mattered: the old reconnect path overwrote platform_user_id with whatever location the picker returned, so reconnecting and picking a different store silently repointed the card — and every post scheduled against it — at another business. That is refused now, with the wrong_account message the sibling flows already show.
Google's Local Posts API answers a create with 200 and a LocalPost whose `state` says what actually happened: LIVE, PROCESSING, SCHEDULED, RECURRING or REJECTED. We read the `name` and threw the rest away, so a post Google refused in review was stored as Published, sent a "published successfully" email, and linked to the location dashboard rather than the post — the one place a user might have noticed it does not exist. The publisher now reports the state it was given and prefers the LocalPost's own `searchUrl` over the dashboard URL, falling back when Google omits it. A REJECTED target is marked rejected, keeping its platform_post_id and URL: unlike a failure, the remote row exists and is worth having for support. Rejected joins Published and Failed as a terminal, finished status, so a post whose only target was rejected finalizes and notifies instead of sitting in publishing forever, and a re-dispatched job leaves it alone. PROCESSING and SCHEDULED still resolve optimistically for now; the polling that settles them comes next.
Reading the state was only half the fix: PROCESSING and SCHEDULED still resolved optimistically, so a post Google had merely accepted was reported as published and only a later rejection would have contradicted it. Those two states now park the target in a new pending_review status, which is neither published nor failed, so the post stays open and notifies nobody. A scheduled command sweeps them every five minutes and dispatches a job that re-reads the Local Post: LIVE and RECURRING publish it with the searchUrl Google returns, REJECTED rejects it, anything else waits. Waiting cannot be forever. A target that has sat in review past REVIEW_CEILING_HOURS is given up on rather than left pending for good — 24 hours, deliberately generous, because Google normally clears review in minutes and failing a merely slow post is the worse mistake. The sweep also skips targets checked in the last five minutes so a tick does not re-poll everything it just polled. Finalizing a post now lives in FinalizePostPublication, shared by the publish job and the reconciliation job, because the target that finishes a post is no longer always the one the publish job was holding. It also lists rejected targets in the failure notification, which the old copy in the publish job would have left out. One fixture changed: a test faked a create response with no `state`. Google always sends one, and an absent state is now read as "not confirmed yet", so the fixture said something the API never says.
The panel read the two Maps impression metrics and skipped the two Search ones, which for most businesses are the larger half of the number — a shop could see a few hundred Maps impressions and never learn about the few thousand from Search. Both are in now, along with BUSINESS_CONVERSATIONS, taking the universal set from five metrics to eight. Bookings, food orders and menu clicks are requested too, but only rendered when the period reported something. Google fills them only for a matching business type, so a dentist would otherwise open the dashboard to three permanent zeros and reasonably conclude the numbers were broken.
…n post The publisher handed Google the raw upload URL and let it fetch whatever the user happened to attach. Google enforces its own size and format rules on that fetch, so a large PNG came back refused with nothing on our side to explain why — and the MediaOptimizer profile for this platform, added with the feature, was never called by anything. It is called now: the image goes out as a JPEG derivative built to that profile, stored beside the original on the default disk. A failure to build one falls back to the original rather than failing the post, since Google may well have accepted it. Two smaller corrections while in here. The accounts listing asked for a page of 100 where the documented maximum is 20; Google clamps it, so pagination carried the difference and the number was simply wrong. And the location picker now skips a listing whose metadata says canOperateLocalPost is false — the flag already arrived in the readMask we request and was thrown away, so a user could connect a location that can never receive a post. An absent flag still counts as allowed; only an explicit refusal disqualifies.
The daily metrics say how many people saw the profile; the search keywords say what they typed. It is the one number in Business Profile analytics that reads like SEO, and the panel had none of it. Google aggregates keywords by month, not by day, so the day range the user picked cannot be honoured. Rather than pretend, the request is widened to the whole months it touches and the block labels the period it actually got. Low-volume terms come back with a privacy threshold instead of a count — "fewer than 15" rather than 15. Those render as `<15`, because printing the floor as if it were the count states a number Google deliberately refused to give. The list paginates, and lives in its own block rather than as more metric cards, since it is neither daily nor a single figure.
paulocastellano
marked this pull request as ready for review
September 1, 2026 00:51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds Google Business Profile as a supported social platform: connect a location over OAuth, publish Local Posts, and read the location's performance in Analytics. Works from the editor, the public API and MCP.
Publishing
200long before the post clears moderation — the response carries astatethat says what actually happened. A post that goesLIVEpublishes with thesearchUrlGoogle returns; one that comes backREJECTEDis marked rejected rather than reported as published;PROCESSINGandSCHEDULEDpark the target in a newpending_reviewstatus, and a command sweeps those every five minutes until they settle. A target that never settles is given up on after 24 hours instead of hanging forever.MediaOptimizerprofile, so Google is not handed a raw upload it will refuse to fetch.metadata.canOperateLocalPostisfalse, so you cannot connect a location that can never receive a post.Analytics
<15instead of stating a number Google withheld.Not included
GET_OFFERis deprecated in the v4ActionTypeenum.ALERTtopic type only acceptsCOVID_19, which Google has disabled for new posts.localPosts.reportInsightswas sunset on 2022-11-21 and discontinued on 2023-02-20, along with the only two metrics it took, with no replacement. Business Profile has no per-post insights API — only location-level metrics on Performance v1.Credit
#318 by @thethomasjfellows landed on the same feature independently and got several things right that this branch had wrong. The review-state reconciliation, the missing search-impression metrics, the search keyword threshold handling and the JPEG derivative all come from reading that work.
Test plan before merging