feat(safety): add support conversations and report history

This commit is contained in:
SimpleTest 2026-07-24 19:33:16 +03:00
parent fa7dcb18be
commit a9984738cc
18 changed files with 1053 additions and 47 deletions

View File

@ -8,7 +8,7 @@ defmodule WhoNeedHelp.Support do
alias WhoNeedHelp.Accounts.{Scope, User} alias WhoNeedHelp.Accounts.{Scope, User}
alias WhoNeedHelp.Pagination alias WhoNeedHelp.Pagination
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Support.{Notifier, SupportRequest} alias WhoNeedHelp.Support.{ConversationMessage, Notifier, StatusEvent, SupportRequest}
alias WhoNeedHelp.Trust alias WhoNeedHelp.Trust
alias WhoNeedHelp.Trust.RateLimiter alias WhoNeedHelp.Trust.RateLimiter
@ -43,6 +43,14 @@ defmodule WhoNeedHelp.Support do
%{ %{
"kind" => to_string(request.kind) "kind" => to_string(request.kind)
} }
),
{:ok, _event} <-
record_status_event(
request,
nil,
request.status,
user && user.id,
:requester
) do ) do
{:ok, request} {:ok, request}
end end
@ -70,7 +78,7 @@ defmodule WhoNeedHelp.Support do
with {:ok, id} <- Ecto.UUID.cast(id), with {:ok, id} <- Ecto.UUID.cast(id),
%SupportRequest{} = request <- %SupportRequest{} = request <-
Repo.get_by(SupportRequest, id: id, requester_id: user_id) do Repo.get_by(SupportRequest, id: id, requester_id: user_id) do
{:ok, request} {:ok, preload_conversation(request)}
else else
_ -> {:error, :not_found} _ -> {:error, :not_found}
end end
@ -81,7 +89,9 @@ defmodule WhoNeedHelp.Support do
{:ok, ^id} <- {:ok, ^id} <-
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token, max_age: 31_536_000), Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token, max_age: 31_536_000),
%SupportRequest{} = request <- Repo.get(SupportRequest, id) do %SupportRequest{} = request <- Repo.get(SupportRequest, id) do
verify_contact(request) with {:ok, request} <- verify_contact(request) do
{:ok, preload_conversation(request)}
end
else else
_ -> {:error, :not_found} _ -> {:error, :not_found}
end end
@ -89,6 +99,17 @@ defmodule WhoNeedHelp.Support do
def get_by_access_token(_id, _token), do: {:error, :not_found} 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 def access_token(%SupportRequest{id: id}) do
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id) Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id)
end end
@ -110,7 +131,12 @@ defmodule WhoNeedHelp.Support do
|> before(cursor) |> before(cursor)
|> order_by([request], desc: request.inserted_at, desc: request.id) |> order_by([request], desc: request.inserted_at, desc: request.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> preload([:requester, :reviewed_by]) |> preload([
:requester,
:reviewed_by,
conversation_messages: :sender,
status_events: :actor
])
|> Repo.all() |> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id}) |> Pagination.page(limit, &{&1.inserted_at, &1.id})
else else
@ -121,9 +147,13 @@ defmodule WhoNeedHelp.Support do
def moderate(%Scope{user: moderator}, id, attrs) do def moderate(%Scope{user: moderator}, id, attrs) do
with {:ok, id} <- Ecto.UUID.cast(id), with {:ok, id} <- Ecto.UUID.cast(id),
true <- Accounts.moderator_authorized?(moderator) do true <- Accounts.moderator_authorized?(moderator) do
attrs = normalize_keys(attrs)
response = normalize_message(attrs["response"] || attrs["resolution_note"])
attrs = attrs =
attrs attrs
|> normalize_keys() |> Map.delete("response")
|> maybe_put_resolution(response)
|> Map.merge(%{ |> Map.merge(%{
"reviewed_at" => DateTime.utc_now(:second), "reviewed_at" => DateTime.utc_now(:second),
"reviewed_by_id" => moderator.id "reviewed_by_id" => moderator.id
@ -137,8 +167,23 @@ defmodule WhoNeedHelp.Support do
|> Repo.one() |> Repo.one()
if request do if request do
previous_status = request.status
response_to_record =
if response && response != request.resolution_note, do: response
with {:ok, request} <- with {:ok, request} <-
request |> SupportRequest.moderation_changeset(attrs) |> Repo.update(), 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} <- {:ok, _audit} <-
Trust.audit( Trust.audit(
moderator.id, moderator.id,
@ -149,7 +194,7 @@ defmodule WhoNeedHelp.Support do
"status" => to_string(request.status) "status" => to_string(request.status)
} }
) do ) do
{:ok, request} {:ok, preload_conversation(request)}
end end
else else
{:error, :not_found} {:error, :not_found}
@ -162,6 +207,118 @@ defmodule WhoNeedHelp.Support do
end end
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 def deletion_assessment(%Scope{user: moderator} = scope, id) do
with true <- Accounts.moderator_authorized?(moderator), with true <- Accounts.moderator_authorized?(moderator),
{:ok, id} <- Ecto.UUID.cast(id), {:ok, id} <- Ecto.UUID.cast(id),
@ -190,6 +347,10 @@ defmodule WhoNeedHelp.Support do
request request
|> Ecto.Changeset.change(response_sent_at: DateTime.utc_now(:second)) |> Ecto.Changeset.change(response_sent_at: DateTime.utc_now(:second))
|> Repo.update() |> Repo.update()
|> then(fn
{:ok, updated} -> {:ok, preload_conversation(updated)}
error -> error
end)
_error -> _error ->
{:ok, request} {:ok, request}
@ -198,6 +359,18 @@ defmodule WhoNeedHelp.Support do
defp notify_decision(result), do: result 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 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))
@ -206,6 +379,75 @@ defmodule WhoNeedHelp.Support do
defp verify_contact(%SupportRequest{} = request), do: {:ok, request} 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, nil), do: query
defp before(query, {inserted_at, id}) do defp before(query, {inserted_at, id}) do

