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/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..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,10 +11,22 @@ defmodule PhoenixChat.RoomChannel do |> Repo.all |> Enum.map(&message_payload/1) |> Enum.reverse + send(self, {:after_join, payload}) {:ok, %{messages: messages}, socket} end) end + 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 + def handle_in("message", payload, socket) do payload = payload |> Map.put("user_id", socket.assigns.user_id) @@ -22,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/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}" 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..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`.