feat(safety): add support conversations and report history
This commit is contained in:
parent
fa7dcb18be
commit
a9984738cc
|
|
@ -8,7 +8,7 @@ defmodule WhoNeedHelp.Support do
|
|||
alias WhoNeedHelp.Accounts.{Scope, User}
|
||||
alias WhoNeedHelp.Pagination
|
||||
alias WhoNeedHelp.Repo
|
||||
alias WhoNeedHelp.Support.{Notifier, SupportRequest}
|
||||
alias WhoNeedHelp.Support.{ConversationMessage, Notifier, StatusEvent, SupportRequest}
|
||||
alias WhoNeedHelp.Trust
|
||||
alias WhoNeedHelp.Trust.RateLimiter
|
||||
|
||||
|
|
@ -43,6 +43,14 @@ defmodule WhoNeedHelp.Support do
|
|||
%{
|
||||
"kind" => to_string(request.kind)
|
||||
}
|
||||
),
|
||||
{:ok, _event} <-
|
||||
record_status_event(
|
||||
request,
|
||||
nil,
|
||||
request.status,
|
||||
user && user.id,
|
||||
:requester
|
||||
) do
|
||||
{:ok, request}
|
||||
end
|
||||
|
|
@ -70,7 +78,7 @@ defmodule WhoNeedHelp.Support do
|
|||
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||
%SupportRequest{} = request <-
|
||||
Repo.get_by(SupportRequest, id: id, requester_id: user_id) do
|
||||
{:ok, request}
|
||||
{:ok, preload_conversation(request)}
|
||||
else
|
||||
_ -> {:error, :not_found}
|
||||
end
|
||||
|
|
@ -81,7 +89,9 @@ defmodule WhoNeedHelp.Support do
|
|||
{:ok, ^id} <-
|
||||
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token, max_age: 31_536_000),
|
||||
%SupportRequest{} = request <- Repo.get(SupportRequest, id) do
|
||||
verify_contact(request)
|
||||
with {:ok, request} <- verify_contact(request) do
|
||||
{:ok, preload_conversation(request)}
|
||||
end
|
||||
else
|
||||
_ -> {:error, :not_found}
|
||||
end
|
||||
|
|
@ -89,6 +99,17 @@ defmodule WhoNeedHelp.Support do
|
|||
|
||||
def get_by_access_token(_id, _token), do: {:error, :not_found}
|
||||
|
||||
def get_for_viewer(scope, id, token \\ nil)
|
||||
|
||||
def get_for_viewer(%Scope{} = scope, id, token) do
|
||||
case get_for_user(scope, id) do
|
||||
{:ok, request} -> {:ok, request}
|
||||
_error -> get_by_access_token(id, token)
|
||||
end
|
||||
end
|
||||
|
||||
def get_for_viewer(nil, id, token), do: get_by_access_token(id, token)
|
||||
|
||||
def access_token(%SupportRequest{id: id}) do
|
||||
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id)
|
||||
end
|
||||
|
|
@ -110,7 +131,12 @@ defmodule WhoNeedHelp.Support do
|
|||
|> before(cursor)
|
||||
|> order_by([request], desc: request.inserted_at, desc: request.id)
|
||||
|> limit(^(limit + 1))
|
||||
|> preload([:requester, :reviewed_by])
|
||||
|> preload([
|
||||
:requester,
|
||||
:reviewed_by,
|
||||
conversation_messages: :sender,
|
||||
status_events: :actor
|
||||
])
|
||||
|> Repo.all()
|
||||
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||
else
|
||||
|
|
@ -121,9 +147,13 @@ defmodule WhoNeedHelp.Support do
|
|||
def moderate(%Scope{user: moderator}, id, attrs) do
|
||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||
true <- Accounts.moderator_authorized?(moderator) do
|
||||
attrs = normalize_keys(attrs)
|
||||
response = normalize_message(attrs["response"] || attrs["resolution_note"])
|
||||
|
||||
attrs =
|
||||
attrs
|
||||
|> normalize_keys()
|
||||
|> Map.delete("response")
|
||||
|> maybe_put_resolution(response)
|
||||
|> Map.merge(%{
|
||||
"reviewed_at" => DateTime.utc_now(:second),
|
||||
"reviewed_by_id" => moderator.id
|
||||
|
|
@ -137,8 +167,23 @@ defmodule WhoNeedHelp.Support do
|
|||
|> Repo.one()
|
||||
|
||||
if request do
|
||||
previous_status = request.status
|
||||
|
||||
response_to_record =
|
||||
if response && response != request.resolution_note, do: response
|
||||
|
||||
with {:ok, request} <-
|
||||
request |> SupportRequest.moderation_changeset(attrs) |> Repo.update(),
|
||||
{:ok, _event} <-
|
||||
maybe_record_status_event(
|
||||
request,
|
||||
previous_status,
|
||||
request.status,
|
||||
moderator.id,
|
||||
:staff
|
||||
),
|
||||
{:ok, _message} <-
|
||||
maybe_record_message(request, moderator.id, :staff, response_to_record),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
moderator.id,
|
||||
|
|
@ -149,7 +194,7 @@ defmodule WhoNeedHelp.Support do
|
|||
"status" => to_string(request.status)
|
||||
}
|
||||
) do
|
||||
{:ok, request}
|
||||
{:ok, preload_conversation(request)}
|
||||
end
|
||||
else
|
||||
{:error, :not_found}
|
||||
|
|
@ -162,6 +207,118 @@ defmodule WhoNeedHelp.Support do
|
|||
end
|
||||
end
|
||||
|
||||
def add_requester_message(scope, id, token, attrs) when is_map(attrs) do
|
||||
with {:ok, request} <- get_for_viewer(scope, id, token),
|
||||
body when is_binary(body) <- attrs["body"] || attrs[:body],
|
||||
{:ok, _limit} <-
|
||||
RateLimiter.check(
|
||||
:support_message,
|
||||
rate_scope(scope_user(scope), request.contact_email)
|
||||
) do
|
||||
actor_id = requester_actor_id(scope, request)
|
||||
|
||||
Repo.transact(fn ->
|
||||
request =
|
||||
SupportRequest
|
||||
|> where([record], record.id == ^request.id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
if request do
|
||||
previous_status = request.status
|
||||
|
||||
next_status =
|
||||
if previous_status in [:waiting_for_requester, :resolved, :closed],
|
||||
do: :open,
|
||||
else: previous_status
|
||||
|
||||
with {:ok, message} <-
|
||||
%ConversationMessage{}
|
||||
|> ConversationMessage.changeset(%{
|
||||
support_request_id: request.id,
|
||||
sender_id: actor_id,
|
||||
sender_role: :requester,
|
||||
body: body
|
||||
})
|
||||
|> Repo.insert(),
|
||||
{:ok, request} <- maybe_update_status(request, next_status),
|
||||
{:ok, _event} <-
|
||||
maybe_record_status_event(
|
||||
request,
|
||||
previous_status,
|
||||
next_status,
|
||||
actor_id,
|
||||
:requester
|
||||
),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
actor_id,
|
||||
"support_request.requester_message_added",
|
||||
"support_request",
|
||||
request.id,
|
||||
%{"status" => to_string(request.status)}
|
||||
) do
|
||||
{:ok, {request, message}}
|
||||
end
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
|> notify_operator_update()
|
||||
|> then(fn
|
||||
{:ok, {updated, _message}} -> {:ok, preload_conversation(updated)}
|
||||
error -> error
|
||||
end)
|
||||
else
|
||||
nil -> {:error, :invalid_message}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
def add_requester_message(_scope, _id, _token, _attrs), do: {:error, :invalid_message}
|
||||
|
||||
def reopen_request(scope, id, token) do
|
||||
with {:ok, request} <- get_for_viewer(scope, id, token),
|
||||
true <- request.status in [:resolved, :closed] do
|
||||
actor_id = requester_actor_id(scope, request)
|
||||
|
||||
Repo.transact(fn ->
|
||||
request =
|
||||
SupportRequest
|
||||
|> where([record], record.id == ^request.id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
if request && request.status in [:resolved, :closed] do
|
||||
previous_status = request.status
|
||||
|
||||
with {:ok, request} <- maybe_update_status(request, :open),
|
||||
{:ok, _event} <-
|
||||
record_status_event(request, previous_status, :open, actor_id, :requester),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
actor_id,
|
||||
"support_request.reopened",
|
||||
"support_request",
|
||||
request.id
|
||||
) do
|
||||
{:ok, request}
|
||||
end
|
||||
else
|
||||
{:error, :invalid_status}
|
||||
end
|
||||
end)
|
||||
|> notify_operator_update()
|
||||
|> then(fn
|
||||
{:ok, updated} -> {:ok, preload_conversation(updated)}
|
||||
error -> error
|
||||
end)
|
||||
else
|
||||
false -> {:error, :invalid_status}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
def deletion_assessment(%Scope{user: moderator} = scope, id) do
|
||||
with true <- Accounts.moderator_authorized?(moderator),
|
||||
{:ok, id} <- Ecto.UUID.cast(id),
|
||||
|
|
@ -190,6 +347,10 @@ defmodule WhoNeedHelp.Support do
|
|||
request
|
||||
|> Ecto.Changeset.change(response_sent_at: DateTime.utc_now(:second))
|
||||
|> Repo.update()
|
||||
|> then(fn
|
||||
{:ok, updated} -> {:ok, preload_conversation(updated)}
|
||||
error -> error
|
||||
end)
|
||||
|
||||
_error ->
|
||||
{:ok, request}
|
||||
|
|
@ -198,6 +359,18 @@ defmodule WhoNeedHelp.Support do
|
|||
|
||||
defp notify_decision(result), do: result
|
||||
|
||||
defp notify_operator_update({:ok, {%SupportRequest{} = request, _message}} = result) do
|
||||
_ = Notifier.deliver_requester_update(request)
|
||||
result
|
||||
end
|
||||
|
||||
defp notify_operator_update({:ok, %SupportRequest{} = request} = result) do
|
||||
_ = Notifier.deliver_requester_update(request)
|
||||
result
|
||||
end
|
||||
|
||||
defp notify_operator_update(result), do: result
|
||||
|
||||
defp verify_contact(%SupportRequest{contact_verified_at: nil} = request) do
|
||||
request
|
||||
|> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second))
|
||||
|
|
@ -206,6 +379,75 @@ defmodule WhoNeedHelp.Support do
|
|||
|
||||
defp verify_contact(%SupportRequest{} = request), do: {:ok, request}
|
||||
|
||||
defp preload_conversation(%SupportRequest{} = request) do
|
||||
Repo.preload(
|
||||
request,
|
||||
[conversation_messages: :sender, status_events: :actor],
|
||||
force: true
|
||||
)
|
||||
end
|
||||
|
||||
defp requester_actor_id(
|
||||
%Scope{user: %User{id: user_id}},
|
||||
%SupportRequest{requester_id: user_id}
|
||||
),
|
||||
do: user_id
|
||||
|
||||
defp requester_actor_id(_scope, _request), do: nil
|
||||
|
||||
defp normalize_message(value) when is_binary(value) do
|
||||
case String.trim(value) do
|
||||
"" -> nil
|
||||
body -> body
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_message(_value), do: nil
|
||||
|
||||
defp maybe_put_resolution(attrs, nil), do: Map.delete(attrs, "resolution_note")
|
||||
defp maybe_put_resolution(attrs, response), do: Map.put(attrs, "resolution_note", response)
|
||||
|
||||
defp maybe_update_status(%SupportRequest{status: status} = request, status),
|
||||
do: {:ok, request}
|
||||
|
||||
defp maybe_update_status(%SupportRequest{} = request, status) do
|
||||
request
|
||||
|> Ecto.Changeset.change(status: status)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
defp maybe_record_message(_request, _sender_id, _sender_role, nil),
|
||||
do: {:ok, :not_added}
|
||||
|
||||
defp maybe_record_message(request, sender_id, sender_role, body) do
|
||||
%ConversationMessage{}
|
||||
|> ConversationMessage.changeset(%{
|
||||
support_request_id: request.id,
|
||||
sender_id: sender_id,
|
||||
sender_role: sender_role,
|
||||
body: body
|
||||
})
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
defp maybe_record_status_event(_request, status, status, _actor_id, _actor_role),
|
||||
do: {:ok, :unchanged}
|
||||
|
||||
defp maybe_record_status_event(request, from_status, to_status, actor_id, actor_role),
|
||||
do: record_status_event(request, from_status, to_status, actor_id, actor_role)
|
||||
|
||||
defp record_status_event(request, from_status, to_status, actor_id, actor_role) do
|
||||
%StatusEvent{}
|
||||
|> StatusEvent.changeset(%{
|
||||
support_request_id: request.id,
|
||||
actor_id: actor_id,
|
||||
actor_role: actor_role,
|
||||
from_status: from_status,
|
||||
to_status: to_status
|
||||
})
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
defp before(query, nil), do: query
|
||||
|
||||
defp before(query, {inserted_at, id}) do
|
||||
|
|
|
|||
23
lib/who_need_help/support/conversation_message.ex
Normal file
23
lib/who_need_help/support/conversation_message.ex
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
defmodule WhoNeedHelp.Support.ConversationMessage do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "support_messages" do
|
||||
field :sender_role, Ecto.Enum, values: [:requester, :staff]
|
||||
field :body, :string
|
||||
belongs_to :support_request, WhoNeedHelp.Support.SupportRequest
|
||||
belongs_to :sender, WhoNeedHelp.Accounts.User
|
||||
timestamps(type: :utc_datetime_usec, updated_at: false)
|
||||
end
|
||||
|
||||
def changeset(message, attrs) do
|
||||
message
|
||||
|> cast(attrs, [:support_request_id, :sender_id, :sender_role, :body])
|
||||
|> validate_required([:support_request_id, :sender_role, :body])
|
||||
|> update_change(:body, &String.trim/1)
|
||||
|> validate_length(:body, min: 1, max: 5_000)
|
||||
end
|
||||
end
|
||||
|
|
@ -60,6 +60,28 @@ defmodule WhoNeedHelp.Support.Notifier do
|
|||
end
|
||||
end
|
||||
|
||||
def deliver_requester_update(%SupportRequest{} = request) do
|
||||
case Application.get_env(:who_need_help, :support_inbox_address) do
|
||||
address when is_binary(address) and address != "" ->
|
||||
deliver(
|
||||
address,
|
||||
"Support request updated by requester #{request.reference}",
|
||||
"""
|
||||
The requester added a message or reopened a support request.
|
||||
|
||||
Reference: #{request.reference}
|
||||
Status: #{request.status}
|
||||
Operator queue: #{WhoNeedHelpWeb.Endpoint.url()}/support/operations
|
||||
|
||||
Sign in with an authorised moderator or administrator account to review it.
|
||||
"""
|
||||
)
|
||||
|
||||
_other ->
|
||||
{:ok, :not_configured}
|
||||
end
|
||||
end
|
||||
|
||||
defp deliver(recipient, subject, body) do
|
||||
from = Application.fetch_env!(:who_need_help, :mailer_from)
|
||||
|
||||
|
|
|
|||
30
lib/who_need_help/support/status_event.ex
Normal file
30
lib/who_need_help/support/status_event.ex
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
defmodule WhoNeedHelp.Support.StatusEvent do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
@statuses [:open, :reviewing, :waiting_for_requester, :resolved, :closed]
|
||||
|
||||
schema "support_status_events" do
|
||||
field :actor_role, Ecto.Enum, values: [:requester, :staff, :system]
|
||||
field :from_status, Ecto.Enum, values: @statuses
|
||||
field :to_status, Ecto.Enum, values: @statuses
|
||||
belongs_to :support_request, WhoNeedHelp.Support.SupportRequest
|
||||
belongs_to :actor, WhoNeedHelp.Accounts.User
|
||||
timestamps(type: :utc_datetime_usec, updated_at: false)
|
||||
end
|
||||
|
||||
def changeset(event, attrs) do
|
||||
event
|
||||
|> cast(attrs, [
|
||||
:support_request_id,
|
||||
:actor_id,
|
||||
:actor_role,
|
||||
:from_status,
|
||||
:to_status
|
||||
])
|
||||
|> validate_required([:support_request_id, :actor_role, :to_status])
|
||||
end
|
||||
end
|
||||
|
|
@ -33,6 +33,13 @@ defmodule WhoNeedHelp.Support.SupportRequest do
|
|||
field :response_sent_at, :utc_datetime
|
||||
belongs_to :requester, WhoNeedHelp.Accounts.User
|
||||
belongs_to :reviewed_by, WhoNeedHelp.Accounts.User
|
||||
|
||||
has_many :conversation_messages, WhoNeedHelp.Support.ConversationMessage,
|
||||
preload_order: [asc: :inserted_at, asc: :id]
|
||||
|
||||
has_many :status_events, WhoNeedHelp.Support.StatusEvent,
|
||||
preload_order: [asc: :inserted_at, asc: :id]
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ defmodule WhoNeedHelp.Trust do
|
|||
alias WhoNeedHelp.Help
|
||||
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
|
||||
alias WhoNeedHelp.Messaging.Message
|
||||
alias WhoNeedHelp.Notifications
|
||||
alias WhoNeedHelp.Pagination
|
||||
alias WhoNeedHelp.Repo
|
||||
alias WhoNeedHelp.Tracking.{Position, TrackingSession}
|
||||
|
|
@ -313,6 +314,23 @@ defmodule WhoNeedHelp.Trust do
|
|||
paginate_reports(%Scope{user: user}, status).entries
|
||||
end
|
||||
|
||||
def list_reports_for_user(%Scope{user: %User{}} = scope) do
|
||||
paginate_reports_for_user(scope).entries
|
||||
end
|
||||
|
||||
def paginate_reports_for_user(%Scope{user: %User{id: user_id}}, options \\ []) do
|
||||
limit = Pagination.limit(options)
|
||||
cursor = Pagination.cursor(options)
|
||||
|
||||
Report
|
||||
|> where([report], report.reporter_id == ^user_id)
|
||||
|> before_report(cursor)
|
||||
|> order_by([report], desc: report.inserted_at, desc: report.id)
|
||||
|> limit(^(limit + 1))
|
||||
|> Repo.all()
|
||||
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||
end
|
||||
|
||||
def paginate_reports(%Scope{user: user}, status \\ nil, options \\ []) do
|
||||
if Accounts.moderator_authorized?(user) do
|
||||
limit = Pagination.limit(options)
|
||||
|
|
@ -366,12 +384,29 @@ defmodule WhoNeedHelp.Trust do
|
|||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
|> notify_reporter()
|
||||
else
|
||||
false -> {:error, :forbidden}
|
||||
{:error, :not_found} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_reporter({:ok, %Report{} = report} = result) do
|
||||
_ =
|
||||
Notifications.notify_user(report.reporter_id, %{
|
||||
kind: :support_update,
|
||||
title: "Your report was updated",
|
||||
body: "The latest moderation status is #{report.status}.",
|
||||
path: "/reports",
|
||||
idempotency_key: "report-moderated:#{report.id}:#{report.status}",
|
||||
data: %{"kind" => "report_updated", "report_id" => report.id}
|
||||
})
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
defp notify_reporter(result), do: result
|
||||
|
||||
def report_evidence(%Scope{user: moderator}, report_id) do
|
||||
with {:ok, report_id} <- cast_id(report_id),
|
||||
true <- Accounts.moderator_authorized?(moderator) do
|
||||
|
|
|
|||
|
|
@ -132,6 +132,12 @@ defmodule WhoNeedHelpWeb.Layouts do
|
|||
<li>
|
||||
<.link navigate={~p"/notifications"}>{gettext("Notifications")}</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link navigate={~p"/reports"}>{gettext("Your reports")}</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/support/requests"}>{gettext("Support requests")}</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
|
||||
</li>
|
||||
|
|
@ -314,6 +320,12 @@ defmodule WhoNeedHelpWeb.Layouts do
|
|||
<li>
|
||||
<.link navigate={~p"/notifications"}>{gettext("Notifications")}</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link navigate={~p"/reports"}>{gettext("Your reports")}</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/support/requests"}>{gettext("Support requests")}</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -80,30 +80,102 @@ defmodule WhoNeedHelpWeb.SupportController do
|
|||
def show(conn, %{"id" => id} = params) do
|
||||
token = params["token"]
|
||||
|
||||
result =
|
||||
case conn.assigns.current_scope do
|
||||
nil ->
|
||||
Support.get_by_access_token(id, token)
|
||||
|
||||
scope ->
|
||||
case Support.get_for_user(scope, id) do
|
||||
{:ok, request} -> {:ok, request}
|
||||
_ -> Support.get_by_access_token(id, token)
|
||||
end
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, request} -> render(conn, :show, request: request)
|
||||
case Support.get_for_viewer(conn.assigns.current_scope, id, token) do
|
||||
{:ok, request} -> render_case(conn, request, token)
|
||||
{:error, :not_found} -> send_resp(conn, :not_found, "Not found")
|
||||
end
|
||||
end
|
||||
|
||||
def add_message(conn, %{"id" => id, "message" => params} = outer_params)
|
||||
when is_map(params) do
|
||||
token = outer_params["token"]
|
||||
|
||||
case Support.add_requester_message(
|
||||
conn.assigns.current_scope,
|
||||
id,
|
||||
token,
|
||||
params
|
||||
) do
|
||||
{:ok, request} ->
|
||||
conn
|
||||
|> 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, :rate_limited} ->
|
||||
conn
|
||||
|> put_flash(:error, gettext("Too many messages. Please try again later."))
|
||||
|> redirect(to: case_path(id, token))
|
||||
|
||||
{:error, :not_found} ->
|
||||
send_resp(conn, :not_found, "Not found")
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Could not send your message. Please check it and try again.")
|
||||
)
|
||||
|> redirect(to: case_path(id, token))
|
||||
end
|
||||
end
|
||||
|
||||
def add_message(conn, _params), do: send_resp(conn, :bad_request, "Bad Request")
|
||||
|
||||
def reopen(conn, %{"id" => id} = params) do
|
||||
token = params["token"]
|
||||
|
||||
case Support.reopen_request(conn.assigns.current_scope, id, token) do
|
||||
{:ok, request} ->
|
||||
conn
|
||||
|> put_flash(:info, gettext("The support request is open again."))
|
||||
|> redirect(to: case_path(request.id, token))
|
||||
|
||||
{:error, :invalid_status} ->
|
||||
conn
|
||||
|> put_flash(:error, gettext("Only a resolved or closed request can be reopened."))
|
||||
|> redirect(to: case_path(id, token))
|
||||
|
||||
{:error, :not_found} ->
|
||||
send_resp(conn, :not_found, "Not found")
|
||||
end
|
||||
end
|
||||
|
||||
def received(conn, %{"reference" => reference}) when is_binary(reference) do
|
||||
render(conn, :received, reference: String.slice(reference, 0, 64))
|
||||
end
|
||||
|
||||
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)
|
||||
)
|
||||
end
|
||||
|
||||
defp case_path(id, token) when is_binary(token) and token != "",
|
||||
do: ~p"/support/cases/#{id}?#{[token: token]}"
|
||||
|
||||
defp case_path(id, _token), do: ~p"/support/cases/#{id}"
|
||||
|
||||
defp render_form(conn, %Ecto.Changeset{} = changeset, _attrs, page) do
|
||||
render(conn, page,
|
||||
form: Phoenix.Component.to_form(changeset, as: :support_request),
|
||||
|
|
|
|||
|
|
@ -19,4 +19,19 @@ defmodule WhoNeedHelpWeb.SupportHTML do
|
|||
def status_label(:resolved), do: gettext("Resolved")
|
||||
def status_label(:closed), do: gettext("Closed")
|
||||
def status_label(value), do: to_string(value)
|
||||
|
||||
def message_sender_label(:requester), do: gettext("You")
|
||||
def message_sender_label(:staff), do: gettext("Support team")
|
||||
def message_sender_label(value), do: to_string(value)
|
||||
|
||||
def event_actor_label(:requester), do: gettext("You")
|
||||
def event_actor_label(:staff), do: gettext("Support team")
|
||||
def event_actor_label(:system), do: gettext("System")
|
||||
def event_actor_label(value), do: to_string(value)
|
||||
|
||||
def format_timestamp(%DateTime{} = value) do
|
||||
Calendar.strftime(value, "%Y-%m-%d %H:%M UTC")
|
||||
end
|
||||
|
||||
def format_timestamp(value), do: to_string(value)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,25 +1,143 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:compact}>
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-outline">{@request.reference}</span>
|
||||
<span class="badge badge-primary">{status_label(@request.status)}</span>
|
||||
</div>
|
||||
<h1 class="mt-4 text-3xl font-black">{@request.subject}</h1>
|
||||
<p class="mt-2 text-sm text-base-content/65">{kind_label(@request.kind)}</p>
|
||||
<div class="mt-6 rounded-2xl bg-base-200 p-5">
|
||||
<p class="whitespace-pre-wrap">{@request.details}</p>
|
||||
</div>
|
||||
<section
|
||||
:if={@request.resolution_note not in [nil, ""]}
|
||||
class="mt-5 rounded-2xl border border-primary/30 p-5"
|
||||
>
|
||||
<h2 class="font-bold">{gettext("Support response")}</h2>
|
||||
<p class="mt-2 whitespace-pre-wrap">{@request.resolution_note}</p>
|
||||
</section>
|
||||
<p class="mt-5 text-xs text-base-content/65">
|
||||
{gettext(
|
||||
"Keep this status link private. A copy is sent to the contact email when email delivery is configured."
|
||||
)}
|
||||
</p>
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:reading}>
|
||||
<div class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<main class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-outline">{@request.reference}</span>
|
||||
<span class="badge badge-primary">{status_label(@request.status)}</span>
|
||||
<span class="text-xs text-base-content/65">{kind_label(@request.kind)}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="mt-4 text-3xl font-black">{@request.subject}</h1>
|
||||
|
||||
<section class="mt-6 rounded-2xl bg-base-200 p-5" aria-labelledby="support-case-details">
|
||||
<h2 id="support-case-details" class="font-bold">{gettext("Your original request")}</h2>
|
||||
<p class="mt-2 whitespace-pre-wrap">{@request.details}</p>
|
||||
<time class="mt-3 block text-xs text-base-content/60">
|
||||
{format_timestamp(@request.inserted_at)}
|
||||
</time>
|
||||
</section>
|
||||
|
||||
<section class="mt-8" aria-labelledby="support-conversation">
|
||||
<div class="flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<h2 id="support-conversation" class="text-2xl font-bold">
|
||||
{gettext("Conversation")}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-base-content/65">
|
||||
{gettext("Only you and authorised support staff can view this case.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3" aria-live="polite">
|
||||
<article
|
||||
:for={message <- @request.conversation_messages}
|
||||
class={[
|
||||
"max-w-[92%] rounded-2xl border p-4",
|
||||
message.sender_role == :requester &&
|
||||
"ml-auto border-primary/25 bg-primary/5",
|
||||
message.sender_role == :staff &&
|
||||
"mr-auto border-base-300 bg-base-100"
|
||||
]}
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<strong class="text-sm">{message_sender_label(message.sender_role)}</strong>
|
||||
<time class="text-xs text-base-content/55">
|
||||
{format_timestamp(message.inserted_at)}
|
||||
</time>
|
||||
</div>
|
||||
<p class="mt-2 whitespace-pre-wrap">{message.body}</p>
|
||||
</article>
|
||||
|
||||
<article
|
||||
:if={
|
||||
@request.conversation_messages == [] &&
|
||||
@request.resolution_note not in [nil, ""]
|
||||
}
|
||||
class="mr-auto max-w-[92%] rounded-2xl border border-base-300 bg-base-100 p-4"
|
||||
>
|
||||
<strong class="text-sm">{gettext("Support team")}</strong>
|
||||
<p class="mt-2 whitespace-pre-wrap">{@request.resolution_note}</p>
|
||||
</article>
|
||||
|
||||
<p
|
||||
:if={
|
||||
@request.conversation_messages == [] &&
|
||||
@request.resolution_note in [nil, ""]
|
||||
}
|
||||
class="rounded-2xl border border-dashed border-base-300 p-5 text-sm text-base-content/60"
|
||||
>
|
||||
{gettext("No replies yet. Add a message if you need to clarify the request.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@request.status in [:resolved, :closed]}
|
||||
class="alert mt-5 border border-info/25 bg-info/5"
|
||||
>
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
<span>
|
||||
{gettext("This case is finished. Sending a new message will reopen it automatically.")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
for={@message_form}
|
||||
action={~p"/support/cases/#{@request.id}/messages"}
|
||||
method="post"
|
||||
class="mt-5"
|
||||
>
|
||||
<input :if={@access_token} type="hidden" name="token" value={@access_token} />
|
||||
<.input
|
||||
field={@message_form[:body]}
|
||||
type="textarea"
|
||||
label={gettext("Message to support")}
|
||||
placeholder={gettext("Add details or answer the support team")}
|
||||
/>
|
||||
<.button class="btn btn-primary mt-3">{gettext("Send message")}</.button>
|
||||
</.form>
|
||||
|
||||
<.form
|
||||
:if={@request.status in [:resolved, :closed]}
|
||||
for={@reopen_form}
|
||||
action={~p"/support/cases/#{@request.id}/reopen"}
|
||||
method="post"
|
||||
class="mt-3"
|
||||
>
|
||||
<input :if={@access_token} type="hidden" name="token" value={@access_token} />
|
||||
<.button class="btn btn-outline">{gettext("Reopen without a message")}</.button>
|
||||
</.form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside class="min-w-0">
|
||||
<section class="rounded-2xl border border-base-300 p-5" aria-labelledby="support-history">
|
||||
<h2 id="support-history" class="font-bold">{gettext("Status history")}</h2>
|
||||
<ol class="mt-4 space-y-4">
|
||||
<li :for={event <- @request.status_events} class="relative pl-5 text-sm">
|
||||
<span
|
||||
class="absolute left-0 top-1.5 size-2 rounded-full bg-primary"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<p class="font-semibold">{status_label(event.to_status)}</p>
|
||||
<p class="mt-0.5 text-xs text-base-content/60">
|
||||
{event_actor_label(event.actor_role)} · {format_timestamp(event.inserted_at)}
|
||||
</p>
|
||||
</li>
|
||||
<li
|
||||
:if={@request.status_events == []}
|
||||
class="text-sm text-base-content/60"
|
||||
>
|
||||
{gettext("No status history is available for this older case.")}
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<p class="mt-4 text-xs text-base-content/65">
|
||||
{gettext(
|
||||
"Keep this status link private. A copy is sent to the contact email when email delivery is configured."
|
||||
)}
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
129
lib/who_need_help_web/live/report_live.ex
Normal file
129
lib/who_need_help_web/live/report_live.ex
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
defmodule WhoNeedHelpWeb.ReportLive do
|
||||
use WhoNeedHelpWeb, :live_view
|
||||
|
||||
alias WhoNeedHelp.Trust
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
page = Trust.paginate_reports_for_user(socket.assigns.current_scope)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Your reports"))
|
||||
|> assign(:reports, page.entries)
|
||||
|> assign(:reports_cursor, page.next_cursor)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("load-more", _params, socket) do
|
||||
page =
|
||||
Trust.paginate_reports_for_user(socket.assigns.current_scope,
|
||||
after: socket.assigns.reports_cursor
|
||||
)
|
||||
|
||||
known_ids = MapSet.new(socket.assigns.reports, & &1.id)
|
||||
|
||||
reports =
|
||||
socket.assigns.reports ++ Enum.reject(page.entries, &MapSet.member?(known_ids, &1.id))
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:reports, reports)
|
||||
|> assign(:reports_cursor, page.next_cursor)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:reading}>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-primary">{gettext("TRUST AND SAFETY")}</div>
|
||||
<h1 class="mt-1 text-4xl font-black">{gettext("Your reports")}</h1>
|
||||
<p class="mt-3 max-w-3xl text-base-content/65">
|
||||
{gettext(
|
||||
"This private history shows reports you submitted and the latest moderation status. Submit a new report from the relevant request, activity, or private chat so moderators receive the correct context."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<.link href={~p"/support/new"} class="btn btn-outline">
|
||||
{gettext("Contact support")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 space-y-4">
|
||||
<article
|
||||
:for={report <- @reports}
|
||||
class="rounded-2xl border border-base-300 p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-primary">{status_label(report.status)}</span>
|
||||
<span class="badge badge-outline">{target_label(report)}</span>
|
||||
<span class="text-xs text-base-content/60">{reason_label(report.reason)}</span>
|
||||
</div>
|
||||
<p class="mt-4 whitespace-pre-wrap">{report.details}</p>
|
||||
<section
|
||||
:if={report.resolution_note not in [nil, ""]}
|
||||
class="mt-4 rounded-xl bg-base-200 p-4"
|
||||
>
|
||||
<h2 class="text-sm font-bold">{gettext("Moderation response")}</h2>
|
||||
<p class="mt-1 whitespace-pre-wrap text-sm">{report.resolution_note}</p>
|
||||
</section>
|
||||
<time class="mt-4 block text-xs text-base-content/55">
|
||||
{format_timestamp(report.inserted_at)}
|
||||
</time>
|
||||
</article>
|
||||
|
||||
<div
|
||||
:if={@reports == []}
|
||||
class="rounded-2xl border border-dashed border-base-300 p-8 text-center"
|
||||
>
|
||||
<h2 class="font-bold">{gettext("No reports submitted")}</h2>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
{gettext("Reports you send from a request, activity, or chat will appear here.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:if={@reports_cursor}
|
||||
type="button"
|
||||
phx-click="load-more"
|
||||
class="btn btn-outline mt-5"
|
||||
>
|
||||
{gettext("Load more")}
|
||||
</button>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_label(:open), do: gettext("Open")
|
||||
defp status_label(:reviewing), do: gettext("Reviewing")
|
||||
defp status_label(:resolved), do: gettext("Resolved")
|
||||
defp status_label(:dismissed), do: gettext("Dismissed")
|
||||
defp status_label(value), do: to_string(value)
|
||||
|
||||
defp reason_label(:dangerous_request), do: gettext("Dangerous request")
|
||||
defp reason_label(:harassment), do: gettext("Harassment")
|
||||
defp reason_label(:fraud), do: gettext("Fraud")
|
||||
defp reason_label(:prohibited_item), do: gettext("Prohibited item")
|
||||
defp reason_label(:spam), do: gettext("Spam")
|
||||
defp reason_label(:impersonation), do: gettext("Impersonation")
|
||||
defp reason_label(:other), do: gettext("Other")
|
||||
defp reason_label(value), do: to_string(value)
|
||||
|
||||
defp target_label(%{request_id: id}) when not is_nil(id), do: gettext("Help request")
|
||||
defp target_label(%{assignment_id: id}) when not is_nil(id), do: gettext("Matched help")
|
||||
defp target_label(%{message_id: id}) when not is_nil(id), do: gettext("Private message")
|
||||
defp target_label(%{activity_id: id}) when not is_nil(id), do: gettext("Activity")
|
||||
|
||||
defp target_label(%{activity_message_id: id}) when not is_nil(id),
|
||||
do: gettext("Activity message")
|
||||
|
||||
defp target_label(_report), do: gettext("Reported content")
|
||||
|
||||
defp format_timestamp(%DateTime{} = value),
|
||||
do: Calendar.strftime(value, "%Y-%m-%d %H:%M UTC")
|
||||
|
||||
defp format_timestamp(value), do: to_string(value)
|
||||
end
|
||||
|
|
@ -242,7 +242,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
|||
scoped_form(
|
||||
%{
|
||||
"status" => to_string(request.status),
|
||||
"resolution_note" => request.resolution_note || ""
|
||||
"response" => ""
|
||||
},
|
||||
"support-#{request.id}"
|
||||
) %>
|
||||
|
|
@ -257,6 +257,50 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
|||
<h3 class="mt-3 font-bold">{request.subject}</h3>
|
||||
<p class="mt-1 text-xs text-base-content/65">{request.contact_email}</p>
|
||||
<p class="mt-3 whitespace-pre-wrap text-sm">{request.details}</p>
|
||||
<section class="mt-4 rounded-2xl bg-base-200 p-4">
|
||||
<h4 class="font-bold">{gettext("Conversation")}</h4>
|
||||
<div class="mt-3 space-y-2">
|
||||
<article
|
||||
:for={message <- request.conversation_messages}
|
||||
class={[
|
||||
"rounded-xl border p-3 text-sm",
|
||||
message.sender_role == :requester &&
|
||||
"border-primary/25 bg-primary/5",
|
||||
message.sender_role == :staff &&
|
||||
"border-base-300 bg-base-100"
|
||||
]}
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<strong>
|
||||
{if message.sender_role == :staff,
|
||||
do: gettext("Support team"),
|
||||
else: gettext("Requester")}
|
||||
</strong>
|
||||
<time class="text-xs text-base-content/55">{message.inserted_at}</time>
|
||||
</div>
|
||||
<p class="mt-1 whitespace-pre-wrap">{message.body}</p>
|
||||
</article>
|
||||
<p
|
||||
:if={request.conversation_messages == []}
|
||||
class="text-sm text-base-content/55"
|
||||
>
|
||||
{gettext("No conversation messages yet.")}
|
||||
</p>
|
||||
</div>
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-sm font-semibold">
|
||||
{gettext("Status history")}
|
||||
</summary>
|
||||
<ol class="mt-2 space-y-1 text-xs text-base-content/65">
|
||||
<li :for={event <- request.status_events}>
|
||||
{status_label(event.to_status)} · {event.actor_role} · {event.inserted_at}
|
||||
</li>
|
||||
<li :if={request.status_events == []}>
|
||||
{gettext("No status history is available for this older case.")}
|
||||
</li>
|
||||
</ol>
|
||||
</details>
|
||||
</section>
|
||||
<div :if={request.kind == :account_deletion} class="mt-4 rounded-2xl bg-base-200 p-4">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -305,9 +349,9 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
|||
]}
|
||||
/>
|
||||
<.input
|
||||
field={form[:resolution_note]}
|
||||
field={form[:response]}
|
||||
type="textarea"
|
||||
placeholder={gettext("Support response")}
|
||||
placeholder={gettext("Reply to the requester (optional)")}
|
||||
/>
|
||||
<.button class="btn btn-primary self-end">{gettext("Save and notify")}</.button>
|
||||
</.form>
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
post "/support", SupportController, :create
|
||||
get "/support/received", SupportController, :received
|
||||
get "/support/cases/:id", SupportController, :show
|
||||
post "/support/cases/:id/messages", SupportController, :add_message
|
||||
post "/support/cases/:id/reopen", SupportController, :reopen
|
||||
get "/account/delete", SupportController, :delete_account
|
||||
post "/account/delete", SupportController, :request_account_deletion
|
||||
get "/legal/content-removal", ContentRemovalController, :new
|
||||
|
|
@ -173,6 +175,7 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
live "/people/:id", PublicProfileLive, :show
|
||||
live "/leaderboard", LeaderboardLive, :index
|
||||
live "/notifications", NotificationLive, :index
|
||||
live "/reports", ReportLive, :index
|
||||
end
|
||||
|
||||
live_session :moderation,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
defmodule WhoNeedHelp.Repo.Migrations.AddSupportConversationAndHistory do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:support_messages, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :support_request_id,
|
||||
references(:support_requests, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :sender_id, references(:users, type: :binary_id, on_delete: :nilify_all)
|
||||
add :sender_role, :string, null: false
|
||||
add :body, :text, null: false
|
||||
|
||||
timestamps(type: :utc_datetime_usec, updated_at: false)
|
||||
end
|
||||
|
||||
create index(:support_messages, [:support_request_id, :inserted_at, :id])
|
||||
|
||||
create table(:support_status_events, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :support_request_id,
|
||||
references(:support_requests, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :actor_id, references(:users, type: :binary_id, on_delete: :nilify_all)
|
||||
add :actor_role, :string, null: false
|
||||
add :from_status, :string
|
||||
add :to_status, :string, null: false
|
||||
|
||||
timestamps(type: :utc_datetime_usec, updated_at: false)
|
||||
end
|
||||
|
||||
create index(:support_status_events, [:support_request_id, :inserted_at, :id])
|
||||
end
|
||||
end
|
||||
|
|
@ -6,7 +6,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
|
||||
alias WhoNeedHelp.{Catalog, ContentRemoval, Help, Repo, Support}
|
||||
alias WhoNeedHelp.ContentRemoval.Notice
|
||||
alias WhoNeedHelp.Support.SupportRequest
|
||||
alias WhoNeedHelp.Support.{ConversationMessage, StatusEvent, SupportRequest}
|
||||
alias WhoNeedHelp.Trust.AuditEvent
|
||||
|
||||
test "public support request is persisted, acknowledged, and verified only through its email link" do
|
||||
|
|
@ -50,6 +50,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
Support.get_by_access_token(request.id, emailed_token)
|
||||
|
||||
assert verified.contact_verified_at
|
||||
assert [%StatusEvent{from_status: nil, to_status: :open}] = verified.status_events
|
||||
|
||||
assert Repo.exists?(
|
||||
from event in AuditEvent,
|
||||
|
|
@ -125,11 +126,106 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
assert resolved.status == :resolved
|
||||
assert resolved.response_sent_at
|
||||
|
||||
assert [%ConversationMessage{sender_role: :staff, body: response}] =
|
||||
resolved.conversation_messages
|
||||
|
||||
assert response == "The export is ready for the verified account owner."
|
||||
assert Enum.map(resolved.status_events, & &1.to_status) == [:open, :resolved]
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
email.subject =~ "support update" and email.text_body =~ "export is ready"
|
||||
end)
|
||||
end
|
||||
|
||||
test "requester replies reopen a finished case and preserve the full conversation history" do
|
||||
previous = Application.get_env(:who_need_help, :support_inbox_address)
|
||||
Application.put_env(:who_need_help, :support_inbox_address, "support@example.com")
|
||||
on_exit(fn -> Application.put_env(:who_need_help, :support_inbox_address, previous) end)
|
||||
|
||||
user = user_fixture()
|
||||
assert_email_sent()
|
||||
scope = user_scope_fixture(user)
|
||||
moderator_scope = moderator_scope()
|
||||
assert_email_sent()
|
||||
|
||||
assert {:ok, request} =
|
||||
Support.create_request(scope, %{
|
||||
"kind" => "technical_issue",
|
||||
"subject" => "Notification setting is unclear",
|
||||
"details" =>
|
||||
"I need help understanding which nearby notifications are currently enabled."
|
||||
})
|
||||
|
||||
assert_email_sent()
|
||||
assert_email_sent()
|
||||
|
||||
assert {:ok, resolved} =
|
||||
Support.moderate(moderator_scope, request.id, %{
|
||||
"status" => "resolved",
|
||||
"response" => "Open notification settings and review the nearby-help switch."
|
||||
})
|
||||
|
||||
assert resolved.status == :resolved
|
||||
assert_email_sent()
|
||||
|
||||
assert {:ok, reopened} =
|
||||
Support.add_requester_message(scope, request.id, nil, %{
|
||||
"body" => "I tried that, but the switch still appears disabled."
|
||||
})
|
||||
|
||||
assert reopened.status == :open
|
||||
|
||||
assert Enum.map(reopened.conversation_messages, &{&1.sender_role, &1.body}) == [
|
||||
{:staff, "Open notification settings and review the nearby-help switch."},
|
||||
{:requester, "I tried that, but the switch still appears disabled."}
|
||||
]
|
||||
|
||||
assert Enum.map(reopened.status_events, & &1.to_status) == [:open, :resolved, :open]
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
email.to == [{"", "support@example.com"}] and
|
||||
email.subject =~ "updated by requester" and
|
||||
not String.contains?(email.text_body, "switch still appears disabled")
|
||||
end)
|
||||
end
|
||||
|
||||
test "only the requester or a valid private token can reply or reopen a support case" do
|
||||
owner = user_fixture()
|
||||
outsider = user_fixture()
|
||||
owner_scope = user_scope_fixture(owner)
|
||||
|
||||
assert {:ok, request} =
|
||||
Support.create_request(owner_scope, %{
|
||||
"kind" => "account_access",
|
||||
"subject" => "Account access follow-up",
|
||||
"details" => "I need to follow up on a private account access question."
|
||||
})
|
||||
|
||||
moderator_scope = moderator_scope()
|
||||
|
||||
assert {:ok, closed} =
|
||||
Support.moderate(moderator_scope, request.id, %{
|
||||
"status" => "closed",
|
||||
"response" => "The account access question was handled."
|
||||
})
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Support.add_requester_message(
|
||||
user_scope_fixture(outsider),
|
||||
closed.id,
|
||||
nil,
|
||||
%{"body" => "This must not be added."}
|
||||
)
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Support.reopen_request(user_scope_fixture(outsider), closed.id, nil)
|
||||
|
||||
token = Support.access_token(closed)
|
||||
assert {:ok, reopened} = Support.reopen_request(nil, closed.id, token)
|
||||
assert reopened.status == :open
|
||||
assert List.last(reopened.status_events).actor_role == :requester
|
||||
end
|
||||
|
||||
test "TAKE IT DOWN notices use a separate urgent queue and never accept an unrelated category" do
|
||||
attrs = %{
|
||||
"category" => "non_consensual_intimate_media",
|
||||
|
|
|
|||
|
|
@ -73,6 +73,45 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
|||
end
|
||||
end
|
||||
|
||||
test "report history is scoped to the reporter and includes the latest decision", context do
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
|
||||
|
||||
assert {:ok, report} =
|
||||
Trust.report(context.helper_scope, %{
|
||||
"request_id" => request.id,
|
||||
"reason" => "dangerous_request",
|
||||
"details" => "Please review the safety information in this request."
|
||||
})
|
||||
|
||||
outsider = user_fixture(display_name: "Report history outsider")
|
||||
assert [] = Trust.list_reports_for_user(user_scope_fixture(outsider))
|
||||
|
||||
moderator =
|
||||
user_fixture(display_name: "Report history moderator")
|
||||
|> Ecto.Changeset.change(role: :moderator)
|
||||
|> Repo.update!()
|
||||
|
||||
assert {:ok, resolved} =
|
||||
Trust.moderate_report(user_scope_fixture(moderator), report.id, %{
|
||||
"status" => "resolved",
|
||||
"resolution_note" =>
|
||||
"The request was reviewed and the appropriate action was recorded."
|
||||
})
|
||||
|
||||
assert resolved.status == :resolved
|
||||
|
||||
assert [visible] = Trust.list_reports_for_user(context.helper_scope)
|
||||
assert visible.id == report.id
|
||||
assert visible.status == :resolved
|
||||
assert visible.resolution_note =~ "appropriate action"
|
||||
|
||||
assert %WhoNeedHelp.Notifications.Notification{path: "/reports"} =
|
||||
Repo.get_by(WhoNeedHelp.Notifications.Notification,
|
||||
user_id: context.helper.id,
|
||||
kind: :support_update
|
||||
)
|
||||
end
|
||||
|
||||
test "forged moderation identifiers return not found without crashing", context do
|
||||
moderator =
|
||||
user_fixture(display_name: "Moderator")
|
||||
|
|
|
|||
|
|
@ -135,6 +135,52 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
|||
|
||||
assert response(get(conn, ~p"/legal/content-removal/#{notice.id}"), 404) == "Not found"
|
||||
end
|
||||
|
||||
test "shows conversation history and lets the requester reply and reopen the case", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
scope = user_scope_fixture(user)
|
||||
|
||||
{:ok, request} =
|
||||
WhoNeedHelp.Support.create_request(scope, %{
|
||||
"kind" => "technical_issue",
|
||||
"subject" => "Support conversation",
|
||||
"details" => "This request verifies the private support conversation flow."
|
||||
})
|
||||
|
||||
moderator =
|
||||
user_fixture()
|
||||
|> Ecto.Changeset.change(role: :moderator)
|
||||
|> WhoNeedHelp.Repo.update!()
|
||||
|
||||
{:ok, _resolved} =
|
||||
WhoNeedHelp.Support.moderate(user_scope_fixture(moderator), request.id, %{
|
||||
"status" => "resolved",
|
||||
"response" => "The support team sent a private answer."
|
||||
})
|
||||
|
||||
page = conn |> get(~p"/support/cases/#{request.id}") |> html_response(200)
|
||||
assert page =~ "The support team sent a private answer."
|
||||
assert page =~ "Reopen without a message"
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/support/cases/#{request.id}/messages", %{
|
||||
"message" => %{"body" => "The issue returned after the last answer."}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == ~p"/support/cases/#{request.id}"
|
||||
|
||||
page =
|
||||
conn
|
||||
|> recycle()
|
||||
|> get(~p"/support/cases/#{request.id}")
|
||||
|> html_response(200)
|
||||
|
||||
assert page =~ "The issue returned after the last answer."
|
||||
assert page =~ "Open"
|
||||
refute page =~ "Reopen without a message"
|
||||
end
|
||||
end
|
||||
|
||||
describe "operator queue authorization" do
|
||||
|
|
|
|||
|
|
@ -15,6 +15,41 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
assert html =~ "Repeated help between the same pair"
|
||||
end
|
||||
|
||||
test "reporter can review private report history and moderation outcome", %{
|
||||
conn: conn,
|
||||
scope: scope
|
||||
} do
|
||||
category = Catalog.seed_defaults()
|
||||
requester = user_fixture(display_name: "Reported requester")
|
||||
|
||||
{:ok, request} =
|
||||
Help.create_request(user_scope_fixture(requester), request_attrs(category))
|
||||
|
||||
{:ok, report} =
|
||||
Trust.report(scope, %{
|
||||
"request_id" => request.id,
|
||||
"reason" => "dangerous_request",
|
||||
"details" => "Review this request and keep the report private."
|
||||
})
|
||||
|
||||
moderator =
|
||||
user_fixture(display_name: "Report moderator")
|
||||
|> Ecto.Changeset.change(role: :moderator)
|
||||
|> Repo.update!()
|
||||
|
||||
{:ok, _report} =
|
||||
Trust.moderate_report(user_scope_fixture(moderator), report.id, %{
|
||||
"status" => "resolved",
|
||||
"resolution_note" => "The report was reviewed."
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/reports")
|
||||
assert html =~ "Your reports"
|
||||
assert html =~ "Review this request and keep the report private."
|
||||
assert html =~ "The report was reviewed."
|
||||
assert html =~ "Resolved"
|
||||
end
|
||||
|
||||
test "notification center manages inbox, preferences, and a private nearby alert", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user