View 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

View File

@ -60,6 +60,28 @@ defmodule WhoNeedHelp.Support.Notifier do
end end
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 defp deliver(recipient, subject, body) do
from = Application.fetch_env!(:who_need_help, :mailer_from) from = Application.fetch_env!(:who_need_help, :mailer_from)

View 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

View File

@ -33,6 +33,13 @@ defmodule WhoNeedHelp.Support.SupportRequest do
field :response_sent_at, :utc_datetime field :response_sent_at, :utc_datetime
belongs_to :requester, WhoNeedHelp.Accounts.User belongs_to :requester, WhoNeedHelp.Accounts.User
belongs_to :reviewed_by, 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) timestamps(type: :utc_datetime)
end end

View File

@ -11,6 +11,7 @@ defmodule WhoNeedHelp.Trust do
alias WhoNeedHelp.Help alias WhoNeedHelp.Help
alias WhoNeedHelp.Help.{Assignment, HelpRequest} alias WhoNeedHelp.Help.{Assignment, HelpRequest}
alias WhoNeedHelp.Messaging.Message alias WhoNeedHelp.Messaging.Message
alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Pagination alias WhoNeedHelp.Pagination
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.{Position, TrackingSession} alias WhoNeedHelp.Tracking.{Position, TrackingSession}
@ -313,6 +314,23 @@ defmodule WhoNeedHelp.Trust do
paginate_reports(%Scope{user: user}, status).entries paginate_reports(%Scope{user: user}, status).entries
end 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 def paginate_reports(%Scope{user: user}, status \\ nil, options \\ []) do
if Accounts.moderator_authorized?(user) do if Accounts.moderator_authorized?(user) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
@ -366,12 +384,29 @@ defmodule WhoNeedHelp.Trust do
{:error, :not_found} {:error, :not_found}
end end
end) end)
|> notify_reporter()
else else
false -> {:error, :forbidden} false -> {:error, :forbidden}
{:error, :not_found} = error -> error {:error, :not_found} = error -> error
end end
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 def report_evidence(%Scope{user: moderator}, report_id) do
with {:ok, report_id} <- cast_id(report_id), with {:ok, report_id} <- cast_id(report_id),
true <- Accounts.moderator_authorized?(moderator) do true <- Accounts.moderator_authorized?(moderator) do

View File

