Class: OauthCredential

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable
Defined in:
app/models/oauth_credential.rb

Overview

== Schema Information

Table name: oauth_credentials
Database name: primary

id :bigint not null, primary key
access_token :text not null
expires_at :datetime
metadata :jsonb
provider :string not null
refresh_token :text
token_type :string default("Bearer")
created_at :datetime not null
updated_at :datetime not null
account_id :bigint

Indexes

index_oauth_credentials_on_provider_and_account_id (provider,account_id) UNIQUE

Foreign Keys

fk_rails_... (account_id => accounts.id)

Constant Summary collapse

ALERT_FALLBACK_EMAIL =

Address alerted about token problems when no owner/account is set.

'heatwaveteam@warmlyyours.com'
RAISE_AFTER_CONSECUTIVE_FAILURES =

Number of CONSECUTIVE failed refreshes required before we flip
refresh_failing_since (and therefore send the alarmist
"needs-manual-reconnect" email). One transient timeout on the wire to a
third-party OAuth host should NOT alarm the credential owner —
everything heals on the next 45-minute tick. Three in a row over ~135
minutes is a real, sustained problem worth a human's attention.

3

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#access_tokenString (readonly)

Returns:

  • (String)


71
# File 'app/models/oauth_credential.rb', line 71

validates :access_token, presence: true

#providerString (readonly)

Returns:

  • (String)


69
# File 'app/models/oauth_credential.rb', line 69

validates :provider, presence: true, uniqueness: { scope: :account_id }

Class Method Details

.activeActiveRecord::Relation<OauthCredential>

A relation of OauthCredentials that are active. Active Record Scope

Returns:

See Also:



73
# File 'app/models/oauth_credential.rb', line 73

scope :active, -> { where('expires_at IS NULL OR expires_at > ?', Time.current) }

.expiring_withinActiveRecord::Relation<OauthCredential>

A relation of OauthCredentials that are expiring within. Active Record Scope

Returns:

See Also:



74
# File 'app/models/oauth_credential.rb', line 74

scope :expiring_within, ->(duration) { where('expires_at IS NOT NULL AND expires_at <= ?', duration.from_now) }

.for(provider, account: nil) ⇒ OauthCredential?

Fetch the credential for a given provider, optionally scoped to an account.

Parameters:

  • provider (String)

    e.g. "basecamp"

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

    specific account, or nil for system-level

Returns:



80
81
82
# File 'app/models/oauth_credential.rb', line 80

def self.for(provider, account: nil)
  find_by(provider: provider, account_id: &.id)
end

.for!(provider, account: nil) ⇒ OauthCredential

Fetch the credential for a given provider, raising if not found.

Parameters:

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

Returns:

Raises:

  • (ActiveRecord::RecordNotFound)


89
90
91
# File 'app/models/oauth_credential.rb', line 89

def self.for!(provider, account: nil)
  find_by!(provider: provider, account_id: &.id)
end

Instance Method Details

#accountAccount?

Returns:



50
# File 'app/models/oauth_credential.rb', line 50

belongs_to :account, optional: true

#account_labelString

Human label for the credential's account in the admin list. account is
nil for system-level integrations (Pinterest/YouTube/Microsoft Ads) — those
read "System". For employee-scoped credentials (Zoom/Basecamp) the person's
name lives on the party (full_name is the name of record), falling back to
the always-present login for the rare account with no party.

Returns:

  • (String)


149
150
151
152
153
# File 'app/models/oauth_credential.rb', line 149

def 
  return 'System' if .nil?

  .party&.full_name.presence || .
end

#alert_recipient_emailString

Email address to notify about a token problem — the owner, falling back
to the scoped account, then the Heatwave team.

Returns:

  • (String)


124
125
126
# File 'app/models/oauth_credential.rb', line 124

def alert_recipient_email
  owner&.email.presence || &.email.presence || ALERT_FALLBACK_EMAIL
end

#expires_inObject

How many seconds until the token expires. Returns nil if no expiry set.



104
105
106
107
108
# File 'app/models/oauth_credential.rb', line 104

def expires_in
  return nil if expires_at.blank?

  [(expires_at - Time.current).to_i, 0].max
end

#ownerAccount?

