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