@ -132,6 +132,12 @@ defmodule WhoNeedHelpWeb.Layouts do
<li> <li>
<.link navigate={~p"/notifications"}>{gettext("Notifications")}</.link> <.link navigate={~p"/notifications"}>{gettext("Notifications")}</.link>
</li> </li>
<li>
<.link navigate={~p"/reports"}>{gettext("Your reports")}</.link>
</li>
<li>
<.link href={~p"/support/requests"}>{gettext("Support requests")}</.link>
</li>
<li> <li>
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link> <.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
</li> </li>
@ -314,6 +320,12 @@ defmodule WhoNeedHelpWeb.Layouts do
<li> <li>
<.link navigate={~p"/notifications"}>{gettext("Notifications")}</.link> <.link navigate={~p"/notifications"}>{gettext("Notifications")}</.link>
</li> </li>
<li>
<.link navigate={~p"/reports"}>{gettext("Your reports")}</.link>
</li>
<li>
<.link href={~p"/support/requests"}>{gettext("Support requests")}</.link>
</li>
<li> <li>
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link> <.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
</li> </li>

View File

@ -80,30 +80,102 @@ defmodule WhoNeedHelpWeb.SupportController do
def show(conn, %{"id" => id} = params) do def show(conn, %{"id" => id} = params) do
token = params["token"] token = params["token"]
result = case Support.get_for_viewer(conn.assigns.current_scope, id, token) do
case conn.assigns.current_scope do {:ok, request} -> render_case(conn, request, token)
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)
{:error, :not_found} -> send_resp(conn, :not_found, "Not found") {:error, :not_found} -> send_resp(conn, :not_found, "Not found")
end end
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 def received(conn, %{"reference" => reference}) when is_binary(reference) do
render(conn, :received, reference: String.slice(reference, 0, 64)) render(conn, :received, reference: String.slice(reference, 0, 64))
end end
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
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 defp render_form(conn, %Ecto.Changeset{} = changeset, _attrs, page) do
render(conn, page, render(conn, page,
form: Phoenix.Component.to_form(changeset, as: :support_request), form: Phoenix.Component.to_form(changeset, as: :support_request),

View File

@ -19,4 +19,19 @@ defmodule WhoNeedHelpWeb.SupportHTML do
def status_label(:resolved), do: gettext("Resolved") def status_label(:resolved), do: gettext("Resolved")
def status_label(:closed), do: gettext("Closed") def status_label(:closed), do: gettext("Closed")
def status_label(value), do: to_string(value) 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 end

View File

@ -1,25 +1,143 @@
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:compact}> <Layouts.app flash={@flash} current_scope={@current_scope} page_width={:reading}>
<div> <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"> <div class="flex flex-wrap items-center gap-2">
<span class="badge badge-outline">{@request.reference}</span> <span class="badge badge-outline">{@request.reference}</span>
<span class="badge badge-primary">{status_label(@request.status)}</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> </div>
<h1 class="mt-4 text-3xl font-black">{@request.subject}</h1> <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"> <section class="mt-6 rounded-2xl bg-base-200 p-5" aria-labelledby="support-case-details">
<p class="whitespace-pre-wrap">{@request.details}</p> <h2 id="support-case-details" class="font-bold">{gettext("Your original request")}</h2>
</div> <p class="mt-2 whitespace-pre-wrap">{@request.details}</p>
<section <time class="mt-3 block text-xs text-base-content/60">
:if={@request.resolution_note not in [nil, ""]} {format_timestamp(@request.inserted_at)}
class="mt-5 rounded-2xl border border-primary/30 p-5" </time>
>
<h2 class="font-bold">{gettext("Support response")}</h2>
<p class="mt-2 whitespace-pre-wrap">{@request.resolution_note}</p>
</section> </section>
<p class="mt-5 text-xs text-base-content/65">
<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( {gettext(
"Keep this status link private. A copy is sent to the contact email when email delivery is configured." "Keep this status link private. A copy is sent to the contact email when email delivery is configured."
)} )}
</p> </p>
</aside>
</div> </div>
</Layouts.app> </Layouts.app>

View 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

View File

