Class: Pinterest::OauthService

Inherits:
Object
  • Object
show all
Defined in:
app/services/pinterest/oauth_service.rb

Overview

Handles Pinterest OAuth 2.0 token exchange and refresh for WarmlyYours'
Pinterest integration: the advertiser (Marketing API) campaign sync that
drives PinterestCampaignSyncWorker, plus organic content management —
creating and updating boards and pins on the brand account.

System-level credential (account_id: nil) — one shared token for the
WarmlyYours Pinterest account, not a per-employee login.

Replaces the manual dev-portal "Generate token" advertiser_access_token,
which expired roughly every 30 days with no refresh path and produced the
recurring campaign-sync 401s (AppSignal #5228 / #5225).

Flow

  1. Crm::PinterestOauthController#authorize redirects an admin to
    #authorization_url.
  2. Pinterest redirects back to /pinterest/oauth/callback?code=….
  3. #exchange_code! swaps the code for an access token + a continuous
    refresh token, stored in OauthCredential.
  4. Access tokens last 30 days; PinterestTokenRefreshWorker refreshes
    well before expiry. The refresh token lasts 60 days and is reissued on
    every refresh ("continuous"), so the credential renews indefinitely
    with no further human action.

Defined Under Namespace

Classes: TokenRefreshError

Constant Summary collapse

PROVIDER =

OAuth provider key stored on OauthCredential#provider.

'pinterest'
AUTHORIZE_URL =

Endpoint the admin's browser is redirected to for consent.

'https://www.pinterest.com/oauth/'
SCOPES =

Scopes requested during the OAuth consent flow, sent comma-separated.
Beyond the campaign sync's read-only ads:read, this token also manages
WarmlyYours' organic Pinterest presence — creating/updating boards and
pins (including secret ones) and reading account analytics.

Widening this list only affects new authorizations: an existing token
keeps its granted scope until the OAuth flow is re-run (the refresh
worker never expands scope). After changing it, reconnect via
/admin/oauth_credentials.

%w[
  ads:read
  user_accounts:read
  boards:read boards:read_secret boards:write boards:write_secret
  pins:read pins:read_secret pins:write pins:write_secret
].join(',')

Instance Method Summary collapse

Constructor Details

#initialize(account: nil) ⇒ OauthService

Returns a new instance of OauthService.

Parameters:

  • account (Account, nil) (defaults to: nil)

    always nil for Pinterest — a single,
    system-level ad-account credential. The keyword is kept for
    signature parity with the other OAuth services (Zoom/Basecamp/YouTube).



57
58
59
60
61
# File 'app/services/pinterest/oauth_service.rb', line 57

def initialize(account: nil)
  @account       = 
  @client_id     = Heatwave::Configuration.fetch(:pinterest, :app_id)
  @client_secret = Heatwave::Configuration.fetch(:pinterest, :app_secret_key)
end

Instance Method Details

#access_token!String

Return a valid access token. Prefers the OAuth credential (refreshing
lazily when expired); otherwise falls back to the environment's static
advertiser_access_token — used in dev/staging, and in production until
the OAuth flow has been connected.

Returns:

  • (String)

Raises:



115
116
117
118
119
120
121
# File 'app/services/pinterest/oauth_service.rb', line 115

def access_token!
  credential = OauthCredential.for(PROVIDER, account: @account)
  return static_token! unless credential

  refresh! if credential.token_expired?
  credential.reload.access_token
end

#authorization_url(state:) ⇒ String

Pinterest consent URL to redirect the admin to for one-time authorization.

Parameters:

  • state (String)

    CSRF token echoed back to the callback

Returns:

  • (String)


67
68
69
70
71
72
73
74
75
76
# File 'app/services/pinterest/oauth_service.rb', line 67

def authorization_url(state:)
  query = {
    client_id:     @client_id,
    redirect_uri:  redirect_uri,
    response_type: 'code',
    scope:         SCOPES,
    state:         state
  }.to_query
  "#{AUTHORIZE_URL}?#{query}"
end

#connected?Boolean

Connected when either an OAuth credential is stored or a static
advertiser_access_token is configured for this environment.

Returns:

  • (Boolean)


127
128
129
# File 'app/services/pinterest/oauth_service.rb', line 127

def connected?
  OauthCredential.for(PROVIDER, account: @account).present? || static_token.present?
end

#connection_statusHash

DB-only connection status (no HTTP) — safe to call on every page load.

Returns:

  • (Hash)

    { connected:, healthy:, credential:, via: } where
    via is :oauth, :static, or nil.



135
136
137
138
139
140
141
142
143
144
# File 'app/services/pinterest/oauth_service.rb', line 135

def connection_status
  credential = OauthCredential.for(PROVIDER, account: @account)
  if credential
    { connected: true, healthy: credential.token_fresh?, credential: credential, via: :oauth }
  elsif static_token.present?
    { connected: true, healthy: true, credential: nil, via: :static }
  else
    { connected: false, healthy: false, credential: nil, via: nil }
  end
end

#disconnect!Object

Remove the stored credential.



147
148
149
# File 'app/services/pinterest/oauth_service.rb', line 147

def disconnect!
  OauthCredential.destroy_by(provider: PROVIDER, account_id: @account&.id)
end

#exchange_code!(code) ⇒ OauthCredential

Exchange the authorization code for tokens and persist them.

Parameters:

  • code (String)

    the code query param from the callback

Returns:

Raises:



83
84
85
86
87
88
89
90
91
92
93
# File 'app/services/pinterest/oauth_service.rb', line 83

def exchange_code!(code)
  response = token_request(
    grant_type:         'authorization_code',
    code:               code,
    redirect_uri:       redirect_uri,
    # Request a continuous (reissued-on-refresh) refresh token. Ignored
    # by apps created on/after 2025-09-25, which get it automatically.
    continuous_refresh: true
  )
  persist_tokens!(response)
end

#healthy?Boolean

Full health check — resolves a token (OAuth lazy-refresh or the static
fallback) and verifies it with a live API call. Makes HTTP requests;
use on admin pages, not hot paths.

Returns:

  • (Boolean)


156
157
158
159
160
# File 'app/services/pinterest/oauth_service.rb', line 156

def healthy?
  verify_api_access(access_token!)
rescue TokenRefreshError, StandardError
  false
end

#refresh!OauthCredential

Exchange the stored refresh token for a fresh access + refresh token pair.

Returns:

Raises:



99
100
101
102
103
104
105
106
# File 'app/services/pinterest/oauth_service.rb', line 99

def refresh!
  credential = OauthCredential.for(PROVIDER, account: @account)
  raise TokenRefreshError, 'No Pinterest credential found' unless credential
  raise TokenRefreshError, 'No refresh token available' if credential.refresh_token.blank?

  response = token_request(grant_type: 'refresh_token', refresh_token: credential.refresh_token)
  persist_tokens!(response, credential: credential)
end