feat(support): add realtime case conversations

This commit is contained in:
SimpleTest 2026-07-24 20:25:02 +03:00
parent a9984738cc
commit 39b7d0c6a3
7 changed files with 336 additions and 61 deletions

View File

@ -13,6 +13,13 @@ defmodule WhoNeedHelp.Support do
alias WhoNeedHelp.Trust.RateLimiter alias WhoNeedHelp.Trust.RateLimiter
@access_salt "support-request-access" @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 def change_request(%SupportRequest{} = request, attrs \\ %{}) do
SupportRequest.submission_changeset(request, attrs) SupportRequest.submission_changeset(request, attrs)
@ -56,6 +63,7 @@ defmodule WhoNeedHelp.Support do
end end
end) end)
|> notify_received() |> notify_received()
|> broadcast_request_update()
end end
end end
@ -201,6 +209,7 @@ defmodule WhoNeedHelp.Support do
end end
end) end)
|> notify_decision() |> notify_decision()
|> broadcast_request_update()
else else
false -> {:error, :forbidden} false -> {:error, :forbidden}
_ -> {:error, :not_found} _ -> {:error, :not_found}
@ -269,6 +278,7 @@ defmodule WhoNeedHelp.Support do
{:ok, {updated, _message}} -> {:ok, preload_conversation(updated)} {:ok, {updated, _message}} -> {:ok, preload_conversation(updated)}
error -> error error -> error
end) end)
|> broadcast_request_update()
else else
nil -> {:error, :invalid_message} nil -> {:error, :invalid_message}
{:error, _reason} = error -> error {:error, _reason} = error -> error
@ -313,6 +323,7 @@ defmodule WhoNeedHelp.Support do
{:ok, updated} -> {:ok, preload_conversation(updated)} {:ok, updated} -> {:ok, preload_conversation(updated)}
error -> error error -> error
end) end)
|> broadcast_request_update()
else else
false -> {:error, :invalid_status} false -> {:error, :invalid_status}
{:error, _reason} = error -> error {:error, _reason} = error -> error
@ -371,6 +382,19 @@ defmodule WhoNeedHelp.Support do
defp notify_operator_update(result), do: result 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 defp verify_contact(%SupportRequest{contact_verified_at: nil} = request) do
request request
|> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second)) |> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second))

View File

@ -101,15 +101,13 @@ defmodule WhoNeedHelpWeb.SupportController do
|> put_flash(:info, gettext("Your message was sent to support.")) |> put_flash(:info, gettext("Your message was sent to support."))
|> redirect(to: case_path(request.id, token)) |> redirect(to: case_path(request.id, token))
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Ecto.Changeset{}} ->
with {:ok, request} <-
Support.get_for_viewer(conn.assigns.current_scope, id, token) do
conn conn
|> put_status(:unprocessable_entity) |> put_flash(
|> render_case(request, token, changeset) :error,
else gettext("Could not send your message. Please check it and try again.")
_ -> send_resp(conn, :not_found, "Not found") )
end |> redirect(to: case_path(id, token))
{:error, :rate_limited} -> {:error, :rate_limited} ->
conn conn
@ -156,18 +154,12 @@ defmodule WhoNeedHelpWeb.SupportController do
def received(conn, _params), do: redirect(conn, to: ~p"/support") def received(conn, _params), do: redirect(conn, to: ~p"/support")
defp render_case(conn, request, token, changeset \\ nil) do defp render_case(conn, request, token) do
message_form = Phoenix.LiveView.Controller.live_render(conn, WhoNeedHelpWeb.SupportCaseLive,
case changeset do session: %{
%Ecto.Changeset{} -> Phoenix.Component.to_form(changeset, as: :message) "support_request_id" => request.id,
nil -> Phoenix.Component.to_form(%{"body" => ""}, as: :message) "support_access_token" => token
end }
render(conn, :show,
request: request,
access_token: token,
message_form: message_form,
reopen_form: Phoenix.Component.to_form(%{}, as: :reopen)
) )
end end

View File

