Un avviso ha un testo per lingua; per differenziare le piattaforme si creano due avvisi. L'app riceve già il copy risolto da Accept-Language. Co-authored-by: Cursor <cursoragent@cursor.com>
241 lines
6.7 KiB
Ruby
241 lines
6.7 KiB
Ruby
class AppAnnouncement < ApplicationRecord
|
|
KINDS = %w[info maintenance update].freeze
|
|
SEVERITIES = %w[info warning critical].freeze
|
|
STATUSES = %w[draft published archived].freeze
|
|
CHANNELS = {
|
|
android: :show_on_android,
|
|
ios: :show_on_ios,
|
|
web_public: :show_on_web_public,
|
|
web_private: :show_on_web_private
|
|
}.freeze
|
|
COPY_KEYS = %w[title body action_url action_label].freeze
|
|
URL_KEYS = %w[action_url].freeze
|
|
TITLE_KEYS = %w[title].freeze
|
|
BODY_KEYS = %w[body].freeze
|
|
LABEL_KEYS = %w[action_label].freeze
|
|
LOCALE_FALLBACKS = {
|
|
"it" => %w[it],
|
|
"en" => %w[en it],
|
|
"fr" => %w[fr en it],
|
|
"de" => %w[de en it],
|
|
"es" => %w[es en it]
|
|
}.freeze
|
|
|
|
belongs_to :created_by_admin, class_name: "AdminAccount", optional: true
|
|
|
|
validates :kind, :severity, :status, :title, :body, presence: true
|
|
validates :kind, inclusion: { in: KINDS }
|
|
validates :severity, inclusion: { in: SEVERITIES }
|
|
validates :status, inclusion: { in: STATUSES }
|
|
validates :title, length: { maximum: 120 }, allow_blank: true
|
|
validates :body, length: { maximum: 2000 }, allow_blank: true
|
|
validates :action_label, length: { maximum: 40 }, allow_blank: true
|
|
URL_KEYS.each do |field|
|
|
validates field, format: { with: /\Ahttps?:\/\/.+/i, allow_blank: true }
|
|
end
|
|
validate :ends_at_after_starts_at
|
|
validate :at_least_one_channel
|
|
validate :translations_are_valid
|
|
before_validation :normalize_translations
|
|
|
|
scope :newest, -> { order(created_at: :desc) }
|
|
scope :published, -> { where(status: "published") }
|
|
scope :active_now, lambda {
|
|
now = Time.current
|
|
published
|
|
.where("starts_at IS NULL OR starts_at <= ?", now)
|
|
.where("ends_at IS NULL OR ends_at > ?", now)
|
|
}
|
|
|
|
def self.for_channel(channel)
|
|
column = CHANNELS[channel.to_sym]
|
|
raise ArgumentError, "unknown announcement channel: #{channel}" if column.blank?
|
|
|
|
active_now.where(column => true)
|
|
end
|
|
|
|
def self.for_app
|
|
active_now.where("show_on_android = TRUE OR show_on_ios = TRUE")
|
|
end
|
|
|
|
def self.for_platform(platform)
|
|
case platform.to_s
|
|
when "android" then for_channel(:android)
|
|
when "ios" then for_channel(:ios)
|
|
else for_app
|
|
end
|
|
end
|
|
|
|
def published?
|
|
status == "published"
|
|
end
|
|
|
|
def selected_channels
|
|
CHANNELS.each_with_object([]) do |(key, column), keys|
|
|
keys << key if public_send(column)
|
|
end
|
|
end
|
|
|
|
def copy_values_for(locale)
|
|
loc = locale.to_s
|
|
if default_locale?(loc)
|
|
COPY_KEYS.index_with { |key| public_send(key).to_s.presence }
|
|
else
|
|
translation_hash(loc)
|
|
end
|
|
end
|
|
|
|
def filled_locale_codes
|
|
LocaleResolver.available.select { |loc| locale_has_copy?(loc) }.map(&:to_s)
|
|
end
|
|
|
|
def as_api_json(platform: nil, locale: I18n.locale)
|
|
{
|
|
id: id,
|
|
kind: kind,
|
|
severity: severity,
|
|
title: copy_for("title", locale: locale),
|
|
body: copy_for("body", locale: locale),
|
|
dismissible: dismissible,
|
|
action_url: resolved_action_url(platform, locale: locale),
|
|
action_label: copy_for("action_label", locale: locale).presence,
|
|
starts_at: starts_at&.iso8601,
|
|
ends_at: ends_at&.iso8601
|
|
}
|
|
end
|
|
|
|
def localized_title(locale: I18n.locale)
|
|
copy_for("title", locale: locale)
|
|
end
|
|
|
|
def localized_body(locale: I18n.locale)
|
|
copy_for("body", locale: locale)
|
|
end
|
|
|
|
def localized_action_label(locale: I18n.locale)
|
|
copy_for("action_label", locale: locale)
|
|
end
|
|
|
|
def resolved_action_url(platform, locale: I18n.locale)
|
|
url = copy_for("action_url", locale: locale)
|
|
return url if url.present?
|
|
return nil unless kind == "update"
|
|
|
|
case platform.to_s
|
|
when "android" then MatchLiveTv.play_store_url
|
|
when "ios" then MatchLiveTv.app_store_url
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def copy_for(field, locale: I18n.locale)
|
|
field = field.to_s
|
|
locale_chain(locale).each do |loc|
|
|
value = value_for(loc, field)
|
|
return value if value.present?
|
|
end
|
|
nil
|
|
end
|
|
|
|
def value_for(locale, key)
|
|
copy_values_for(locale)[key].presence
|
|
end
|
|
|
|
def translation_hash(locale)
|
|
raw = translations.is_a?(Hash) ? translations : {}
|
|
values = raw.stringify_keys[locale.to_s]
|
|
return {} unless values.is_a?(Hash)
|
|
|
|
values.stringify_keys
|
|
end
|
|
|
|
def locale_chain(locale)
|
|
loc = (LocaleResolver.normalize(locale) || I18n.default_locale).to_s
|
|
LOCALE_FALLBACKS[loc] || [loc, I18n.default_locale.to_s].uniq
|
|
end
|
|
|
|
def locale_has_copy?(locale)
|
|
values = copy_values_for(locale)
|
|
values["title"].present? || values["body"].present?
|
|
end
|
|
|
|
def default_locale?(locale)
|
|
locale.to_s == I18n.default_locale.to_s
|
|
end
|
|
|
|
def normalize_translations
|
|
self.translations = self.class.sanitize_translations(translations)
|
|
end
|
|
|
|
def self.sanitize_translations(value)
|
|
raw = coerce_translation_hash(value)
|
|
LocaleResolver.available.each_with_object({}) do |loc, acc|
|
|
next if loc.to_s == I18n.default_locale.to_s
|
|
|
|
payload = raw[loc.to_s]
|
|
next unless payload.is_a?(Hash)
|
|
|
|
cleaned = COPY_KEYS.each_with_object({}) do |key, fields|
|
|
text = payload[key].to_s.strip
|
|
fields[key] = text if text.present?
|
|
end
|
|
acc[loc.to_s] = cleaned if cleaned.any?
|
|
end
|
|
end
|
|
|
|
def self.coerce_translation_hash(value)
|
|
hash = if value.is_a?(ActionController::Parameters)
|
|
value.to_unsafe_h
|
|
elsif value.is_a?(Hash)
|
|
value
|
|
else
|
|
{}
|
|
end
|
|
hash.deep_stringify_keys
|
|
end
|
|
private_class_method :coerce_translation_hash
|
|
|
|
def at_least_one_channel
|
|
return if show_on_android? || show_on_ios? || show_on_web_public? || show_on_web_private?
|
|
|
|
errors.add(:base, :no_channel)
|
|
end
|
|
|
|
def ends_at_after_starts_at
|
|
return if starts_at.blank? || ends_at.blank?
|
|
return if ends_at > starts_at
|
|
|
|
errors.add(:ends_at, :invalid)
|
|
end
|
|
|
|
def translations_are_valid
|
|
translation_entries.each do |locale, payload|
|
|
TITLE_KEYS.each do |key|
|
|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 120
|
|
end
|
|
BODY_KEYS.each do |key|
|
|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 2000
|
|
end
|
|
LABEL_KEYS.each do |key|
|
|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 40
|
|
end
|
|
URL_KEYS.each do |key|
|
|
url = payload[key].to_s
|
|
next if url.blank? || url.match?(/\Ahttps?:\/\/.+/i)
|
|
|
|
errors.add(:base, :translation_invalid_url, locale: locale)
|
|
end
|
|
end
|
|
end
|
|
|
|
def translation_entries
|
|
raw = translations.is_a?(Hash) ? translations : {}
|
|
raw.stringify_keys.filter_map do |locale, payload|
|
|
next unless payload.is_a?(Hash)
|
|
|
|
[locale, payload.stringify_keys]
|
|
end
|
|
end
|
|
end
|