diff --git a/.iex.exs b/.iex.exs new file mode 100644 index 0000000..89c4dba --- /dev/null +++ b/.iex.exs @@ -0,0 +1 @@ +alias PhoenixChat.{Repo, User, AnonymousUser, Message} diff --git a/mix.exs b/mix.exs index fe6be79..6713923 100644 --- a/mix.exs +++ b/mix.exs @@ -21,6 +21,7 @@ defmodule PhoenixChat.Mixfile do applications: [ :comeonin, :cowboy, + :faker, :gettext, :logger, :phoenix, @@ -45,6 +46,7 @@ defmodule PhoenixChat.Mixfile do {:comeonin, "~> 2.3"}, {:corsica, "~> 0.4"}, {:cowboy, "~> 1.0"}, + {:faker, "~> 0.7"}, {:gettext, "~> 0.11"}, {:guardian, "~> 0.10"}, {:phoenix, "~> 1.2.0"}, diff --git a/mix.lock b/mix.lock index 3e56fd5..3ea002b 100644 --- a/mix.lock +++ b/mix.lock @@ -7,6 +7,7 @@ "db_connection": {:hex, :db_connection, "1.0.0-rc.5", "1d9ab6e01387bdf2de7a16c56866971f7c2f75aea7c69cae2a0346e4b537ae0d", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: true]}, {:sbroker, "~> 1.0.0-beta.3", [hex: :sbroker, optional: true]}]}, "decimal": {:hex, :decimal, "1.1.2", "79a769d4657b2d537b51ef3c02d29ab7141d2b486b516c109642d453ee08e00c", [:mix], []}, "ecto": {:hex, :ecto, "2.0.5", "7f4c79ac41ffba1a4c032b69d7045489f0069c256de606523c65d9f8188e502d", [:mix], [{:db_connection, "~> 1.0-rc.4", [hex: :db_connection, optional: true]}, {:decimal, "~> 1.1.2 or ~> 1.2", [hex: :decimal, optional: false]}, {:mariaex, "~> 0.7.7", [hex: :mariaex, optional: true]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: false]}, {:postgrex, "~> 0.12.0", [hex: :postgrex, optional: true]}, {:sbroker, "~> 1.0-beta", [hex: :sbroker, optional: true]}]}, + "faker": {:hex, :faker, "0.7.0", "2c42deeac7be717173c78c77fb3edc749fb5d5e460e33d01fe592ae99acc2f0d", [:mix], []}, "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], []}, "gettext": {:hex, :gettext, "0.11.0", "80c1dd42d270482418fa158ec5ba073d2980e3718bacad86f3d4ad71d5667679", [:mix], []}, "guardian": {:hex, :guardian, "0.12.0", "ab1f0a1ab0cd8f4f9c8cca6e28d61136ca682684cf0f82e55a50e8061be7575a", [:mix], [{:jose, "~> 1.6", [hex: :jose, optional: false]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}, {:poison, ">= 1.3.0", [hex: :poison, optional: false]}, {:uuid, ">=1.1.1", [hex: :uuid, optional: false]}]}, diff --git a/priv/repo/migrations/20160915111446_create_anonymous_users.exs b/priv/repo/migrations/20160915111446_create_anonymous_users.exs new file mode 100644 index 0000000..7aa75a9 --- /dev/null +++ b/priv/repo/migrations/20160915111446_create_anonymous_users.exs @@ -0,0 +1,16 @@ +defmodule PhoenixChat.Repo.Migrations.CreateAnonymousUsers do + use Ecto.Migration + + def change do + # We want to use a `uuid` as primary key so we need to set `primary_key: false`. + create table(:anonymous_users, primary_key: false) do + # We add the `:id` column manually with a type of `uuid` and set + # it as `primary_key`. + add :id, :uuid, primary_key: true + add :name, :string + add :avatar, :string + + timestamps + end + end +end diff --git a/priv/repo/migrations/20160915111609_message_belongs_to_anonymous_user.exs b/priv/repo/migrations/20160915111609_message_belongs_to_anonymous_user.exs new file mode 100644 index 0000000..ec8dd03 --- /dev/null +++ b/priv/repo/migrations/20160915111609_message_belongs_to_anonymous_user.exs @@ -0,0 +1,18 @@ +defmodule PhoenixChat.Repo.Migrations.MessageBelongsToAnonymousUser do + use Ecto.Migration + + def up do + alter table(:messages) do + # We need to set `type` as `uuid` so it does not default to `integer`. + add :anonymous_user_id, references(:anonymous_users, on_delete: :nilify_all, type: :uuid) + remove :from + end + end + + def down do + alter table(:messages) do + remove :anonymous_user_id + add :from, :string + end + end +end diff --git a/web/channels/admin_channel.ex b/web/channels/admin_channel.ex index 5e880a0..00d6eee 100644 --- a/web/channels/admin_channel.ex +++ b/web/channels/admin_channel.ex @@ -1,12 +1,14 @@ defmodule PhoenixChat.AdminChannel do @moduledoc """ - The channel used to give the administrator access to all users. + The channel used to give the administrator access to all users. """ use PhoenixChat.Web, :channel require Logger - alias PhoenixChat.{Presence} + alias PhoenixChat.{Presence, Repo, AnonymousUser} + + intercept ~w(lobby_list) @doc """ The `admin:active_users` topic is how we identify all users currently using the app. @@ -14,20 +16,60 @@ defmodule PhoenixChat.AdminChannel do def join("admin:active_users", payload, socket) do authorize(payload, fn -> send(self, :after_join) - {:ok, socket} + id = socket.assigns[:uuid] || socket.assigns[:user_id] + lobby_list = AnonymousUser.recently_active_users + |> Repo.all + {:ok, %{id: id, lobby_list: lobby_list}, socket} end) end @doc """ - This handles the `:after_join` event and tracks the presence of the socket that has subscribed to the `admin:active_users` topic. + This handles the `:after_join` event and tracks the presence of the socket that + has subscribed to the `admin:active_users` topic. """ - def handle_info(:after_join, socket) do + # We no longer track admin's presence. This will lead to simpler code in the + # frontend by not having to filter out admin from the list of users in the Sidebar. + def handle_info(:after_join, %{assigns: %{user_id: _user_id}} = socket) do + push socket, "presence_state", Presence.list(socket) + {:noreply, socket} + end + + # We track only the presence of anonymous users and ensure they are stored + # in our DB. + def handle_info(:after_join, %{assigns: %{uuid: uuid}} = socket) do + # We save anonymous user to DB when it hasn't been saved before + user = ensure_user_saved!(uuid) + + # Used by the frontend to update their lobby_list (chatrooms displayed + # on the sidebar) + broadcast! socket, "lobby_list", user + push socket, "presence_state", Presence.list(socket) Logger.debug "Presence for socket: #{inspect socket}" - id = socket.assigns.user_id || socket.assigns.uuid - {:ok, _} = Presence.track(socket, id, %{ + {:ok, _} = Presence.track(socket, uuid, %{ online_at: inspect(System.system_time(:seconds)) }) {:noreply, socket} end + + @doc """ + Sends the lobby_list only to admins + """ + def handle_out("lobby_list", payload, socket) do + assigns = socket.assigns + if assigns[:user_id] do + push socket, "lobby_list", payload + end + {:noreply, socket} + end + + defp ensure_user_saved!(uuid) do + user_exists = Repo.get(AnonymousUser, uuid) + if user_exists do + user_exists + else + changeset = AnonymousUser.changeset(%AnonymousUser{}, %{id: uuid}) + Repo.insert!(changeset) + end + end end diff --git a/web/channels/room_channel.ex b/web/channels/room_channel.ex index 6b9641c..8affd52 100644 --- a/web/channels/room_channel.ex +++ b/web/channels/room_channel.ex @@ -9,7 +9,6 @@ defmodule PhoenixChat.RoomChannel do messages = room_id |> Message.latest_room_messages |> Repo.all - |> Enum.map(&message_payload/1) |> Enum.reverse {:ok, %{messages: messages}, socket} end) @@ -17,26 +16,16 @@ defmodule PhoenixChat.RoomChannel do def handle_in("message", payload, socket) do payload = payload - |> Map.put("user_id", socket.assigns.user_id) - |> Map.put("from", socket.assigns[:uuid]) + |> Map.put("user_id", socket.assigns[:user_id]) + |> Map.put("anonymous_user_id", socket.assigns[:uuid]) changeset = Message.changeset(%Message{}, payload) case Repo.insert(changeset) do {:ok, message} -> - payload = message_payload(message) - broadcast! socket, "message", payload + broadcast! socket, "message", message {:reply, :ok, socket} {:error, changeset} -> {:reply, {:error, %{errors: changeset}}, socket} end end - - defp message_payload(message) do - from = message.user_id || message.from - %{body: message.body, - timestamp: message.timestamp, - room: message.room, - from: from, - id: message.id} - end end diff --git a/web/channels/user_socket.ex b/web/channels/user_socket.ex index 2490ff4..10a970f 100644 --- a/web/channels/user_socket.ex +++ b/web/channels/user_socket.ex @@ -25,21 +25,23 @@ defmodule PhoenixChat.UserSocket do def connect(params, socket) do user_id = params["id"] user = user_id && Repo.get(User, user_id) + # We added this validation. More explanation below at the function def. + validate_params!(params) socket = if user do - socket + socket |> assign(:user_id, user_id) |> assign(:username, user.username) |> assign(:email, user.email) else + # No longer add `:user_id` field to socket.assigns socket - |> assign(:user_id, nil) - |> assign(:uuid, params["uuid"]) + |> assign(:uuid, params["uuid"]) end {:ok, socket} end - + # Socket id's are topics that allow you to identify all sockets for a given user: # # def id(socket), do: "users_socket:#{socket.assigns.user_id}" @@ -51,4 +53,14 @@ defmodule PhoenixChat.UserSocket do # # Returning `nil` makes this socket anonymous. def id(_socket), do: nil + + @empty ["", nil] + # We want to raise an error as early as here so our code in channels will not + # have to worry about empty values for id or uuid and our frontend won't be + # be able to connect to the socket unless they provide an id or uuid. + defp validate_params!(%{"id" => id, "uuid" => uuid}) when id in @empty or uuid in @empty do + raise "id or uuid must not be empty" + end + + defp validate_params!(_), do: nil #noop end diff --git a/web/models/anonymous_user.ex b/web/models/anonymous_user.ex new file mode 100644 index 0000000..e5653c2 --- /dev/null +++ b/web/models/anonymous_user.ex @@ -0,0 +1,52 @@ +defmodule PhoenixChat.AnonymousUser do + use PhoenixChat.Web, :model + + alias PhoenixChat.Message + + # Since we provide the `id` for our AnonymousUser record, we will need to set + # the primary key to not autogenerate it. + @primary_key {:id, :binary_id, autogenerate: false} + # We need to set `@foreign_key_type` below since it defaults to `:integer`. + # We are using a UUID as `id` so we need to set type as `:binary_id`. + @foreign_key_type :binary_id + @derive {Poison.Encoder, only: ~w(id name avatar)a} + + schema "anonymous_users" do + field :name + field :avatar + has_many :messages, Message + + timestamps + end + + def changeset(model, params \\ :empty) do + model + |> cast(params, ~w(id), ~w()) + |> put_avatar + |> put_name + end + + @doc """ + This query returns users with the most recent message sent up to a given limit. + """ + def recently_active_users(limit \\ 20) do + from u in __MODULE__, + left_join: m in Message, on: m.anonymous_user_id == u.id, + limit: ^limit, + distinct: u.id, + order_by: [desc: u.inserted_at, desc: m.inserted_at] + end + + # Set a fake name for our anonymous user every time we create one + defp put_name(changeset) do + name = (Faker.Color.fancy_name <> " " <> Faker.Company.buzzword()) |> String.downcase + changeset + |> put_change(:name, name) + end + + # Set a fake avatar for our anonymous user every time we create one + defp put_avatar(changeset) do + changeset + |> put_change(:avatar, Faker.Avatar.image_url(25, 25)) + end +end diff --git a/web/models/message.ex b/web/models/message.ex index 1a033fc..01e0f7f 100644 --- a/web/models/message.ex +++ b/web/models/message.ex @@ -1,18 +1,33 @@ defmodule PhoenixChat.Message do use PhoenixChat.Web, :model + alias PhoenixChat.{DateTime} + + # @derive is a module attribute for you to be able to customize how an + # Elixir Protocol treats a custom struct. + # In this case, we instruct the Poison.Encode protocol to only encode + # certain fields and ignore the rest. + # More info at: + # - https://github.com/devinus/poison#encoding-only-some-attributes + # + # This replaces the message_payload/1 function in RoomChannel + @derive {Poison.Encoder, only: ~w(id body timestamp room user_id anonymous_user_id)a} + schema "messages" do field :body, :string - field :timestamp, PhoenixChat.DateTime + field :timestamp, DateTime field :room, :string - field :from, :string + belongs_to :user, PhoenixChat.User + # Note that we set `:type` below. This is so Ecto is aware the type of the + # foreign_key is not an `:integer` but a `:binary_id`. + belongs_to :anonymous_user, PhoenixChat.AnonymousUser, type: :binary_id timestamps end @required_fields ~w(body timestamp room) - @optional_fields ~w(user_id from) + @optional_fields ~w(anonymous_user_id user_id) @doc """ Creates a changeset based on the `model` and `params`.