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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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(
%{
"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>

View File

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

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.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",

View File

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

View File

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

View File

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