Skip to content

Add Google Business Profile as a supported social platform - #270

Open
paulocastellano wants to merge 56 commits into
mainfrom
worktree-google-business-profile
Open

Add Google Business Profile as a supported social platform#270
paulocastellano wants to merge 56 commits into
mainfrom
worktree-google-business-profile

Conversation

@paulocastellano

@paulocastellano paulocastellano commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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

  • Update, Event and Offer posts, with an optional call-to-action button and image.
  • The review state is respected. Google answers a create with 200 long before the post clears moderation — the response carries a state that says what actually happened. A post that goes LIVE publishes with the searchUrl Google returns; one that comes back REJECTED is marked rejected rather than reported as published; PROCESSING and SCHEDULED park the target in a new pending_review status, 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.
  • Images are sent as a JPEG derivative built to the platform's MediaOptimizer profile, so Google is not handed a raw upload it will refuse to fetch.
  • The location picker skips listings whose metadata.canOperateLocalPost is false, so you cannot connect a location that can never receive a post.
  • Content length, token refresh, connection verification and error classification follow the same shape as the sibling platforms.

Analytics

  • Eight metrics every profile reports: search and maps impressions (desktop and mobile), website clicks, calls, direction requests and conversations.
  • Bookings, food orders and menu clicks are fetched too, but only rendered when the period reported something — Google fills them only for a matching business type, and three permanent zeros read as a broken dashboard.
  • Search keywords: the terms people typed before landing on the profile. Google aggregates these monthly, so the range is widened to whole months and the block labels the period it actually got. Low-volume terms come back as a privacy threshold rather than a count, and render as <15 instead of stating a number Google withheld.

Not included

  • GET_OFFER is deprecated in the v4 ActionType enum.
  • The ALERT topic type only accepts COVID_19, which Google has disabled for new posts.
  • Per-post insights: localPosts.reportInsights was 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.
  • Event recurrence is a deliberate follow-up, not an oversight.

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

  • Automated test suite passes
  • Connect a real Google Business Profile location end-to-end
  • Publish one post of each type (Update, Event, Offer)
  • Confirm the post shows up on Google, and that a post Google rejects lands as Rejected here
  • Check the analytics tab shows real metrics and real search keywords

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.
…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().
…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 paulocastellano self-assigned this Aug 11, 2026
@paulocastellano
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
paulocastellano marked this pull request as ready for review September 1, 2026 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant