At present, anyone involved in a thread gets an email. That's bound to become annoying. Let's add the concept of "watching" a Thread, so that a user can specify whether or not they wish to receive notifications for new posts.

Project

We're starting with the dailydrip/firestorm repo tagged before this episode.

We'll start by writing a test:

vim test/forums_test.exs
defmodule FirestormWeb.ForumsTest do
  # ...
  describe "watching a thread" do
    setup [:create_user, :create_category, :create_thread]

    test "watching a thread", %{thread: thread, user: user} do
      {:ok, watch} = user |> Forums.watch(thread)
      assert thread |> Forums.watched_by?(user)
    end
  end
  # ...
end

This test will of course fail. Let's begin by adding a schema for our watches. These will be polymorphic, and each thing that can be watched will have its own table to store the corresponding Watches:

vim lib/firestorm_web/forums/watch.ex
defmodule FirestormWeb.Forums.Watch do
  @moduledoc """
  A `Watch` is a polymorphic representation that a user is watching a thing in our system.
  """

  use Ecto.Schema
  import Ecto.Changeset

  schema "abstract table: watches" do
    # This will be used by associations on each "concrete" table
    field :assoc_id, :integer
    field :user_id, :integer

    timestamps()
  end
end

We'll add an association to the Thread schema to represent these watches:

vim lib/firestorm_web/forums/thread.ex
defmodule FirestormWeb.Forums.Thread do
  # ...
  alias FirestormWeb.Forums.{Category, Post, Watch, User}
  # ...
  schema "forums_threads" do
    # ...
    # We're specifying the table to find associated watches through, rather than
    # just providing another schema.
    has_many :watches, {"forums_threads_watches", Watch}, foreign_key: :assoc_id
    # We'll also use `many_to_many` to find all the users watching this thread
    # through the same association.
    many_to_many :watchers, User, join_through: "forums_threads_watches", join_keys: [assoc_id: :id, user_id: :id]
    # ...
  end
end

Next, we'll open up the Forums context and add the functions we identified in our test, closing the loop:

vim lib/firestorm_web/forums/forums.ex
defmodule FirestormWeb.Forums do
  # ...
  alias FirestormWeb.Forums.{User, Category, Thread, Post, Watch}
  # ...
  @doc """
  Have a user watch a thread:

      iex> %User{} |> watch(%Thread{})
      {:ok, %Watch{}}

  """
  def watch(%User{} = user, %Thread{} = thread) do
    thread
    |> Ecto.build_assoc(:watches, %{user_id: user.id})
    |> watch_changeset(%{})
    |> Repo.insert()
  end

  defp watch_changeset(%Watch{} = watch, attrs) do
    watch
    |> cast(attrs, [:assoc_id, :user_id])
    |> validate_required([:assoc_id, :user_id])
  end
end

If we run the test, we'll find that we don't have the table to handle this polymorphic association on threads yet. Let's add it:

mix ecto.gen.migration add_forums_threads_watches
defmodule FirestormWeb.Repo.Migrations.AddForumsThreadsWatches do
  use Ecto.Migration

  def change do
    create table(:forums_threads_watches) do
      add :assoc_id, references(:forums_threads)
      add :user_id, references(:forums_users)
      timestamps()
    end
  end
end
mix ecto.migrate

If we run the one test now, we see that it gets a bit further - now we need a way to know if a thread is watched by a given user. Let's write that function. In the process, we'll support checking anything that's watchable, by which I mean it has the same general associations around watches as Thread has. This will allow us to reuse this in the future:

defmodule FirestormWeb.Forums do
  # ...
  @doc """
  Determine if a user is watching a given watchable (Thread, etc):

      iex> %Thread{} |> watched_by?(%User{})
      false

  """
  def watched_by?(watchable, user = %User{}) do
    watch_count(watchable, user) > 0
  end

  def watcher_ids(watchable) do
    watchable
    |> watches()
    |> select([f], f.user_id)
    |> Repo.all
  end

  def watch_count(watchable) do
    watchable
    |> watches()
    |> Repo.aggregate(:count, :id)
  end
  defp watch_count(watchable, user = %User{}) do
    watchable
    |> watches()
    |> where([f], f.user_id == ^user.id)
    |> Repo.aggregate(:count, :id)
  end

  defp watches(watchable) do
    watchable
    |> Ecto.assoc(:watches)
  end

  defp watch_changeset(%Watch{} = watch, attrs) do
    watch
    |> cast(attrs, [:assoc_id, :user_id])
    |> validate_required([:assoc_id, :user_id])
  end
end

With this, the tests pass.

Summary

Today we added support for watching a Thread in the Forums context. In the process, we saw one way to manage polymorphic associations in Ecto, using an abstract table in the Watch schema. In the next episode, we'll add support for watching and unwatching Threads in the controllers. We'll also only send notifications about new posts to users watching the Thread. See you soon!

Resources