From 06c2f83002ff99364e03a4819c16dbb5dac1d10d Mon Sep 17 00:00:00 2001 From: Gabriel Jaldon Date: Thu, 15 Sep 2016 19:35:22 +0800 Subject: [PATCH 1/3] Create AnonymousUser model. We want to start recording anonymous users so we can store relevant data such as a fake name, fake avatar, and when their chatroom was last viewed by an admin. 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`. --- mix.exs | 2 + mix.lock | 1 + .../20160915111446_create_anonymous_users.exs | 18 +++++ ...1609_message_belongs_to_anonymous_user.exs | 18 +++++ web/models/anonymous_user.ex | 65 +++++++++++++++++++ web/models/message.ex | 13 ++-- 6 files changed, 113 insertions(+), 4 deletions(-) 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/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..55d51ba --- /dev/null +++ b/priv/repo/migrations/20160915111446_create_anonymous_users.exs @@ -0,0 +1,18 @@ +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 + add :public_key, :string + add :last_viewed_by_admin_at, :datetime + + 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..c19a851 --- /dev/null +++ b/web/models/anonymous_user.ex @@ -0,0 +1,65 @@ +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 + field :public_key + field :last_viewed_by_admin_at, PhoenixChat.DateTime + has_many :messages, Message + + timestamps + end + + def changeset(model, params \\ :empty) do + model + |> cast(params, ~w(public_key id), ~w()) + |> put_avatar + |> put_name + end + + def last_viewed_changeset(model) do + params = %{last_viewed_by_admin_at: System.system_time(:milliseconds)} + model + |> cast(params, ~w(last_viewed_by_admin_at), []) + end + + @doc """ + This query returns all users and the respective last messages they + have sent. + + Once the query is run, the return value is a tuple of two elements: + `{user, message}` + """ + def by_public_key(public_key, limit \\ 20) do + from u in __MODULE__, + join: m in Message, on: m.anonymous_user_id == u.id, + where: u.public_key == ^public_key, + limit: ^limit, + distinct: u.id, + order_by: [desc: m.inserted_at], + select: {u, m} + 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..db56ddc 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, User, AnonymousUser} + schema "messages" do field :body, :string - field :timestamp, PhoenixChat.DateTime + field :timestamp, DateTime field :room, :string - field :from, :string - belongs_to :user, PhoenixChat.User + + belongs_to :user, 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 :user, 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 0fea1524a4ad9de122ba1d5f5ef0236d0949a00d Mon Sep 17 00:00:00 2001 From: Gabriel Jaldon Date: Thu, 15 Sep 2016 20:41:36 +0800 Subject: [PATCH 2/3] Store anonymous user and pass the users to frontend. We make sure that an anonymous user is recorded in our DB when a user joins the "admin:active_users" and "room:#{room_id}" topics. Both are possibly the first entry points to our app for our user. Our anonymous users are passed as a 'lobby_list' to the frontend when an admin joins the "admin:active_users" topic so the sidebar can be populated with these users. Now, our admin can see users they've chatted with over the week/month/year! The "lobby_list" event is only meant for admins. To ensure that only admins receive payloads for this event, we `intercept/1` it and use `handle_out/2` to filter only the admins with the same public key as the user will receive the event. Note that we extract public functions that are used or will be used in both AdminChannel and RoomChannel. Keeping our channels clean of public functions that are not callbacks is a good way to keep them clean and maintainable. --- web/channels/admin_channel.ex | 45 ++++++++++++++++++++++++++++----- web/channels/channel_helpers.ex | 39 ++++++++++++++++++++++++++++ web/channels/room_channel.ex | 8 ++++++ web/channels/user_socket.ex | 9 ++++--- 4 files changed, 90 insertions(+), 11 deletions(-) diff --git a/web/channels/admin_channel.ex b/web/channels/admin_channel.ex index 5e880a0..4d95391 100644 --- a/web/channels/admin_channel.ex +++ b/web/channels/admin_channel.ex @@ -1,33 +1,64 @@ 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. """ def join("admin:active_users", payload, socket) do authorize(payload, fn -> + public_key = socket.assigns.public_key + lobby_list = public_key + |> AnonymousUser.by_public_key + |> Repo.all + |> user_payload send(self, :after_join) - {:ok, socket} + {:ok, %{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 + track_presence(socket, socket.assigns) + {:noreply, socket} + end + + @doc """ + Sends the lobby_list only to admins + """ + def handle_out("lobby_list", payload, socket) do + %{assigns: assigns} = socket + if assigns.user_id && assigns.public_key == payload.public_key do + push socket, "lobby_list", payload + end + {:noreply, socket} + end + + defp track_presence(socket, %{uuid: uuid}) do + user = get_or_create_anonymous_user!(uuid) + + payload = user_payload(user) + # Keep track of rooms to be displayed to admins + broadcast! socket, "lobby_list", payload + # Keep track of users that are online (not keepin track of admin presence) 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 track_presence(_socket, _), do: nil #noop end diff --git a/web/channels/channel_helpers.ex b/web/channels/channel_helpers.ex index 57afa00..61e55e3 100644 --- a/web/channels/channel_helpers.ex +++ b/web/channels/channel_helpers.ex @@ -3,6 +3,8 @@ defmodule PhoenixChat.ChannelHelpers do Convenience functions imported in all Channels """ + alias PhoenixChat.{AnonymousUser, Repo, Message} + @doc """ Convenience function for authorization """ @@ -21,4 +23,41 @@ defmodule PhoenixChat.ChannelHelpers do def authorized?(_payload) do true end + + @doc """ + Returns an anonymous user record. + + This either gets or creates an anonymous user with the `uuid` from `socket.assigns.`. + """ + def get_or_create_anonymous_user!(%{uuid: uuid} = assigns) do + if user = Repo.get(AnonymousUser, uuid) do + user + else + params = %{public_key: assigns.public_key, id: uuid} + changeset = AnonymousUser.changeset(%AnonymousUser{}, params) + Repo.insert!(changeset) + end + end + + # We do not need to create signed-up users + def get_or_create_anonymous_user!(_socket), do: nil #noop + + def user_payload(list) when is_list(list) do + Enum.map(list, &user_payload/1) + end + + def user_payload({user, message}) do + %{name: user.name, + avatar: user.avatar, + id: user.id, + public_key: user.public_key, + last_viewed_by_admin_at: user.last_viewed_by_admin_at, + last_message: message && message.body, + last_message_sent_at: message && message.inserted_at} + end + + def user_payload(user) do + message = Message.latest_room_messages(user.id, 1) |> Repo.one + user_payload({user, message}) + end end diff --git a/web/channels/room_channel.ex b/web/channels/room_channel.ex index 6b9641c..575f747 100644 --- a/web/channels/room_channel.ex +++ b/web/channels/room_channel.ex @@ -11,10 +11,18 @@ defmodule PhoenixChat.RoomChannel do |> Repo.all |> Enum.map(&message_payload/1) |> Enum.reverse + send(self, :after_join) {:ok, %{messages: messages}, socket} end) end + def handle_info(:after_join, socket) do + # We create the anonymous user in our DB if its `uuid` does not match + # any existing record. + get_or_create_anonymous_user!(socket) + {:noreply, socket} + end + def handle_in("message", payload, socket) do payload = payload |> Map.put("user_id", socket.assigns.user_id) diff --git a/web/channels/user_socket.ex b/web/channels/user_socket.ex index 2490ff4..ed12881 100644 --- a/web/channels/user_socket.ex +++ b/web/channels/user_socket.ex @@ -27,19 +27,20 @@ defmodule PhoenixChat.UserSocket do user = user_id && Repo.get(User, user_id) socket = if user do - socket + socket |> assign(:user_id, user_id) |> assign(:username, user.username) |> assign(:email, user.email) else socket - |> assign(:user_id, nil) - |> assign(:uuid, params["uuid"]) + |> assign(:user_id, nil) + |> assign(:uuid, params["uuid"]) end + |> assign(:public_key, params["public_key"]) {: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}" From 2b7679c656cac6496fe81b4a061f26bca692bf28 Mon Sep 17 00:00:00 2001 From: Gabriel Jaldon Date: Thu, 15 Sep 2016 21:22:33 +0800 Subject: [PATCH 3/3] Record when admin views a chatroom and send more events. We expect "previousRoom" and "nextRoom" in the payload when a user joins a room topic. We use this to record a timestamp of when admin has viewed both rooms. This means we record when an admin both leaves a previous room and joins a new room. We then publish two more events every time an anonymous user sends a message. The "lobby_list" event for updating the user's last message sent and timestamp and the "notifications" event for triggering browser notifications. We'll get into more detail on that once we work on the frontend. --- web/channels/room_channel.ex | 57 ++++++++++++++++++++++++++++++------ web/models/message.ex | 6 ++-- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/web/channels/room_channel.ex b/web/channels/room_channel.ex index 575f747..01078a0 100644 --- a/web/channels/room_channel.ex +++ b/web/channels/room_channel.ex @@ -2,7 +2,7 @@ defmodule PhoenixChat.RoomChannel do use PhoenixChat.Web, :channel require Logger - alias PhoenixChat.{Message, Repo} + alias PhoenixChat.{Message, Repo, Endpoint, AnonymousUser} def join("room:" <> room_id, payload, socket) do authorize(payload, fn -> @@ -11,15 +11,19 @@ defmodule PhoenixChat.RoomChannel do |> Repo.all |> Enum.map(&message_payload/1) |> Enum.reverse - send(self, :after_join) + send(self, {:after_join, payload}) {:ok, %{messages: messages}, socket} end) end - def handle_info(:after_join, socket) do + def handle_info({:after_join, payload}, socket) do # We create the anonymous user in our DB if its `uuid` does not match # any existing record. get_or_create_anonymous_user!(socket) + + # We record when admin views a room + update_last_viewed_at(payload["previousRoom"]) + update_last_viewed_at(payload["nextRoom"]) {:noreply, socket} end @@ -30,21 +34,56 @@ defmodule PhoenixChat.RoomChannel do changeset = Message.changeset(%Message{}, payload) case Repo.insert(changeset) do + # This branch gets triggered when a message is sent by an anonymous user + {:ok, %{anonymous_user_id: uuid} = message} when not is_nil(uuid) -> + user = Repo.preload(message, :anonymous_user).anonymous_user + message_payload = message_payload(message, user) + broadcast! socket, "message", message_payload + + # Apart from sending the message, we want to update the lobby list + # with the last message sent by the user and its timestamp + Endpoint.broadcast_from! self, "admin:active_users", + "lobby_list", user_payload({user, message}) + + # We also send the message via the "notifications" event. This event + # will be listened to in the frontend and will publish an Notification + # via the browser when admin is not viewing the sender's chatroom. + Endpoint.broadcast_from! self, "admin:active_users", + "notifications", message_payload + + # This branch gets triggered when a message is sent by admin {:ok, message} -> - payload = message_payload(message) - broadcast! socket, "message", payload - {:reply, :ok, socket} + broadcast! socket, "message", message_payload(message) {:error, changeset} -> {:reply, {:error, %{errors: changeset}}, socket} end end - defp message_payload(message) do - from = message.user_id || message.from + defp update_last_viewed_at(nil), do: nil #noop + + defp update_last_viewed_at(uuid) do + user = Repo.get(AnonymousUser, uuid) + changeset = AnonymousUser.last_viewed_changeset(user) + user = Repo.update!(changeset) + Endpoint.broadcast_from! self, "admin:active_users", + "lobby_list", user_payload(user) + end + + defp message_payload(%{anonymous_user_id: nil} = message) do + %{body: message.body, + timestamp: message.timestamp, + room: message.room, + from: message.user_id, + id: message.id} + end + + defp message_payload(message, user \\ nil) do + user = user || Repo.preload(message, :anonymous_user).anonymous_user %{body: message.body, timestamp: message.timestamp, room: message.room, - from: from, + from: user.name, + uuid: user.id, id: message.id} end end diff --git a/web/models/message.ex b/web/models/message.ex index db56ddc..48b0a0e 100644 --- a/web/models/message.ex +++ b/web/models/message.ex @@ -1,17 +1,17 @@ defmodule PhoenixChat.Message do use PhoenixChat.Web, :model - alias PhoenixChat.{DateTime, User, AnonymousUser} + alias PhoenixChat.{DateTime} schema "messages" do field :body, :string field :timestamp, DateTime field :room, :string - belongs_to :user, User + 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 :user, AnonymousUser, type: :binary_id + belongs_to :anonymous_user, PhoenixChat.AnonymousUser, type: :binary_id timestamps end