Presently, we support sending notifications via email. We might not always have a user's email address, or they might not want to receive emails. We'll add an in-app notification system that will allow you to see notifications without requiring us to have your email address. Let's get started.

Project

We'll start by writing a test:

defmodule FirestormWeb.ForumsTest do
  # ...
  describe "notifications" do
    setup [:create_user, :create_category, :create_thread]

    test "getting a user's notifications when there aren't any", %{user: user} do
      assert [] = Forums.notifications_for(user)
    end
  end
  # ...
end

It will fail because there's no such function - let's write a function that makes this test pass:

defmodule FirestormWeb.Forums do
  # ...
  def notifications_for(%User{} = user) do
    []
  end
end

Next, we know we want to store these in the database. Let's create a schema:

defmodule FirestormWeb.Forums.Notification do
  @moduledoc """
  Schema for notifications.
  """

  use Ecto.Schema
  alias FirestormWeb.Forums.{User, Thread, View}

  schema "forums_notifications" do
    field :body, :string
    belongs_to :user, User

    timestamps()
  end
end

And write the migration:

mix ecto.gen.migration add_notifications
defmodule FirestormWeb.Repo.Migrations.AddNotifications do
  use Ecto.Migration

  def change do
    create table(:forums_notifications) do
      add :body, :text
      add :user_id, references(:forums_users)
      timestamps()
    end
  end
end

Let's write a test to prove that we can generate notifications and fetch them:

defmodule FirestormWeb.ForumsTest do
  # ...
  describe "notifications" do
    setup [:create_user, :create_category, :create_thread]

    test "getting a user's notifications when there aren't any", %{user: user} do
      assert [] = Forums.notifications_for(user)
    end

    test "getting a user's notifications", %{user: user} do
      body = "You are looking dapper today!"
      {:ok, _} = Forums.notify(user, body)
      [first_notification | _] = Forums.notifications_for(user)
      assert body == first_notification.body
    end
  end
  # ...
end

We need to implement notify and tweak notifications_for:

defmodule FirestormWeb.Forums do
  # ...
  def notifications_for(%User{} = user) do
    Notification
    |> where([n], n.user_id == ^user.id)
    |> Repo.all()
  end

  defp notification_changeset(%Notification{} = notification, attrs) do
    notification
    |> cast(attrs, [:user_id, :body])
    |> validate_required([:user_id, :body])
  end

  @doc """
  Send a notification to a user:

      iex> %User{} |> notify("Nice shoes")
      {:ok, %Notification{}}

  """
  def notify(%User{} = user, body) do
    %Notification{}
    |> notification_changeset(%{body: body, user_id: user.id})
    |> Repo.insert()
  end
  # ...
end

Now that we can create notifications, we'll modify the notifications service to create a notification at the same time that it sends out emails:

vim lib/firestorm_web/notifications.ex
defmodule FirestormWeb.Notifications do
  # ...
  alias FirestormWeb.Forums
  # ...
  def handle_cast({:post_created, %Post{} = post}, :nostate) do
    # 1) Find all users that are watching this thread
    with post when not is_nil(post) <- Repo.preload(post, [thread: [:watchers]]),
         thread when not is_nil(thread) <- post.thread,
         users <- thread.watchers |> Enum.uniq do
           # 2) Send each of them an email about it and notify them via the
           # internal notifications system.
           for user <- users do
             # Send internal notification
             {:ok, _} = Forums.notify(user, "There was a new post in a thread you are watching.")

             # Send email
             user
             |> Emails.thread_new_post_notification(post.thread, post)
             |> Mailer.deliver_now()
           end
    else
      _ ->
        :ok
    end
    {:noreply, :nostate}
  end

Next, we want to show you your notifications. I'm not going to build out the ability to actually view the notifications, but we'll show a link in the drawer. To do that, since the drawer is in the layout, we need to always fetch and assign your notifications. We'll write a plug for this:

defmodule FirestormWeb.Web.Plugs.Notifications do
  @moduledoc """
  A `Plug` to assign `:notifications` based on the current user
  """

  import Plug.Conn
  import FirestormWeb.Web.Session, only: [current_user: 1]
  alias FirestormWeb.Forums

  def init(options), do: options

  def call(conn, _opts) do
    case current_user(conn) do
      nil ->
        conn
        |> assign(:notifications, [])
      user ->
        conn
        |> assign(:notifications, Forums.notifications_for(user))
    end
  end
end

Then we'll wire it up in the browser pipeline:

defmodule FirestormWeb.Web.Router do
  # ...
  pipeline :browser do
    # ...
    plug FirestormWeb.Web.Plugs.Notifications
  end
  # ...
end

From here, all we need to do is modify the layout to pass this assign to the drawer, and show them in the drawer:

<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- ... -->
  </head>
  <body class="layout-app <%= page_class(@conn) %>">
    <!-- ... -->
    <div class="pure-container" data-effect="pure-effect-slide">
      <!-- ... -->
      <div class="pure-drawer" data-position="right">
        <%= render "_drawer.html", conn: @conn, notifications: @notifications %>
      </div>
      <!-- ... -->
      <div class="pure-pusher-container">
        <!-- ... -->
        <div class="pure-pusher">
          <!-- ... -->
          <div class="layout-content">
            <!-- ... -->
            <div class="layout-drawer-tablet">
              <!-- ... -->
              <%= render "_drawer.html", conn: @conn, notifications: @notifications %>
            </div>
            <!-- ... -->
          </div>
          <!-- ... -->
        </div>
        <!-- ... -->
      </div>
      <!-- ... -->
    </div>
  </body>
</html>
<div class="navigation-drawer">
  <!-- ... -->
  <%= if current_user(@conn) do %>
    <ul>
      <!-- ... -->
      <%= render "_navigation_item.html", conn: @conn, text: "[#{Enum.count(@notifications)}] Notifications", url: "#" %>
    </ul>
  <% end %>
  <!-- ... -->
</div>

Now, if we reply to a post, we'll see our notifications count increase in the drawer.

Summary

Today, we started to add in-app notifications. There's a lot left to build, but the 'hard' part is done - though it honestly wasn't hard, of course. We'll make this better over time, but now we have the kernel we can expand on. I hope you enjoyed it. See you soon!

Resources