who_need_help/lib/who_need_help/trust.ex

1364 lines
42 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.Notifications
alias WhoNeedHelp.Pagination
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
if Accounts.eligible_user_id?(user.id) do
case RateLimiter.check(action, user.id) do
{:error, :rate_limited} = error ->
create_velocity_signal_once(user.id, action)
error
result ->
result
end
else
{:error, :account_not_eligible}
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)
with {:ok, _limit} <- authorize_action(scope, :review) do
result =
Repo.transact(fn ->
current =
Assignment
|> where([current], current.id == ^assignment.id)
|> lock("FOR UPDATE")
|> Repo.one()
if current do
request = Repo.get!(HelpRequest, current.request_id)
cond do
current.status != :completed ->
{:error, :forbidden}
user.id not in [current.helper_id, request.requester_id] ->
{:error, :forbidden}
true ->
reviewee_id =
if user.id == current.helper_id,
do: request.requester_id,
else: current.helper_id
with {:ok, review} <-
%Review{}
|> Review.changeset(
Map.merge(attrs, %{
"assignment_id" => current.id,
"reviewer_id" => user.id,
"reviewee_id" => reviewee_id
})
)
|> Repo.insert(),
reviews <- Repo.all(from r in Review, where: r.assignment_id == ^current.id),
true <- length(reviews) <= 2,
revealed? <- length(reviews) == 2,
{_count, _rows} <-
maybe_reveal_reviews(current.id, revealed?),
{:ok, _audit} <-
audit(user.id, "review.submitted", "assignment", current.id, %{
"revealed" => revealed?
}) do
{:ok, review}
else
false -> {:error, :invalid_review_count}
other -> other
end
end
else
{:error, :not_found}
end
end)
with {:ok, review} <- result do
:ok = Help.notify_request_updated(assignment.request_id)
{:ok, review}
end
end
end
def review_submitted?(%Scope{user: %User{id: user_id}}, %Assignment{id: assignment_id}) do
Repo.exists?(
from review in Review,
where: review.assignment_id == ^assignment_id and review.reviewer_id == ^user_id
)
end
def review_submitted?(_scope, _assignment), do: false
def reviews_revealed?(%Assignment{id: assignment_id}) do
Repo.exists?(
from review in Review,
where: review.assignment_id == ^assignment_id and not is_nil(review.revealed_at)
)
end
def reviews_revealed?(_assignment), do: false
defp maybe_reveal_reviews(assignment_id, true) do
Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment_id),
set: [revealed_at: DateTime.utc_now(:second)]
)
end
defp maybe_reveal_reviews(_assignment_id, false), do: {0, nil}
def visible_reviews(user_id) do
paginate_visible_reviews(user_id).entries
end
def paginate_visible_reviews(user_id, options \\ []) do
limit = Pagination.limit(options)
cursor = Pagination.cursor(options)
public_user = Accounts.public_user_query()
Review
|> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at))
|> before_review(cursor)
|> order_by([review], desc: review.inserted_at, desc: review.id)
|> limit(^(limit + 1))
|> preload([review], reviewer: ^public_user)
|> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
end
@empty_reputation %{
completed: 0,
unique_people: 0,
verified_handovers: 0,
location_supported: 0,
rating: nil
}
def reputation(user_id), do: Map.fetch!(reputations([user_id]), user_id)
def reputations([]), do: %{}
def reputations(user_ids) when is_list(user_ids) do
user_ids = Enum.uniq(user_ids)
helper_rows =
Assignment
|> join(:inner, [assignment], request in HelpRequest,
on: request.id == assignment.request_id
)
|> where(
[assignment],
assignment.status == :completed and assignment.helper_id in ^user_ids
)
|> select([assignment, request], %{
id: assignment.id,
user_id: assignment.helper_id,
counterpart_id: request.requester_id,
verified: not is_nil(assignment.handover_verified_at),
location_supported:
not is_nil(assignment.proximity_observed_at) and
not is_nil(assignment.helper_movement_observed_at)
})
requester_rows =
Assignment
|> join(:inner, [assignment], request in HelpRequest,
on: request.id == assignment.request_id
)
|> where(
[assignment, request],
assignment.status == :completed and request.requester_id in ^user_ids and
assignment.helper_id != request.requester_id
)
|> select([assignment, request], %{
id: assignment.id,
user_id: request.requester_id,
counterpart_id: assignment.helper_id,
verified: not is_nil(assignment.handover_verified_at),
location_supported: not is_nil(assignment.proximity_observed_at)
})
aggregates =
helper_rows
|> union_all(^requester_rows)
|> subquery()
|> group_by([row], row.user_id)
|> select([row], %{
user_id: row.user_id,
completed: count(row.id),
unique_people: count(row.counterpart_id, :distinct),
verified_handovers: filter(count(row.id), row.verified),
location_supported: filter(count(row.id), row.location_supported)
})
ratings =
Review
|> where(
[review],
review.reviewee_id in ^user_ids and not is_nil(review.revealed_at)
)
|> group_by([review], review.reviewee_id)
|> select([review], %{user_id: review.reviewee_id, rating: avg(review.rating)})
rows =
aggregates
|> subquery()
|> join(:left, [aggregate], rating in subquery(ratings),
on: rating.user_id == aggregate.user_id
)
|> select([aggregate, rating], %{
user_id: aggregate.user_id,
completed: aggregate.completed,
unique_people: aggregate.unique_people,
verified_handovers: aggregate.verified_handovers,
location_supported: aggregate.location_supported,
rating: rating.rating
})
|> Repo.all()
Enum.reduce(rows, Map.new(user_ids, &{&1, @empty_reputation}), fn row, reputations ->
Map.put(
reputations,
row.user_id,
row
|> Map.delete(:user_id)
|> Map.update!(:rating, &decimal_average/1)
)
end)
end
def leaderboard do
paginate_leaderboard().entries
end
def paginate_leaderboard(options \\ []) do
limit = Pagination.limit(options)
cursor = decode_leaderboard_cursor(Keyword.get(options, :after))
rows = leaderboard_rows(cursor, limit)
page_rows = Enum.take(rows, limit)
users_by_id = users_by_id(Enum.map(page_rows, & &1.helper_id))
entries =
Enum.map(page_rows, fn row ->
%{
user: Map.fetch!(users_by_id, row.helper_id),
completed: row.completed,
unique_people: row.unique_people,
location_supported_people: row.location_supported_people,
verified_people: row.verified_people,
rating: decimal_average(row.rating)
}
end)
next_cursor =
if length(rows) > limit do
page_rows
|> List.last()
|> encode_leaderboard_cursor()
end
%Pagination.Page{entries: entries, next_cursor: next_cursor}
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(),
{:ok, _audit} <-
audit(user.id, "report.created", "report", report.id, %{
"reason" => to_string(report.reason)
}) do
{:ok, report}
end
end)
end
end
def list_reports(%Scope{user: user}, status \\ nil) 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)
cursor = Pagination.cursor(options)
Report
|> maybe_status(status)
|> before_report(cursor)
|> order_by([report], desc: report.inserted_at, desc: report.id)
|> limit(^(limit + 1))
|> preload([
:reporter,
:reviewed_by,
request: :requester,
assignment: :helper,
message: :sender,
activity: :creator,
activity_message: :sender
])
|> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
else
%Pagination.Page{}
end
end
def moderate_report(%Scope{user: moderator}, report_id, attrs) do
with {:ok, report_id} <- cast_id(report_id),
true <- 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()
if report do
with {:ok, report} <- report |> Report.moderation_changeset(attrs) |> Repo.update(),
{:ok, _audit} <-
audit(moderator.id, "report.moderated", "report", report.id, %{
"status" => to_string(report.status)
}) do
{:ok, report}
end
else
{: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
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], desc: message.inserted_at, desc: message.id)
|> limit(200)
|> preload(:sender)
|> Repo.all()
|> Enum.reverse()
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], desc: message.inserted_at, desc: message.id)
|> limit(200)
|> preload(:sender)
|> Repo.all()
|> Enum.reverse()
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
false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end
end
def block(%Scope{user: user} = scope, blocked_id) do
with {:ok, blocked_id} <- cast_id(blocked_id),
{:ok, _limit} <- authorize_action(scope, :block),
%User{} <- Repo.get(User, blocked_id) do
result =
Repo.transact(fn ->
with :ok <- lock_user_pair(user.id, blocked_id),
{:ok, block} <-
%Block{}
|> Block.changeset(%{blocker_id: user.id, blocked_id: blocked_id})
|> Repo.insert(),
{:ok, _audit} <- audit(user.id, "user.blocked", "user", blocked_id) do
tracking = clear_pair_tracking(user.id, blocked_id)
activity_ids = pair_activity_ids(user.id, blocked_id)
{:ok, %{block: block, tracking: tracking, activity_ids: activity_ids}}
end
end)
with {:ok, %{block: block, tracking: tracking, activity_ids: activity_ids}} <- result do
Enum.each(tracking.stopped, fn {assignment_id, stopped_user_id} ->
Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub,
"tracking:#{assignment_id}",
{:tracking_stopped, stopped_user_id}
)
end)
Enum.each(tracking.request_ids, &Help.notify_request_updated/1)
Enum.each(activity_ids, &Activities.notify_activity_updated/1)
{:ok, block}
end
else
nil -> {:error, :not_found}
other -> other
end
end
defp pair_activity_ids(first_user_id, second_user_id) do
Activity
|> join(:inner, [activity], participant in Participant,
on: participant.activity_id == activity.id
)
|> where(
[activity, participant],
participant.status == :approved and
((activity.creator_id == ^first_user_id and participant.user_id == ^second_user_id) or
(activity.creator_id == ^second_user_id and participant.user_id == ^first_user_id))
)
|> select([activity], activity.id)
|> distinct(true)
|> Repo.all()
end
def unblock(%Scope{user: user}, blocked_id) do
with {:ok, blocked_id} <- cast_id(blocked_id) do
Repo.transact(fn ->
:ok = lock_user_pair(user.id, blocked_id)
case Repo.get_by(Block, blocker_id: user.id, blocked_id: blocked_id) do
nil ->
{:ok, :already_unblocked}
block ->
with {:ok, _block} <- Repo.delete(block),
{:ok, _audit} <- audit(user.id, "user.unblocked", "user", blocked_id) do
{:ok, :unblocked}
end
end
end)
end
end
def list_blocks(%Scope{user: user}) do
paginate_blocks(%Scope{user: user}).entries
end
def paginate_blocks(%Scope{user: user}, options \\ []) do
limit = Pagination.limit(options)
cursor = Pagination.cursor(options)
public_user = Accounts.public_user_query()
Block
|> where([block], block.blocker_id == ^user.id)
|> before_block(cursor)
|> order_by([block], desc: block.inserted_at, desc: block.id)
|> limit(^(limit + 1))
|> preload([block], blocked: ^public_user)
|> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
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
@doc false
def lock_user_pair(first_user_id, second_user_id) do
User
|> where([user], user.id in ^Enum.sort([first_user_id, second_user_id]))
|> order_by([user], asc: user.id)
|> lock("FOR UPDATE")
|> Repo.all()
:ok
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
paginate_abuse_signals(%Scope{user: user}, status).entries
end
def paginate_abuse_signals(%Scope{user: user}, status \\ :open, options \\ []) do
if Accounts.moderator_authorized?(user) do
limit = Pagination.limit(options)
cursor = Pagination.cursor(options)
AbuseSignal
|> where([signal], signal.status == ^status)
|> before_abuse_signal(cursor)
|> order_by([signal], desc: signal.inserted_at, desc: signal.id)
|> limit(^(limit + 1))
|> preload([:subject, :assignment, :reviewed_by])
|> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
else
%Pagination.Page{}
end
end
def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do
with {:ok, signal_id} <- cast_id(signal_id),
true <- 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()
if signal do
with {:ok, signal} <- signal |> AbuseSignal.moderation_changeset(attrs) |> Repo.update(),
{:ok, _audit} <-
audit(moderator.id, "abuse_signal.moderated", "abuse_signal", signal.id, %{
"status" => to_string(signal.status)
}) do
{:ok, signal}
end
else
{:error, :not_found}
end
end)
else
false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end
end
def hide_request(%Scope{user: moderator}, request_id, reason) do
result =
with {:ok, request_id} <- cast_id(request_id),
true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
request =
HelpRequest
|> where([request], request.id == ^request_id)
|> lock("FOR UPDATE")
|> Repo.one()
if request do
with {:ok, request} <-
request
|> HelpRequest.moderation_changeset(%{
hidden_at: DateTime.utc_now(:second),
hidden_reason: reason
})
|> Repo.update(),
{:ok, _audit} <-
audit(moderator.id, "request.hidden", "request", request.id, %{
"reason" => reason
}) do
{:ok, request}
end
else
{:error, :not_found}
end
end)
else
false -> {:error, :forbidden}
{:error, :not_found} = error -> error
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 =
with {:ok, request_id} <- cast_id(request_id),
true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
request =
HelpRequest
|> where([request], request.id == ^request_id)
|> lock("FOR UPDATE")
|> Repo.one()
if request do
with {:ok, request} <-
request
|> HelpRequest.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
|> Repo.update(),
{:ok, _audit} <-
audit(moderator.id, "request.restored", "request", request.id) do
{:ok, request}
end
else
{:error, :not_found}
end
end)
else
false -> {:error, :forbidden}
{:error, :not_found} = error -> error
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 =
with {:ok, activity_id} <- cast_id(activity_id),
true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
activity =
Activity
|> where([activity], activity.id == ^activity_id)
|> lock("FOR UPDATE")
|> Repo.one()
if activity do
with {:ok, activity} <-
activity
|> Activity.moderation_changeset(%{
hidden_at: DateTime.utc_now(:second),
hidden_reason: reason
})
|> Repo.update(),
{:ok, _audit} <-
audit(moderator.id, "activity.hidden", "activity", activity.id, %{
"reason" => reason
}) do
{:ok, activity}
end
else
{:error, :not_found}
end
end)
else
false -> {:error, :forbidden}
{:error, :not_found} = error -> error
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 =
with {:ok, activity_id} <- cast_id(activity_id),
true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
activity =
Activity
|> where([activity], activity.id == ^activity_id)
|> lock("FOR UPDATE")
|> Repo.one()
if activity do
with {:ok, activity} <-
activity
|> Activity.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
|> Repo.update(),
{:ok, _audit} <-
audit(moderator.id, "activity.restored", "activity", activity.id) do
{:ok, activity}
end
else
{:error, :not_found}
end
end)
else
false -> {:error, :forbidden}
{:error, :not_found} = error -> error
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
result =
Repo.transact(fn ->
with {:ok, %{user: user} = moderation} <-
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
tracking =
if user.moderation_status == :suspended,
do: clear_user_tracking(user.id),
else: %{stopped: [], request_ids: []}
{:ok, Map.put(moderation, :tracking, tracking)}
end
end)
with {:ok, %{user: user, expired_session_tokens: tokens, tracking: tracking}} <-
result do
Enum.each(tokens, fn token ->
token
|> WhoNeedHelpWeb.UserAuth.live_socket_id()
|> WhoNeedHelpWeb.Endpoint.broadcast("disconnect", %{})
end)
Enum.each(tracking.stopped, fn {assignment_id, stopped_user_id} ->
Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub,
"tracking:#{assignment_id}",
{:tracking_stopped, stopped_user_id}
)
end)
Enum.each(tracking.request_ids, &Help.notify_request_updated/1)
{:ok, user}
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 users_by_id([]), do: %{}
defp users_by_id(ids) do
Accounts.public_user_query()
|> where([user], user.id in ^ids)
|> Repo.all()
|> Map.new(&{&1.id, &1})
end
defp leaderboard_rows(cursor, limit) do
stats =
Assignment
|> join(:inner, [assignment], request in HelpRequest,
on: request.id == assignment.request_id
)
|> where([assignment], assignment.status == :completed)
|> group_by([assignment], assignment.helper_id)
|> select([assignment, request], %{
helper_id: assignment.helper_id,
completed: count(assignment.id),
unique_people: count(request.requester_id, :distinct),
location_supported_people:
filter(
count(request.requester_id, :distinct),
not is_nil(assignment.proximity_observed_at) and
not is_nil(assignment.helper_movement_observed_at)
),
verified_people:
filter(
count(request.requester_id, :distinct),
not is_nil(assignment.handover_verified_at)
)
})
ratings =
Review
|> where([review], not is_nil(review.revealed_at))
|> group_by([review], review.reviewee_id)
|> select([review], %{reviewee_id: review.reviewee_id, rating: avg(review.rating)})
stats
|> subquery()
|> join(:inner, [stats], helper in User, on: helper.id == stats.helper_id)
|> join(:left, [stats, _helper], rating in subquery(ratings),
on: rating.reviewee_id == stats.helper_id
)
|> where(^leaderboard_after(cursor))
|> order_by(
[stats, helper],
desc: stats.location_supported_people,
desc: stats.verified_people,
desc: stats.unique_people,
desc: stats.completed,
asc: fragment("coalesce(lower(?), '')", helper.display_name),
asc: helper.id
)
|> limit(^(limit + 1))
|> select([stats, helper, rating], %{
helper_id: helper.id,
completed: stats.completed,
unique_people: stats.unique_people,
location_supported_people: stats.location_supported_people,
verified_people: stats.verified_people,
rating: rating.rating,
normalized_name: fragment("coalesce(lower(?), '')", helper.display_name)
})
|> Repo.all()
end
defp leaderboard_after(nil), do: dynamic(true)
defp leaderboard_after(cursor) do
dynamic(
[stats, helper],
stats.location_supported_people < ^cursor.location_supported_people or
(stats.location_supported_people == ^cursor.location_supported_people and
stats.verified_people < ^cursor.verified_people) or
(stats.location_supported_people == ^cursor.location_supported_people and
stats.verified_people == ^cursor.verified_people and
stats.unique_people < ^cursor.unique_people) or
(stats.location_supported_people == ^cursor.location_supported_people and
stats.verified_people == ^cursor.verified_people and
stats.unique_people == ^cursor.unique_people and stats.completed < ^cursor.completed) or
(stats.location_supported_people == ^cursor.location_supported_people and
stats.verified_people == ^cursor.verified_people and
stats.unique_people == ^cursor.unique_people and stats.completed == ^cursor.completed and
fragment("coalesce(lower(?), '')", helper.display_name) > ^cursor.normalized_name) or
(stats.location_supported_people == ^cursor.location_supported_people and
stats.verified_people == ^cursor.verified_people and
stats.unique_people == ^cursor.unique_people and stats.completed == ^cursor.completed and
fragment("coalesce(lower(?), '')", helper.display_name) == ^cursor.normalized_name and
helper.id > ^cursor.helper_id)
)
end
defp encode_leaderboard_cursor(row) do
%{
"location_supported_people" => row.location_supported_people,
"verified_people" => row.verified_people,
"unique_people" => row.unique_people,
"completed" => row.completed,
"normalized_name" => row.normalized_name,
"helper_id" => row.helper_id
}
|> Jason.encode!()
|> Base.url_encode64(padding: false)
end
defp decode_leaderboard_cursor(value) when value in [nil, ""], do: nil
defp decode_leaderboard_cursor(value) when is_binary(value) do
with {:ok, json} <- Base.url_decode64(value, padding: false),
{:ok,
%{
"location_supported_people" => location_supported_people,
"verified_people" => verified_people,
"unique_people" => unique_people,
"completed" => completed,
"normalized_name" => normalized_name,
"helper_id" => helper_id
}} <- Jason.decode(json),
true <-
Enum.all?(
[location_supported_people, verified_people, unique_people, completed],
&(is_integer(&1) and &1 >= 0)
),
true <- is_binary(normalized_name) and byte_size(normalized_name) <= 80,
{:ok, helper_id} <- Ecto.UUID.cast(helper_id) do
%{
location_supported_people: location_supported_people,
verified_people: verified_people,
unique_people: unique_people,
completed: completed,
normalized_name: normalized_name,
helper_id: helper_id
}
else
_ -> nil
end
end
defp decode_leaderboard_cursor(_value), do: nil
defp decimal_average(nil), do: nil
defp decimal_average(value), do: value |> Decimal.to_float() |> Float.round(1)
defp before_review(query, nil), do: query
defp before_review(query, {inserted_at, id}) do
where(
query,
[review],
review.inserted_at < ^inserted_at or
(review.inserted_at == ^inserted_at and review.id < ^id)
)
end
defp before_report(query, nil), do: query
defp before_report(query, {inserted_at, id}) do
where(
query,
[report],
report.inserted_at < ^inserted_at or
(report.inserted_at == ^inserted_at and report.id < ^id)
)
end
defp before_block(query, nil), do: query
defp before_block(query, {inserted_at, id}) do
where(
query,
[block],
block.inserted_at < ^inserted_at or
(block.inserted_at == ^inserted_at and block.id < ^id)
)
end
defp before_abuse_signal(query, nil), do: query
defp before_abuse_signal(query, {inserted_at, id}) do
where(
query,
[signal],
signal.inserted_at < ^inserted_at or
(signal.inserted_at == ^inserted_at and signal.id < ^id)
)
end
defp authorize_report_target(%Scope{user: user}, %{"request_id" => request_id})
when is_binary(request_id) do
with {:ok, request_id} <- cast_id(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
end
defp authorize_report_target(scope, %{"assignment_id" => assignment_id})
when is_binary(assignment_id) do
with {:ok, assignment_id} <- cast_id(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
end
defp authorize_report_target(scope, %{"message_id" => message_id}) when is_binary(message_id) do
with {:ok, message_id} <- cast_id(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
end
defp authorize_report_target(%Scope{user: user}, %{"activity_id" => activity_id})
when is_binary(activity_id) do
with {:ok, activity_id} <- cast_id(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
end
defp authorize_report_target(
%Scope{user: user},
%{"activity_message_id" => message_id}
)
when is_binary(message_id) do
with {:ok, message_id} <- cast_id(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
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
assignments =
Assignment
|> join(:inner, [assignment], request in HelpRequest,
on: request.id == assignment.request_id
)
|> where(
[assignment, request],
assignment.status in [:accepted, :in_progress] and
((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))
)
|> select([assignment, request], {assignment.id, request.id})
|> Repo.all()
active_assignment_ids = Enum.map(assignments, &elem(&1, 0))
active_sessions =
if active_assignment_ids == [] do
[]
else
TrackingSession
|> where([session], session.active and session.assignment_id in ^active_assignment_ids)
|> select([session], {session.id, session.assignment_id, session.user_id})
|> Repo.all()
end
session_ids = Enum.map(active_sessions, &elem(&1, 0))
if session_ids != [] do
Repo.delete_all(
from position in Position, where: position.tracking_session_id in ^session_ids
)
Repo.update_all(
from(session in TrackingSession, where: session.id in ^session_ids),
set: [
active: false,
ended_at: DateTime.utc_now(:second),
updated_at: DateTime.utc_now(:second)
]
)
end
%{
request_ids: assignments |> Enum.map(&elem(&1, 1)) |> Enum.uniq(),
stopped:
Enum.map(active_sessions, fn {_id, assignment_id, user_id} -> {assignment_id, user_id} end)
}
end
defp clear_user_tracking(user_id) do
active_sessions =
TrackingSession
|> join(:inner, [session], assignment in Assignment,
on: assignment.id == session.assignment_id
)
|> join(:inner, [_session, assignment], request in HelpRequest,
on: request.id == assignment.request_id
)
|> where([session], session.active and session.user_id == ^user_id)
|> select([session, assignment, request], {session.id, assignment.id, request.id})
|> Repo.all()
session_ids = Enum.map(active_sessions, &elem(&1, 0))
if session_ids != [] do
now = DateTime.utc_now(:second)
Repo.delete_all(
from position in Position, where: position.tracking_session_id in ^session_ids
)
Repo.update_all(
from(session in TrackingSession, where: session.id in ^session_ids),
set: [active: false, ended_at: now, updated_at: now]
)
end
%{
stopped:
Enum.map(active_sessions, fn {_session_id, assignment_id, _request_id} ->
{assignment_id, user_id}
end),
request_ids: active_sessions |> Enum.map(&elem(&1, 2)) |> Enum.uniq()
}
end
defp stringify_keys(attrs) do
Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
end
defp cast_id(value) do
case Ecto.UUID.cast(value) do
{:ok, id} -> {:ok, id}
:error -> {:error, :not_found}
end
end
end