1526 lines
48 KiB
Elixir
1526 lines
48 KiB
Elixir
defmodule WhoNeedHelp.Trust do
|
|
@moduledoc "Reviews, reports, blocks, reputation, abuse signals, and auditable moderation."
|
|
|
|
# The audit log is intentionally reserved for security-sensitive identity
|
|
# changes and staff/safety/legal decisions. Routine product lifecycle events
|
|
# already have durable domain records and must not duplicate high-volume data
|
|
# here. The allowlist also makes a newly introduced audit action opt-in.
|
|
@retained_audit_actions MapSet.new(~w(
|
|
abuse_signal.moderated
|
|
activity.hidden
|
|
activity.restored
|
|
auth_identity.connected
|
|
auth_identity.disconnected
|
|
category_proposal.approved
|
|
category_proposal.merged
|
|
category_proposal.rejected
|
|
content_removal_notice.moderated
|
|
report.evidence_viewed
|
|
report.moderated
|
|
request.hidden
|
|
request.restored
|
|
social_identity.verified
|
|
support_request.contact_verified
|
|
support_request.moderated
|
|
user.admin_bootstrapped
|
|
user.blocked
|
|
user.moderated
|
|
user.staff_roles_changed
|
|
user.unblocked
|
|
))
|
|
|
|
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.authorized?(user, :moderation_view) do
|
|
limit = Pagination.limit(options)
|
|
cursor = Pagination.cursor(options)
|
|
|
|
Report
|
|
|> maybe_status(status)
|
|
|> maybe_report_reason(Keyword.get(options, :reason))
|
|
|> maybe_report_search(Keyword.get(options, :search))
|
|
|> before_report(cursor)
|
|
|> order_by([report], desc: report.inserted_at, desc: report.id)
|
|
|> limit(^(limit + 1))
|
|
|> preload([
|
|
:reporter,
|
|
:reviewed_by,
|
|
request: :requester,
|
|
assignment: [:helper, :request],
|
|
message: [:sender, assignment: :request],
|
|
activity: :creator,
|
|
activity_message: [:sender, :activity]
|
|
])
|
|
|> 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.authorized?(moderator, :moderation_manage) 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,
|
|
"status" => to_string(report.status)
|
|
}
|
|
})
|
|
|
|
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.authorized?(moderator, :moderation_evidence_view) 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.authorized?(user, :moderation_view) do
|
|
limit = Pagination.limit(options)
|
|
cursor = Pagination.cursor(options)
|
|
|
|
AbuseSignal
|
|
|> maybe_signal_status(status)
|
|
|> maybe_signal_kind(Keyword.get(options, :kind))
|
|
|> maybe_signal_search(Keyword.get(options, :search))
|
|
|> 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.authorized?(moderator, :moderation_manage) 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.authorized?(moderator, :moderation_manage) 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.authorized?(moderator, :moderation_manage) 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.authorized?(moderator, :moderation_manage) 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.authorized?(moderator, :moderation_manage) 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 set_staff_roles(%Scope{user: admin}, user_id, roles) do
|
|
result =
|
|
Repo.transact(fn ->
|
|
with {:ok, %{user: user} = change} <- Accounts.set_staff_roles(admin, user_id, roles),
|
|
{:ok, _audit} <-
|
|
audit(admin.id, "user.staff_roles_changed", "user", user.id, %{
|
|
"roles" => Enum.map(Accounts.loaded_staff_roles(user), &to_string/1)
|
|
}) do
|
|
{:ok, change}
|
|
end
|
|
end)
|
|
|
|
with {:ok, %{user: user, session_tokens: session_tokens}} <- result do
|
|
WhoNeedHelpWeb.UserAuth.disconnect_sessions(session_tokens)
|
|
{:ok, user}
|
|
end
|
|
end
|
|
|
|
def paginate_audit_events(%Scope{user: user}, options \\ []) do
|
|
if Accounts.authorized?(user, :audit_view) do
|
|
limit = Pagination.limit(options)
|
|
cursor = Pagination.cursor(options)
|
|
|
|
AuditEvent
|
|
|> maybe_audit_action(Keyword.get(options, :action))
|
|
|> maybe_audit_actor(Keyword.get(options, :actor_id))
|
|
|> maybe_audit_target_type(Keyword.get(options, :target_type))
|
|
|> maybe_audit_search(Keyword.get(options, :search))
|
|
|> before_audit_event(cursor)
|
|
|> order_by([event], desc: event.inserted_at, desc: event.id)
|
|
|> limit(^(limit + 1))
|
|
|> preload(:actor)
|
|
|> Repo.all()
|
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
|
else
|
|
%Pagination.Page{}
|
|
end
|
|
end
|
|
|
|
def audit(actor_id, action, target_type, target_id, metadata \\ %{}) do
|
|
if retained_audit_action?(action) do
|
|
%AuditEvent{}
|
|
|> AuditEvent.changeset(%{
|
|
actor_id: actor_id,
|
|
action: action,
|
|
target_type: target_type,
|
|
target_id: target_id,
|
|
metadata: metadata
|
|
})
|
|
|> Repo.insert()
|
|
else
|
|
{:ok, :not_audited}
|
|
end
|
|
end
|
|
|
|
def retained_audit_actions, do: @retained_audit_actions |> Enum.sort()
|
|
def retained_audit_action?(action), do: MapSet.member?(@retained_audit_actions, action)
|
|
|
|
defp maybe_audit_action(query, value) when value in [nil, ""], do: query
|
|
defp maybe_audit_action(query, value), do: where(query, [event], event.action == ^value)
|
|
|
|
defp maybe_audit_actor(query, value) when value in [nil, ""], do: query
|
|
|
|
defp maybe_audit_actor(query, value) do
|
|
case cast_id(value) do
|
|
{:ok, actor_id} -> where(query, [event], event.actor_id == ^actor_id)
|
|
_ -> where(query, [event], false)
|
|
end
|
|
end
|
|
|
|
defp maybe_audit_target_type(query, value) when value in [nil, ""], do: query
|
|
|
|
defp maybe_audit_target_type(query, value),
|
|
do: where(query, [event], event.target_type == ^value)
|
|
|
|
defp maybe_audit_search(query, value) when value in [nil, ""], do: query
|
|
|
|
defp maybe_audit_search(query, value) do
|
|
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
|
pattern = "%#{term}%"
|
|
|
|
where(
|
|
query,
|
|
[event],
|
|
ilike(event.action, ^pattern) or ilike(event.target_type, ^pattern) or
|
|
fragment("CAST(? AS text) ILIKE ?", event.target_id, ^pattern)
|
|
)
|
|
end
|
|
|
|
defp before_audit_event(query, nil), do: query
|
|
|
|
defp before_audit_event(query, {inserted_at, id}) do
|
|
where(
|
|
query,
|
|
[event],
|
|
event.inserted_at < ^inserted_at or
|
|
(event.inserted_at == ^inserted_at and event.id < ^id)
|
|
)
|
|
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, ""), do: query
|
|
defp maybe_status(query, status), do: where(query, [report], report.status == ^status)
|
|
|
|
defp maybe_report_reason(query, value) when value in [nil, ""], do: query
|
|
defp maybe_report_reason(query, value), do: where(query, [report], report.reason == ^value)
|
|
|
|
defp maybe_report_search(query, value) when value in [nil, ""], do: query
|
|
|
|
defp maybe_report_search(query, value) do
|
|
pattern = search_pattern(value)
|
|
|
|
query
|
|
|> join(:left, [report], reporter in assoc(report, :reporter))
|
|
|> where(
|
|
[report, reporter],
|
|
ilike(report.details, ^pattern) or ilike(reporter.email, ^pattern) or
|
|
ilike(reporter.display_name, ^pattern) or
|
|
fragment("CAST(? AS text) ILIKE ?", report.id, ^pattern)
|
|
)
|
|
end
|
|
|
|
defp maybe_signal_status(query, value) when value in [nil, ""], do: query
|
|
defp maybe_signal_status(query, value), do: where(query, [signal], signal.status == ^value)
|
|
|
|
defp maybe_signal_kind(query, value) when value in [nil, ""], do: query
|
|
defp maybe_signal_kind(query, value), do: where(query, [signal], signal.kind == ^value)
|
|
|
|
defp maybe_signal_search(query, value) when value in [nil, ""], do: query
|
|
|
|
defp maybe_signal_search(query, value) do
|
|
pattern = search_pattern(value)
|
|
|
|
query
|
|
|> join(:left, [signal], subject in assoc(signal, :subject))
|
|
|> where(
|
|
[signal, subject],
|
|
ilike(subject.email, ^pattern) or ilike(subject.display_name, ^pattern) or
|
|
fragment("CAST(? AS text) ILIKE ?", signal.id, ^pattern)
|
|
)
|
|
end
|
|
|
|
defp search_pattern(value) do
|
|
clean =
|
|
value
|
|
|> to_string()
|
|
|> String.trim()
|
|
|> String.replace("%", "")
|
|
|> String.replace("_", "")
|
|
|
|
"%#{clean}%"
|
|
end
|
|
|
|
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
|