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

    -
    -

    {@request.details}

    -
    -
    -

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

    + +
    + +
    +
    +
    +

    + {gettext("Conversation")} +

    +

    + {gettext("Only you and authorised support staff can view this case.")} +

    +
    +
    + +
    +
    +
    + {message_sender_label(message.sender_role)} + +
    +

    {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")} + +
    +
    + +
    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}

    +
    + +
    + +
    +

    {gettext("No reports submitted")}

    +

    + {gettext("Reports you send from a request, activity, or chat will appear here.")} +

    +
    +
    + + +
    + """ + 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.body}

    +
    +

    + {gettext("No conversation messages yet.")} +

    +
    +
    + + {gettext("Status history")} + +
      +
    1. + {status_label(event.to_status)} · {event.actor_role} · {event.inserted_at} +
    2. +
    3. + {gettext("No status history is available for this older case.")} +
    4. +
    +
    +