app/models/decidim/meetings/meeting.rb



# frozen_string_literal: true

module Decidim
  module Meetings
    # The data store for a Meeting in the Decidim::Meetings component. It stores a
    # title, description and any other useful information to render a custom meeting.
    class Meeting < Meetings::ApplicationRecord
      include Decidim::Resourceable
      include Decidim::HasAttachments
      include Decidim::HasAttachmentCollections
      include Decidim::HasComponent
      include Decidim::HasReference
      include Decidim::ScopableResource
      include Decidim::HasCategory
      include Decidim::Followable
      include Decidim::Comments::CommentableWithComponent
      include Decidim::Comments::HasAvailabilityAttributes
      include Decidim::Searchable
      include Decidim::Traceable
      include Decidim::Loggable
      include Decidim::Forms::HasQuestionnaire
      include Decidim::Paddable
      include Decidim::ActsAsAuthor
      include Decidim::Reportable
      include Decidim::Authorable
      include Decidim::TranslatableResource
      include Decidim::Publicable
      include Decidim::FilterableResource

      TYPE_OF_MEETING = %w(in_person online hybrid).freeze
      REGISTRATION_TYPE = %w(registration_disabled on_this_platform on_different_platform).freeze

      translatable_fields :title, :description, :location, :location_hints, :closing_report, :registration_terms

      has_many :registrations, class_name: "Decidim::Meetings::Registration", foreign_key: "decidim_meeting_id", dependent: :destroy
      has_many :invites, class_name: "Decidim::Meetings::Invite", foreign_key: "decidim_meeting_id", dependent: :destroy
      has_many :services, class_name: "Decidim::Meetings::Service", foreign_key: "decidim_meeting_id", dependent: :destroy
      has_one :agenda, class_name: "Decidim::Meetings::Agenda", foreign_key: "decidim_meeting_id", dependent: :destroy
      has_one :poll, class_name: "Decidim::Meetings::Poll", foreign_key: "decidim_meeting_id", dependent: :destroy
      has_many(
        :public_participants,
        -> { merge(Registration.public_participant) },
        through: :registrations,
        class_name: "Decidim::User",
        foreign_key: :decidim_user_id,
        source: :user
      )

      enum iframe_access_level: [:all, :signed_in, :registered], _prefix: true
      enum iframe_embed_type: [:none, :embed_in_meeting_page, :open_in_live_event_page, :open_in_new_tab], _prefix: true

      component_manifest_name "meetings"

      validates :title, presence: true

      geocoded_by :address

      scope :published, -> { where.not(published_at: nil) }
      scope :past, -> { where(arel_table[:end_time].lteq(Time.current)) }
      scope :upcoming, -> { where(arel_table[:end_time].gteq(Time.current)) }
      scope :withdrawn, -> { where(state: "withdrawn") }
      scope :except_withdrawn, -> { where.not(state: "withdrawn").or(where(state: nil)) }
      scope :with_availability, lambda { |state_key|
        case state_key
        when "withdrawn"
          withdrawn
        else
          except_withdrawn
        end
      }
      scope_search_multi :with_any_date, [:upcoming, :past]
      scope :with_any_space, lambda { |*target_space|
        target_spaces = target_space.compact.compact_blank

        return self if target_spaces.blank? || target_spaces.include?("all")

        joins(:component).where(
          decidim_components: { participatory_space_type: target_spaces.map(&:classify) }
        )
      }

      scope :visible_for, lambda { |user|
        (all.distinct if user&.admin?) ||
          if user.present?
            spaces = Decidim.participatory_space_registry.manifests.filter_map do |manifest|
              table_name = manifest.model_class_name.constantize.try(:table_name)
              next if table_name.blank?

              {
                name: table_name.singularize,
                class_name: manifest.model_class_name
              }
            end
            user_role_queries = spaces.map do |space|
              roles_table = "#{space[:name]}_user_roles"
              next unless connection.table_exists?(roles_table)

              "SELECT decidim_components.id FROM decidim_components
              WHERE CONCAT(decidim_components.participatory_space_id, '-', decidim_components.participatory_space_type)
              IN
              (SELECT CONCAT(#{roles_table}.#{space[:name]}_id, '-#{space[:class_name]}')
              FROM #{roles_table} WHERE #{roles_table}.decidim_user_id = ?)
              "
            end

            query = "
              decidim_meetings_meetings.private_meeting = ?
              OR decidim_meetings_meetings.transparent = ?
              OR decidim_meetings_meetings.id IN (
                SELECT decidim_meetings_registrations.decidim_meeting_id FROM decidim_meetings_registrations WHERE decidim_meetings_registrations.decidim_user_id = ?
              )
              OR decidim_meetings_meetings.decidim_component_id IN (
                SELECT decidim_components.id FROM decidim_components
                WHERE CONCAT(decidim_components.participatory_space_id, '-', decidim_components.participatory_space_type)
                IN
                  (SELECT CONCAT(decidim_participatory_space_private_users.privatable_to_id, '-', decidim_participatory_space_private_users.privatable_to_type)
                  FROM decidim_participatory_space_private_users WHERE decidim_participatory_space_private_users.decidim_user_id = ?)
              )
            "
            if user_role_queries.any?
              query = "#{query} OR decidim_meetings_meetings.decidim_component_id IN
                (#{user_role_queries.compact.join(" UNION ")})
              "
            end

            where(Arel.sql(query).to_s, false, true, user.id, user.id, *user_role_queries.compact.map { user.id }).published.distinct
          else
            published.visible
          end
      }

      scope :visible, -> { where("decidim_meetings_meetings.private_meeting != ? OR decidim_meetings_meetings.transparent = ?", true, true) }

      scope :authored_by, ->(author) { where(decidim_author_id: author) }

      scope_search_multi :with_any_type, TYPE_OF_MEETING.map(&:to_sym)

      TYPE_OF_MEETING.each do |type|
        scope type.to_sym, -> { where(type_of_meeting: type.to_sym) }
        scope "not_#{type}".to_sym, -> { where.not(type_of_meeting: type.to_sym) }
      end

      searchable_fields({
                          scope_id: :decidim_scope_id,
                          participatory_space: { component: :participatory_space },
                          A: :title,
                          D: [:description, :address],
                          datetime: :start_time
                        },
                        index_on_create: ->(meeting) { meeting.visible? && meeting.published? },
                        index_on_update: ->(meeting) { meeting.visible? && meeting.published? })

      # we create a salt for the meeting only on new meetings to prevent changing old IDs for existing (Ether)PADs
      before_create :set_default_salt

      def self.participants_iframe_embed_types
        iframe_embed_types.except(:open_in_live_event_page)
      end

      # Return registrations of a particular meeting made by users representing a group
      def user_group_registrations
        registrations.where.not(decidim_user_group_id: nil)
      end

      # Returns the presenter for this author, to be used in the views.
      # Required by ActsAsAuthor.
      def presenter
        Decidim::Meetings::MeetingPresenter.new(self)
      end

      def self.log_presenter_class_for(_log)
        Decidim::Meetings::AdminLog::MeetingPresenter
      end

      def can_be_joined_by?(user)
        !closed? && registrations_enabled? && can_participate?(user)
      end

      def can_register_invitation?(user)
        !closed? && registrations_enabled? &&
          can_participate_in_space?(user) && user_has_invitation_for_meeting?(user)
      end

      def closed?
        closed_at.present?
      end

      def past?
        end_time < Time.current
      end

      def emendation?
        false
      end

      def has_available_slots?
        return true if available_slots.zero?

        (available_slots - reserved_slots) > registrations.count
      end

      def remaining_slots
        available_slots - reserved_slots - registrations.count
      end

      def has_registration_for?(user)
        registrations.where(user: user).any?
      end

      def maps_enabled?
        component.settings.maps_enabled?
      end

      # Public: Overrides the `accepts_new_comments?` CommentableWithComponent concern method.
      def accepts_new_comments?
        commentable? && !component.current_settings.comments_blocked && comments_allowed?
      end

      # Public: Overrides the `allow_resource_permissions?` Resourceable concern method.
      def allow_resource_permissions?
        component.settings.resources_permissions_enabled
      end

      # Public: Overrides the `comments_have_alignment?` Commentable concern method.
      def comments_have_alignment?
        true
      end

      # Public: Overrides the `comments_have_votes?` Commentable concern method.
      def comments_have_votes?
        true
      end

      # Public: Override Commentable concern method `users_to_notify_on_comment_created`
      def users_to_notify_on_comment_created
        followers
      end

      def can_participate?(user)
        can_participate_in_space?(user) && can_participate_in_meeting?(user)
      end

      def current_user_can_visit_meeting?(user)
        Decidim::Meetings::Meeting.visible_for(user).exists?(id: id)
      end

      def iframe_access_level_allowed_for_user?(user)
        case iframe_access_level
        when "all"
          true
        when "signed_in"
          user.present?
        else
          has_registration_for?(user)
        end
      end

      # Return the duration of the meeting in minutes
      def meeting_duration
        @meeting_duration ||= ((end_time - start_time) / 1.minute).abs
      end

      def resource_visible?
        return false if hidden?

        !private_meeting? || transparent?
      end

      # Public: Checks if the author has withdrawn the meeting.
      #
      # Returns Boolean.
      def withdrawn?
        state == "withdrawn"
      end

      # Checks whether the user can withdraw the given meeting.
      #
      # user - the user to check for withdrawability.
      # past meetings cannot be withdrawn
      def withdrawable_by?(user)
        user && !withdrawn? && !past? && authored_by?(user)
      end

      # Overwrites method from Paddable to add custom rules in order to know
      # when to display a pad or not.
      def pad_is_visible?
        return false unless pad

        (start_time - Time.current) <= 24.hours
      end

      # Overwrites method from Paddable to add custom rules in order to know
      # when a pad is writable or not.
      def pad_is_writable?
        return false unless pad_is_visible?

        (Time.current - end_time) < 72.hours
      end

      def authored_proposals
        return [] unless Decidim::Meetings.enable_proposal_linking

        Decidim::Proposals::Proposal
          .joins(:coauthorships)
          .where(
            decidim_coauthorships: {
              decidim_author_type: "Decidim::Meetings::Meeting",
              decidim_author_id: id
            }
          )
      end

      # Public: Overrides the `reported_content_url` Reportable concern method.
      def reported_content_url
        ResourceLocatorPresenter.new(self).url
      end

      # Public: Overrides the `reported_attributes` Reportable concern method.
      def reported_attributes
        [:description]
      end

      # Public: Overrides the `reported_searchable_content_extras` Reportable concern method.
      def reported_searchable_content_extras
        [normalized_author.name]
      end

      TYPE_OF_MEETING.each do |type|
        define_method("#{type}_meeting?") do
          type_of_meeting == type
        end
      end

      def registration_disabled?
        registration_type == "registration_disabled"
      end

      def on_this_platform?
        registration_type == "on_this_platform"
      end

      def on_different_platform?
        registration_type == "on_different_platform"
      end

      def has_contributions?
        !!contributions_count && contributions_count.positive?
      end

      def has_attendees?
        !!attendees_count && attendees_count.positive?
      end

      def live?
        start_time &&
          end_time &&
          Time.current >= (start_time - 10.minutes) &&
          Time.current <= end_time
      end

      def self.sort_by_translated_title_asc
        field = Arel::Nodes::InfixOperation.new("->>", arel_table[:title], Arel::Nodes.build_quoted(I18n.locale))
        order(Arel::Nodes::InfixOperation.new("", field, Arel.sql("ASC")))
      end

      def self.sort_by_translated_title_desc
        field = Arel::Nodes::InfixOperation.new("->>", arel_table[:title], Arel::Nodes.build_quoted(I18n.locale))
        order(Arel::Nodes::InfixOperation.new("", field, Arel.sql("DESC")))
      end

      # Create i18n ransackers for :title and :description.
      # Create the :search_text ransacker alias for searching from both of these.
      ransacker_i18n_multi :search_text, [:title, :description]

      ransacker :id_string do
        Arel.sql(%{cast("decidim_meetings_meetings"."id" as text)})
      end

      ransacker :is_upcoming do
        Arel.sql("(start_time > NOW())")
      end

      def self.ransackable_scopes(_auth_object = nil)
        [:with_any_type, :with_any_date, :with_any_space, :with_any_origin, :with_any_scope, :with_any_category, :with_any_global_category]
      end

      def self.ransack(params = {}, options = {})
        MeetingSearch.new(self, params, options)
      end

      private

      def can_participate_in_meeting?(user)
        return true unless private_meeting?
        return false unless user

        registrations.exists?(decidim_user_id: user.id)
      end

      def user_has_invitation_for_meeting?(user)
        return true unless private_meeting?
        return false unless user

        invites.exists?(decidim_user_id: user.id)
      end

      # salt is used to generate secure hash in pads
      def set_default_salt
        self.salt ||= Tokenizer.random_salt
      end
    end
  end
end