opf/openproject

View on GitHub
app/models/users/scopes/having_reminder_mail_to_send.rb

Summary

Maintainability
A
0 mins
Test Coverage
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module Users::Scopes
  module HavingReminderMailToSend
    extend ActiveSupport::Concern

    class_methods do
      # Returns all users for which a reminder mails should be sent. A user
      # will be included if:
      #
      # * That user has an unread notification that is not older than the latest_time value.
      # * The user hasn't been informed about the unread notification before
      # * The user has configured reminder mails to be within the time frame
      #   between the provided earliest_time and latest_time.
      #
      # This assumes that users only have full hours specified for the times
      # they desire to receive a reminder mail at.
      #
      # @param [DateTime] earliest_time The earliest time to consider as a
      #   matching slot. All quarter hours from that time to now are included.
      #
      #   Only the time part is used which is moved forward to the next quarter
      #   hour (e.g. 2021-05-03 10:34:12+02:00 -> 08:45:00). This is done
      #   because time zones always have a mod(15) == 0 minutes offset. Needs to
      #   be before the latest_time.
      # @param [DateTime] latest_time The latest time to consider as a matching
      #  slot.
      #
      #  Only the time part is used which is moved back to the last quarter hour
      #  less than the latest_time value.
      def having_reminder_mail_to_send(earliest_time, latest_time)
        local_times = local_times_from(earliest_time, latest_time)

        return none if local_times.empty?

        # Left outer join as not all user instances have preferences associated
        # but we still want to select them.
        recipient_candidates = User
                                 .active
                                 .left_joins(:preference)
                                 .joins(local_time_join(local_times))

        subscriber_ids = Notification
                           .unsent_reminders_before(recipient: recipient_candidates, time: latest_time)
                           .group(:recipient_id)
                           .select(:recipient_id)

        where(id: subscriber_ids)
      end

      private

      def local_time_join(local_times)
        # Joins the times local to the user preferences and then checks whether:
        #
        #   * reminders are enabled
        #   * any of the configured reminder time is the local time
        #   * the local workday is enabled to receive a reminder on
        #
        # If no time zone is present or if it is blank, the configured default
        # time zone or UTC is assumed.
        #
        # If no reminder settings are present, sending a reminder at 08:00 local
        # time is assumed.
        #
        # If no workdays are specified, 1 - 5 is assumed which represents Monday
        # to Friday.
        times_sql = arel_table
                      .grouping(Arel::Nodes::ValuesList.new(local_times))
                      .as("t(today_local, hours, zone, workday)")

        default_timezone = Arel::Nodes::build_quoted(Setting.user_default_timezone.presence)

        <<~SQL.squish
          JOIN (SELECT * FROM #{times_sql.to_sql}) AS local_times
          ON COALESCE(NULLIF(user_preferences.settings->>'time_zone',''), #{default_timezone.to_sql}, 'Etc/UTC') = local_times.zone
          AND (
            user_preferences.settings->'workdays' @> to_jsonb(local_times.workday)
            OR (
              user_preferences.settings->'workdays' IS NULL
              AND local_times.workday BETWEEN 1 AND 5
            )
          )
          AND (
            (
              user_preferences.settings->'daily_reminders'->'times' IS NULL
              AND local_times.hours = '08:00:00+00:00'
            )
            OR
            (
              (user_preferences.settings->'daily_reminders'->>'enabled')::boolean
              AND user_preferences.settings->'daily_reminders'->'times' ? local_times.hours
            )
          )
          AND (
            (
              user_preferences.settings->'pause_reminders' IS NULL
              OR (user_preferences.settings->'pause_reminders'->>'enabled')::boolean = false
            )
            OR
            (
              (user_preferences.settings->'pause_reminders'->>'enabled')::boolean
              AND (
               local_times.today_local::date
               NOT BETWEEN (user_preferences.settings->'pause_reminders'->>'first_day')::date
               AND (user_preferences.settings->'pause_reminders'->>'last_day')::date
              )
            )
          )
        SQL
      end

      def local_times_from(earliest_time, latest_time)
        times = quarters_between_earliest_and_latest(earliest_time, latest_time)

        times_for_zones(times)
      end

      def times_for_zones(times)
        UserPreferences::UpdateContract
          .assignable_time_zones
          .flat_map { |zone| build_local_times(times, zone) }
          .compact
      end

      def build_local_times(times, zone)
        times.map do |time|
          local_time = time.in_time_zone(zone)

          # Get the iso weekday of the current time to check
          # which users have it enabled as a workday
          workday = local_time.to_date.cwday

          # Get the corresponding date by conversion
          # to compare them with pause_reminder dates input from the users' frontend local times.
          local_date = local_time.to_date

          # Since only full hours can be configured, we can disregard any local time that is not
          # a full hour.
          next if local_time.min != 0

          [
            local_date,
            local_time.strftime("%H:00:00+00:00"),
            zone.tzinfo.canonical_zone.name,
            workday
          ]
        end
      end

      def quarters_between_earliest_and_latest(earliest_time, latest_time) # rubocop:disable Metrics/AbcSize
        raise ArgumentError if latest_time < earliest_time || (latest_time - earliest_time) > 1.day

        # The first quarter is equal or greater to the earliest time
        first_quarter = earliest_time.change(min: (earliest_time.min.to_f / 15).ceil * 15)
        # The last quarter is the one smaller than the latest time. But needs to be at least equal to the first quarter.
        last_quarter = [first_quarter, latest_time.change(min: latest_time.min / 15 * 15)].max

        (first_quarter.to_i..last_quarter.to_i)
          .step(15.minutes)
          .map do |time|
          Time.zone.at(time)
        end
      end
    end
  end
end