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 Schedulable

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 Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#access_tokenObject (readonly)



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

validates :access_token, presence: true

#providerObject (readonly)



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

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:



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

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:



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

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:



76
77
78
# File 'app/models/oauth_credential.rb', line 76

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)


85
86
87
# File 'app/models/oauth_credential.rb', line 85

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

Instance Method Details

#accountAccount

Returns:

See Also:



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

belongs_to :account, optional: true

#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)


118
119
120
# File 'app/models/oauth_credential.rb', line 118

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.



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

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:

See Also:



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

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)


128
129
130
131
132
133
134
135
# File 'app/models/oauth_credential.rb', line 128

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)


158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'app/models/oauth_credential.rb', line 158

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.



183
184
185
186
187
188
189
190
191
192
# File 'app/models/oauth_credential.rb', line 183

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



138
139
140
# File 'app/models/oauth_credential.rb', line 138

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)


144
145
146
# File 'app/models/oauth_credential.rb', line 144

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

#token_expired?Boolean

Returns:

  • (Boolean)


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

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

#token_fresh?Boolean

Returns:

  • (Boolean)


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

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)


106
107
108
109
110
111
112
113
# File 'app/models/oauth_credential.rb', line 106

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