Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
247 lines
8 KiB
Elixir
247 lines
8 KiB
Elixir
defmodule TarakanWeb.ModerationCaseLive.Show do
|
|
use TarakanWeb, :live_view
|
|
|
|
alias Tarakan.Accounts
|
|
alias Tarakan.Moderation
|
|
alias Tarakan.Policy
|
|
|
|
@impl true
|
|
def mount(%{"id" => id}, _session, socket) do
|
|
case Moderation.get_case(socket.assigns.current_scope, id) do
|
|
{:ok, case_record} ->
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, "Moderation case ##{case_record.id}")
|
|
|> assign(:appeal_form, appeal_form())
|
|
|> assign(:resolution_form, resolution_form())
|
|
|> assign_case(case_record)}
|
|
|
|
{:error, :not_found} ->
|
|
raise Ecto.NoResultsError, queryable: Tarakan.Moderation.Case
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("assign", _params, socket) do
|
|
case Moderation.assign(
|
|
socket.assigns.current_scope,
|
|
socket.assigns.case_record
|
|
) do
|
|
{:ok, case_record} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign_case(case_record)
|
|
|> put_flash(:info, "Case assigned for independent review.")}
|
|
|
|
error ->
|
|
{:noreply, put_flash(socket, :error, error_message(error))}
|
|
end
|
|
end
|
|
|
|
def handle_event(
|
|
"resolve",
|
|
%{"disposition" => disposition, "resolution" => %{"reason" => reason}},
|
|
socket
|
|
)
|
|
when disposition in ["resolved", "dismissed"] do
|
|
with_recent_auth(socket, fn ->
|
|
case Moderation.resolve(
|
|
socket.assigns.current_scope,
|
|
socket.assigns.case_record,
|
|
disposition,
|
|
reason
|
|
) do
|
|
{:ok, case_record} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:resolution_form, resolution_form())
|
|
|> assign_case(case_record)
|
|
|> put_flash(:info, resolution_message(disposition))}
|
|
|
|
error ->
|
|
{:noreply, put_flash(socket, :error, error_message(error))}
|
|
end
|
|
end)
|
|
end
|
|
|
|
def handle_event("resolve", _params, socket) do
|
|
{:noreply, put_flash(socket, :error, "Choose a valid moderation outcome.")}
|
|
end
|
|
|
|
def handle_event("appeal", %{"appeal" => attrs}, socket) do
|
|
case Moderation.appeal(
|
|
socket.assigns.current_scope,
|
|
socket.assigns.case_record,
|
|
attrs
|
|
) do
|
|
{:ok, _appeal} ->
|
|
{:noreply,
|
|
socket
|
|
|> refresh_case()
|
|
|> put_flash(:info, "Appeal submitted for independent review.")}
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
{:noreply, assign(socket, :appeal_form, to_form(changeset, as: :appeal))}
|
|
|
|
error ->
|
|
{:noreply, put_flash(socket, :error, error_message(error))}
|
|
end
|
|
end
|
|
|
|
def handle_event("appeal", _params, socket) do
|
|
{:noreply, put_flash(socket, :error, "The appeal is incomplete.")}
|
|
end
|
|
|
|
def handle_event(
|
|
"decide_appeal",
|
|
%{
|
|
"decision" => decision,
|
|
"appeal_decision" => %{"appeal_id" => appeal_id, "reason" => reason}
|
|
},
|
|
socket
|
|
)
|
|
when decision in ["upheld", "denied"] do
|
|
with_recent_auth(socket, fn ->
|
|
with {:ok, appeal_id} <- normalize_id(appeal_id),
|
|
appeal when not is_nil(appeal) <-
|
|
Enum.find(socket.assigns.case_record.appeals, &(&1.id == appeal_id)) do
|
|
case Moderation.decide_appeal(
|
|
socket.assigns.current_scope,
|
|
appeal,
|
|
decision,
|
|
reason
|
|
) do
|
|
{:ok, _appeal} ->
|
|
{:noreply,
|
|
socket
|
|
|> refresh_case()
|
|
|> put_flash(:info, appeal_decision_message(decision))}
|
|
|
|
error ->
|
|
{:noreply, put_flash(socket, :error, error_message(error))}
|
|
end
|
|
else
|
|
_other -> {:noreply, put_flash(socket, :error, "That appeal is no longer available.")}
|
|
end
|
|
end)
|
|
end
|
|
|
|
def handle_event("decide_appeal", _params, socket) do
|
|
{:noreply, put_flash(socket, :error, "Choose a valid appeal decision.")}
|
|
end
|
|
|
|
defp refresh_case(socket) do
|
|
case Moderation.get_case(socket.assigns.current_scope, socket.assigns.case_record.id) do
|
|
{:ok, case_record} -> assign_case(socket, case_record)
|
|
{:error, :not_found} -> push_navigate(socket, to: ~p"/")
|
|
end
|
|
end
|
|
|
|
defp assign_case(socket, case_record) do
|
|
socket
|
|
|> assign(:case_record, case_record)
|
|
|> assign(:moderator?, moderator?(socket.assigns.current_scope))
|
|
|> assign(
|
|
:appeal_decision_forms,
|
|
Map.new(case_record.appeals, &{&1.id, appeal_decision_form(&1)})
|
|
)
|
|
end
|
|
|
|
defp moderator?(scope), do: Policy.moderator?(scope) and scope.account_state == "active"
|
|
|
|
defp can_assign?(case_record, scope) do
|
|
moderator?(scope) and independent_from_case?(case_record, scope) and
|
|
(case_record.status == "open" or
|
|
(case_record.status == "in_review" and scope.platform_role == "admin"))
|
|
end
|
|
|
|
defp can_resolve?(case_record, scope) do
|
|
moderator?(scope) and case_record.status == "in_review" and
|
|
case_record.assigned_to_id == scope.account_id
|
|
end
|
|
|
|
defp can_appeal?(case_record, scope) do
|
|
case_record.status == "resolved" and case_record.appeals == [] and
|
|
(case_record.subject_owner_id == scope.account_id or
|
|
Policy.repository_steward?(scope, case_record))
|
|
end
|
|
|
|
defp can_decide_appeal?(case_record, appeal, scope) do
|
|
moderator?(scope) and appeal.status == "open" and
|
|
scope.account_id not in [
|
|
case_record.reporter_id,
|
|
case_record.subject_owner_id,
|
|
case_record.resolved_by_id,
|
|
appeal.appellant_id
|
|
]
|
|
end
|
|
|
|
defp independent_from_case?(case_record, scope) do
|
|
scope.account_id not in [case_record.reporter_id, case_record.subject_owner_id]
|
|
end
|
|
|
|
defp appeal_form, do: to_form(%{"reason" => ""}, as: :appeal)
|
|
defp resolution_form, do: to_form(%{"reason" => ""}, as: :resolution)
|
|
|
|
defp with_recent_auth(socket, fun) do
|
|
if Accounts.sudo_mode?(socket.assigns.current_scope.account) do
|
|
fun.()
|
|
else
|
|
return_to = ~p"/moderation/cases/#{socket.assigns.case_record.id}"
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(
|
|
:error,
|
|
"Confirm it's you with a magic link before deciding moderation outcomes (sign-in older than 8 hours)."
|
|
)
|
|
|> push_navigate(to: TarakanWeb.AccountAuth.reauth_path(return_to))}
|
|
end
|
|
end
|
|
|
|
defp appeal_decision_form(appeal) do
|
|
to_form(%{"appeal_id" => appeal.id, "reason" => ""}, as: :appeal_decision)
|
|
end
|
|
|
|
defp normalize_id(id) when is_integer(id) and id > 0, do: {:ok, id}
|
|
|
|
defp normalize_id(id) when is_binary(id) do
|
|
case Integer.parse(id) do
|
|
{parsed, ""} when parsed > 0 -> {:ok, parsed}
|
|
_other -> {:error, :invalid_id}
|
|
end
|
|
end
|
|
|
|
defp normalize_id(_id), do: {:error, :invalid_id}
|
|
|
|
defp status_label("in_review"), do: "In review"
|
|
defp status_label(status), do: String.capitalize(status)
|
|
|
|
defp subject_label("review_task"), do: "Job"
|
|
|
|
defp subject_label(subject_type),
|
|
do: subject_type |> String.replace("_", " ") |> String.capitalize()
|
|
|
|
defp reason_label(reason), do: reason |> String.replace("_", " ") |> String.capitalize()
|
|
|
|
defp resolution_message("resolved"), do: "Case resolved."
|
|
defp resolution_message("dismissed"), do: "Case dismissed."
|
|
|
|
defp appeal_decision_message("upheld"), do: "Appeal upheld and case overturned."
|
|
defp appeal_decision_message("denied"), do: "Appeal denied."
|
|
|
|
defp error_message({:error, :conflict_of_interest}),
|
|
do: "An independent moderator must handle this action."
|
|
|
|
defp error_message({:error, :not_assigned}), do: "This case is assigned to another moderator."
|
|
defp error_message({:error, :not_appealable}), do: "This case is not eligible for appeal."
|
|
defp error_message({:error, :already_decided}), do: "That appeal has already been decided."
|
|
defp error_message({:error, :invalid_reason}), do: "Provide a reason of at least 10 characters."
|
|
|
|
defp error_message({:error, :invalid_transition}),
|
|
do: "That case transition is no longer valid."
|
|
|
|
defp error_message({:error, :unauthorized}), do: "You are not authorized for that action."
|
|
defp error_message({:error, :not_found}), do: "The case is no longer available."
|
|
defp error_message({:error, _reason}), do: "The moderation action could not be completed."
|
|
end
|