loomio/loomio

View on GitHub
app/services/poll_service.rb

Summary

Maintainability
D
1 day
Test Coverage
class PollService
  def self.create(poll:, actor:, params: {})
    actor.ability.authorize! :create, poll

    poll.assign_attributes(author: actor)
    poll.prioritise_poll_options!

    return false unless poll.valid?
    poll.save!
    poll.update_counts!

    if !poll.specified_voters_only
      stances = create_stances(poll: poll, actor: actor, include_actor: true, audience: 'group')
    else
      stances = Stance.none
    end

    user_ids = params[:notify_recipients] ? stances.pluck(:participant_id) : []
    
    EventBus.broadcast('poll_create', poll, actor)
    Events::PollCreated.publish!(poll, actor, recipient_user_ids: user_ids - [actor.id])
  end

  def self.update(poll:, params:, actor:)
    actor.ability.authorize! :update, poll

    UserInviter.authorize!(
      user_ids: params[:recipient_user_ids],
      emails: params[:recipient_emails],
      audience: params[:recipient_audience],
      model: poll,
      actor: actor
    )

    poll.assign_attributes_and_files(params.except(:poll_type, :discussion_id, :poll_template_id, :poll_template_key))

    # check again, because the group id could be updated to a untrusted group
    actor.ability.authorize! :update, poll

    poll.prioritise_poll_options!

    return false unless poll.valid?

    poll.save!
    poll.update_counts!
    GenericWorker.perform_async('SearchService', 'reindex_by_poll_id', poll.id)

    users = UserInviter.where_or_create!(
      actor: actor,
      user_ids: params[:recipient_user_ids],
      emails: params[:recipient_emails],
      audience: params[:recipient_audience],
      model: poll
    )

    EventBus.broadcast('poll_update', poll, actor)

    Events::PollEdited.publish!(
      poll: poll,
      actor: actor,
      recipient_user_ids: users.pluck(:id),
      recipient_chatbot_ids: params[:recipient_chatbot_ids],
      recipient_audience: params[:recipient_audience],
      recipient_message: params[:recipient_message]
    )
  end

  def self.invite(poll:, actor:, params:)
    UserInviter.authorize!(
      user_ids: params[:recipient_user_ids],
      emails: params[:recipient_emails],
      audience: params[:recipient_audience],
      model: poll,
      actor: actor,
    )

    if poll.discussion
      DiscussionService.add_users(
        discussion: poll.discussion,
        actor: actor,
        user_ids: params[:recipient_user_ids],
        emails: params[:recipient_emails],
        audience: params[:recipient_audience],
      )
    end

    stances = create_stances(
      poll: poll, actor: actor,
      user_ids: params[:recipient_user_ids],
      emails: params[:recipient_emails],
      include_actor: params[:include_actor],
      audience: params[:recipient_audience]
    )

    if params[:notify_recipients]
      Events::PollAnnounced.publish!(
        poll: poll,
        actor: actor,
        stances: stances,
        recipient_user_ids: params[:recipient_user_ids],
        recipient_chatbot_ids: params[:recipient_chatbot_ids],
        recipient_audience: params[:recipient_audience],
        recipient_message:  params[:recipient_message],
      )
    end

    stances
  end

  def self.remind(poll:, actor:, params:)
    actor.ability.authorize! :remind, poll

    users = UserInviter.where_existing(
      user_ids: params[:recipient_user_ids],
      audience: params[:recipient_audience],
      model: poll,
      actor: actor
    )

    Events::PollReminder.publish!(
      poll: poll,
      actor: actor,
      recipient_user_ids: users.pluck(:id),
      recipient_chatbot_ids: params[:recipient_chatbot_ids],
      recipient_audience: params[:recipient_audience],
      recipient_message: params[:recipient_message]
    )
  end

  def self.create_stances(poll:, actor:, user_ids: [], emails: [], audience: nil, include_actor: false)
    existing_voter_ids = Stance.latest.where(poll_id: poll.id).pluck(:participant_id)

    users = UserInviter.where_or_create!(
      actor: actor,
      model: poll,
      user_ids: user_ids,
      audience: audience,
      include_actor: include_actor,
      emails: emails
    ).where.not(id: existing_voter_ids)

    volumes = {}
    group_member_ids = (poll.group || NullGroup.new).member_ids

    # if the user has chosen to mute the thread or group then mute the poll too, but dont subsribe
    if poll.discussion_id
      DiscussionReader.active.where(
        discussion_id: poll.discussion_id,
        user_id: users.pluck(:id),
        volume: 1
      ).find_each do |dr|
        volumes[dr.user_id] = dr.volume
      end
    end

    if poll.group_id
      Membership.active.where(
        group_id: poll.group_id,
        user_id: users.pluck(:id),
        volume: 1
      ).find_each do |m|
        volumes[m.user_id] = m.volume unless volumes.has_key? m.user_id
      end
    end

    reinvited_user_ids = Stance.revoked.where(poll_id: poll.id).pluck(:participant_id) & users.pluck(:id)

    Stance.where(poll_id: poll.id, participant_id: reinvited_user_ids).each do |stance|
      stance.update(revoked_at: nil, revoker_id: nil, inviter_id: actor.id, admin: false)
    end

    new_stances = users.where.not(id: reinvited_user_ids).map do |user|
      Stance.new(
        participant: user,
        poll: poll,
        inviter: actor,
        guest: !group_member_ids.include?(user.id),
        volume: volumes[user.id] || DiscussionReader.volumes[:normal],
        latest: true,
        reason_format: user.default_format,
        created_at: Time.zone.now
      )
    end

    Stance.import(new_stances, on_duplicate_key_ignore: true)

    poll.reset_latest_stances!
    poll.update_counts!

    Stance.where(participant_id: users.pluck(:id), poll_id: poll.id, latest: true)
  end

  def self.discard(poll:, actor:)
    actor.ability.authorize!(:destroy, poll)

    poll.update(discarded_at: Time.now, discarded_by: actor.id)
    Event.where(kind: ["stance_created", "stance_updated"], eventable_id: poll.stances.pluck(:id)).update_all(discussion_id: nil)
    poll.created_event.update!(user_id: nil, child_count: 0, pinned: false)
    MessageChannelService.publish_models([poll.created_event], scope: {current_user: actor, current_user_id: actor.id}, group_id: poll.group_id)
    poll.created_event
  end

  def self.close(poll:, actor:)
    actor.ability.authorize! :close, poll
    do_closing_work(poll: poll)
    Events::PollClosedByUser.publish!(poll, actor)
  end

  def self.reopen(poll:, params:, actor:)
    actor.ability.authorize! :reopen, poll

    poll.assign_attributes(closing_at: params[:closing_at], closed_at: nil)
    return false unless poll.valid?

    poll.save!

    EventBus.broadcast('poll_reopen', poll, actor)
    Events::PollReopened.publish!(poll, actor)
  end

  def self.publish_closing_soon
    hour_start = 1.day.from_now.at_beginning_of_hour
    hour_finish = hour_start + 1.hour
    this_hour_tomorrow = hour_start..hour_finish
    Poll.closing_soon_not_published(this_hour_tomorrow).each do |poll|
      Events::PollClosingSoon.publish!(poll)
    end
  end

  def self.group_members_added(group_id)
    member_ids = Group.find(group_id).members.humans.pluck(:id)
    Poll.active.where(group_id: group_id, specified_voters_only: false).each do |poll|
      revoked_user_ids = poll.stances.revoked.pluck(:participant_id).uniq
      PollService.create_stances(
        poll: poll,
        actor: poll.author,
        user_ids: (member_ids - poll.voter_ids) - revoked_user_ids
      )
      poll.update_counts!
    end
  end

  def self.group_members_removed(group_id, removed_user_ids, actor_id)
    Poll.active.where(group_id: group_id).each do |poll|
      Stance.where(
        poll_id: poll.id,
        revoked_at: nil,
        participant_id: Array(removed_user_ids),
      ).update_all(revoked_at: Time.zone.now, revoker_id: actor_id)
      poll.update_counts!
    end
  end

  def self.expire_lapsed_polls
    Poll.lapsed_but_not_closed.each do |poll|
      CloseExpiredPollWorker.perform_async(poll.id)
    end
  end

  def self.do_closing_work(poll:)
    return if poll.closed_at
    poll.stances.update_all(participant_id: nil) if poll.anonymous
    if poll.discussion_id && poll.hide_results == 'until_closed'
      stance_ids = poll.stances.latest.reject(&:body_is_blank?).map(&:id)
      Event.where(kind: 'stance_created', eventable_id: stance_ids, discussion_id: nil).update_all(discussion_id: poll.discussion_id)
      EventService.repair_thread(poll.discussion_id)
    end
    poll.update_attribute(:closed_at, Time.now)
    GenericWorker.perform_async('SearchService', 'reindex_by_poll_id', poll.id)
  end

  # def self.destroy(poll:, actor:)
  #   actor.ability.authorize! :destroy, poll
  #   poll.destroy
  #
  #   EventBus.broadcast('poll_destroy', poll, actor)
  # end

  def self.add_to_thread(poll:, params:, actor:)
    discussion = Discussion.find(params[:discussion_id])
    actor.ability.authorize! :update, poll
    actor.ability.authorize! :update, discussion
    ActiveRecord::Base.transaction do
      poll.update(discussion_id: discussion.id, group_id: discussion.group.id)
      event = poll.created_event
      event.discussion_id = discussion.id
      event.parent_id = discussion.created_event.id
      event.pinned = true
      event.set_sequences
      event.save
      poll.created_event.update_sequence_info!
    end

    if (poll.closed? || poll.hide_results != 'until_closed')
      stance_ids = poll.stances.latest.reject(&:body_is_blank?).map(&:id)
      Event.where(kind: 'stance_created', eventable_id: stance_ids).update_all(discussion_id: poll.discussion_id)
      EventService.repair_thread(poll.discussion_id)
    end

    GenericWorker.perform_async('SearchService', 'reindex_by_discussion_id', discussion.id)

    poll.created_event
  end

  def self.calculate_results(poll, poll_options)
    sorted_poll_options = case poll.order_results_by
    when 'priority'
      poll_options.sort_by {|o| o.priority }
    else
      # when 'total_score_desc'
      poll_options.sort_by {|o| -(o.total_score)}
    end

    l = sorted_poll_options.each_with_index.map do |option, index|
      option_name = poll.poll_option_name_format == 'i18n' ? "poll_#{poll.poll_type}_options."+option.name : option.name
      {
        id: option.id,
        poll_id: option.poll_id,
        name: option_name,
        name_format: poll.poll_option_name_format,
        icon: option.icon,
        rank: index+1,
        score: option.total_score,
        target_percent: ((option.icon == 'agree') && (poll.agree_target.to_i > 0)) ? ((option.total_score.to_f / poll.agree_target.to_f) * 100) : 0,
        score_percent: poll.total_score > 0 ? ((option.total_score.to_f / poll.total_score.to_f) * 100) : 0,
        max_score_percent: poll.total_score > 0 ? ((option.total_score.to_f / poll.stance_counts.max.to_f) * 100) : 0,
        voter_percent: poll.voters_count > 0 ? ((option.voter_count.to_f / poll.voters_count.to_f) * 100) : 0,
        average: option.average_score,
        voter_scores: option.voter_scores,
        voter_ids: option.voter_ids.take(500),
        voter_count: option.voter_count,
        color: option.color
      }.with_indifferent_access.freeze
    end
    if poll.results_include_undecided
      l.push({
          id: 0,
          poll_id: poll.id,
          name: 'poll_common_votes_panel.undecided',
          name_format: 'i18n',
          rank: nil,
          score: 0,
          score_percent: 0,
          max_score_percent: 0,
          target_percent: poll.voters_count > 0 ? (poll.undecided_voters_count.to_f / poll.voters_count.to_f * 100) : 0,
          voter_percent: poll.voters_count > 0 ? (poll.undecided_voters_count.to_f / poll.voters_count.to_f * 100) : 0,
          average: 0,
          voter_scores: {},
          voter_ids: poll.undecided_voters.map(&:id).take(500),
          voter_count: poll.undecided_voters_count,
          color: '#BBBBBB'
      }.with_indifferent_access.freeze)
    end
    l
  end
end