diff --git a/lib/notifications/db/detour.ex b/lib/notifications/db/detour.ex index 9c9dbc410..4181e6c6f 100644 --- a/lib/notifications/db/detour.ex +++ b/lib/notifications/db/detour.ex @@ -9,7 +9,11 @@ defmodule Notifications.Db.Detour do @derive {Jason.Encoder, only: [ :__struct__, - :status + :status, + :headsign, + :route, + :direction, + :origin ]} typed_schema "detour_notifications" do @@ -17,6 +21,12 @@ defmodule Notifications.Db.Detour do has_one :notification, Notifications.Db.Notification field :status, Ecto.Enum, values: [:activated] + + # Derived from the associated detour + field :headsign, :any, virtual: true + field :route, :any, virtual: true + field :direction, :any, virtual: true + field :origin, :any, virtual: true end def changeset( @@ -33,4 +43,104 @@ defmodule Notifications.Db.Detour do :status ]) end + + defmodule Queries do + @moduledoc """ + Defines composable queries for retrieving `Notifications.Db.Detour` info. + """ + + import Ecto.Query + + @doc """ + The "base" query that queries `Notifications.Db.Detour`'s without restriction + + + ## Examples + + The `base` query returns all Detour Notifications + + iex> :detour + ...> |> insert() + ...> |> Notifications.Notification.create_activated_detour_notification_from_detour() + ...> + iex> all_detour_notifications = + ...> Notifications.Db.Detour.Queries.base() + ...> |> Skate.Repo.all() + ...> + iex> match?( + ...> [ + ...> %Notifications.Db.Detour{} + ...> ], + ...> all_detour_notifications + ...> ) + true + + """ + def base() do + from(d in Notifications.Db.Detour, as: :detour_notification, select_merge: d) + end + + @doc """ + Retrieves detour information for notifications from the `Notifications.Db.Detour` table + + ## Examples + + iex> :detour + ...> |> insert() + ...> |> Notifications.Notification.create_activated_detour_notification_from_detour() + ...> + iex> all_detour_notifications = + ...> Notifications.Db.Detour.Queries.get_derived_info() + ...> |> Skate.Repo.all() + ...> + iex> [ + ...> %Notifications.Db.Detour{ + ...> route: route, + ...> origin: origin, + ...> headsign: headsign, + ...> direction: direction + ...> } + ...> ] = all_detour_notifications + iex> Enum.any?([route, origin, headsign, direction], &is_nil/1) + false + + """ + def get_derived_info(query \\ base()) do + from( + [detour_notification: dn] in query, + left_join: ad in assoc(dn, :detour), + as: :associated_detour, + select_merge: %{ + route: ad.state["context"]["route"]["name"], + origin: ad.state["context"]["routePattern"]["name"], + headsign: ad.state["context"]["routePattern"]["headsign"], + + # Ecto can't figure out how to index a JSON map via another JSON value + # because (in the ways it was tried) Ecto won't allow us to use the + # value from the associated detour, `ad`, as a value in the + # ["JSON path"](https://hexdocs.pm/ecto/Ecto.Query.API.html#json_extract_path/2). + # + # i.e., this + # ad.state["context"]["route"]["directionNames"][ + # ad.state["context"]["routePattern"]["directionId"] + # ] + # + # But, Postgres _is_ able to do this, _if_ we get the types correct. + # A JSON value in Postgres is either of type JSON or JSONB, but + # - indexing a JSON array requires an `INTEGER`, + # - accessing a JSON map, requires Postgres's `TEXT` type. + # + # So because we know the `directionId` will correspond to the keys in + # `directionNames`, casting the `directionId` to `TEXT` allows us to + # access the `directionNames` JSON map + direction: + fragment( + "? -> CAST(? AS TEXT)", + ad.state["context"]["route"]["directionNames"], + ad.state["context"]["routePattern"]["directionId"] + ) + } + ) + end + end end diff --git a/lib/notifications/db/notification.ex b/lib/notifications/db/notification.ex index 8141e5c88..0ef3e9852 100644 --- a/lib/notifications/db/notification.ex +++ b/lib/notifications/db/notification.ex @@ -68,4 +68,131 @@ defmodule Notifications.Db.Notification do ) |> validate_required(:created_at) end + + defmodule Queries do + @moduledoc """ + Composable queries for accessing `Notifications.Db.Notification` + related data + """ + import Ecto.Query + + @doc """ + The "base" query that queries `Notifications.Db.Notification`'s without restriction + """ + def base() do + from(n in Notifications.Db.Notification, as: :notification, select_merge: n) + end + + def select_user_read_state(query \\ base(), user_id \\ nil) do + from([notification: n] in query, + join: nu in assoc(n, :notification_users), + as: :notification_user, + join: u in assoc(nu, :user), + as: :user, + where: u.id == ^user_id, + select_merge: %{ + state: nu.state + } + ) + end + + @doc """ + Joins associated `Notifications.Db.Detour`'s on + `Notifications.Db.Notification`'s and retrieves the Detour's + associated info. + + ## Examples + + There is a `base` query struct that can be provided at the + beginning of a query: + + iex> :detour + ...> |> insert() + ...> |> Notifications.Notification.create_activated_detour_notification_from_detour() + ...> + iex> all_detour_notifications = + ...> Notifications.Db.Notification.Queries.base() + ...> |> Notifications.Db.Notification.Queries.select_detour_info() + ...> |> Skate.Repo.all() + ...> |> Skate.Repo.preload(:detour) + ...> + iex> match?( + ...> [ + ...> %Notifications.Db.Notification{ + ...> detour: %Notifications.Db.Detour{} + ...> } + ...> ], all_detour_notifications + ...> ) + true + + If `base` is omitted, then it's inferred: + + iex> :detour + ...> |> insert() + ...> |> Notifications.Notification.create_activated_detour_notification_from_detour() + ...> + iex> all_detour_notifications = + ...> Notifications.Db.Notification.Queries.select_detour_info() + ...> |> Skate.Repo.all() + ...> |> Skate.Repo.preload(:detour) + ...> + iex> match?( + ...> [ + ...> %Notifications.Db.Notification{ + ...> detour: %Notifications.Db.Detour{} + ...> } + ...> ], + ...> all_detour_notifications + ...> ) + true + + """ + @spec select_detour_info(Ecto.Query.t()) :: Ecto.Query.t() + @spec select_detour_info() :: Ecto.Query.t() + def select_detour_info(query \\ base()) do + from([notification: n] in query, + left_join: detour in subquery(Notifications.Db.Detour.Queries.get_derived_info()), + on: detour.id == n.detour_id, + select_merge: %{ + detour: detour + } + ) + end + + @doc """ + Joins associated `Notifications.Db.BridgeMovement`'s on + `Notifications.Db.Notification`'s + """ + @spec select_bridge_movements(Ecto.Query.t()) :: Ecto.Query.t() + def select_bridge_movements(query \\ base()) do + query + |> with_named_binding(:bridge_movement, fn query, binding -> + from([notification: n] in query, + left_join: bm in assoc(n, ^binding), + as: ^binding + ) + end) + |> select_merge([bridge_movement: bm], %{ + bridge_movement: bm + }) + end + + @doc """ + Joins associated `Notifications.Db.BlockWaiver`'s on + `Notifications.Db.Notification`'s + """ + @spec select_block_waivers(Ecto.Query.t()) :: Ecto.Query.t() + def select_block_waivers(query \\ base()) do + query + |> with_named_binding(:block_waiver, fn query, binding -> + from([notification: n] in query, + left_join: bw in assoc(n, ^binding), + as: ^binding + ) + end) + |> select_merge([block_waiver: bw], %{ + block_waiver: bw + }) + end + end end diff --git a/lib/notifications/detour.ex b/lib/notifications/detour.ex new file mode 100644 index 000000000..0a412f00e --- /dev/null +++ b/lib/notifications/detour.ex @@ -0,0 +1,24 @@ +defmodule Notifications.Detour do + @moduledoc """ + Context for working with Detour notifications + """ + + @doc """ + Creates a detour notification struct from a detour to insert into the database + """ + def detour_notification(%Skate.Detours.Db.Detour{} = detour) do + %Notifications.Db.Detour{ + detour: detour + } + end + + @doc """ + Creates a activated detour notification struct to insert into the database + """ + def activated_detour(%Skate.Detours.Db.Detour{} = detour) do + %{ + detour_notification(detour) + | status: :activated + } + end +end diff --git a/lib/notifications/notification.ex b/lib/notifications/notification.ex index e1782f5da..f50ff8c70 100644 --- a/lib/notifications/notification.ex +++ b/lib/notifications/notification.ex @@ -50,6 +50,66 @@ defmodule Notifications.Notification do :content ] + @doc """ + Inserts a new notification for an activated detour into the database + and returns the detour notification with notification info. + """ + def create_activated_detour_notification_from_detour(%Skate.Detours.Db.Detour{} = detour) do + import Notifications.Db.Notification.Queries + + notification = + activated_detour_notification(detour) + |> unread_notifications_for_users(Skate.Settings.User.get_all()) + |> Skate.Repo.insert!() + + # We need the associated values in the Detour JSON, so query the DB with the + # id to load the extra data. + select_detour_info() + |> where([notification: n], n.id == ^notification.id) + |> Skate.Repo.one!() + |> from_db_notification() + end + + # Creates a new notification set to the current time + defp new_notification_now() do + %Notifications.Db.Notification{ + created_at: DateTime.to_unix(DateTime.utc_now()) + } + end + + # Adds a activated detour notification relation to a `Notifications.Db.Notification` + defp activated_detour_notification(%Skate.Detours.Db.Detour{} = detour) do + %Notifications.Db.Notification{ + new_notification_now() + | detour: Notifications.Detour.activated_detour(detour) + } + end + + defp notification_for_user(%Skate.Settings.Db.User{} = user) do + %Notifications.Db.NotificationUser{ + user: user + } + end + + defp unread_notification(%Notifications.Db.NotificationUser{} = user_notification) do + %{ + user_notification + | state: :unread + } + end + + defp unread_notifications_for_users(%Notifications.Db.Notification{} = notification, users) do + %{ + notification + | notification_users: + for user <- users do + user + |> notification_for_user() + |> unread_notification() + end + } + end + @spec get_or_create_from_block_waiver(map()) :: t() def get_or_create_from_block_waiver(block_waiver_values) do changeset = @@ -141,26 +201,17 @@ defmodule Notifications.Notification do @spec unexpired_notifications_for_user(DbUser.id(), (-> Util.Time.timestamp())) :: [t()] def unexpired_notifications_for_user(user_id, now_fn \\ &Util.Time.now/0) do - cutoff_time = now_fn.() - @notification_expiration_threshold + import Notifications.Db.Notification.Queries - query = - from(n in DbNotification, - join: nu in assoc(n, :notification_users), - join: u in assoc(nu, :user), - left_join: bw in assoc(n, :block_waiver), - left_join: bm in assoc(n, :bridge_movement), - select: %DbNotification{ - id: n.id, - created_at: n.created_at, - state: nu.state, - block_waiver: bw, - bridge_movement: bm - }, - where: n.created_at > ^cutoff_time and u.id == ^user_id, - order_by: [desc: n.created_at] - ) + cutoff_time = now_fn.() - @notification_expiration_threshold - query + base() + |> select_user_read_state(user_id) + |> select_bridge_movements() + |> select_block_waivers() + |> select_detour_info() + |> where([notification: n], n.created_at > ^cutoff_time) + |> order_by([notification: n], desc: n.created_at) |> Skate.Repo.all() |> Enum.map(&from_db_notification/1) end @@ -215,18 +266,25 @@ defmodule Notifications.Notification do id: db_notification.id, created_at: db_notification.created_at, state: db_notification.state, - content: - case db_notification do - %DbNotification{ - block_waiver: %BlockWaiver{} = bw - } -> - bw - - %DbNotification{ - bridge_movement: %BridgeMovement{} = bm - } -> - bm - end + content: content_from_db_notification(db_notification) } end + + defp content_from_db_notification(%DbNotification{ + block_waiver: %BlockWaiver{} = bw + }) do + bw + end + + defp content_from_db_notification(%DbNotification{ + bridge_movement: %BridgeMovement{} = bm + }) do + bm + end + + defp content_from_db_notification(%DbNotification{ + detour: %Notifications.Db.Detour{} = detour + }) do + detour + end end diff --git a/test/notifications/db/detour_test.exs b/test/notifications/db/detour_test.exs new file mode 100644 index 000000000..93210dbe8 --- /dev/null +++ b/test/notifications/db/detour_test.exs @@ -0,0 +1,7 @@ +defmodule Notifications.Db.DetourTest do + use Skate.DataCase + + import Skate.Factory + + doctest Notifications.Db.Detour.Queries +end diff --git a/test/notifications/db/notification_test.exs b/test/notifications/db/notification_test.exs new file mode 100644 index 000000000..35c864e1b --- /dev/null +++ b/test/notifications/db/notification_test.exs @@ -0,0 +1,7 @@ +defmodule Notifications.Db.NotificationTest do + use Skate.DataCase + + import Skate.Factory + + doctest Notifications.Db.Notification.Queries +end diff --git a/test/notifications/notification_test.exs b/test/notifications/notification_test.exs index 9c3227979..cc2f00fda 100644 --- a/test/notifications/notification_test.exs +++ b/test/notifications/notification_test.exs @@ -10,14 +10,14 @@ defmodule Notifications.NotificationTest do import Ecto.Query - setup do - user1 = User.upsert("user1", "user1@test.com") - user2 = User.upsert("user2", "user2@test.com") - user3 = User.upsert("user3", "user3@test.com") - {:ok, %{user1: user1, user2: user2, user3: user3}} - end - describe "get_or_create_from_block_waiver/1" do + setup do + user1 = User.upsert("user1", "user1@test.com") + user2 = User.upsert("user2", "user2@test.com") + user3 = User.upsert("user3", "user3@test.com") + {:ok, %{user1: user1, user2: user2, user3: user3}} + end + test "associates a new notification with users subscribed to an affected route", %{ user1: user1, user2: user2, @@ -74,6 +74,13 @@ defmodule Notifications.NotificationTest do end describe "unexpired_notifications_for_user/2" do + setup do + user1 = User.upsert("user1", "user1@test.com") + user2 = User.upsert("user2", "user2@test.com") + user3 = User.upsert("user3", "user3@test.com") + {:ok, %{user1: user1, user2: user2, user3: user3}} + end + test "returns all unexpired notifications for the given user, in chronological order by creation timestamp", %{user1: user1, user2: user2} do baseline_time = 1_000_000_000 @@ -315,4 +322,77 @@ defmodule Notifications.NotificationTest do |> Enum.sort_by(& &1.id) end end + + describe "create_activated_detour_notification_from_detour/1" do + test "inserts new record into the database" do + count = 3 + + # create new notification + for _ <- 1..count do + :detour + |> insert() + |> Notifications.Notification.create_activated_detour_notification_from_detour() + end + + # assert it is in the database + assert count == Skate.Repo.aggregate(Notifications.Db.Detour, :count) + end + + test "creates an unread notification for all users" do + number_of_users = 5 + [user | _] = insert_list(number_of_users, :user) + + # create new notification + detour = + :detour + |> insert( + # don't create a new user and affect the user count + author: user + ) + |> Notifications.Notification.create_activated_detour_notification_from_detour() + + detour_notification = + Notifications.Db.Notification + |> Skate.Repo.get!(detour.id) + |> Skate.Repo.preload(:users) + + # assert all users have a notification that is unread + assert Kernel.length(detour_notification.users) == number_of_users + end + + test "returns detour information" do + # create new notification + %{ + state: %{ + "context" => %{ + "route" => %{ + "name" => route_name + }, + "routePattern" => %{ + "name" => route_pattern_name, + "headsign" => headsign + } + } + } + } = + detour = + :detour + |> build() + |> with_direction(:inbound) + |> insert() + + detour_notification = + Notifications.Notification.create_activated_detour_notification_from_detour(detour) + + # assert fields are set + assert %Notifications.Notification{ + content: %Notifications.Db.Detour{ + route: ^route_name, + origin: ^route_pattern_name, + headsign: ^headsign, + direction: "Inbound" + } + } = detour_notification + end + end end diff --git a/test/support/factory.ex b/test/support/factory.ex index 36bf21887..212ad2cb5 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -415,8 +415,31 @@ defmodule Skate.Factory do def detour_factory do %Skate.Detours.Db.Detour{ - state: %{}, - author: build(:user) + author: build(:user), + state: %{ + "context" => %{ + "route" => %{ + "name" => sequence("detour_route_name:"), + "directionNames" => %{ + "0" => "Outbound", + "1" => "Inbound" + } + }, + "routePattern" => %{ + "name" => sequence("detour_route_pattern_name:"), + "headsign" => sequence("detour_route_pattern_headsign:"), + "directionId" => sequence(:detour_route_pattern_direction, [0, 1]) + } + } + } } end + + def with_direction(%Skate.Detours.Db.Detour{} = detour, :inbound) do + put_in(detour.state["context"]["routePattern"]["directionId"], 1) + end + + def with_direction(%Skate.Detours.Db.Detour{} = detour, :outbound) do + put_in(detour.state["context"]["routePattern"]["directionId"], 0) + end end