Class: Studio::EmailSetting
- Inherits:
-
ApplicationRecord
- Object
- ApplicationRecord
- Studio::EmailSetting
- Defined in:
- app/models/studio/email_setting.rb
Overview
An operator's per-email overrides, editable from /admin/emails.
The registry (code) supplies defaults; a row here overrides them for THIS app. That split matters: the scrim is the dial between a readable header and a visible picture, and the right value depends on artwork that changes without a deploy — so it has to be tunable by the person looking at it.
Nil-safe throughout, because the table is installed by a migration the host runs. An app that has not run it yet must still send email.
Constant Summary collapse
- SCRIM_RANGE =
(0..100).freeze
- COPY_FIELDS =
The banner's words and logo. Each is nil until the operator sets it, and nil means INHERIT — never "empty".
%i[header header_fallback subtext logo_url subject body cta_text cta_color].freeze
- FOOTER_KEY =
The footer is shared by every email this app sends, so it is stored once under a reserved key rather than copied onto each row. Underscored so it cannot collide with a registry key, which is always a plain identifier.
"_footer".freeze
- FOOTER_FIELDS =
%i[discord_url logo_url].freeze
Class Method Summary collapse
-
.copy_for(key, field) ⇒ Object
One saved copy field, or nil to inherit.
-
.cta_enabled_for(key) ⇒ Object
nil when the operator has not decided — the registry then answers.
-
.footer ⇒ Object
The shared footer, as a plain hash.
-
.for_key(key) ⇒ Object
The row for this email, memoised PER REQUEST.
-
.forget!(key = nil) ⇒ Object
Called after any write, because a memoised row that outlives its update serves the operator their old copy back and looks like the save failed.
-
.hide_logo?(key) ⇒ Boolean
True when the operator has explicitly hidden the logo — which is a different answer from "no logo url saved" (that one inherits).
-
.scrim_for(key) ⇒ Object
The saved scrim for this email as a 0.0-1.0 fraction, or nil when the operator has not set one (the registry default then applies).
-
.set_copy(key, attrs) ⇒ Object
Save the words.
- .set_cta_enabled(key, value) ⇒ Object
- .set_footer(attrs) ⇒ Object
-
.set_scrim(key, percent) ⇒ Object
Store a percent, or clear the override with nil/blank so the email falls back to the registry default rather than being pinned to whatever the default happened to be on the day.
-
.table_ready? ⇒ Boolean
Reference the constant directly so Zeitwerk autoloads it — defined?() does NOT trigger autoload, so it reads "undefined" for a not-yet-loaded const and would silently disable every setting.
-
.update_footer(discord_url: nil, logo_url: nil) ⇒ Object
Write the footer ONLY when it actually changes.
Class Method Details
.copy_for(key, field) ⇒ Object
One saved copy field, or nil to inherit. Blank is stored as nil by #set_copy, so a blank return here always means "not set".
44 45 46 47 48 49 |
# File 'app/models/studio/email_setting.rb', line 44 def copy_for(key, field) return nil unless table_ready? return nil unless COPY_FIELDS.include?(field.to_sym) for_key(key)&.public_send(field).presence end |
.cta_enabled_for(key) ⇒ Object
nil when the operator has not decided — the registry then answers.
131 132 133 134 135 |
# File 'app/models/studio/email_setting.rb', line 131 def cta_enabled_for(key) return nil unless table_ready? for_key(key)&.cta_enabled end |
.footer ⇒ Object
The shared footer, as a plain hash. Reads through the same per-request memo as everything else, so rendering it on every email in a list costs one query rather than one per email. The saved footer, or NIL when the operator has never touched it.
nil and {} are different answers and the distinction is load-bearing: no row means "apply the defaults", while a row whose fields are blank means "I cleared these on purpose". Returning {} for both made clearing the logo hand the default straight back, so the field could not be emptied.
87 88 89 90 91 92 93 94 |
# File 'app/models/studio/email_setting.rb', line 87 def return nil unless table_ready? row = for_key(FOOTER_KEY) return nil if row.nil? FOOTER_FIELDS.index_with { |field| row.public_send(field).presence } end |
.for_key(key) ⇒ Object
The row for this email, memoised PER REQUEST.
Building one banner asks for the header, the fallback, the sub-text, the logo, the subject, the scrim and hide_logo — seven find_by calls for one row, on the mail DELIVERY path, and multiplied by every row on /admin/emails. The cache is request-scoped rather than a class variable so a write in one request cannot be served to the next. IsolatedExecutionState, not Thread.current: Puma reuses threads, so a thread-local outlives the request that filled it and the next request served by that thread would get the previous one's row. Rails resets IsolatedExecutionState around every request and job.
62 63 64 65 66 67 |
# File 'app/models/studio/email_setting.rb', line 62 def for_key(key) cache = ActiveSupport::IsolatedExecutionState[:studio_email_settings] ||= {} return cache[key.to_s] if cache.key?(key.to_s) cache[key.to_s] = find_by(email_key: key.to_s) end |
.forget!(key = nil) ⇒ Object
Called after any write, because a memoised row that outlives its update serves the operator their old copy back and looks like the save failed.
71 72 73 74 75 76 |
# File 'app/models/studio/email_setting.rb', line 71 def forget!(key = nil) cache = ActiveSupport::IsolatedExecutionState[:studio_email_settings] return if cache.nil? key.nil? ? cache.clear : cache.delete(key.to_s) end |
.hide_logo?(key) ⇒ Boolean
True when the operator has explicitly hidden the logo — which is a different answer from "no logo url saved" (that one inherits).
149 150 151 152 153 154 155 |
# File 'app/models/studio/email_setting.rb', line 149 def hide_logo?(key) return false unless table_ready? for_key(key)&.hide_logo || false rescue ActiveRecord::ActiveRecordError false end |
.scrim_for(key) ⇒ Object
The saved scrim for this email as a 0.0-1.0 fraction, or nil when the operator has not set one (the registry default then applies).
35 36 37 38 39 40 |
# File 'app/models/studio/email_setting.rb', line 35 def scrim_for(key) return nil unless table_ready? percent = for_key(key)&.scrim_percent percent.nil? ? nil : percent / 100.0 end |
.set_copy(key, attrs) ⇒ Object
Save the words. A blank field is stored as NULL rather than "", so clearing a box means "go back to the registry default" — the same gesture that resets the tint.
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 |
# File 'app/models/studio/email_setting.rb', line 160 def set_copy(key, attrs) return nil unless table_ready? record = find_or_initialize_by(email_key: key.to_s) COPY_FIELDS.each do |field| next unless attrs.key?(field) || attrs.key?(field.to_s) record.public_send(:"#{field}=", (attrs[field] || attrs[field.to_s]).presence) end # ONLY when the form carried it. Two separate cards post to this method, # and an absent checkbox means "this form does not manage the logo", not # "show the logo" — writing false either way let saving the subject # silently un-hide a logo the operator had hidden. if attrs.key?(:hide_logo) || attrs.key?("hide_logo") record.hide_logo = ActiveModel::Type::Boolean.new.cast(attrs[:hide_logo] || attrs["hide_logo"]) || false end record.save! # Drop the memo, or the operator is shown the value they just replaced — # the same "saved successfully, changed nothing" shape the permit bug had. forget!(key) record end |
.set_cta_enabled(key, value) ⇒ Object
137 138 139 140 141 142 143 144 145 |
# File 'app/models/studio/email_setting.rb', line 137 def set_cta_enabled(key, value) return nil unless table_ready? record = find_or_initialize_by(email_key: key.to_s) record.cta_enabled = value.nil? ? nil : ActiveModel::Type::Boolean.new.cast(value) record.save! forget!(key) record end |
.set_footer(attrs) ⇒ Object
116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'app/models/studio/email_setting.rb', line 116 def (attrs) return nil unless table_ready? record = find_or_initialize_by(email_key: FOOTER_KEY) FOOTER_FIELDS.each do |field| next unless attrs.key?(field) || attrs.key?(field.to_s) record.public_send(:"#{field}=", (attrs[field] || attrs[field.to_s]).presence) end record.save! forget!(FOOTER_KEY) record end |
.set_scrim(key, percent) ⇒ Object
Store a percent, or clear the override with nil/blank so the email falls back to the registry default rather than being pinned to whatever the default happened to be on the day.
186 187 188 189 190 191 192 193 194 |
# File 'app/models/studio/email_setting.rb', line 186 def set_scrim(key, percent) return nil unless table_ready? record = find_or_initialize_by(email_key: key.to_s) record.scrim_percent = percent.presence&.to_i record.save! forget!(key) record end |
.table_ready? ⇒ Boolean
Reference the constant directly so Zeitwerk autoloads it — defined?() does NOT trigger autoload, so it reads "undefined" for a not-yet-loaded const and would silently disable every setting.
199 200 201 202 203 |
# File 'app/models/studio/email_setting.rb', line 199 def table_ready? table_exists? rescue ActiveRecord::ActiveRecordError, NameError false end |
.update_footer(discord_url: nil, logo_url: nil) ⇒ Object
Write the footer ONLY when it actually changes.
There is one Save for the whole page, so every save posts the footer inputs — blank ones included, from a page where the operator only touched the subject. Writing those blanks created a row of nils, which reads the same as "cleared", so the shared footer vanished from every email the app sends and could not be recovered without retyping it.
Comparing against what is stored keeps both meanings: blanks matching an untouched footer write nothing, blanks replacing a stored value clear it.
106 107 108 109 110 111 112 113 114 |
# File 'app/models/studio/email_setting.rb', line 106 def (discord_url: nil, logo_url: nil) posted = { discord_url: discord_url.presence, logo_url: logo_url.presence } stored = return nil if stored.nil? && posted.values.all?(&:nil?) return nil if stored.present? && stored.slice(:discord_url, :logo_url) == posted (posted) end |