diff --git a/lib/who_need_help/support.ex b/lib/who_need_help/support.ex
index 78ca8a2..9159271 100644
--- a/lib/who_need_help/support.ex
+++ b/lib/who_need_help/support.ex
@@ -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
diff --git a/lib/who_need_help/support/conversation_message.ex b/lib/who_need_help/support/conversation_message.ex
new file mode 100644
index 0000000..071d487
--- /dev/null
+++ b/lib/who_need_help/support/conversation_message.ex
@@ -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
diff --git a/lib/who_need_help/support/notifier.ex b/lib/who_need_help/support/notifier.ex
index 4059f59..046ec31 100644
--- a/lib/who_need_help/support/notifier.ex
+++ b/lib/who_need_help/support/notifier.ex
@@ -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)
diff --git a/lib/who_need_help/support/status_event.ex b/lib/who_need_help/support/status_event.ex
new file mode 100644
index 0000000..c8cf7aa
--- /dev/null
+++ b/lib/who_need_help/support/status_event.ex
@@ -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
diff --git a/lib/who_need_help/support/support_request.ex b/lib/who_need_help/support/support_request.ex
index 7047d5a..1033eb4 100644
--- a/lib/who_need_help/support/support_request.ex
+++ b/lib/who_need_help/support/support_request.ex
@@ -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
diff --git a/lib/who_need_help/trust.ex b/lib/who_need_help/trust.ex
index d891ad1..6a1ec00 100644
--- a/lib/who_need_help/trust.ex
+++ b/lib/who_need_help/trust.ex
@@ -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
diff --git a/lib/who_need_help_web/components/layouts.ex b/lib/who_need_help_web/components/layouts.ex
index 3ac96fc..5dee141 100644
--- a/lib/who_need_help_web/components/layouts.ex
+++ b/lib/who_need_help_web/components/layouts.ex
@@ -132,6 +132,12 @@ defmodule WhoNeedHelpWeb.Layouts do
<.link navigate={~p"/notifications"}>{gettext("Notifications")}
+
+ <.link navigate={~p"/reports"}>{gettext("Your reports")}
+
+
+ <.link href={~p"/support/requests"}>{gettext("Support requests")}
+
<.link href={~p"/users/settings"}>{gettext("Account settings")}
@@ -314,6 +320,12 @@ defmodule WhoNeedHelpWeb.Layouts do
<.link navigate={~p"/notifications"}>{gettext("Notifications")}
+
+ <.link navigate={~p"/reports"}>{gettext("Your reports")}
+
+
+ <.link href={~p"/support/requests"}>{gettext("Support requests")}
+
<.link href={~p"/users/settings"}>{gettext("Account settings")}
diff --git a/lib/who_need_help_web/controllers/support_controller.ex b/lib/who_need_help_web/controllers/support_controller.ex
index 6a4a5ea..f2b6b75 100644
--- a/lib/who_need_help_web/controllers/support_controller.ex
+++ b/lib/who_need_help_web/controllers/support_controller.ex
@@ -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),
diff --git a/lib/who_need_help_web/controllers/support_html.ex b/lib/who_need_help_web/controllers/support_html.ex
index cababfd..100b017 100644
--- a/lib/who_need_help_web/controllers/support_html.ex
+++ b/lib/who_need_help_web/controllers/support_html.ex
@@ -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
diff --git a/lib/who_need_help_web/controllers/support_html/show.html.heex b/lib/who_need_help_web/controllers/support_html/show.html.heex
index 52d774f..ca7cbf2 100644
--- a/lib/who_need_help_web/controllers/support_html/show.html.heex
+++ b/lib/who_need_help_web/controllers/support_html/show.html.heex
@@ -1,25 +1,143 @@
-
-
-
- {@request.reference}
- {status_label(@request.status)}
-
-
{@request.subject}
-
{kind_label(@request.kind)}
-
-
- {gettext("Support response")}
- {@request.resolution_note}
-
-
- {gettext(
- "Keep this status link private. A copy is sent to the contact email when email delivery is configured."
- )}
-
+
+
+
+
+ {@request.reference}
+ {status_label(@request.status)}
+ {kind_label(@request.kind)}
+
+
+ {@request.subject}
+
+
+ {gettext("Your original request")}
+ {@request.details}
+
+ {format_timestamp(@request.inserted_at)}
+
+
+
+
+
+
+
+ {gettext("Conversation")}
+
+
+ {gettext("Only you and authorised support staff can view this case.")}
+
+
+
+
+
+
+
+ {message_sender_label(message.sender_role)}
+
+ {format_timestamp(message.inserted_at)}
+
+
+ {message.body}
+
+
+
+ {gettext("Support team")}
+ {@request.resolution_note}
+
+
+
+ {gettext("No replies yet. Add a message if you need to clarify the request.")}
+
+
+
+
+ <.icon name="hero-information-circle" class="size-5" />
+
+ {gettext("This case is finished. Sending a new message will reopen it automatically.")}
+
+
+
+ <.form
+ for={@message_form}
+ action={~p"/support/cases/#{@request.id}/messages"}
+ method="post"
+ class="mt-5"
+ >
+
+ <.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")}
+
+
+ <.form
+ :if={@request.status in [:resolved, :closed]}
+ for={@reopen_form}
+ action={~p"/support/cases/#{@request.id}/reopen"}
+ method="post"
+ class="mt-3"
+ >
+
+ <.button class="btn btn-outline">{gettext("Reopen without a message")}
+
+
+
+
+
+
+ {gettext("Status history")}
+
+
+
+ {status_label(event.to_status)}
+
+ {event_actor_label(event.actor_role)} · {format_timestamp(event.inserted_at)}
+
+
+
+ {gettext("No status history is available for this older case.")}
+
+
+
+
+
+ {gettext(
+ "Keep this status link private. A copy is sent to the contact email when email delivery is configured."
+ )}
+
+
diff --git a/lib/who_need_help_web/live/report_live.ex b/lib/who_need_help_web/live/report_live.ex
new file mode 100644
index 0000000..0f14d01
--- /dev/null
+++ b/lib/who_need_help_web/live/report_live.ex
@@ -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"""
+
+
+
+
{gettext("TRUST AND SAFETY")}
+
{gettext("Your reports")}
+
+ {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."
+ )}
+
+
+ <.link href={~p"/support/new"} class="btn btn-outline">
+ {gettext("Contact support")}
+
+
+
+
+
+
+ {status_label(report.status)}
+ {target_label(report)}
+ {reason_label(report.reason)}
+
+ {report.details}
+
+ {gettext("Moderation response")}
+ {report.resolution_note}
+
+
+ {format_timestamp(report.inserted_at)}
+
+
+
+
+
{gettext("No reports submitted")}
+
+ {gettext("Reports you send from a request, activity, or chat will appear here.")}
+
+
+
+
+
+ {gettext("Load more")}
+
+
+ """
+ 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
diff --git a/lib/who_need_help_web/live/support_operations_live.ex b/lib/who_need_help_web/live/support_operations_live.ex
index 781f458..5ae0544 100644
--- a/lib/who_need_help_web/live/support_operations_live.ex
+++ b/lib/who_need_help_web/live/support_operations_live.ex
@@ -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
{request.subject}
{request.contact_email}
{request.details}
+
+ {gettext("Conversation")}
+
+
+
+
+ {if message.sender_role == :staff,
+ do: gettext("Support team"),
+ else: gettext("Requester")}
+
+ {message.inserted_at}
+
+ {message.body}
+
+
+ {gettext("No conversation messages yet.")}
+
+
+
+
+ {gettext("Status history")}
+
+
+
+ {status_label(event.to_status)} · {event.actor_role} · {event.inserted_at}
+
+
+ {gettext("No status history is available for this older case.")}
+
+
+
+
<.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")}
diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex
index 0984dcf..3301a53 100644
--- a/lib/who_need_help_web/router.ex
+++ b/lib/who_need_help_web/router.ex
@@ -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,
diff --git a/priv/repo/migrations/20260724161628_add_support_conversation_and_history.exs b/priv/repo/migrations/20260724161628_add_support_conversation_and_history.exs
new file mode 100644
index 0000000..00cd327
--- /dev/null
+++ b/priv/repo/migrations/20260724161628_add_support_conversation_and_history.exs
@@ -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
diff --git a/test/who_need_help/support_and_content_removal_test.exs b/test/who_need_help/support_and_content_removal_test.exs
index 7434723..fea02cc 100644
--- a/test/who_need_help/support_and_content_removal_test.exs
+++ b/test/who_need_help/support_and_content_removal_test.exs
@@ -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",
diff --git a/test/who_need_help/trust_safety_test.exs b/test/who_need_help/trust_safety_test.exs
index 776775c..60aaae1 100644
--- a/test/who_need_help/trust_safety_test.exs
+++ b/test/who_need_help/trust_safety_test.exs
@@ -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")
diff --git a/test/who_need_help_web/controllers/support_controller_test.exs b/test/who_need_help_web/controllers/support_controller_test.exs
index 3060d1b..0aa3d17 100644
--- a/test/who_need_help_web/controllers/support_controller_test.exs
+++ b/test/who_need_help_web/controllers/support_controller_test.exs
@@ -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
diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs
index 2107b7c..4c2b58f 100644
--- a/test/who_need_help_web/live/mutual_aid_live_test.exs
+++ b/test/who_need_help_web/live/mutual_aid_live_test.exs
@@ -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,