who_need_help/lib/who_need_help/trust.ex

833 lines
24 KiB
Elixir

defmodule WhoNeedHelp.Trust do
@moduledoc "Reviews, reports, blocks, reputation, abuse signals, and auditable moderation."
import Ecto.Query
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.{Scope, User}
alias WhoNeedHelp.Activities
alias WhoNeedHelp.Activities.{Activity, Participant}
alias WhoNeedHelp.Activities.Message, as: ActivityMessage
alias WhoNeedHelp.Help
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
alias WhoNeedHelp.Messaging.Message
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.{Position, TrackingSession}
alias WhoNeedHelp.Trust.{
AbuseSignal,
AuditEvent,
Block,
RateLimiter,
Report,
Review
}
def authorize_action(%Scope{user: %User{} = user}, action) do
user = Repo.get(User, user.id)
cond do
not Accounts.eligible_for_trust_actions?(user) ->
{:error, :account_not_eligible}
true ->
case RateLimiter.check(action, user.id) do
{:error, :rate_limited} = error ->
create_velocity_signal_once(user.id, action)
error
result ->
result
end
end
end
def authorize_action(_, _action), do: {:error, :account_not_eligible}
def eligible?(%Scope{user: %User{id: id}}), do: Accounts.eligible_user_id?(id)
def eligible?(_scope), do: false
def submit_review(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
attrs = stringify_keys(attrs)
request =
case assignment.request do
%HelpRequest{} = request -> request
_ -> Repo.get!(HelpRequest, assignment.request_id)
end
with {:ok, _limit} <- authorize_action(scope, :review),
true <- assignment.status == :completed,
true <- user.id in [assignment.helper_id, request.requester_id] do
reviewee_id =
if user.id == assignment.helper_id, do: request.requester_id, else: assignment.helper_id
Repo.transact(fn ->
with {:ok, review} <-
%Review{}
|> Review.changeset(
Map.merge(attrs, %{
"assignment_id" => assignment.id,
"reviewer_id" => user.id,
"reviewee_id" => reviewee_id
})
)
|> Repo.insert(),
reviews <- Repo.all(from r in Review, where: r.assignment_id == ^assignment.id),
true <- length(reviews) <= 2 do
if length(reviews) == 2 do
now = DateTime.utc_now(:second)
Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment.id),
set: [revealed_at: now]
)
end
audit(user.id, "review.submitted", "assignment", assignment.id, %{
"revealed" => length(reviews) == 2
})
{:ok, review}
else
false -> {:error, :invalid_review_count}
other -> other
end
end)
else
false -> {:error, :forbidden}
other -> other
end
end
def visible_reviews(user_id) do
Review
|> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at))
|> order_by([review], desc: review.inserted_at)
|> preload(:reviewer)
|> Repo.all()
end
def reputation(user_id) do
rows = completed_rows() |> Enum.filter(&participant?(&1, user_id))
counterparts = Enum.map(rows, &counterpart(&1, user_id))
ratings = ratings(user_id)
%{
completed: length(rows),
unique_people: counterparts |> Enum.uniq() |> length(),
verified_handovers: Enum.count(rows, &(not is_nil(&1.handover_verified_at))),
location_supported:
Enum.count(
rows,
&(not is_nil(&1.proximity_observed_at) and movement_for_user?(&1, user_id))
),
rating: average(ratings)
}
end
def leaderboard do
ratings_by_user =
Review
|> where([review], not is_nil(review.revealed_at))
|> group_by([review], review.reviewee_id)
|> select([review], {review.reviewee_id, avg(review.rating)})
|> Repo.all()
|> Map.new()
completed_rows()
|> Enum.group_by(& &1.helper_id)
|> Enum.map(fn {helper_id, rows} ->
helper = rows |> hd() |> Map.fetch!(:helper)
supported_people =
rows
|> Enum.filter(
&(not is_nil(&1.proximity_observed_at) and
not is_nil(&1.helper_movement_observed_at))
)
|> Enum.map(& &1.requester_id)
|> Enum.uniq()
|> length()
verified_people =
rows
|> Enum.filter(&(not is_nil(&1.handover_verified_at)))
|> Enum.map(& &1.requester_id)
|> Enum.uniq()
|> length()
%{
user: helper,
completed: length(rows),
unique_people: rows |> Enum.map(& &1.requester_id) |> Enum.uniq() |> length(),
location_supported_people: supported_people,
verified_people: verified_people,
rating: ratings_by_user |> Map.get(helper_id) |> decimal_average()
}
end)
|> Enum.sort_by(fn entry ->
{
-entry.location_supported_people,
-entry.verified_people,
-entry.unique_people,
-entry.completed,
String.downcase(entry.user.display_name || "")
}
end)
end
def report(%Scope{user: user} = scope, attrs) do
attrs = stringify_keys(attrs)
with {:ok, _limit} <- authorize_action(scope, :report),
:ok <- authorize_report_target(scope, attrs) do
Repo.transact(fn ->
with {:ok, report} <-
%Report{reporter_id: user.id}
|> Report.changeset(attrs)
|> Repo.insert() do
audit(user.id, "report.created", "report", report.id, %{
"reason" => to_string(report.reason)
})
{:ok, report}
end
end)
end
end
def list_reports(%Scope{user: user}, status \\ nil) do
if Accounts.moderator_authorized?(user) do
Report
|> maybe_status(status)
|> order_by([report], asc: report.status, desc: report.inserted_at)
|> preload([
:reporter,
:reviewed_by,
request: :requester,
assignment: :helper,
message: :sender,
activity: :creator,
activity_message: :sender
])
|> Repo.all()
else
[]
end
end
def moderate_report(%Scope{user: moderator}, report_id, attrs) do
if Accounts.moderator_authorized?(moderator) do
attrs =
attrs
|> stringify_keys()
|> Map.merge(%{
"reviewed_at" => DateTime.utc_now(:second),
"reviewed_by_id" => moderator.id
})
Repo.transact(fn ->
report =
Report |> where([report], report.id == ^report_id) |> lock("FOR UPDATE") |> Repo.one!()
with {:ok, report} <- report |> Report.moderation_changeset(attrs) |> Repo.update() do
audit(moderator.id, "report.moderated", "report", report.id, %{
"status" => to_string(report.status)
})
{:ok, report}
end
end)
else
{:error, :forbidden}
end
end
def report_evidence(%Scope{user: moderator}, report_id) do
if Accounts.moderator_authorized?(moderator) do
report =
Report
|> Repo.get(report_id)
|> Repo.preload([
:reporter,
:request,
:assignment,
:activity,
message: :assignment,
activity_message: :activity
])
if report do
assignment_id =
cond do
report.assignment_id -> report.assignment_id
report.message -> report.message.assignment_id
true -> nil
end
messages =
if assignment_id do
Message
|> where([message], message.assignment_id == ^assignment_id)
|> order_by([message], asc: message.inserted_at)
|> preload(:sender)
|> Repo.all()
else
[]
end
activity_id =
case report.activity_message do
%ActivityMessage{activity_id: activity_id} -> activity_id
_ -> nil
end
activity_messages =
if activity_id do
ActivityMessage
|> where([message], message.activity_id == ^activity_id)
|> order_by([message], asc: message.inserted_at)
|> preload(:sender)
|> Repo.all()
else
[]
end
with {:ok, _audit} <-
audit(moderator.id, "report.evidence_viewed", "report", report.id, %{
"assignment_id" => assignment_id,
"activity_id" => activity_id,
"message_count" => length(messages),
"activity_message_count" => length(activity_messages)
}) do
{:ok, %{report: report, messages: messages, activity_messages: activity_messages}}
end
else
{:error, :not_found}
end
else
{:error, :forbidden}
end
end
def block(%Scope{user: user} = scope, blocked_id) do
with {:ok, _limit} <- authorize_action(scope, :block),
%User{} <- Repo.get(User, blocked_id) do
Repo.transact(fn ->
with {:ok, block} <-
%Block{}
|> Block.changeset(%{blocker_id: user.id, blocked_id: blocked_id})
|> Repo.insert() do
audit(user.id, "user.blocked", "user", blocked_id)
clear_pair_tracking(user.id, blocked_id)
{:ok, block}
end
end)
else
nil -> {:error, :not_found}
other -> other
end
end
def unblock(%Scope{user: user}, blocked_id) do
case Repo.get_by(Block, blocker_id: user.id, blocked_id: blocked_id) do
nil ->
{:ok, :already_unblocked}
block ->
Repo.transact(fn ->
with {:ok, _block} <- Repo.delete(block) do
audit(user.id, "user.unblocked", "user", blocked_id)
{:ok, :unblocked}
end
end)
end
end
def list_blocks(%Scope{user: user}) do
Block
|> where([block], block.blocker_id == ^user.id)
|> order_by([block], desc: block.inserted_at)
|> preload(:blocked)
|> Repo.all()
end
def blocked_between?(first_user_id, second_user_id) do
Block
|> where(
[block],
(block.blocker_id == ^first_user_id and block.blocked_id == ^second_user_id) or
(block.blocker_id == ^second_user_id and block.blocked_id == ^first_user_id)
)
|> Repo.exists?()
end
def blocked_by?(blocker_id, blocked_id) do
Repo.exists?(
from block in Block,
where: block.blocker_id == ^blocker_id and block.blocked_id == ^blocked_id
)
end
def record_completion_signals(%Assignment{} = assignment) do
request =
case assignment.request do
%HelpRequest{} = request -> request
_ -> Repo.get!(HelpRequest, assignment.request_id)
end
repeated_pair? =
Assignment
|> join(:inner, [candidate], req in HelpRequest, on: req.id == candidate.request_id)
|> where(
[candidate, req],
candidate.status == :completed and candidate.id != ^assignment.id and
candidate.helper_id == ^assignment.helper_id and
req.requester_id == ^request.requester_id
)
|> Repo.exists?()
if repeated_pair? do
create_signal_once(:repeated_pair, assignment.helper_id, assignment.id, %{
"counterpart_id" => request.requester_id
})
end
reciprocal? =
Assignment
|> join(:inner, [candidate], req in HelpRequest, on: req.id == candidate.request_id)
|> where(
[candidate, req],
candidate.status == :completed and candidate.helper_id == ^request.requester_id and
req.requester_id == ^assignment.helper_id
)
|> Repo.exists?()
if reciprocal? do
create_signal_once(:reciprocal_ring, assignment.helper_id, assignment.id, %{
"counterpart_id" => request.requester_id,
"factual_signal_only" => true
})
end
if is_nil(assignment.proximity_observed_at) do
create_signal_once(
:handover_without_location_evidence,
assignment.helper_id,
assignment.id,
%{"tracking_optional" => true}
)
end
if is_nil(assignment.helper_movement_observed_at) do
create_signal_once(:location_without_movement, assignment.helper_id, assignment.id, %{
"tracking_optional" => true
})
end
:ok
end
def list_abuse_signals(%Scope{user: user}, status \\ :open) do
if Accounts.moderator_authorized?(user) do
AbuseSignal
|> where([signal], signal.status == ^status)
|> order_by([signal], desc: signal.inserted_at)
|> preload([:subject, :assignment, :reviewed_by])
|> Repo.all()
else
[]
end
end
def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do
if Accounts.moderator_authorized?(moderator) do
attrs =
attrs
|> stringify_keys()
|> Map.merge(%{
"reviewed_at" => DateTime.utc_now(:second),
"reviewed_by_id" => moderator.id
})
Repo.transact(fn ->
signal =
AbuseSignal
|> where([signal], signal.id == ^signal_id)
|> lock("FOR UPDATE")
|> Repo.one!()
with {:ok, signal} <- signal |> AbuseSignal.moderation_changeset(attrs) |> Repo.update() do
audit(moderator.id, "abuse_signal.moderated", "abuse_signal", signal.id, %{
"status" => to_string(signal.status)
})
{:ok, signal}
end
end)
else
{:error, :forbidden}
end
end
def hide_request(%Scope{user: moderator}, request_id, reason) do
result =
if Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
request =
HelpRequest
|> where([request], request.id == ^request_id)
|> lock("FOR UPDATE")
|> Repo.one!()
with {:ok, request} <-
request
|> HelpRequest.moderation_changeset(%{
hidden_at: DateTime.utc_now(:second),
hidden_reason: reason
})
|> Repo.update() do
audit(moderator.id, "request.hidden", "request", request.id, %{"reason" => reason})
{:ok, request}
end
end)
else
{:error, :forbidden}
end
with {:ok, request} <- result do
Help.notify_request_updated(request.id)
{:ok, request}
end
end
def restore_request(%Scope{user: moderator}, request_id) do
result =
if Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
request =
HelpRequest
|> where([request], request.id == ^request_id)
|> lock("FOR UPDATE")
|> Repo.one!()
with {:ok, request} <-
request
|> HelpRequest.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
|> Repo.update() do
audit(moderator.id, "request.restored", "request", request.id)
{:ok, request}
end
end)
else
{:error, :forbidden}
end
with {:ok, request} <- result do
Help.notify_request_updated(request.id)
{:ok, request}
end
end
def hide_activity(%Scope{user: moderator}, activity_id, reason) do
result =
if Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
activity =
Activity
|> where([activity], activity.id == ^activity_id)
|> lock("FOR UPDATE")
|> Repo.one!()
with {:ok, activity} <-
activity
|> Activity.moderation_changeset(%{
hidden_at: DateTime.utc_now(:second),
hidden_reason: reason
})
|> Repo.update() do
audit(moderator.id, "activity.hidden", "activity", activity.id, %{
"reason" => reason
})
{:ok, activity}
end
end)
else
{:error, :forbidden}
end
with {:ok, activity} <- result do
Activities.notify_activity_updated(activity.id)
{:ok, activity}
end
end
def restore_activity(%Scope{user: moderator}, activity_id) do
result =
if Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
activity =
Activity
|> where([activity], activity.id == ^activity_id)
|> lock("FOR UPDATE")
|> Repo.one!()
with {:ok, activity} <-
activity
|> Activity.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
|> Repo.update() do
audit(moderator.id, "activity.restored", "activity", activity.id)
{:ok, activity}
end
end)
else
{:error, :forbidden}
end
with {:ok, activity} <- result do
Activities.notify_activity_updated(activity.id)
{:ok, activity}
end
end
def moderate_user(%Scope{user: moderator}, user_id, attrs) do
Repo.transact(fn ->
with {:ok, user} <- Accounts.moderate_user(moderator, user_id, attrs),
{:ok, _audit} <-
audit(moderator.id, "user.moderated", "user", user.id, %{
"moderation_status" => to_string(user.moderation_status)
}) do
{:ok, user}
end
end)
end
def moderate_role(%Scope{user: admin}, user_id, attrs) do
Repo.transact(fn ->
with {:ok, user} <- Accounts.change_user_role(admin, user_id, attrs),
{:ok, _audit} <-
audit(admin.id, "user.role_changed", "user", user.id, %{
"role" => to_string(user.role)
}) do
{:ok, user}
end
end)
end
def audit(actor_id, action, target_type, target_id, metadata \\ %{}) do
%AuditEvent{}
|> AuditEvent.changeset(%{
actor_id: actor_id,
action: action,
target_type: target_type,
target_id: target_id,
metadata: metadata
})
|> Repo.insert()
end
defp completed_rows do
Assignment
|> join(:inner, [assignment], request in HelpRequest, on: request.id == assignment.request_id)
|> join(:inner, [assignment, _request], helper in User, on: helper.id == assignment.helper_id)
|> where([assignment], assignment.status == :completed)
|> select([assignment, request, helper], %{
id: assignment.id,
helper_id: assignment.helper_id,
requester_id: request.requester_id,
handover_verified_at: assignment.handover_verified_at,
proximity_observed_at: assignment.proximity_observed_at,
helper_movement_observed_at: assignment.helper_movement_observed_at,
helper: helper
})
|> Repo.all()
end
defp participant?(row, user_id), do: row.helper_id == user_id or row.requester_id == user_id
defp counterpart(row, user_id),
do: if(row.helper_id == user_id, do: row.requester_id, else: row.helper_id)
defp movement_for_user?(row, user_id) do
if row.helper_id == user_id, do: not is_nil(row.helper_movement_observed_at), else: true
end
defp ratings(user_id) do
Review
|> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at))
|> select([review], review.rating)
|> Repo.all()
end
defp average([]), do: nil
defp average(values), do: Float.round(Enum.sum(values) / length(values), 1)
defp decimal_average(nil), do: nil
defp decimal_average(value), do: value |> Decimal.to_float() |> Float.round(1)
defp authorize_report_target(%Scope{user: user}, %{"request_id" => request_id})
when is_binary(request_id) do
case Repo.get(HelpRequest, request_id) do
%HelpRequest{requester_id: requester_id} when requester_id != user.id -> :ok
%HelpRequest{} -> {:error, :cannot_report_self}
nil -> {:error, :not_found}
end
end
defp authorize_report_target(scope, %{"assignment_id" => assignment_id})
when is_binary(assignment_id) do
case Repo.get(Assignment, assignment_id) do
%Assignment{} = assignment ->
if Help.participant?(scope, assignment), do: :ok, else: {:error, :forbidden}
nil ->
{:error, :not_found}
end
end
defp authorize_report_target(scope, %{"message_id" => message_id}) when is_binary(message_id) do
case Message |> Repo.get(message_id) |> Repo.preload(:assignment) do
%Message{sender_id: sender_id, assignment: assignment} ->
cond do
sender_id == scope.user.id -> {:error, :cannot_report_self}
Help.participant?(scope, assignment) -> :ok
true -> {:error, :forbidden}
end
nil ->
{:error, :not_found}
end
end
defp authorize_report_target(%Scope{user: user}, %{"activity_id" => activity_id})
when is_binary(activity_id) do
case Repo.get(Activity, activity_id) do
%Activity{creator_id: creator_id} when creator_id == user.id ->
{:error, :cannot_report_self}
%Activity{} = activity ->
if is_nil(activity.hidden_at) or activity_participant?(activity.id, user.id),
do: :ok,
else: {:error, :not_found}
nil ->
{:error, :not_found}
end
end
defp authorize_report_target(
%Scope{user: user},
%{"activity_message_id" => message_id}
)
when is_binary(message_id) do
case Repo.get(ActivityMessage, message_id) do
%ActivityMessage{sender_id: sender_id} when sender_id == user.id ->
{:error, :cannot_report_self}
%ActivityMessage{activity_id: activity_id} ->
if activity_participant?(activity_id, user.id),
do: :ok,
else: {:error, :forbidden}
nil ->
{:error, :not_found}
end
end
defp authorize_report_target(_scope, _attrs), do: {:error, :invalid_target}
defp activity_participant?(activity_id, user_id) do
Repo.exists?(
from participant in Participant,
where:
participant.activity_id == ^activity_id and participant.user_id == ^user_id and
participant.status == :approved
)
end
defp create_signal_once(kind, subject_id, assignment_id, metadata) do
query =
from signal in AbuseSignal,
where:
signal.kind == ^kind and signal.subject_id == ^subject_id and
signal.assignment_id == ^assignment_id
if Repo.exists?(query) do
{:ok, :already_recorded}
else
%AbuseSignal{}
|> AbuseSignal.changeset(%{
kind: kind,
subject_id: subject_id,
assignment_id: assignment_id,
metadata: metadata
})
|> Repo.insert()
end
end
defp create_velocity_signal_once(subject_id, action) do
query =
from signal in AbuseSignal,
where:
signal.kind == :velocity and signal.subject_id == ^subject_id and
signal.status == :open
case Repo.one(query) do
nil ->
%AbuseSignal{}
|> AbuseSignal.changeset(%{
kind: :velocity,
subject_id: subject_id,
metadata: %{"action" => to_string(action)}
})
|> Repo.insert()
signal ->
signal
|> Ecto.Changeset.change(
metadata: Map.put(signal.metadata, "latest_action", to_string(action))
)
|> Repo.update()
end
end
defp maybe_status(query, nil), do: query
defp maybe_status(query, status), do: where(query, [report], report.status == ^status)
defp clear_pair_tracking(first_user_id, second_user_id) do
assignment_ids =
Assignment
|> join(:inner, [assignment], request in HelpRequest,
on: request.id == assignment.request_id
)
|> where(
[assignment, request],
(assignment.helper_id == ^first_user_id and request.requester_id == ^second_user_id) or
(assignment.helper_id == ^second_user_id and request.requester_id == ^first_user_id)
)
|> where([assignment], assignment.status in [:accepted, :in_progress])
|> select([assignment], assignment.id)
session_ids =
TrackingSession
|> where([session], session.active and session.assignment_id in subquery(assignment_ids))
|> select([session], session.id)
Repo.delete_all(
from position in Position, where: position.tracking_session_id in subquery(session_ids)
)
Repo.update_all(
from(session in TrackingSession, where: session.id in subquery(session_ids)),
set: [active: false, ended_at: DateTime.utc_now(:second)]
)
:ok
end
defp stringify_keys(attrs) do
Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
end
end