Class: OauthCredential
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- OauthCredential
- 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
- #access_token ⇒ Object readonly
- #provider ⇒ Object readonly
Belongs to collapse
- #account ⇒ Account
-
#owner ⇒ Account
The Account accountable for this credential — who connected it, and who gets alerted when a refresh fails.
Methods included from Models::Auditable
Class Method Summary collapse
-
.active ⇒ ActiveRecord::Relation<OauthCredential>
A relation of OauthCredentials that are active.
-
.expiring_within ⇒ ActiveRecord::Relation<OauthCredential>
A relation of OauthCredentials that are expiring within.
-
.for(provider, account: nil) ⇒ OauthCredential?
Fetch the credential for a given provider, optionally scoped to an account.
-
.for!(provider, account: nil) ⇒ OauthCredential
Fetch the credential for a given provider, raising if not found.
Instance Method Summary collapse
-
#alert_recipient_email ⇒ String
Email address to notify about a token problem — the owner, falling back to the scoped account, then the Heatwave team.
-
#expires_in ⇒ Object
How many seconds until the token expires.
-
#reconnect_url ⇒ String
URL the alert email should send the recipient to in order to reconnect.
-
#record_refresh_failure!(error_message) ⇒ Object
Record a failed proactive refresh.
-
#record_refresh_success! ⇒ Object
Clear the failure state after a refresh succeeds.
-
#refresh_failing? ⇒ Boolean
Whether a proactive refresh is currently failing.
-
#refresh_token_expires_at ⇒ ActiveSupport::TimeWithZone?
Parsed refresh-token expiry from metadata, or nil when absent or malformed.
- #token_expired? ⇒ Boolean
- #token_fresh? ⇒ Boolean
-
#update_from_oauth2_token!(oauth2_token) ⇒ Object
Update the credential with a new token response from OAuth2::AccessToken.
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
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#access_token ⇒ Object (readonly)
67 |
# File 'app/models/oauth_credential.rb', line 67 validates :access_token, presence: true |
#provider ⇒ Object (readonly)
66 |
# File 'app/models/oauth_credential.rb', line 66 validates :provider, presence: true, uniqueness: { scope: :account_id } |
Class Method Details
.active ⇒ ActiveRecord::Relation<OauthCredential>
A relation of OauthCredentials that are active. Active Record Scope
69 |
# File 'app/models/oauth_credential.rb', line 69 scope :active, -> { where('expires_at IS NULL OR expires_at > ?', Time.current) } |
.expiring_within ⇒ ActiveRecord::Relation<OauthCredential>
A relation of OauthCredentials that are expiring within. Active Record Scope
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.
76 77 78 |
# File 'app/models/oauth_credential.rb', line 76 def self.for(provider, account: nil) find_by(provider: provider, account_id: account&.id) end |
.for!(provider, account: nil) ⇒ OauthCredential
Fetch the credential for a given provider, raising if not found.
85 86 87 |
# File 'app/models/oauth_credential.rb', line 85 def self.for!(provider, account: nil) find_by!(provider: provider, account_id: account&.id) end |
Instance Method Details
#account ⇒ Account
49 |
# File 'app/models/oauth_credential.rb', line 49 belongs_to :account, optional: true |
#alert_recipient_email ⇒ String
Email address to notify about a token problem — the owner, falling back
to the scoped account, then the Heatwave team.
118 119 120 |
# File 'app/models/oauth_credential.rb', line 118 def alert_recipient_email owner&.email.presence || account&.email.presence || ALERT_FALLBACK_EMAIL end |
#expires_in ⇒ Object
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 |
#owner ⇒ Account
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).
53 |
# File 'app/models/oauth_credential.rb', line 53 belongs_to :owner, class_name: 'Account', optional: true |
#reconnect_url ⇒ String
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).
128 129 130 131 132 133 134 135 |
# File 'app/models/oauth_credential.rb', line 128 def reconnect_url employee = (owner || account)&.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.
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!() = ['consecutive_failures'] = (['consecutive_failures'].to_i + 1) ['last_refresh_error'] = .to_s.truncate(500) if ['consecutive_failures'] >= RAISE_AFTER_CONSECUTIVE_FAILURES ['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(['owner_alerted_at']) due = last_alerted.nil? || last_alerted < 24.hours.ago ['owner_alerted_at'] = Time.current.iso8601 if due () OauthCredentialMailer.refresh_failed(self).deliver_later if due else () 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! = had_counter = .key?('consecutive_failures') was_failing = refresh_failing? return unless had_counter || was_failing ( .except('refresh_failing_since', 'last_refresh_error', 'owner_alerted_at', 'consecutive_failures') ) end |
#refresh_failing? ⇒ Boolean
Returns whether a proactive refresh is currently failing.
138 139 140 |
# File 'app/models/oauth_credential.rb', line 138 def refresh_failing? ['refresh_failing_since'].present? end |
#refresh_token_expires_at ⇒ ActiveSupport::TimeWithZone?
Parsed refresh-token expiry from metadata, or nil when absent or malformed.
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
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
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.
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 |