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 Models::Schedulable
Models::Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
- #access_token ⇒ String readonly
- #provider ⇒ String 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
-
#account_label ⇒ String
Human label for the credential's account in the admin list.
-
#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 Models::Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#access_token ⇒ String (readonly)
71 |
# File 'app/models/oauth_credential.rb', line 71 validates :access_token, presence: true |
#provider ⇒ String (readonly)
69 |
# File 'app/models/oauth_credential.rb', line 69 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
73 |
# File 'app/models/oauth_credential.rb', line 73 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
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.
80 81 82 |
# File 'app/models/oauth_credential.rb', line 80 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.
89 90 91 |
# File 'app/models/oauth_credential.rb', line 89 def self.for!(provider, account: nil) find_by!(provider: provider, account_id: account&.id) end |
Instance Method Details
#account ⇒ Account?
50 |
# File 'app/models/oauth_credential.rb', line 50 belongs_to :account, optional: true |
#account_label ⇒ String
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.
149 150 151 152 153 |
# File 'app/models/oauth_credential.rb', line 149 def account_label return 'System' if account.nil? account.party&.full_name.presence || account.login end |
#alert_recipient_email ⇒ String
Email address to notify about a token problem — the owner, falling back
to the scoped account, then the Heatwave team.
124 125 126 |
# File 'app/models/oauth_credential.rb', line 124 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.
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 |
#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).
55 |
# File 'app/models/oauth_credential.rb', line 55 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).
134 135 136 137 138 139 140 141 |
# File 'app/models/oauth_credential.rb', line 134 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.
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!() = ['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.
201 202 203 204 205 206 207 208 209 210 |
# File 'app/models/oauth_credential.rb', line 201 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.
156 157 158 |
# File 'app/models/oauth_credential.rb', line 156 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.
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
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
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.
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 |