diff --git a/Gemfile b/Gemfile index e9b9ead..e232461 100644 --- a/Gemfile +++ b/Gemfile @@ -37,4 +37,5 @@ group :test do gem "mocktail" gem "rspec" gem "rspec-rails" + gem "timecop" end diff --git a/Gemfile.lock b/Gemfile.lock index 98fca3e..bdb04c3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -141,6 +141,8 @@ GEM net-smtp (0.3.3) net-protocol nio4r (2.5.8) + nokogiri (1.13.9-aarch64-linux) + racc (~> 1.4) nokogiri (1.13.9-arm64-darwin) racc (~> 1.4) nokogiri (1.13.9-x86_64-darwin) @@ -270,6 +272,8 @@ GEM rubocop-performance (~> 1.19.1) stimulus-rails (1.2.1) railties (>= 6.0.0) + tailwindcss-rails (2.0.21-aarch64-linux) + railties (>= 6.0.0) tailwindcss-rails (2.0.21-arm64-darwin) railties (>= 6.0.0) tailwindcss-rails (2.0.21-x86_64-darwin) @@ -277,6 +281,7 @@ GEM tailwindcss-rails (2.0.21-x86_64-linux) railties (>= 6.0.0) thor (1.2.1) + timecop (0.9.8) timeout (0.3.0) todo_or_die (0.1.1) turbo-rails (1.3.2) @@ -292,6 +297,7 @@ GEM zeitwerk (2.6.6) PLATFORMS + aarch64-linux arm64-darwin-20 arm64-darwin-21 arm64-darwin-22 @@ -322,6 +328,7 @@ DEPENDENCIES sprockets-rails standard tailwindcss-rails + timecop todo_or_die RUBY VERSION diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 8666d2f..d5d13ed 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -2,6 +2,16 @@ @tailwind components; @tailwind utilities; +td,th { + @apply p-2; +} + +.actions { + a { + @apply px-1 mx-1 border border-black; + } +} + /* @layer components { diff --git a/app/controllers/matchmaking_groups_controller.rb b/app/controllers/matchmaking_groups_controller.rb new file mode 100644 index 0000000..598dbbd --- /dev/null +++ b/app/controllers/matchmaking_groups_controller.rb @@ -0,0 +1,55 @@ +class MatchmakingGroupsController < ApplicationController + def index + @groups = CollectGroups.new.call.sort_by { |group| [group.readonly? ? 0 : 1, group.name] } + end + + def new + @group = MatchmakingGroup.new + end + + def create + if MatchmakingGroup.name_exists?(group_params[:name]) + flash[:error] = "Group already exists" + else + MatchmakingGroup.create(group_params.merge(slack_user_id: @current_user.slack_user_id)) + flash[:notice] = "Group created" + end + + redirect_to matchmaking_groups_path + end + + def edit + @group = MatchmakingGroup.find_by(id: params[:id]) + redirect_to matchmaking_groups_path if @group.nil? + end + + def update + @group = MatchmakingGroup.find_by(id: params[:id]) + + if @group.name != group_params[:name] && MatchmakingGroup.name_exists?(group_params[:name]) + flash[:error] = "Other group already exists with that name" + else + @group = MatchmakingGroup.find_by(id: params[:id]) + @group.update(group_params) + + flash[:notice] = "Group updated" + end + redirect_to matchmaking_groups_path + end + + def destroy + @group = MatchmakingGroup.find_by(id: params[:id]) + @group.destroy if @group.present? + redirect_to matchmaking_groups_path + end + + private + + def group_params + params.require(:matchmaking_group) + .permit(:name, :slack_channel_name, :schedule, :target_size, :is_active) + .tap do |hash| + hash[:name] = hash[:name].strip + end + end +end diff --git a/app/jobs/establish_matches_for_grouping_job.rb b/app/jobs/establish_matches_for_grouping_job.rb deleted file mode 100644 index e0ccc64..0000000 --- a/app/jobs/establish_matches_for_grouping_job.rb +++ /dev/null @@ -1,46 +0,0 @@ -class EstablishMatchesForGroupingJob - def initialize(config: nil) - @loads_slack_channels = Slack::LoadsSlackChannels.new - @loads_slack_channel_members = Slack::LoadsSlackChannelMembers.new - @match_participants = Matchmaking::MatchParticipants.new(config: config) - - @config = config || Rails.application.config.x.matchmaking - end - - def perform(grouping:) - channel = channel_for_grouping(grouping) - - participants = @loads_slack_channel_members.call(channel: channel.id) - - matches = @match_participants.call(participants, grouping) - matches.each do |match| - HistoricalMatch.create( - members: match, - grouping: grouping, - matched_on: Date.today, - pending_notifications: [ - PendingNotification.create(strategy: "email"), - PendingNotification.create(strategy: "slack") - ] - ) - end - rescue => e - ReportsError.report(e) - end - - private - - def channel_for_grouping(grouping) - raise "No config found for grouping '#{grouping}'" unless @config.respond_to?(grouping.intern) - - channel_name = @config.send(grouping)&.channel - raise "No configured channel for grouping '#{grouping}'" unless channel_name - - selected_channel = @loads_slack_channels.call(types: "public_channel").find { |channel| - channel.name_normalized == channel_name - } - raise "No channel found with name '#{channel_name}' for grouping '#{grouping}'" unless selected_channel - - selected_channel - end -end diff --git a/app/models/matchmaking_group.rb b/app/models/matchmaking_group.rb new file mode 100644 index 0000000..2742740 --- /dev/null +++ b/app/models/matchmaking_group.rb @@ -0,0 +1,44 @@ +class MatchmakingGroup < ApplicationRecord + validate :name_not_in_config + validates :name, uniqueness: true + + def self.name_exists?(name) + Rails.application.config.x.matchmaking.to_h.transform_keys(&:to_s).key?(name) || exists?(name: name) + end + + def active? + is_active + end + + def active + is_active + end + + def active=(value) + self.is_active = value + end + + def channel + slack_channel_name + end + + def channel=(value) + self.slack_channel_name = value + end + + def size + target_size + end + + def size=(value) + self.target_size = value + end + + private + + def name_not_in_config + if Rails.application.config.x.matchmaking.to_h.key?(name.intern) + errors.add(:name, "cannot be the same as a key in the matchmaking config") + end + end +end diff --git a/app/services/collect_groups.rb b/app/services/collect_groups.rb new file mode 100644 index 0000000..50c8a1a --- /dev/null +++ b/app/services/collect_groups.rb @@ -0,0 +1,27 @@ +class CollectGroups + def initialize + @config = Rails.application.config.x.matchmaking + end + + def call + extra_groups = MatchmakingGroup.all + readonly_groups + extra_groups + end + + private + + def readonly_groups + @config.to_h.map do |name, group_config| + normalized = group_config.to_h.transform_keys do |key| + next :target_size if key.intern == :size + next :is_active if key.intern == :active + next :slack_channel_name if key.intern == :channel + key + end + + group = MatchmakingGroup.new(normalized.merge(name: name)) + group.define_singleton_method(:readonly?) { true } + group + end + end +end diff --git a/app/services/mailer/builds_grouping_mailer_message.rb b/app/services/mailer/build_group_mailer_message.rb similarity index 90% rename from app/services/mailer/builds_grouping_mailer_message.rb rename to app/services/mailer/build_group_mailer_message.rb index c50376f..888e8a7 100644 --- a/app/services/mailer/builds_grouping_mailer_message.rb +++ b/app/services/mailer/build_group_mailer_message.rb @@ -1,5 +1,5 @@ module Mailer - class BuildsGroupingMailerMessage + class BuildGroupMailerMessage def render(recipient:, channel:, grouping:, other_members:) GroupingMailer.encourage_match( recipient: recipient, diff --git a/app/services/matchmaking/choose_strategy.rb b/app/services/matchmaking/choose_strategy.rb index 7cb87cc..e720d1a 100644 --- a/app/services/matchmaking/choose_strategy.rb +++ b/app/services/matchmaking/choose_strategy.rb @@ -1,16 +1,11 @@ module Matchmaking class ChooseStrategy - def initialize(config: nil) - @config = config || Rails.application.config.x.matchmaking - end - - def call(grouping) - group_config = @config.send(grouping.intern) - return nil unless group_config&.active + def call(group) + return nil unless group&.active? - return Strategies::PairByFewestEncounters.new if group_config.size == 2 + return Strategies::PairByFewestEncounters.new if group.target_size == 2 - Strategies::ArrangeGroupsGenetically.new(target_group_size: group_config.size) + Strategies::ArrangeGroupsGenetically.new(target_group_size: group.target_size) end end end diff --git a/app/services/matchmaking/collect_scored_participants.rb b/app/services/matchmaking/collect_scored_participants.rb index 365e6ff..fb1cd80 100644 --- a/app/services/matchmaking/collect_scored_participants.rb +++ b/app/services/matchmaking/collect_scored_participants.rb @@ -4,9 +4,9 @@ def initialize @assign_score_to_candidates = AssignScoreToCandidates.new end - def call(participants, grouping) + def call(participants, group) participants.reduce({}) do |memo, participant| - recent_matches = HistoricalMatch.scoreable.with_member(participant).in_grouping(grouping) + recent_matches = HistoricalMatch.scoreable.with_member(participant).in_grouping(group.name) candidates = participants.difference([participant]) scored_candidates = @assign_score_to_candidates.call(candidates, recent_matches) diff --git a/app/services/matchmaking/errors/channel_not_found.rb b/app/services/matchmaking/errors/channel_not_found.rb new file mode 100644 index 0000000..d1da66c --- /dev/null +++ b/app/services/matchmaking/errors/channel_not_found.rb @@ -0,0 +1,9 @@ +module Matchmaking + module Errors + class ChannelNotFound < StandardError + def initialize(group_name, channel_name) + super("No channel found with name '#{channel_name}' for grouping '#{group_name}'") + end + end + end +end diff --git a/app/services/matchmaking/errors/no_configured_channel.rb b/app/services/matchmaking/errors/no_configured_channel.rb new file mode 100644 index 0000000..2eaef72 --- /dev/null +++ b/app/services/matchmaking/errors/no_configured_channel.rb @@ -0,0 +1,9 @@ +module Matchmaking + module Errors + class NoConfiguredChannel < StandardError + def initialize(group_name) + super("No configured channel for grouping '#{group_name}'") + end + end + end +end diff --git a/app/services/matchmaking/establish_matches_for_group.rb b/app/services/matchmaking/establish_matches_for_group.rb new file mode 100644 index 0000000..afdc9d7 --- /dev/null +++ b/app/services/matchmaking/establish_matches_for_group.rb @@ -0,0 +1,49 @@ +module Matchmaking + class EstablishMatchesForGroup + def initialize + @loads_slack_channels = Slack::LoadsSlackChannels.new + @loads_slack_channel_members = Slack::LoadsSlackChannelMembers.new + @match_participants = Matchmaking::MatchParticipants.new + end + + def call(group) + ensure_channel_configured(group) + + channel = fetch_slack_channel(group.slack_channel_name) + ensure_channel_found(channel, group) + + participants = @loads_slack_channel_members.call(channel: channel.id) + + matches = @match_participants.call(participants, group) + matches.each do |match| + HistoricalMatch.create( + members: match, + grouping: group.name, + matched_on: Date.today, + pending_notifications: [ + PendingNotification.create(strategy: "email"), + PendingNotification.create(strategy: "slack") + ] + ) + end + rescue => e + ReportsError.report(e) + end + + private + + def ensure_channel_configured(group) + raise Errors::NoConfiguredChannel.new(group.name) unless group.slack_channel_name + end + + def ensure_channel_found(channel, group) + raise Errors::ChannelNotFound.new(group.slack_channel_name, group.name) unless channel + end + + def fetch_slack_channel(channel_name) + @loads_slack_channels.call(types: "public_channel").find { |channel| + channel.name_normalized == channel_name + } + end + end +end diff --git a/app/services/matchmaking/match_participants.rb b/app/services/matchmaking/match_participants.rb index db1d9f3..4873215 100644 --- a/app/services/matchmaking/match_participants.rb +++ b/app/services/matchmaking/match_participants.rb @@ -1,18 +1,17 @@ module Matchmaking class MatchParticipants - def initialize(config: nil) + def initialize @collect_scored_participants = CollectScoredParticipants.new - @config = config || Rails.application.config.x.matchmaking - @choose_strategy = ChooseStrategy.new(config: @config) + @choose_strategy = ChooseStrategy.new end - def call(participants, grouping) + def call(participants, group) # We don't want to consider a group of 1 a match return [] if participants.size < 2 - scored_participants = @collect_scored_participants.call(participants, grouping) + scored_participants = @collect_scored_participants.call(participants, group) - strategy = @choose_strategy.call(grouping) + strategy = @choose_strategy.call(group) return [] unless strategy strategy.call(scored_participants) diff --git a/app/services/notify/use_email_to_deliver_notification.rb b/app/services/notify/use_email_to_deliver_notification.rb new file mode 100644 index 0000000..ff6772a --- /dev/null +++ b/app/services/notify/use_email_to_deliver_notification.rb @@ -0,0 +1,33 @@ +module Notify + class UseEmailToDeliverNotification + def initialize + @retrieves_slack_user_info = Slack::RetrievesSlackUserInfo.new + @build_group_mailer_message = Mailer::BuildGroupMailerMessage.new + end + + def call(notification, group) + return unless notification.use_email? + + match = notification.historical_match + + member_users = match.members.map { |id| convert_to_match_member(id) } + member_users.each do |user| + mailer = @build_group_mailer_message.render( + recipient: user, + channel: group.slack_channel_name, + grouping: match.grouping, + other_members: member_users.reject { |u| u.email == user.email } + ) + + mailer.deliver_now + end + end + + private + + def convert_to_match_member(member_id) + slack_user = @retrieves_slack_user_info.call(user: member_id) + Mailer::MatchMember.from_slack_user(slack_user) + end + end +end diff --git a/app/services/notify/uses_slack_to_deliver_notification.rb b/app/services/notify/use_slack_to_deliver_notification.rb similarity index 69% rename from app/services/notify/uses_slack_to_deliver_notification.rb rename to app/services/notify/use_slack_to_deliver_notification.rb index 622adc8..f03a8c6 100644 --- a/app/services/notify/uses_slack_to_deliver_notification.rb +++ b/app/services/notify/use_slack_to_deliver_notification.rb @@ -1,25 +1,26 @@ module Notify - class UsesSlackToDeliverNotification - def initialize(config: nil) + class UseSlackToDeliverNotification + def initialize @opens_slack_conversation = Slack::OpensSlackConversation.new @sends_slack_message = Slack::SendsSlackMessage.new @builds_grouping_slack_message = Slack::BuildsGroupingSlackMessage.new - - @config = config || Rails.application.config.x.matchmaking end - def call(notification:) + def call(notification, group) return unless notification.use_slack? match = notification.historical_match - channel_name = channel_name_for_grouping(match.grouping) match_conversation = @opens_slack_conversation.call(users: match.members) @sends_slack_message.call( channel: match_conversation, # TODO refactor to pass match instead of match attributes - blocks: @builds_grouping_slack_message.render(grouping: match.grouping, members: match.members, channel_name: channel_name) + blocks: @builds_grouping_slack_message.render( + grouping: match.grouping, + members: match.members, + channel_name: group.slack_channel_name + ) ) end diff --git a/app/services/notify/uses_email_to_deliver_notification.rb b/app/services/notify/uses_email_to_deliver_notification.rb deleted file mode 100644 index 4e5c59d..0000000 --- a/app/services/notify/uses_email_to_deliver_notification.rb +++ /dev/null @@ -1,42 +0,0 @@ -module Notify - class UsesEmailToDeliverNotification - def initialize(config: nil) - @retrieves_slack_user_info = Slack::RetrievesSlackUserInfo.new - @builds_grouping_mailer_message = Mailer::BuildsGroupingMailerMessage.new - - @config = config || Rails.application.config.x.matchmaking - end - - def call(notification:) - return unless notification.use_email? - - match = notification.historical_match - - channel_name = channel_name_for_grouping(match.grouping) - - member_users = match.members.map { |id| convert_to_match_member(id) } - member_users.each do |user| - @builds_grouping_mailer_message.render( - recipient: user, - channel: channel_name, - grouping: match.grouping, - other_members: member_users.reject { |u| u.email == user.email } - ).deliver_now - end - end - - private - - def convert_to_match_member(member_id) - Mailer::MatchMember.from_slack_user(@retrieves_slack_user_info.call(user: member_id)) - end - - def channel_name_for_grouping(grouping) - grouping_sym = grouping.intern - - raise "No config found for grouping '#{grouping}'" unless @config.respond_to?(grouping_sym) - - @config.send(grouping_sym)&.channel - end - end -end diff --git a/app/services/rakes/run_matchmaking.rb b/app/services/rakes/run_matchmaking.rb new file mode 100644 index 0000000..0916691 --- /dev/null +++ b/app/services/rakes/run_matchmaking.rb @@ -0,0 +1,36 @@ +module Rakes + class RunMatchmaking + def initialize(stdout:, stderr:) + @stdout = stdout + @stderr = stderr + + @identifies_nearest_date = IdentifiesNearestDate.new + @collect_groups = CollectGroups.new + @establish_matches_for_group = Matchmaking::EstablishMatchesForGroup.new + end + + def call + @collect_groups.call.each do |group| + next unless should_run_today?(group.schedule) + + unless group.active? + @stdout.puts "Skipping matchmaking for '#{group.name}'" + next + end + + @stdout.puts "Starting matchmaking for '#{group.name}'" + @establish_matches_for_group.call(group) + rescue => e + @stderr.puts "Failed to run matchmaking for '#{group.name}'. Reporting to Bugsnag." + ReportsError.report(e) + end + @stdout.puts "Matchmaking successfully completed" + end + + private + + def should_run_today?(schedule) + @identifies_nearest_date.call(schedule).today? + end + end +end diff --git a/app/services/rakes/runs_matchmaking.rb b/app/services/rakes/runs_matchmaking.rb deleted file mode 100644 index 46aef3a..0000000 --- a/app/services/rakes/runs_matchmaking.rb +++ /dev/null @@ -1,35 +0,0 @@ -module Rakes - class RunsMatchmaking - def initialize(stdout:, stderr:, config: nil) - @stdout = stdout - @stderr = stderr - @config = config || Rails.application.config.x.matchmaking - - @identifies_nearest_date = IdentifiesNearestDate.new - end - - def call - @config.each_pair do |grouping, grouping_config| - next unless should_run_today?(grouping_config.schedule) - - unless grouping_config.active - @stdout.puts "Skipping matchmaking for '#{grouping}'" - next - end - - @stdout.puts "Starting matchmaking for '#{grouping}'" - EstablishMatchesForGroupingJob.new.perform(grouping: grouping) - rescue => e - @stderr.puts "Failed to run matchmaking for '#{grouping}'. Reporting to Bugsnag." - ReportsError.report(e) - end - @stdout.puts "Matchmaking successfully completed" - end - - private - - def should_run_today?(schedule) - @identifies_nearest_date.call(schedule).today? - end - end -end diff --git a/app/services/rakes/send_pending_notifications.rb b/app/services/rakes/send_pending_notifications.rb new file mode 100644 index 0000000..3c72143 --- /dev/null +++ b/app/services/rakes/send_pending_notifications.rb @@ -0,0 +1,48 @@ +module Rakes + class SendPendingNotifications + def initialize(stdout:, stderr:) + @stdout = stdout + @stderr = stderr + + @collect_groups = CollectGroups.new + @retrieves_pending_notifications = Notify::RetrievesPendingNotifications.new + @determines_retriability = Notify::DeterminesRetriability.new + @use_email_to_deliver_notification = Notify::UseEmailToDeliverNotification.new + @use_slack_to_deliver_notification = Notify::UseSlackToDeliverNotification.new + end + + def call + @collect_groups.call.each do |group| + notifications = @retrieves_pending_notifications.call(grouping: group.name) + + if notifications.empty? + @stdout.puts "No pending notifications found for '#{group.name}'" + next + end + + notifications.each do |notification| + @stdout.puts "Sending notifications for '#{group.name}'" + + if sendable_today?(group, notification) + notification_strategy = pick_strategy(notification) + notification_strategy&.call(notification, group) + end + + notification.delete + @stdout.puts "#{notification.strategy.titleize} notification sent" + end + end + end + + private + + def sendable_today?(group, notification) + @determines_retriability.can_retry?(group.schedule, original_date: notification.created_at.to_date) + end + + def pick_strategy(notification) + return @use_email_to_deliver_notification if notification.use_email? + @use_slack_to_deliver_notification if notification.use_slack? + end + end +end diff --git a/app/services/rakes/sends_pending_notifications.rb b/app/services/rakes/sends_pending_notifications.rb deleted file mode 100644 index f5a5d1c..0000000 --- a/app/services/rakes/sends_pending_notifications.rb +++ /dev/null @@ -1,48 +0,0 @@ -module Rakes - class SendsPendingNotifications - def initialize(stdout:, stderr:, config: nil) - @stdout = stdout - @stderr = stderr - @config = config || Rails.application.config.x.matchmaking - - @retrieves_pending_notifications = Notify::RetrievesPendingNotifications.new - @determines_retriability = Notify::DeterminesRetriability.new - @uses_email_to_deliver_notification = Notify::UsesEmailToDeliverNotification.new - @uses_slack_to_deliver_notification = Notify::UsesSlackToDeliverNotification.new - end - - def call - @config.each_pair do |grouping, grouping_config| - notifications = @retrieves_pending_notifications.call(grouping: grouping) - - if notifications.empty? - @stdout.puts "No pending notifications found for '#{grouping}'" - next - end - - notifications.each do |notification| - @stdout.puts "Sending notifications for '#{grouping}'" - - if sendable_today?(grouping_config, notification) - notification_strategy = pick_strategy(notification) - notification_strategy&.call(notification: notification) - end - - notification.delete - @stdout.puts "#{notification.strategy.titleize} notification sent" - end - end - end - - private - - def sendable_today?(grouping_config, notification) - @determines_retriability.can_retry?(grouping_config.schedule, original_date: notification.created_at.to_date) - end - - def pick_strategy(notification) - return @uses_email_to_deliver_notification if notification.use_email? - @uses_slack_to_deliver_notification if notification.use_slack? - end - end -end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index bd6b52d..d00dd72 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -22,6 +22,7 @@