@ -242,7 +242,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
scoped_form( scoped_form(
%{ %{
"status" => to_string(request.status), "status" => to_string(request.status),
"resolution_note" => request.resolution_note || "" "response" => ""
}, },
"support-#{request.id}" "support-#{request.id}"
) %> ) %>
@ -257,6 +257,50 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
<h3 class="mt-3 font-bold">{request.subject}</h3> <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-1 text-xs text-base-content/65">{request.contact_email}</p>
<p class="mt-3 whitespace-pre-wrap text-sm">{request.details}</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"> <div :if={request.kind == :account_deletion} class="mt-4 rounded-2xl bg-base-200 p-4">
<button <button
type="button" type="button"
@ -305,9 +349,9 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
]} ]}
/> />
<.input <.input
field={form[:resolution_note]} field={form[:response]}
type="textarea" 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> <.button class="btn btn-primary self-end">{gettext("Save and notify")}</.button>
</.form> </.form>

View File

@ -89,6 +89,8 @@ defmodule WhoNeedHelpWeb.Router do
post "/support", SupportController, :create post "/support", SupportController, :create
get "/support/received", SupportController, :received get "/support/received", SupportController, :received
get "/support/cases/:id", SupportController, :show 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 get "/account/delete", SupportController, :delete_account
post "/account/delete", SupportController, :request_account_deletion post "/account/delete", SupportController, :request_account_deletion
get "/legal/content-removal", ContentRemovalController, :new get "/legal/content-removal", ContentRemovalController, :new
@ -173,6 +175,7 @@ defmodule WhoNeedHelpWeb.Router do
live "/people/:id", PublicProfileLive, :show live "/people/:id", PublicProfileLive, :show
live "/leaderboard", LeaderboardLive, :index live "/leaderboard", LeaderboardLive, :index
live "/notifications", NotificationLive, :index live "/notifications", NotificationLive, :index
live "/reports", ReportLive, :index
end end
live_session :moderation, live_session :moderation,

View File

@ -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

View File

@ -6,7 +6,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
alias WhoNeedHelp.{Catalog, ContentRemoval, Help, Repo, Support} alias WhoNeedHelp.{Catalog, ContentRemoval, Help, Repo, Support}
alias WhoNeedHelp.ContentRemoval.Notice alias WhoNeedHelp.ContentRemoval.Notice
alias WhoNeedHelp.Support.SupportRequest alias WhoNeedHelp.Support.{ConversationMessage, StatusEvent, SupportRequest}
alias WhoNeedHelp.Trust.AuditEvent alias WhoNeedHelp.Trust.AuditEvent
test "public support request is persisted, acknowledged, and verified only through its email link" do 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) Support.get_by_access_token(request.id, emailed_token)
assert verified.contact_verified_at assert verified.contact_verified_at
assert [%StatusEvent{from_status: nil, to_status: :open}] = verified.status_events
assert Repo.exists?( assert Repo.exists?(
from event in AuditEvent, from event in AuditEvent,
@ -125,11 +126,106 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert resolved.status == :resolved assert resolved.status == :resolved
assert resolved.response_sent_at 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 -> assert_email_sent(fn email ->
email.subject =~ "support update" and email.text_body =~ "export is ready" email.subject =~ "support update" and email.text_body =~ "export is ready"
end) end)
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 test "TAKE IT DOWN notices use a separate urgent queue and never accept an unrelated category" do
attrs = %{ attrs = %{
"category" => "non_consensual_intimate_media", "category" => "non_consensual_intimate_media",

View File

@ -73,6 +73,45 @@ defmodule WhoNeedHelp.TrustSafetyTest do
end end
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 test "forged moderation identifiers return not found without crashing", context do
moderator = moderator =
user_fixture(display_name: "Moderator") user_fixture(display_name: "Moderator")

View File

@ -135,6 +135,52 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
assert response(get(conn, ~p"/legal/content-removal/#{notice.id}"), 404) == "Not found" assert response(get(conn, ~p"/legal/content-removal/#{notice.id}"), 404) == "Not found"
end 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 end
describe "operator queue authorization" do describe "operator queue authorization" do

View File

@ -15,6 +15,41 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Repeated help between the same pair" assert html =~ "Repeated help between the same pair"
end 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", %{ test "notification center manages inbox, preferences, and a private nearby alert", %{
conn: conn, conn: conn,
user: user, user: user,