From d8f3e8c10b4352c23547b554d3323b53bec1d204 Mon Sep 17 00:00:00 2001 From: Gabriel Jaldon Date: Thu, 15 Sep 2016 19:35:22 +0800 Subject: [PATCH 1/5] Store anonymous users. We want to start recording anonymous users so we can store relevant data such as a fake name, fake avatar, and more later on. We generate the relevant migrations and add the fields we need for an `anonymous_user`. Since the frontend generates a UUID for every anonymous user, the UUID would be perfect as `id` for our AnonymousUser records. We use the `uuid` type for the `id` column of our AnonymousUser and set it to not autogenerate an id. That way, we use the UUID passed to us from the frontend as `id` every time we create a new AnonymousUser record. Now that we have an AnonymousUser, we can associate it with Message so we can easily get all the messages sent by a user. Note that there are a few extra steps for this because we are using a `:uuid` type as `id` instead of the default `:integer`. --- .iex.exs | 1 + mix.exs | 2 + mix.lock | 1 + .../20160915111446_create_anonymous_users.exs | 16 ++++++ ...1609_message_belongs_to_anonymous_user.exs | 18 +++++++ web/models/anonymous_user.ex | 51 +++++++++++++++++++ web/models/message.ex | 11 ++-- 7 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 .iex.exs create mode 100644 priv/repo/migrations/20160915111446_create_anonymous_users.exs create mode 100644 priv/repo/migrations/20160915111609_message_belongs_to_anonymous_user.exs create mode 100644 web/models/anonymous_user.ex 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/models/anonymous_user.ex b/web/models/anonymous_user.ex new file mode 100644 index 0000000..879c5c1 --- /dev/null +++ b/web/models/anonymous_user.ex @@ -0,0 +1,51 @@ +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 + + 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..48b0a0e 100644 --- a/web/models/message.ex +++ b/web/models/message.ex @@ -1,18 +1,23 @@ defmodule PhoenixChat.Message do use PhoenixChat.Web, :model + alias PhoenixChat.{DateTime} + 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`. From ba2699bd623c223e5869cbd050e293766e6987ef Mon Sep 17 00:00:00 2001 From: Gabriel Jaldon Date: Fri, 16 Sep 2016 16:50:12 +0800 Subject: [PATCH 2/5] Ensure that anonymous user is saved. When an anonymous user joins admin:active_users topic, we make sure that user is saved to our DB. Also, we no longer track the presence of admins to simplify our code especially in the frontend (no more filtering out admin's id). We also add a simple validation on the params passed on socket connect. We raise an error if both id and uuid params are empty. That way, code that gets executed in channels can expect that a socket.assigns will always have a non-empty user_id or uuid. This helps to simplify our code too and informs the frontend that an id or uuid param is expected. --- web/channels/admin_channel.ex | 34 +++++++++++++++++++++++++++------- web/channels/user_socket.ex | 20 ++++++++++++++++---- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/web/channels/admin_channel.ex b/web/channels/admin_channel.ex index 5e880a0..d33a3ad 100644 --- a/web/channels/admin_channel.ex +++ b/web/channels/admin_channel.ex @@ -1,12 +1,12 @@ 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} @doc """ The `admin:active_users` topic is how we identify all users currently using the app. @@ -14,20 +14,40 @@ 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] + {:ok, %{id: id}, 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 + {: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 + ensure_user_saved!(uuid) + 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 + + defp ensure_user_saved!(uuid) do + user_exists = Repo.get(AnonymousUser, uuid) + unless user_exists do + changeset = AnonymousUser.changeset(%AnonymousUser{}, %{id: uuid}) + Repo.insert!(changeset) + end + 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 From 76af21c15fa4cca68bddf2800ac9fb70dd415a0e Mon Sep 17 00:00:00 2001 From: Gabriel Jaldon Date: Fri, 16 Sep 2016 18:00:41 +0800 Subject: [PATCH 3/5] Refactor to use @derive for Poison. Defining a @derive module attribute is a convenient way to customize/instruct how Protocols treat our struct. We use @derive for Poison.Encoder in our Message and AnonymousUser struct so that Poison knows which fields to include when it encodes it to JSON. This saves us from writing a bunch of code for specifying which fields to include in our JSON version of our structs. Apart from specifying the fields for our JSON payloads, we also pass the most recently active users as lobby_list once admin joins the admin:active_users topic. This allows the frontend to load an initial list of users in the sidebar for admin. --- web/channels/admin_channel.ex | 4 +++- web/channels/room_channel.ex | 17 +++-------------- web/models/anonymous_user.ex | 1 + web/models/message.ex | 10 ++++++++++ 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/web/channels/admin_channel.ex b/web/channels/admin_channel.ex index d33a3ad..c3f46a3 100644 --- a/web/channels/admin_channel.ex +++ b/web/channels/admin_channel.ex @@ -15,7 +15,9 @@ defmodule PhoenixChat.AdminChannel do authorize(payload, fn -> send(self, :after_join) id = socket.assigns[:uuid] || socket.assigns[:user_id] - {:ok, %{id: id}, socket} + lobby_list = AnonymousUser.recently_active_users + |> Repo.all + {:ok, %{id: id, lobby_list: lobby_list}, socket} 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/models/anonymous_user.ex b/web/models/anonymous_user.ex index 879c5c1..e5653c2 100644 --- a/web/models/anonymous_user.ex +++ b/web/models/anonymous_user.ex @@ -9,6 +9,7 @@ defmodule PhoenixChat.AnonymousUser do # 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 diff --git a/web/models/message.ex b/web/models/message.ex index 48b0a0e..01e0f7f 100644 --- a/web/models/message.ex +++ b/web/models/message.ex @@ -3,6 +3,16 @@ defmodule PhoenixChat.Message do 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, DateTime From 513e2a864062bde07eedae5dc76aadb46ea97303 Mon Sep 17 00:00:00 2001 From: Gabriel Jaldon Date: Fri, 16 Sep 2016 20:36:26 +0800 Subject: [PATCH 4/5] Update lobby_list for admins. We broadcast to admins every time user connects to our app via the lobby_list event. Our frontend will then be able to listen to this event and handle updates accordingly. This event will get triggered every time a user joins the admin:active_users topic so it will be up to the frontend to check whether a user is already on the list. --- web/channels/admin_channel.ex | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/web/channels/admin_channel.ex b/web/channels/admin_channel.ex index c3f46a3..d35a284 100644 --- a/web/channels/admin_channel.ex +++ b/web/channels/admin_channel.ex @@ -8,6 +8,8 @@ defmodule PhoenixChat.AdminChannel do 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. """ @@ -35,7 +37,11 @@ defmodule PhoenixChat.AdminChannel do # 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 - ensure_user_saved!(uuid) + 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}" @@ -45,9 +51,22 @@ defmodule PhoenixChat.AdminChannel do {: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) - unless user_exists do + if user_exists do + user_exists + else changeset = AnonymousUser.changeset(%AnonymousUser{}, %{id: uuid}) Repo.insert!(changeset) end From ad66ab81b6957a88935c36427fa64eff3ef73ad0 Mon Sep 17 00:00:00 2001 From: Gabriel Jaldon Date: Thu, 22 Sep 2016 11:08:56 +0800 Subject: [PATCH 5/5] initial list of presences for admin --- web/channels/admin_channel.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/web/channels/admin_channel.ex b/web/channels/admin_channel.ex index d35a284..00d6eee 100644 --- a/web/channels/admin_channel.ex +++ b/web/channels/admin_channel.ex @@ -30,6 +30,7 @@ defmodule PhoenixChat.AdminChannel 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