feat(support): add realtime case conversations
This commit is contained in:
parent
a9984738cc
commit
39b7d0c6a3
|
|
@ -13,6 +13,13 @@ defmodule WhoNeedHelp.Support do
|
|||
alias WhoNeedHelp.Trust.RateLimiter
|
||||
|
||||
@access_salt "support-request-access"
|
||||
@staff_topic "support:staff"
|
||||
|
||||
def subscribe_request(id),
|
||||
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, request_topic(id))
|
||||
|
||||
def subscribe_staff,
|
||||
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @staff_topic)
|
||||
|
||||
def change_request(%SupportRequest{} = request, attrs \\ %{}) do
|
||||
SupportRequest.submission_changeset(request, attrs)
|
||||
|
|
@ -56,6 +63,7 @@ defmodule WhoNeedHelp.Support do
|
|||
end
|
||||
end)
|
||||
|> notify_received()
|
||||
|> broadcast_request_update()
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -201,6 +209,7 @@ defmodule WhoNeedHelp.Support do
|
|||
end
|
||||
end)
|
||||
|> notify_decision()
|
||||
|> broadcast_request_update()
|
||||
else
|
||||
false -> {:error, :forbidden}
|
||||
_ -> {:error, :not_found}
|
||||
|
|
@ -269,6 +278,7 @@ defmodule WhoNeedHelp.Support do
|
|||
{:ok, {updated, _message}} -> {:ok, preload_conversation(updated)}
|
||||
error -> error
|
||||
end)
|
||||
|> broadcast_request_update()
|
||||
else
|
||||
nil -> {:error, :invalid_message}
|
||||
{:error, _reason} = error -> error
|
||||
|
|
@ -313,6 +323,7 @@ defmodule WhoNeedHelp.Support do
|
|||
{:ok, updated} -> {:ok, preload_conversation(updated)}
|
||||
error -> error
|
||||
end)
|
||||
|> broadcast_request_update()
|
||||
else
|
||||
false -> {:error, :invalid_status}
|
||||
{:error, _reason} = error -> error
|
||||
|
|
@ -371,6 +382,19 @@ defmodule WhoNeedHelp.Support do
|
|||
|
||||
defp notify_operator_update(result), do: result
|
||||
|
||||
defp broadcast_request_update({:ok, %SupportRequest{} = request} = result) do
|
||||
event = {:support_request_updated, request.id}
|
||||
|
||||
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, request_topic(request.id), event)
|
||||
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @staff_topic, event)
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
defp broadcast_request_update(result), do: result
|
||||
|
||||
defp request_topic(id), do: "support:request:#{id}"
|
||||
|
||||
defp verify_contact(%SupportRequest{contact_verified_at: nil} = request) do
|
||||
request
|
||||
|> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second))
|
||||
|
|
|
|||
|
|
@ -101,15 +101,13 @@ defmodule WhoNeedHelpWeb.SupportController do
|
|||
|> put_flash(:info, gettext("Your message was sent to support."))
|
||||
|> redirect(to: case_path(request.id, token))
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
with {:ok, request} <-
|
||||
Support.get_for_viewer(conn.assigns.current_scope, id, token) do
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> render_case(request, token, changeset)
|
||||
else
|
||||
_ -> send_resp(conn, :not_found, "Not found")
|
||||
end
|
||||
{:error, %Ecto.Changeset{}} ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Could not send your message. Please check it and try again.")
|
||||
)
|
||||
|> redirect(to: case_path(id, token))
|
||||
|
||||
{:error, :rate_limited} ->
|
||||
conn
|
||||
|
|
@ -156,18 +154,12 @@ defmodule WhoNeedHelpWeb.SupportController do
|
|||
|
||||
def received(conn, _params), do: redirect(conn, to: ~p"/support")
|
||||
|
||||
defp render_case(conn, request, token, changeset \\ nil) do
|
||||
message_form =
|
||||
case changeset do
|
||||
%Ecto.Changeset{} -> Phoenix.Component.to_form(changeset, as: :message)
|
||||
nil -> Phoenix.Component.to_form(%{"body" => ""}, as: :message)
|
||||
end
|
||||
|
||||
render(conn, :show,
|
||||
request: request,
|
||||
access_token: token,
|
||||
message_form: message_form,
|
||||
reopen_form: Phoenix.Component.to_form(%{}, as: :reopen)
|
||||
defp render_case(conn, request, token) do
|
||||
Phoenix.LiveView.Controller.live_render(conn, WhoNeedHelpWeb.SupportCaseLive,
|
||||
session: %{
|
||||
"support_request_id" => request.id,
|
||||
"support_access_token" => token
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,18 @@
|
|||
>
|
||||
<div class="text-center">
|
||||
<div class="text-sm font-semibold text-success">{gettext("REQUEST RECEIVED")}</div>
|
||||
<h1 class="mt-2 text-4xl font-black">{gettext("Check your email")}</h1>
|
||||
<p class="mt-4 text-base-content/65">
|
||||
<h1 class="mt-2 text-4xl font-black">
|
||||
{if @current_scope,
|
||||
do: gettext("Support request created"),
|
||||
else: gettext("Check your email")}
|
||||
</h1>
|
||||
<p :if={@current_scope} class="mt-4 text-base-content/65">
|
||||
{gettext(
|
||||
"We created support request %{reference} and added it to your account. You can open it now and return to it from My support requests.",
|
||||
reference: @reference
|
||||
)}
|
||||
</p>
|
||||
<p :if={is_nil(@current_scope)} class="mt-4 text-base-content/65">
|
||||
{gettext(
|
||||
"We created support request %{reference}. A private status link has been sent to the contact email when delivery is configured. Opening that link verifies the address for public requests.",
|
||||
reference: @reference
|
||||
|
|
|
|||
|
|
@ -83,13 +83,16 @@
|
|||
|
||||
<.form
|
||||
for={@message_form}
|
||||
id="support-message-form"
|
||||
action={~p"/support/cases/#{@request.id}/messages"}
|
||||
method="post"
|
||||
phx-submit="send-message"
|
||||
class="mt-5"
|
||||
>
|
||||
<input :if={@access_token} type="hidden" name="token" value={@access_token} />
|
||||
<.input
|
||||
field={@message_form[:body]}
|
||||
id={"support-message-body-#{@message_form_version}"}
|
||||
type="textarea"
|
||||
label={gettext("Message to support")}
|
||||
placeholder={gettext("Add details or answer the support team")}
|
||||
|
|
@ -100,8 +103,10 @@
|
|||
<.form
|
||||
:if={@request.status in [:resolved, :closed]}
|
||||
for={@reopen_form}
|
||||
id="support-reopen-form"
|
||||
action={~p"/support/cases/#{@request.id}/reopen"}
|
||||
method="post"
|
||||
phx-submit="reopen"
|
||||
class="mt-3"
|
||||
>
|
||||
<input :if={@access_token} type="hidden" name="token" value={@access_token} />
|
||||
|
|
|
|||
127
lib/who_need_help_web/live/support_case_live.ex
Normal file
127
lib/who_need_help_web/live/support_case_live.ex
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
defmodule WhoNeedHelpWeb.SupportCaseLive do
|
||||
use WhoNeedHelpWeb, :live_view
|
||||
|
||||
alias WhoNeedHelp.Support
|
||||
alias WhoNeedHelpWeb.SupportHTML
|
||||
|
||||
on_mount {WhoNeedHelpWeb.UserAuth, :mount_current_scope}
|
||||
|
||||
@impl true
|
||||
def mount(
|
||||
_params,
|
||||
%{
|
||||
"support_request_id" => request_id,
|
||||
"support_access_token" => access_token
|
||||
},
|
||||
socket
|
||||
) do
|
||||
case Support.get_for_viewer(socket.assigns.current_scope, request_id, access_token) do
|
||||
{:ok, request} ->
|
||||
if connected?(socket), do: Support.subscribe_request(request.id)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, request.subject)
|
||||
|> assign(:request, request)
|
||||
|> assign(:access_token, access_token)
|
||||
|> assign(:message_form_version, 0)
|
||||
|> assign_message_form()
|
||||
|> assign(:reopen_form, to_form(%{}, as: :reopen))}
|
||||
|
||||
{:error, :not_found} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, gettext("This support request is no longer available."))
|
||||
|> redirect(to: ~p"/support")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("send-message", %{"message" => params}, socket) do
|
||||
case Support.add_requester_message(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.request.id,
|
||||
socket.assigns.access_token,
|
||||
params
|
||||
) do
|
||||
{:ok, request} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:request, request)
|
||||
|> update(:message_form_version, &(&1 + 1))
|
||||
|> assign_message_form()
|
||||
|> put_flash(:info, gettext("Your message was sent to support."))}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :message_form, to_form(changeset, as: :message))}
|
||||
|
||||
{:error, :rate_limited} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("Too many messages. Please try again later."))}
|
||||
|
||||
{:error, :not_found} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("This support request is no longer available."))
|
||||
|> redirect(to: ~p"/support")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Could not send your message. Please check it and try again.")
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("reopen", _params, socket) do
|
||||
case Support.reopen_request(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.request.id,
|
||||
socket.assigns.access_token
|
||||
) do
|
||||
{:ok, request} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:request, request)
|
||||
|> put_flash(:info, gettext("The support request is open again."))}
|
||||
|
||||
{:error, :invalid_status} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Only a resolved or closed request can be reopened.")
|
||||
)}
|
||||
|
||||
{:error, :not_found} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("This support request is no longer available."))
|
||||
|> redirect(to: ~p"/support")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(
|
||||
{:support_request_updated, request_id},
|
||||
%{assigns: %{request: %{id: request_id}}} = socket
|
||||
) do
|
||||
case Support.get_for_viewer(
|
||||
socket.assigns.current_scope,
|
||||
request_id,
|
||||
socket.assigns.access_token
|
||||
) do
|
||||
{:ok, request} -> {:noreply, assign(socket, :request, request)}
|
||||
{:error, :not_found} -> {:noreply, redirect(socket, to: ~p"/support")}
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_message_form(socket) do
|
||||
assign(socket, :message_form, to_form(%{"body" => ""}, as: :message))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns), do: SupportHTML.show(assigns)
|
||||
end
|
||||
|
|
@ -5,6 +5,8 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
|||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket), do: Support.subscribe_staff()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Support operations"))
|
||||
|
|
@ -12,6 +14,11 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
|||
|> load()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:support_request_updated, _request_id}, socket) do
|
||||
{:noreply, load(socket)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("moderate-support", %{"id" => id, "moderation" => params}, socket) do
|
||||
respond(
|
||||
|
|
@ -196,29 +203,44 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
|||
</p>
|
||||
<.form
|
||||
for={form}
|
||||
id={"removal-moderation-#{notice.id}"}
|
||||
phx-submit="moderate-removal"
|
||||
phx-value-id={notice.id}
|
||||
class="mt-4 grid gap-2 md:grid-cols-[13rem_1fr_auto]"
|
||||
class="mt-4 rounded-2xl border border-error/20 bg-error/5 p-4"
|
||||
>
|
||||
<.input
|
||||
field={form[:status]}
|
||||
type="select"
|
||||
options={[
|
||||
{gettext("Open"), "open"},
|
||||
{gettext("Urgent review"), "urgent_review"},
|
||||
{gettext("Reviewing"), "reviewing"},
|
||||
{gettext("More information needed"), "needs_information"},
|
||||
{gettext("Action taken"), "actioned"},
|
||||
{gettext("Rejected"), "rejected"},
|
||||
{gettext("Closed"), "closed"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={form[:resolution_note]}
|
||||
type="textarea"
|
||||
placeholder={gettext("Decision or information request")}
|
||||
/>
|
||||
<.button class="btn btn-error self-end">{gettext("Save and notify")}</.button>
|
||||
<div>
|
||||
<h4 class="font-bold">{gettext("Operator action")}</h4>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
{gettext("Record the decision and notify the submitter when contact is available.")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-[13rem_minmax(0,1fr)_auto] md:items-end">
|
||||
<.input
|
||||
field={form[:status]}
|
||||
type="select"
|
||||
label={gettext("Status")}
|
||||
options={[
|
||||
{gettext("Open"), "open"},
|
||||
{gettext("Urgent review"), "urgent_review"},
|
||||
{gettext("Reviewing"), "reviewing"},
|
||||
{gettext("More information needed"), "needs_information"},
|
||||
{gettext("Action taken"), "actioned"},
|
||||
{gettext("Rejected"), "rejected"},
|
||||
{gettext("Closed"), "closed"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={form[:resolution_note]}
|
||||
type="textarea"
|
||||
rows="2"
|
||||
label={gettext("Decision or information request")}
|
||||
placeholder={gettext("Explain the decision or request the missing information")}
|
||||
class="textarea min-h-20 w-full resize-y"
|
||||
/>
|
||||
<.button class="btn btn-error mb-2 min-w-40">
|
||||
{gettext("Save and notify")}
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</article>
|
||||
<p :if={@removal_notices == []} class="text-base-content/50">
|
||||
|
|
@ -333,27 +355,42 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
|||
</div>
|
||||
<.form
|
||||
for={form}
|
||||
id={"support-moderation-#{request.id}"}
|
||||
phx-submit="moderate-support"
|
||||
phx-value-id={request.id}
|
||||
class="mt-4 grid gap-2 md:grid-cols-[13rem_1fr_auto]"
|
||||
class="mt-4 rounded-2xl border border-primary/20 bg-primary/5 p-4"
|
||||
>
|
||||
<.input
|
||||
field={form[:status]}
|
||||
type="select"
|
||||
options={[
|
||||
{gettext("Open"), "open"},
|
||||
{gettext("Reviewing"), "reviewing"},
|
||||
{gettext("Waiting for requester"), "waiting_for_requester"},
|
||||
{gettext("Resolved"), "resolved"},
|
||||
{gettext("Closed"), "closed"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={form[:response]}
|
||||
type="textarea"
|
||||
placeholder={gettext("Reply to the requester (optional)")}
|
||||
/>
|
||||
<.button class="btn btn-primary self-end">{gettext("Save and notify")}</.button>
|
||||
<div>
|
||||
<h4 class="font-bold">{gettext("Operator action")}</h4>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
{gettext("Update the case status and add a private reply when needed.")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-[13rem_minmax(0,1fr)_auto] md:items-end">
|
||||
<.input
|
||||
field={form[:status]}
|
||||
type="select"
|
||||
label={gettext("Status")}
|
||||
options={[
|
||||
{gettext("Open"), "open"},
|
||||
{gettext("Reviewing"), "reviewing"},
|
||||
{gettext("Waiting for requester"), "waiting_for_requester"},
|
||||
{gettext("Resolved"), "resolved"},
|
||||
{gettext("Closed"), "closed"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={form[:response]}
|
||||
type="textarea"
|
||||
rows="2"
|
||||
label={gettext("Private reply (optional)")}
|
||||
placeholder={gettext("Write a clear reply for the requester")}
|
||||
class="textarea min-h-20 w-full resize-y"
|
||||
/>
|
||||
<.button class="btn btn-primary mb-2 min-w-40">
|
||||
{gettext("Save and notify")}
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</article>
|
||||
<p :if={@support_requests == []} class="text-base-content/50">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule WhoNeedHelpWeb.SupportControllerTest do
|
||||
use WhoNeedHelpWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import WhoNeedHelp.AccountsFixtures
|
||||
import Swoosh.TestAssertions
|
||||
|
||||
|
|
@ -80,6 +81,18 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
|||
describe "authenticated support" do
|
||||
setup :register_and_log_in_user
|
||||
|
||||
test "receipt gives an authenticated requester immediate account access", %{conn: conn} do
|
||||
page =
|
||||
conn
|
||||
|> get(~p"/support/received?reference=SUP-ACCOUNT")
|
||||
|> html_response(200)
|
||||
|
||||
assert page =~ "Support request created"
|
||||
assert page =~ "added it to your account"
|
||||
assert page =~ "View my requests"
|
||||
refute page =~ "<h1 class=\"mt-2 text-4xl font-black\">Check your email</h1>"
|
||||
end
|
||||
|
||||
test "lists the current user's support cases and removal notices", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
|
|
@ -181,6 +194,62 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
|||
assert page =~ "Open"
|
||||
refute page =~ "Reopen without a message"
|
||||
end
|
||||
|
||||
test "requester and operator receive support conversation updates in real time", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
previous_test_process = Application.get_env(:swoosh, :shared_test_process)
|
||||
Application.put_env(:swoosh, :shared_test_process, self())
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:swoosh, :shared_test_process, previous_test_process)
|
||||
end)
|
||||
|
||||
scope = user_scope_fixture(user)
|
||||
|
||||
{:ok, request} =
|
||||
WhoNeedHelp.Support.create_request(scope, %{
|
||||
"kind" => "technical_issue",
|
||||
"subject" => "Realtime support conversation",
|
||||
"details" => "Both connected support views must refresh without a browser reload."
|
||||
})
|
||||
|
||||
moderator =
|
||||
user_fixture()
|
||||
|> Ecto.Changeset.change(role: :moderator)
|
||||
|> WhoNeedHelp.Repo.update!()
|
||||
|
||||
{:ok, requester_view, _html} = live(conn, ~p"/support/cases/#{request.id}")
|
||||
|
||||
{:ok, operator_view, _html} =
|
||||
build_conn()
|
||||
|> log_in_user(moderator)
|
||||
|> live(~p"/support/operations")
|
||||
|
||||
operator_view
|
||||
|> form("#support-moderation-#{request.id}", %{
|
||||
"moderation" => %{
|
||||
"status" => "reviewing",
|
||||
"response" => "We are reviewing this case now."
|
||||
}
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
assert render(requester_view) =~ "We are reviewing this case now."
|
||||
assert render(requester_view) =~ "reviewing"
|
||||
|
||||
requester_view
|
||||
|> form("#support-message-form", %{
|
||||
"message" => %{"body" => "Here is the requested follow-up."}
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
assert render(operator_view) =~ "Here is the requested follow-up."
|
||||
|
||||
refute render(element(requester_view, "#support-message-form")) =~
|
||||
"Here is the requested follow-up."
|
||||
end
|
||||
end
|
||||
|
||||
describe "operator queue authorization" do
|
||||
|
|
@ -240,6 +309,17 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
|||
"select[id^='support-'] option[value='open'][selected]"
|
||||
)
|
||||
) == 1
|
||||
|
||||
assert Enum.count(
|
||||
LazyHTML.query(document, "form[id^='removal-moderation-'] textarea[rows='2']")
|
||||
) == 1
|
||||
|
||||
assert Enum.count(
|
||||
LazyHTML.query(document, "form[id^='support-moderation-'] textarea[rows='2']")
|
||||
) == 1
|
||||
|
||||
assert response =~ "Operator action"
|
||||
assert response =~ "Private reply (optional)"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user