The Account accountable for this credential — who connected it, and who
gets alerted when a refresh fails. Distinct from account (the
credential's scope, which is nil for system-level integrations).

Returns:



55
# File 'app/models/oauth_credential.rb', line 55

belongs_to :owner, class_name: 'Account', optional: true

#reconnect_urlString

URL the alert email should send the recipient to in order to reconnect.
Per-employee scheduler-tab page when the credential is tied to an
employee (Zoom/Google Calendar for scheduling); the OAuth admin page
otherwise (system-level integrations like Pinterest/YouTube where there
IS no per-user reconnect surface).

Returns:

  • (String)


134
135
136
137
138
139
140
141
# File 'app/models/oauth_credential.rb', line 134

def reconnect_url
  employee = (owner || )&.employee
  if employee
    "#{CRM_URL}/employees/#{employee.id}?tab=scheduler"
  else
    "#{CRM_URL}/admin/oauth_credentials"
  end
end

#record_refresh_failure!(error_message) ⇒ Object

Record a failed proactive refresh. We do NOT alarm the owner on the
first failure — most are transient blips (TCP timeout to a third-party
OAuth host, brief 5xx, etc.) that heal on the next tick. Increment a
consecutive_failures counter and only flip refresh_failing_since
(and email the owner) once the counter crosses
RAISE_AFTER_CONSECUTIVE_FAILURES — roughly ~135 minutes of sustained
failure on the standard 45-minute refresh cadence. After that, the
alert is debounced to once per 24h while the failure persists.

Parameters:

  • error_message (String)


176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'app/models/oauth_credential.rb', line 176

def record_refresh_failure!(error_message)
  meta = refresh_meta
  meta['consecutive_failures'] = (meta['consecutive_failures'].to_i + 1)
  meta['last_refresh_error'] = error_message.to_s.truncate(500)

  if meta['consecutive_failures'] >= RAISE_AFTER_CONSECUTIVE_FAILURES
    meta['refresh_failing_since'] ||= Time.current.iso8601

    # A malformed/missing owner_alerted_at counts as "alert due" — never
    # let bad metadata raise out of the refresh worker.
    last_alerted = parse_time(meta['owner_alerted_at'])
    due          = last_alerted.nil? || last_alerted < 24.hours.ago
    meta['owner_alerted_at'] = Time.current.iso8601 if due

    persist_refresh_meta(meta)
    OauthCredentialMailer.refresh_failed(self).deliver_later if due
  else
    persist_refresh_meta(meta)
  end
end

#record_refresh_success!Object

Clear the failure state after a refresh succeeds. Always resets the
consecutive-failure counter (so a single success undoes any
below-threshold accumulation); when the credential was already in the
refresh_failing_since state, also clears the alert bookkeeping.



201
202
203
204
205
206
207
208
209
210
# File 'app/models/oauth_credential.rb', line 201

def record_refresh_success!
  meta = refresh_meta
  had_counter = meta.key?('consecutive_failures')
  was_failing = refresh_failing?
  return unless had_counter || was_failing

  persist_refresh_meta(
    meta.except('refresh_failing_since', 'last_refresh_error', 'owner_alerted_at', 'consecutive_failures')
  )
end

#refresh_failing?Boolean

Returns whether a proactive refresh is currently failing.

Returns:

  • (Boolean)

    whether a proactive refresh is currently failing



156
157
158
# File 'app/models/oauth_credential.rb', line 156

def refresh_failing?
  refresh_meta['refresh_failing_since'].present?
end

#refresh_token_expires_atActiveSupport::TimeWithZone?

Parsed refresh-token expiry from metadata, or nil when absent or malformed.

Returns:

  • (ActiveSupport::TimeWithZone, nil)


162
163
164
# File 'app/models/oauth_credential.rb', line 162

def refresh_token_expires_at
  parse_time(&.dig('refresh_token_expires_at'))
end

#token_expired?Boolean

Returns:

  • (Boolean)


94
95
96
# File 'app/models/oauth_credential.rb', line 94

def token_expired?
  expires_at.present? && expires_at <= Time.current
end

#token_fresh?Boolean

Returns:

  • (Boolean)


99
100
101
# File 'app/models/oauth_credential.rb', line 99

def token_fresh?
  !token_expired?
end

#update_from_oauth2_token!(oauth2_token) ⇒ Object

Update the credential with a new token response from OAuth2::AccessToken.

Parameters:

  • oauth2_token (OAuth2::AccessToken)


112
113
114
115
116
117
118
119
# File 'app/models/oauth_credential.rb', line 112

def update_from_oauth2_token!(oauth2_token)
  update!(
    access_token: oauth2_token.token,
    refresh_token: oauth2_token.refresh_token || refresh_token, # Keep old if not returned
    expires_at: oauth2_token.expires_at ? Time.zone.at(oauth2_token.expires_at) : nil,
    token_type: oauth2_token.params['token_type'] || 'Bearer'
  )
end