diff --git a/config/config.exs b/config/config.exs index 8e4f072d..991fc859 100644 --- a/config/config.exs +++ b/config/config.exs @@ -71,12 +71,6 @@ config :fun_with_flags, :persistence, repo: Pears.Repo, ecto_table_name: "feature_flags" -config :pears, Pears.Scheduler, - jobs: [ - # At 5:00 PM Pacific on every day-of-week from Monday through Friday. - {"0 12 * * 1-5", {Pears.Slack, :send_stand_down_reminders, []}} - ] - # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/config/prod.exs b/config/prod.exs index ced1eb67..b4398cfc 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -20,5 +20,11 @@ config :swoosh, local: false # Do not print debug messages in production config :logger, level: :info +config :pears, Pears.Scheduler, + jobs: [ + # At 5:00 PM Pacific on every day-of-week from Monday through Friday. + {"0 12 * * 1-5", {Pears, :send_hand_off_reminders, []}} + ] + # Runtime production configuration, including reading # of environment variables, is done on config/runtime.exs. diff --git a/lib/pears.ex b/lib/pears.ex index 2257fa01..3e531653 100644 --- a/lib/pears.ex +++ b/lib/pears.ex @@ -12,6 +12,7 @@ defmodule Pears do alias Pears.Core.Recommendator alias Pears.Core.Team alias Pears.Persistence + alias Pears.Slack @topic inspect(__MODULE__) @@ -457,6 +458,31 @@ defmodule Pears do end end + @decorate trace("pears.send_hand_off_reminders") + def send_hand_off_reminders do + teams = Persistence.find_teams_with_slack_tokens() + + Enum.each(teams, &send_hand_off_reminder/1) + + {:ok, nil} + end + + @decorate trace("pears.send_hand_off_reminder", include: [:team]) + defp send_hand_off_reminder(team_record) do + with {:ok, team} <- TeamSession.find_or_start_session(team_record.name), + {:ok, snapshot} <- Persistence.get_latest_snapshot(team.name), + true <- is_snapshot_from_today(snapshot), + matches <- Team.misaligned_tz_matches(team) do + Enum.each(matches, fn pears -> + Slack.send_hand_off_reminder(team, pears) + end) + end + end + + defp is_snapshot_from_today(snapshot) do + snapshot.inserted_at >= Date.utc_today() + end + @decorate trace("pears.validate_pear_available", include: [:team, :pear_name]) defp validate_pear_available(team, pear_name) do case Team.find_available_pear(team, pear_name) do diff --git a/lib/pears/boundary/team_session.ex b/lib/pears/boundary/team_session.ex index c82f276b..95ab21a6 100644 --- a/lib/pears/boundary/team_session.ex +++ b/lib/pears/boundary/team_session.ex @@ -177,7 +177,8 @@ defmodule Pears.Boundary.TeamSession do Team.add_pear(team, pear_record.name, id: pear_record.id, slack_name: pear_record.slack_name, - slack_id: pear_record.slack_id + slack_id: pear_record.slack_id, + timezone_offset: pear_record.timezone_offset ) end) end diff --git a/lib/pears/core/team.ex b/lib/pears/core/team.ex index 695c4abe..e07cb201 100644 --- a/lib/pears/core/team.ex +++ b/lib/pears/core/team.ex @@ -425,17 +425,30 @@ defmodule Pears.Core.Team do Returns the difference in hours between two pears' timezone offsets. """ @spec timezone_difference(Pear.t(), Pear.t()) :: integer - @decorate with_span("team.timezone_difference") def timezone_difference(%{timezone_offset: nil}, _), do: 0 def timezone_difference(_, %{timezone_offset: nil}), do: 0 - def timezone_difference(pear1, pear2) do - difference_seconds = abs(pear1.timezone_offset - pear2.timezone_offset) + def timezone_difference(%{timezone_offset: left}, %{timezone_offset: right}) do + offset_difference_in_hours(parse_int(left), parse_int(right)) + end + + defp parse_int(maybe_a_string) when is_binary(maybe_a_string) do + {number, _remainder} = Integer.parse(maybe_a_string) + number + end + + defp parse_int(maybe_a_string) when is_integer(maybe_a_string) do + maybe_a_string + end + + @decorate with_span("team.offset_difference_in_hours") + defp offset_difference_in_hours(left, right) do + difference_seconds = abs(left - right) difference_hours = div(difference_seconds, 3600) O11y.set_attributes( - left: pear1.timezone_offset, - right: pear2.timezone_offset, + left: left, + right: right, difference_hours: difference_hours, difference_seconds: difference_seconds ) @@ -443,20 +456,25 @@ defmodule Pears.Core.Team do difference_hours end - @spec misaligned_tz_matches(Team.t()) :: [Pear.t()] + @spec misaligned_tz_matches(Team.t()) :: list(list(Pear.t())) @decorate with_span("team.misaligned_tz_matches") def misaligned_tz_matches(team) do team.tracks - |> Enum.map(fn {_track_name, track} -> + |> Enum.map(fn {track_name, track} -> pears = Map.values(track.pears) - if significant_tz_differences?(pears), do: pears, else: [] + + if significant_tz_differences?(pears) do + O11y.add_event("misaligned_tz_match", %{ + track: track_name, + pears: Enum.map(pears, & &1.name) + }) + + pears + else + [] + end end) |> Enum.reject(&Enum.empty?/1) - |> tap(fn matches -> - Enum.map(matches, fn match -> - O11y.add_event("misaligned_tz_match", %{match: match}) - end) - end) end def metadata(team) do diff --git a/lib/pears/persistence.ex b/lib/pears/persistence.ex index 4d150983..37a35ed9 100644 --- a/lib/pears/persistence.ex +++ b/lib/pears/persistence.ex @@ -44,6 +44,7 @@ defmodule Pears.Persistence do end @decorate trace("persistence.find_teams_with_slack_tokens") + @spec find_teams_with_slack_tokens() :: [TeamRecord.t()] def find_teams_with_slack_tokens do Repo.all(from t in TeamRecord, where: not is_nil(t.slack_token)) end @@ -80,7 +81,7 @@ defmodule Pears.Persistence do {:tracks, :pears}, {:tracks, :anchor}, {:snapshots, :matches}, - snapshots: from(s in SnapshotRecord, order_by: [desc: s.inserted_at]) + snapshots: from(s in SnapshotRecord, order_by: [desc: s.id]) ]) case result do @@ -307,6 +308,15 @@ defmodule Pears.Persistence do end end + def get_latest_snapshot(team_name) do + with {:ok, team} <- get_team_by_name(team_name), + {:ok, snapshot} <- find_latest_snapshot(team) do + {:ok, snapshot} + else + error -> error + end + end + defp save_snapshot(team, snapshot) do %SnapshotRecord{} |> SnapshotRecord.changeset(%{ @@ -316,6 +326,13 @@ defmodule Pears.Persistence do |> Repo.insert() end + defp find_latest_snapshot(team) do + case Enum.at(team.snapshots, 0) do + nil -> {:error, :no_snapshots} + snapshot -> {:ok, snapshot} + end + end + defp build_matches(snapshot) do Enum.map(snapshot, &build_match/1) end diff --git a/lib/pears/slack.ex b/lib/pears/slack.ex index 1afe7ec9..8f4ac495 100644 --- a/lib/pears/slack.ex +++ b/lib/pears/slack.ex @@ -8,6 +8,7 @@ defmodule Pears.Slack do alias Pears.Slack.Details alias Pears.Slack.Messages.DailyPairsMessage alias Pears.Slack.Messages.EndOfSessionQuestion + alias Pears.Slack.Messages alias Pears.Slack.User def slack_client do @@ -78,11 +79,6 @@ defmodule Pears.Slack do end end - @decorate trace("slack.send_stand_down_reminders") - def send_stand_down_reminders() do - O11y.set_attributes(cron: "0 12 * * 1-5") - end - @decorate trace("slack.send_message_to_team", include: [:team_name, :message]) def send_message_to_team(team_name, message) do case TeamSession.find_or_start_session(team_name) do @@ -112,6 +108,12 @@ defmodule Pears.Slack do end end + @decorate trace("slack.send_hand_off_reminder") + def send_hand_off_reminder(team, pears) do + message = Messages.HandOffReminder.new() + send_message_to_pears(team, pears, message) + end + defp do_save_slack_names(team_name, users, pears, params) do Enum.map(params, fn {pear_name, ""} -> @@ -119,7 +121,7 @@ defmodule Pears.Slack do {pear_name, slack_id} -> with {:ok, team} <- TeamSession.find_or_start_session(team_name), - user <- Enum.find(users, fn user -> user.id == slack_id end), + %User{} = user <- Enum.find(users, fn user -> user.id == slack_id end), {:ok, pear_record} <- Persistence.add_pear_slack_details(team.name, pear_name, %{ slack_id: user.id, @@ -136,6 +138,7 @@ defmodule Pears.Slack do {:ok, pear_record} end end) + |> Enum.reject(&(&1 == nil)) |> Enum.group_by(fn {success, _} -> success end, fn {_, result} -> result end) |> Map.get(:ok, []) end @@ -168,19 +171,19 @@ defmodule Pears.Slack do defp do_send_end_of_session_question(team, track) do message = EndOfSessionQuestion.new(track) + send_message_to_pears(team, Map.values(track.pears), message) + end - case find_or_create_group_chat(team, track) do + defp send_message_to_pears(team, pears, message) do + case find_or_create_group_chat(team, pears) do {:ok, channel_id} -> do_send_message(team, channel_id, message) _ -> {:error, :error_creating_group_chat} end end - @decorate trace("slack.find_or_create_group_chat", include: [[:track, :pears], :user_ids]) - defp find_or_create_group_chat(%{slack_token: token}, track) do - user_ids = - track.pears - |> Map.values() - |> Enum.map(&Map.get(&1, :slack_id)) + @decorate trace("slack.find_or_create_group_chat", include: [:pears, :user_ids]) + defp find_or_create_group_chat(%{slack_token: token}, pears) do + user_ids = Enum.map(pears, &Map.get(&1, :slack_id)) case slack_client().find_or_create_group_chat(user_ids, token) do %{"ok" => true} = response -> diff --git a/lib/pears/slack/messages/hand_off_reminder.ex b/lib/pears/slack/messages/hand_off_reminder.ex new file mode 100644 index 00000000..f38dd3b8 --- /dev/null +++ b/lib/pears/slack/messages/hand_off_reminder.ex @@ -0,0 +1,17 @@ +defmodule Pears.Slack.Messages.HandOffReminder do + def new do + [ + %{ + "type" => "section", + "text" => %{ + "type" => "mrkdwn", + "text" => + "Hey, friends! 👋\n\nLooks like you're pairing across timezones!\nThis is your friendly reminder to update your pair with any context they might need to get started tomorrow." + } + }, + %{ + "type" => "divider" + } + ] + end +end diff --git a/mix.exs b/mix.exs index 2781ae99..aa58a4d1 100644 --- a/mix.exs +++ b/mix.exs @@ -64,6 +64,7 @@ defmodule Pears.MixProject do {:phoenix_live_dashboard, "~> 0.8.3"}, {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:heroicons, github: "tailwindlabs/heroicons", tag: "v2.1.1", diff --git a/mix.lock b/mix.lock index ae1619f8..61b2d7a5 100644 --- a/mix.lock +++ b/mix.lock @@ -16,10 +16,12 @@ "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"}, + "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, "ecto_sql": {:hex, :ecto_sql, "3.11.3", "4eb7348ff8101fbc4e6bbc5a4404a24fecbe73a3372d16569526b0cf34ebc195", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e5f36e3d736b99c7fee3e631333b8394ade4bafe9d96d35669fca2d81c2be928"}, "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, diff --git a/test/pears/persistence_test.exs b/test/pears/persistence_test.exs index d729d50f..e7f08611 100644 --- a/test/pears/persistence_test.exs +++ b/test/pears/persistence_test.exs @@ -195,5 +195,27 @@ defmodule Pears.PersistenceTest do assert Enum.member?(matches, %{track_name: "track one", pear_names: ["pear1", "pear2"]}) assert Enum.member?(matches, %{track_name: "track two", pear_names: ["pear3"]}) end + + test "can get the most recent snapshot" do + team = create_team("New Team") + + {:error, _} = Persistence.get_latest_snapshot(team.name) + + {:ok, _} = + Persistence.add_snapshot_to_team(team.name, [ + {"track one", ["pear1", "pear2"]}, + {"track two", ["pear3"]} + ]) + + {:ok, second_snapshot} = + Persistence.add_snapshot_to_team(team.name, [ + {"track one", ["pear1", "pear2"]}, + {"track two", ["pear3"]} + ]) + + {:ok, retrieved_snapshot} = Persistence.get_latest_snapshot(team.name) + + assert retrieved_snapshot.id == second_snapshot.id + end end end diff --git a/test/pears/slack_test.exs b/test/pears/slack_test.exs index 76a38a25..d90e759b 100644 --- a/test/pears/slack_test.exs +++ b/test/pears/slack_test.exs @@ -23,7 +23,7 @@ defmodule Pears.SlackTest do test "exchanges a code for an access token and saves it", %{team: team} do valid_token = "xoxb-XXXXXXXX-XXXXXXXX-XXXXX" - expect(MockSlackClient, :retrieve_access_tokens, fn _code, _url -> + expect(MockSlackClient, :retrieve_access_tokens, fn "valid_code", _url -> SlackFixtures.valid_token_response(%{access_token: valid_token}) end) diff --git a/test/pears_test.exs b/test/pears_test.exs index 46c3fc09..dc894c83 100644 --- a/test/pears_test.exs +++ b/test/pears_test.exs @@ -2,9 +2,17 @@ defmodule PearsTest do use Pears.DataCase, async: true import TeamAssertions + import Mox + alias Pears.Boundary.TeamManager alias Pears.Boundary.TeamSession + alias Pears.MockSlackClient alias Pears.Persistence + alias Pears.Slack + alias Pears.SlackFixtures + + # Make sure mocks are verified when the test exits + setup :verify_on_exit! setup [:name] @@ -417,6 +425,100 @@ defmodule PearsTest do assert Enum.member?(["Pear One", "Pear Two"], new_facilitator.name) end + describe "hand off reminders" do + setup do + MockSlackClient + |> stub(:retrieve_access_tokens, fn _code, _url -> SlackFixtures.valid_token_response() end) + |> stub(:channels, fn _, _ -> SlackFixtures.channels_response() end) + |> stub(:users, fn _, _ -> SlackFixtures.users_response() end) + |> stub(:find_or_create_group_chat, fn _, _ -> %{} end) + + :ok + end + + test "sends reminders to teams with a slack token and snapshot from today", %{name: name} do + MockSlackClient + |> stub(:retrieve_access_tokens, fn _code, _url -> + SlackFixtures.valid_token_response(%{"access_token" => "valid_token"}) + end) + |> stub(:channels, fn _, _ -> SlackFixtures.channels_response() end) + |> stub(:users, fn _, "" -> + SlackFixtures.users_response([ + %{id: "XXXXXXXXXX", name: "marc", tz_offset: "-28800"}, + %{id: "YYYYYYYYYY", name: "milo", tz_offset: "-18000"} + ]) + end) + |> stub(:find_or_create_group_chat, fn ["XXXXXXXXXX", "YYYYYYYYYY"], "valid_token" -> + SlackFixtures.channel_response("ZZZZZZZZZZ", "idk what a dm channel name is") + end) + + {:ok, _} = Pears.add_team(name) + {:ok, _} = Pears.add_pear(name, "Marc") + {:ok, _} = Pears.add_pear(name, "Milo") + {:ok, _} = Pears.add_track(name, "Track One") + {:ok, _} = Pears.add_pear_to_track(name, "Marc", "Track One") + {:ok, _} = Pears.add_pear_to_track(name, "Milo", "Track One") + {:ok, _} = Pears.record_pears(name) + + {:ok, _} = Slack.onboard_team(name, "valid_code") + {:ok, details} = Slack.get_details(name) + + params = %{"Marc" => "XXXXXXXXXX", "Milo" => "YYYYYYYYYY"} + {:ok, _} = Slack.save_slack_names(details, name, params) + + MockSlackClient + |> expect(:send_message, fn "ZZZZZZZZZZ", _blocks, "valid_token" -> %{"ok" => true} end) + + assert {:ok, _} = Pears.send_hand_off_reminders() + end + + test "does not send reminders to teams with no slack token", %{name: name} do + {:ok, _} = Pears.add_team(name) + + MockSlackClient + |> expect(:send_message, 0, fn _channel, _blocks, _token -> %{"ok" => true} end) + + assert {:ok, _} = Pears.send_hand_off_reminders() + end + + test "does not send reminders if users don't have slack names saved", %{name: name} do + {:ok, _} = Pears.add_team(name) + {:ok, _} = Pears.add_pear(name, "Marc") + {:ok, _} = Pears.add_pear(name, "Milo") + {:ok, _} = Pears.add_track(name, "Track One") + {:ok, _} = Pears.add_pear_to_track(name, "Marc", "Track One") + {:ok, _} = Pears.add_pear_to_track(name, "Milo", "Track One") + {:ok, _} = Pears.record_pears(name) + + MockSlackClient + |> expect(:send_message, 0, fn _channel, _blocks, _token -> %{"ok" => true} end) + + assert {:ok, _} = Pears.send_hand_off_reminders() + end + + test "does not send reminders to teams that are not pairing today", %{name: name} do + {:ok, _} = Pears.add_team(name) + {:ok, _} = Pears.add_pear(name, "Marc") + {:ok, _} = Pears.add_pear(name, "Milo") + {:ok, _} = Pears.add_track(name, "Track One") + {:ok, _} = Pears.add_pear_to_track(name, "Marc", "Track One") + {:ok, _} = Pears.add_pear_to_track(name, "Milo", "Track One") + + {:ok, _} = Slack.onboard_team(name, "valid_code") + {:ok, details} = Slack.get_details(name) + + params = %{"Marc" => "XXXXXXXXXX", "Milo" => "YYYYYYYYYY"} + {:ok, _} = Slack.save_slack_names(details, name, params) + + # Note that we DON'T record pears here, so there is no snapshot for today + + MockSlackClient + |> expect(:send_message, 0, fn _channel, _blocks, _token -> %{"ok" => true} end) + + assert {:ok, _} = Pears.send_hand_off_reminders() + end + end + def name(_) do {:ok, name: Ecto.UUID.generate()} end diff --git a/test/support/fixtures/slack_fixtures.ex b/test/support/fixtures/slack_fixtures.ex index b5af4899..16a5bc30 100644 --- a/test/support/fixtures/slack_fixtures.ex +++ b/test/support/fixtures/slack_fixtures.ex @@ -143,6 +143,13 @@ defmodule Pears.SlackFixtures do } end + def channel_response(id, name) do + %{ + "channel" => channel(id, name), + "ok" => true + } + end + defp channel(id, name) do %{ "created" => 123_456_789,