@ -6,8 +6,18 @@
> >
<div class="text-center"> <div class="text-center">
<div class="text-sm font-semibold text-success">{gettext("REQUEST RECEIVED")}</div> <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> <h1 class="mt-2 text-4xl font-black">
<p class="mt-4 text-base-content/65"> {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( {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.", "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 reference: @reference

View File

@ -83,13 +83,16 @@
<.form <.form
for={@message_form} for={@message_form}
id="support-message-form"
action={~p"/support/cases/#{@request.id}/messages"} action={~p"/support/cases/#{@request.id}/messages"}
method="post" method="post"
phx-submit="send-message"
class="mt-5" class="mt-5"
> >
<input :if={@access_token} type="hidden" name="token" value={@access_token} /> <input :if={@access_token} type="hidden" name="token" value={@access_token} />
<.input <.input
field={@message_form[:body]} field={@message_form[:body]}
id={"support-message-body-#{@message_form_version}"}
type="textarea" type="textarea"
label={gettext("Message to support")} label={gettext("Message to support")}
placeholder={gettext("Add details or answer the support team")} placeholder={gettext("Add details or answer the support team")}
@ -100,8 +103,10 @@
<.form <.form
:if={@request.status in [:resolved, :closed]} :if={@request.status in [:resolved, :closed]}
for={@reopen_form} for={@reopen_form}
id="support-reopen-form"
action={~p"/support/cases/#{@request.id}/reopen"} action={~p"/support/cases/#{@request.id}/reopen"}
method="post" method="post"
phx-submit="reopen"
class="mt-3" class="mt-3"
> >
<input :if={@access_token} type="hidden" name="token" value={@access_token} /> <input :if={@access_token} type="hidden" name="token" value={@access_token} />

View 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

View File

@ -5,6 +5,8 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
if connected?(socket), do: Support.subscribe_staff()
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Support operations")) |> assign(:page_title, gettext("Support operations"))
@ -12,6 +14,11 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|> load()} |> load()}
end end
@impl true
def handle_info({:support_request_updated, _request_id}, socket) do
{:noreply, load(socket)}
end
@impl true @impl true
def handle_event("moderate-support", %{"id" => id, "moderation" => params}, socket) do def handle_event("moderate-support", %{"id" => id, "moderation" => params}, socket) do
respond( respond(
@ -196,13 +203,22 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
</p> </p>
<.form <.form
for={form} for={form}
id={"removal-moderation-#{notice.id}"}
phx-submit="moderate-removal" phx-submit="moderate-removal"
phx-value-id={notice.id} 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"
> >
<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 <.input
field={form[:status]} field={form[:status]}
type="select" type="select"
label={gettext("Status")}
options={[ options={[
{gettext("Open"), "open"}, {gettext("Open"), "open"},
{gettext("Urgent review"), "urgent_review"}, {gettext("Urgent review"), "urgent_review"},
@ -216,9 +232,15 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
<.input <.input
field={form[:resolution_note]} field={form[:resolution_note]}
type="textarea" type="textarea"
placeholder={gettext("Decision or information request")} 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 self-end">{gettext("Save and notify")}</.button> <.button class="btn btn-error mb-2 min-w-40">
{gettext("Save and notify")}
</.button>
</div>
</.form> </.form>
</article> </article>
<p :if={@removal_notices == []} class="text-base-content/50"> <p :if={@removal_notices == []} class="text-base-content/50">
@ -333,13 +355,22 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
</div> </div>
<.form <.form
for={form} for={form}
id={"support-moderation-#{request.id}"}
phx-submit="moderate-support" phx-submit="moderate-support"
phx-value-id={request.id} 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"
> >
<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 <.input
field={form[:status]} field={form[:status]}
type="select" type="select"
label={gettext("Status")}
options={[ options={[
{gettext("Open"), "open"}, {gettext("Open"), "open"},
{gettext("Reviewing"), "reviewing"}, {gettext("Reviewing"), "reviewing"},
@ -351,9 +382,15 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
<.input <.input
field={form[:response]} field={form[:response]}
type="textarea" type="textarea"
placeholder={gettext("Reply to the requester (optional)")} 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 self-end">{gettext("Save and notify")}</.button> <.button class="btn btn-primary mb-2 min-w-40">
{gettext("Save and notify")}
</.button>
</div>
</.form> </.form>
</article> </article>
<p :if={@support_requests == []} class="text-base-content/50"> <p :if={@support_requests == []} class="text-base-content/50">

View File

@ -1,6 +1,7 @@
defmodule WhoNeedHelpWeb.SupportControllerTest do defmodule WhoNeedHelpWeb.SupportControllerTest do
use WhoNeedHelpWeb.ConnCase, async: false use WhoNeedHelpWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
import Swoosh.TestAssertions import Swoosh.TestAssertions
@ -80,6 +81,18 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
describe "authenticated support" do describe "authenticated support" do
setup :register_and_log_in_user 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", %{ test "lists the current user's support cases and removal notices", %{
conn: conn, conn: conn,
user: user user: user
@ -181,6 +194,62 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
assert page =~ "Open" assert page =~ "Open"
refute page =~ "Reopen without a message" refute page =~ "Reopen without a message"
end 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 end
describe "operator queue authorization" do describe "operator queue authorization" do
@ -240,6 +309,17 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
"select[id^='support-'] option[value='open'][selected]" "select[id^='support-'] option[value='open'][selected]"
) )
) == 1 ) == 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 end
end end