Shared

Google OAuth callback: exchange the code, resolve the location, store the connection

get/api/google-business/callback

Where Google sends the browser back to after the owner grants or denies consent: same browser, same session as /connect, which is why this reuses requireMember('admin') rather than a separate scheme.

Validates state against the gbp_oauth_state cookie /connect set; a mismatch (or missing cookie) is gbp_error=state_mismatch rather than proceeding. Exchanges code for tokens, requires a refresh token to be present (a fresh grant with prompt=consent should always include one; a response without one is refused rather than silently stored access-token-only, which would look connected and stop working the moment that token expires).

v1 assumes one Google Business Profile location per connection, matching this app's own one-book_companies-row-per-physical-location model. Takes the caller's first Google account, then within it the location whose metadata.placeId matches this company's own google_place_id (0031, the same id already captured for free during onboarding's Places search), falling back to that account's first location when there is no match or the company never ran that search. A business with several GBP locations connecting the wrong one is a documented v1 limitation (docs/google-business-profile.md), not a silent bug.

Stores the connection via book_google_business_connections (0090): a refresh token AES-256-GCM encrypted at rest, an access token cached alongside it. The Google account's email is read from the signed-in admin's own session, not from Google's token response (which doesn't include it), since a fifth external call for a label shown for reassurance only was not worth it.

Authorization

sessionCookie
sb-qxrvgfkjyvbngipqvslu-auth-token<token>

The dashboard's Supabase Auth session cookie, set at sign-in. Large sessions are split across numbered chunks (…auth-token.0, .1), so treat this as a cookie family rather than one name.

Every request re-validates it against the Auth server (getUser()), never by decoding the cookie locally: a JWT nothing has checked is not a credential. Tenancy is then read from the verified app_metadata.company_id claim and enforced by row-level security; it is never read from request input, on any route, ever.

role (admin / staff) is deliberately not in RLS. It gates specific actions in route code, the operations marked admin below, so hiding a button in the UI is cosmetic only, and a route's own check is the enforcement.

In: cookie

Query Parameters

code?string

Google's authorization code. Absent when error is present instead.

state?string

Echoed back from /connect; must match the gbp_oauth_state cookie.

error?string

Google's own denial shape, e.g. access_denied when the owner clicks Cancel on the consent screen. Not a fault; they changed their mind.

Response Body

curl -X GET "https://example.com/api/google-business/callback"
Empty

Start the Google Business Profile OAuth flow (admin, redirect-only) GET

Full top-level navigation, not a fetch: clicking "Connect" in the Reviews module lands here, and this redirects straight to Google's own consent screen for the `business.manage` scope. `access_type=offline` + `prompt=consent` force a refresh token on every grant. Blocked until Google approves this app's own Business Profile API access AND verifies the OAuth consent screen for that scope (see docs/google-business-profile.md); `not_configured` below is what a deployment without both looks like. Sets a short-lived, httpOnly `gbp_oauth_state` cookie carrying a random nonce, echoed back to `/api/google-business/callback` as `state`. This is the CSRF defence for this flow: without it, an attacker who starts their OWN consent grant could trick a victim admin into completing it, linking the attacker's Google account to the victim's company. Every failure redirects back to `/reviews?tab=google&gbp_error=<code>` rather than returning JSON: there is no client here to react to a JSON body, only a browser mid-navigation.

Disconnect this company's Google Business Profile POST

Admin-only. Best-effort revokes the stored refresh token against Google's own `/revoke` endpoint BEFORE deleting the `book_google_business_connections` row (0090): a revoke that fails (already expired, a network blip) must not block the disconnect, or an owner trying to leave would be stuck because of the very connection they're trying to leave. Cached reviews in `book_google_reviews` are left as historical record, not deleted; they simply stop refreshing once the connection is gone.