Class: Studio::EmailSetting

Inherits:
ApplicationRecord
  • Object
show all
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".freeze
%i[discord_url logo_url].freeze

Class Method Summary collapse

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

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 footer
  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).

Returns:

  • (Boolean)


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)&. || 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. = 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


116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'app/models/studio/email_setting.rb', line 116

def set_footer(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.

Returns:

  • (Boolean)


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

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 update_footer(discord_url: nil, logo_url: nil)
  posted = { discord_url: discord_url.presence, logo_url: logo_url.presence }
  stored = footer

  return nil if stored.nil? && posted.values.all?(&:nil?)
  return nil if stored.present? && stored.slice(:discord_url, :logo_url) == posted

  set_footer(posted)
end