diff --git a/lib/chat_api/conversations.ex b/lib/chat_api/conversations.ex index 9bcab856a..5769ca380 100644 --- a/lib/chat_api/conversations.ex +++ b/lib/chat_api/conversations.ex @@ -8,6 +8,7 @@ defmodule ChatApi.Conversations do alias ChatApi.Accounts.Account alias ChatApi.Conversations.Conversation + alias ChatApi.Customers alias ChatApi.Customers.Customer alias ChatApi.Messages.Message alias ChatApi.Tags.{Tag, ConversationTag} @@ -189,6 +190,17 @@ defmodule ChatApi.Conversations do |> Repo.all() end + @spec find_latest_conversation(binary(), map()) :: Conversation.t() | nil + def find_latest_conversation(account_id, filters) do + Conversation + |> where(^filter_where(filters)) + |> where(account_id: ^account_id) + |> where([c], is_nil(c.archived_at)) + |> order_by(desc: :inserted_at) + |> first() + |> Repo.one() + end + # Used internally in dashboard @spec list_recent_by_customer(binary(), binary(), integer()) :: [Conversation.t()] def list_recent_by_customer(customer_id, account_id, limit \\ 5) do @@ -496,6 +508,57 @@ defmodule ChatApi.Conversations do |> Repo.update() end + @spec find_or_create_customer_and_conversation(String.t(), String.t()) :: + {:ok, Customer.t(), Conversation.t()} + | {:error, Ecto.Changeset.t()} + def find_or_create_customer_and_conversation(account_id, phone) do + now = DateTime.utc_now() + sms_source = "sms" + + case Customers.list_customers(account_id, %{phone: phone}) do + [] -> + with {:ok, customer} <- + Customers.create_customer(%{ + phone: phone, + account_id: account_id, + first_seen: now, + last_seen: now + }), + {:ok, conversation} <- + create_conversation(%{ + account_id: account_id, + customer_id: customer.id, + source: sms_source + }) do + {:ok, customer, conversation} + else + error -> + error + end + + [customer] -> + with nil <- + find_latest_conversation(account_id, %{ + customer_id: customer.id, + source: sms_source + }), + {:ok, conversation} <- + create_conversation(%{ + account_id: account_id, + customer_id: customer.id, + source: sms_source + }) do + {:ok, customer, conversation} + else + %Conversation{} = conversation -> + {:ok, customer, conversation} + + error -> + error + end + end + end + ##################### # Private methods ##################### @@ -519,6 +582,9 @@ defmodule ChatApi.Conversations do {"account_id", value}, dynamic -> dynamic([p], ^dynamic and p.account_id == ^value) + {"source", value}, dynamic -> + dynamic([p], ^dynamic and p.source == ^value) + {_, _}, dynamic -> # Not a where parameter dynamic diff --git a/lib/chat_api/conversations/conversation.ex b/lib/chat_api/conversations/conversation.ex index 3669726cf..81da3ce51 100644 --- a/lib/chat_api/conversations/conversation.ex +++ b/lib/chat_api/conversations/conversation.ex @@ -76,7 +76,7 @@ defmodule ChatApi.Conversations.Conversation do :metadata ]) |> validate_required([:status, :account_id, :customer_id]) - |> validate_inclusion(:source, ["chat", "slack", "email"]) + |> validate_inclusion(:source, ["chat", "slack", "email", "sms"]) |> put_closed_at() |> foreign_key_constraint(:account_id) |> foreign_key_constraint(:customer_id) diff --git a/lib/chat_api_web/controllers/twilio_controller.ex b/lib/chat_api_web/controllers/twilio_controller.ex index 69371b17f..db3cad464 100644 --- a/lib/chat_api_web/controllers/twilio_controller.ex +++ b/lib/chat_api_web/controllers/twilio_controller.ex @@ -1,7 +1,9 @@ defmodule ChatApiWeb.TwilioController do use ChatApiWeb, :controller + alias ChatApi.Messages alias ChatApi.Twilio + alias ChatApi.Conversations alias ChatApi.Twilio.TwilioAuthorization require Logger @@ -60,20 +62,36 @@ defmodule ChatApiWeb.TwilioController do end @spec webhook(Plug.Conn.t(), map()) :: Plug.Conn.t() - def webhook(conn, payload) do + def webhook( + conn, + %{"AccountSid" => account_sid, "To" => to, "From" => from, "Body" => body} = payload + ) do Logger.debug("Payload from Twilio webhook: #{inspect(payload)}") - # TODO: implement me! - # - # When new SMS message comes in... - # - Check if the receiving number matches one of our `twilio_authorizations` - # - If it does, use that to determine the `account_id` (from the `twilio_authorizations` table) - # Next, find or create a conversation for the account (with `source: "sms"`) - # - First, find customer by phone number (implement `Customers.find_by_phone/2`) - # - If no customer exists, create new customer record and new conversation (with `source: "sms"`) - # - If customer exists, fetch latest open conversation (with `source: "sms"`) - # - If open conversation exists, add message to conversation - # - Otherwise, create new conversation (with `source: "sms"`) - send_resp(conn, 200, "") + + with %TwilioAuthorization{account_id: account_id} <- + Twilio.find_twilio_authorization(%{ + twilio_account_sid: account_sid, + from_phone_number: to + }), + {:ok, customer, conversation} <- + Conversations.find_or_create_customer_and_conversation(account_id, from), + {:ok, _mesage} <- + Messages.create_message(%{ + body: body, + account_id: account_id, + customer_id: customer.id, + conversation_id: conversation.id + }) do + send_resp(conn, 200, "") + else + nil -> + Logger.warn("Twilio account not found") + send_resp(conn, 200, "") + + error -> + Logger.error(inspect(error)) + send_resp(conn, 500, "") + end end @spec verify_authorization(map()) :: :ok | {:error, atom(), any()} diff --git a/test/chat_api/conversations_test.exs b/test/chat_api/conversations_test.exs index 99227231c..d74661682 100644 --- a/test/chat_api/conversations_test.exs +++ b/test/chat_api/conversations_test.exs @@ -771,6 +771,66 @@ defmodule ChatApi.ConversationsTest do end end + describe "find_or_create_customer_and_conversation/2" do + test "returns customer and new conversation when customer exists" do + account = insert(:account) + customer = insert(:customer, account: account) + + {:ok, found_customer, found_conversation} = + Conversations.find_or_create_customer_and_conversation(account.id, customer.phone) + + assert customer.id == found_customer.id + + assert customer.id == found_conversation.customer_id + assert "sms" == found_conversation.source + end + + test "returns new customer and conversation when customer doesn't exist" do + account = insert(:account) + phone_number = "+18675309" + + {:ok, found_customer, found_conversation} = + Conversations.find_or_create_customer_and_conversation(account.id, phone_number) + + assert phone_number == found_customer.phone + assert account.id == found_customer.account_id + assert account.id == found_conversation.account_id + end + + test "returns latest conversations when multiple conversations exist" do + account = insert(:account) + customer = insert(:customer, account: account) + + insert(:conversation, + account: account, + customer: customer, + inserted_at: ~N[2020-12-01 00:00:00], + source: "sms" + ) + + insert(:conversation, + account: account, + customer: customer, + inserted_at: ~N[2020-12-01 00:01:00], + source: "sms" + ) + + latest_conversation = + insert(:conversation, + account: account, + customer: customer, + inserted_at: ~N[2020-12-01 00:02:00], + source: "sms" + ) + + {:ok, found_customer, found_conversation} = + Conversations.find_or_create_customer_and_conversation(account.id, "+18675309") + + assert latest_conversation.id == found_conversation.id + assert customer.id == found_customer.id + end + end + defp days_ago(days) do DateTime.utc_now() |> DateTime.add(days * 60 * 60 * 24 * -1) diff --git a/test/chat_api_web/controllers/twilio_controller_test.exs b/test/chat_api_web/controllers/twilio_controller_test.exs new file mode 100644 index 000000000..82da2ca28 --- /dev/null +++ b/test/chat_api_web/controllers/twilio_controller_test.exs @@ -0,0 +1,110 @@ +defmodule ChatApiWeb.TwilioControllerTest do + use ChatApiWeb.ConnCase + + import Mock + import ChatApi.Factory + + alias ChatApi.Twilio + alias ChatApi.Customers.Customer + alias ChatApi.Messages + alias ChatApi.Messages.Message + alias ChatApi.Conversations + alias ChatApi.Conversations.Conversation + alias ChatApi.Twilio.TwilioAuthorization + + setup %{conn: conn} do + account = insert(:account) + user = insert(:user, account: account) + conn = put_req_header(conn, "accept", "application/json") + authed_conn = Pow.Plug.assign_current_user(conn, user, []) + + {:ok, conn: conn, authed_conn: authed_conn, account: account} + end + + describe "webhook" do + @request_body %{ + "AccountSid" => "1234", + "To" => "1234", + "From" => "1234", + "Body" => "body" + } + + test "returns 200 when message is successfuly created", + %{authed_conn: authed_conn} do + with_mocks([ + { + Twilio, + [], + [ + find_twilio_authorization: fn _ -> + %TwilioAuthorization{account_id: 1} + end + ] + }, + { + Conversations, + [], + [ + find_or_create_customer_and_conversation: fn _, __ -> + {:ok, %Customer{id: 1}, %Conversation{id: 1}} + end + ] + }, + { + Messages, + [], + create_message: fn _ -> {:ok, %Message{id: 1}} end + } + ]) do + conn = post(authed_conn, Routes.twilio_path(authed_conn, :webhook), @request_body) + + assert response(conn, 200) + end + end + + test "returns 200 when twilio account is not found", + %{authed_conn: authed_conn} do + with_mocks([ + { + Twilio, + [], + [ + find_twilio_authorization: fn _ -> nil end + ] + } + ]) do + conn = post(authed_conn, Routes.twilio_path(authed_conn, :webhook), @request_body) + + assert response(conn, 200) + end + end + + test "returns 500 when unexpected error occurs", + %{authed_conn: authed_conn} do + with_mocks([ + { + Twilio, + [], + [ + find_twilio_authorization: fn _ -> + %TwilioAuthorization{account_id: 1} + end + ] + }, + { + Conversations, + [], + [ + find_or_create_customer_and_conversation: fn _, __ -> + {:error, %Ecto.Changeset{}} + end + ] + } + ]) do + conn = post(authed_conn, Routes.twilio_path(authed_conn, :webhook), @request_body) + + assert response(conn, 500) + end + end + end +end