who_need_help/lib/who_need_help/help.ex

746 lines
24 KiB
Elixir

defmodule WhoNeedHelp.Help do
@moduledoc "Authorization-aware request matching and completion workflow."
import Ecto.Query
alias Ecto.Multi
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Catalog
alias WhoNeedHelp.Catalog.Category
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
alias WhoNeedHelp.Pagination
alias WhoNeedHelp.Push
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust
alias WhoNeedHelp.Trust.Block
@topic "help:requests"
def subscribe, do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic)
def subscribe_request(id),
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "help:request:#{id}")
def notify_request_updated(request_id) do
request = get_request!(request_id)
broadcast({:request_updated, request})
:ok
end
def list_open_requests(%Scope{user: user}, filters \\ %{}) do
paginate_open_requests(%Scope{user: user}, filters).entries
end
def paginate_open_requests(%Scope{user: user}, filters \\ %{}, options \\ []) do
now = DateTime.utc_now(:second)
limit = Pagination.limit(options)
cursor = Pagination.cursor(options)
HelpRequest
|> where(
[request],
request.status == :open and request.expires_at > ^now and is_nil(request.hidden_at)
)
|> where(
[request],
request.requester_id not in subquery(
from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id
)
)
|> where(
[request],
request.requester_id not in subquery(
from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id
)
)
|> filter_open_requests(filters)
|> after_open_request(cursor)
|> order_by([request], asc: request.expires_at, asc: request.id)
|> limit(^(limit + 1))
|> Repo.all()
|> preload_request_relations()
|> Pagination.page(limit, &{&1.expires_at, &1.id})
end
def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required")
def visible_open_request?(%Scope{user: user}, %HelpRequest{} = request, filters \\ %{}) do
request.status == :open and is_nil(request.hidden_at) and
DateTime.after?(request.expires_at, DateTime.utc_now(:second)) and
request_filter_matches?(request, filters) and
not Trust.blocked_between?(user.id, request.requester_id)
end
def list_my_requests(%Scope{user: user}) do
paginate_my_requests(%Scope{user: user}).entries
end
def paginate_my_requests(%Scope{user: user}, options \\ []) do
limit = Pagination.limit(options)
cursor = Pagination.cursor(options)
HelpRequest
|> where([r], r.requester_id == ^user.id)
|> before_my_request(cursor)
|> order_by([request], desc: request.inserted_at, desc: request.id)
|> limit(^(limit + 1))
|> Repo.all()
|> preload_request_relations()
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
end
defp after_open_request(query, nil), do: query
defp after_open_request(query, {expires_at, id}) do
where(
query,
[request],
request.expires_at > ^expires_at or
(request.expires_at == ^expires_at and request.id > ^id)
)
end
defp before_my_request(query, nil), do: query
defp before_my_request(query, {inserted_at, id}) do
where(
query,
[request],
request.inserted_at < ^inserted_at or
(request.inserted_at == ^inserted_at and request.id < ^id)
)
end
def get_request!(id) do
HelpRequest
|> Repo.get!(id)
|> then(&preload_request_relations([&1], social_identities: true))
|> hd()
end
def get_request(%Scope{user: user} = scope, id) do
with {:ok, id} <- Ecto.UUID.cast(id),
%HelpRequest{} = request <- Repo.get(HelpRequest, id) do
request =
[request]
|> preload_request_relations(social_identities: true)
|> hd()
cond do
request.requester_id == user.id ->
{:ok, request}
Accounts.moderator_authorized?(user) ->
{:ok, request}
not is_nil(request.hidden_at) ->
{:error, :not_found}
Trust.blocked_between?(user.id, request.requester_id) ->
{:error, :not_found}
request.assignment && participant?(scope, request.assignment) ->
{:ok, request}
request.status == :open and DateTime.after?(request.expires_at, DateTime.utc_now(:second)) ->
{:ok, request}
true ->
{:error, :not_found}
end
else
_invalid_or_missing -> {:error, :not_found}
end
end
def get_assignment_for_participant(%Scope{} = scope, id) do
with {:ok, id} <- Ecto.UUID.cast(id),
%Assignment{} = assignment <-
Assignment
|> Repo.get(id)
|> Repo.preload(:request),
true <- participant?(scope, assignment) do
{:ok, assignment}
else
false -> {:error, :forbidden}
nil -> {:error, :not_found}
:error -> {:error, :not_found}
end
end
def change_request(%HelpRequest{} = request, attrs \\ %{}) do
request
|> HelpRequest.create_changeset(attrs)
|> validate_structured_data()
end
def create_request(%Scope{user: user}, attrs) do
result =
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :create_request) do
Repo.transact(fn ->
with {:ok, request} <-
%HelpRequest{requester_id: user.id}
|> HelpRequest.create_changeset(attrs)
|> validate_structured_data()
|> Repo.insert(),
{:ok, _audit} <-
Trust.audit(user.id, "request.created", "request", request.id, %{
"urgency" => to_string(request.urgency),
"category_id" => request.category_id
}) do
{:ok, request}
end
end)
end
with {:ok, request} <- result do
broadcast({:request_created, get_request!(request.id)})
{:ok, request}
end
end
def accept_request(%Scope{user: helper}, request_id) do
result =
with {:ok, request_id} <- cast_id(request_id),
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(helper), :accept_request) do
now = DateTime.utc_now(:second)
code = handover_code(request_id)
Repo.transact(fn ->
request =
HelpRequest
|> where([r], r.id == ^request_id)
|> lock("FOR UPDATE")
|> Repo.one()
if request do
:ok = Trust.lock_user_pair(request.requester_id, helper.id)
cond do
request.requester_id == helper.id ->
{:error, :own_request}
not is_nil(request.hidden_at) ->
{:error, :not_open}
Trust.blocked_between?(request.requester_id, helper.id) ->
{:error, :blocked}
request.status != :open ->
{:error, :not_open}
DateTime.compare(request.expires_at, now) != :gt ->
{:error, :expired}
true ->
with {:ok, assignment} <-
%Assignment{}
|> Assignment.changeset(%{
request_id: request.id,
helper_id: helper.id,
accepted_at: now,
handover_code_hash: code_hash(code)
})
|> Repo.insert(),
{:ok, _request} <-
request |> Ecto.Changeset.change(status: :matched) |> Repo.update(),
{:ok, _audit} <-
Trust.audit(helper.id, "request.accepted", "assignment", assignment.id, %{
"request_id" => request.id
}),
{:ok, _push_job} <-
Push.enqueue_request_accepted(
assignment.id,
request.id,
request.requester_id
) do
{:ok, assignment}
end
end
else
{:error, :not_found}
end
end)
else
:error -> {:error, :not_found}
end
case result do
{:ok, assignment} ->
request = get_request!(assignment.request_id)
broadcast({:request_updated, request})
{:ok,
Repo.preload(assignment,
helper: Accounts.public_user_query(),
request: []
)}
other ->
other
end
end
def start_assignment(%Scope{user: user}, assignment_id) do
transition_assignment(user, assignment_id, :start)
end
def confirm_completion(%Scope{user: user}, assignment_id) do
transition_assignment(user, assignment_id, :confirm)
end
def verify_handover(%Scope{user: user}, assignment_id, code) do
with {:ok, assignment_id} <- cast_id(assignment_id),
true <- valid_handover_code?(code),
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :verify_handover) do
Repo.transact(fn ->
with %Assignment{} = assignment <- locked_assignment(assignment_id) do
request = Repo.get!(HelpRequest, assignment.request_id)
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
cond do
user.id != assignment.helper_id ->
{:error, :forbidden}
Trust.blocked_between?(assignment.helper_id, request.requester_id) ->
{:error, :blocked}
assignment.status not in [:accepted, :in_progress] or
not is_nil(assignment.handover_verified_at) ->
{:error, :invalid_transition}
not Plug.Crypto.secure_compare(code_hash(code), assignment.handover_code_hash) ->
{:error, :invalid_code}
true ->
now = DateTime.utc_now(:second)
assignment
|> Assignment.changeset(%{handover_verified_at: now})
|> maybe_complete(request)
|> audit_assignment_transition(user.id, "handover.verified", request.id)
end
else
nil -> {:error, :not_found}
end
end)
else
:error -> {:error, :not_found}
false -> {:error, :invalid_code}
end
|> after_transition()
end
def cancel_request(%Scope{user: user}, request_id) do
with {:ok, request_id} <- cast_id(request_id),
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :cancel_request) do
Repo.transact(fn ->
request =
HelpRequest
|> where([r], r.id == ^request_id)
|> lock("FOR UPDATE")
|> Repo.one()
cond do
is_nil(request) ->
{:error, :not_found}
request.requester_id == user.id and request.status in [:open, :matched] ->
now = DateTime.utc_now(:second)
assignment =
Assignment
|> where([assignment], assignment.request_id == ^request.id)
|> lock("FOR UPDATE")
|> Repo.one()
with {:ok, request} <-
request
|> Ecto.Changeset.change(status: :cancelled, cancelled_at: now)
|> Repo.update(),
{:ok, _assignment} <- cancel_assignment(assignment),
{:ok, _audit} <-
Trust.audit(user.id, "request.cancelled", "request", request.id) do
{:ok, request}
end
true ->
{:error, :forbidden}
end
end)
else
:error -> {:error, :not_found}
end
|> case do
{:ok, request} ->
WhoNeedHelp.Tracking.cleanup_finished_sessions()
request = get_request!(request.id)
broadcast({:request_updated, request})
{:ok, request}
other ->
other
end
end
def withdraw_assignment(%Scope{user: user}, assignment_id) do
with {:ok, assignment_id} <- cast_id(assignment_id),
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :withdraw_assignment) do
Repo.transact(fn ->
with %Assignment{} = assignment <- locked_assignment(assignment_id) do
request = Repo.get!(HelpRequest, assignment.request_id)
if assignment.helper_id == user.id and
assignment.status in [:accepted, :in_progress] do
now = DateTime.utc_now(:second)
with {:ok, assignment} <-
assignment
|> Assignment.changeset(%{status: :cancelled})
|> Repo.update(),
{:ok, _request} <-
request
|> Ecto.Changeset.change(status: :cancelled, cancelled_at: now)
|> Repo.update(),
{:ok, _audit} <-
Trust.audit(user.id, "assignment.withdrawn", "assignment", assignment.id, %{
"request_id" => request.id
}) do
{:ok, assignment}
end
else
{:error, :invalid_transition}
end
else
nil -> {:error, :not_found}
end
end)
else
:error -> {:error, :not_found}
end
|> after_transition()
|> case do
{:ok, _assignment} = result ->
WhoNeedHelp.Tracking.cleanup_finished_sessions()
result
other ->
other
end
end
def participant?(%Scope{user: user}, %Assignment{} = assignment) do
request =
case assignment.request do
%HelpRequest{} = request -> request
_ -> Repo.get!(HelpRequest, assignment.request_id)
end
user.id in [assignment.helper_id, request.requester_id]
end
defp preload_request_relations(requests, options \\ []) do
requests = Repo.preload(requests, [:category, :assignment])
user_ids =
requests
|> Enum.flat_map(fn request ->
[request.requester_id, request.assignment && request.assignment.helper_id]
end)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
users =
Accounts.public_user_query()
|> where([user], user.id in ^user_ids)
|> Repo.all()
|> maybe_preload_social_identities(options)
|> Map.new(&{&1.id, &1})
Enum.map(requests, fn request ->
assignment =
case request.assignment do
%Assignment{} = assignment ->
%{assignment | helper: Map.fetch!(users, assignment.helper_id)}
nil ->
nil
end
request
|> Map.put(:requester, Map.fetch!(users, request.requester_id))
|> Map.put(:assignment, assignment)
|> attach_assignment_request()
end)
end
defp maybe_preload_social_identities(users, options) do
if Keyword.get(options, :social_identities, false) do
Repo.preload(users, social_identities: Accounts.public_social_identity_query())
else
users
end
end
defp attach_assignment_request(%HelpRequest{assignment: %Assignment{} = assignment} = request) do
%{request | assignment: %{assignment | request: request}}
end
defp attach_assignment_request(%HelpRequest{} = request), do: request
def requester?(%Scope{user: user}, %HelpRequest{requester_id: id}), do: user.id == id
def helper?(%Scope{user: user}, %Assignment{helper_id: id}), do: user.id == id
def request_coordinates(%Scope{} = scope, %HelpRequest{} = request) do
owner = request.requester_id == scope.user.id
matched_participant =
not owner and not is_nil(request.assignment) and participant?(scope, request.assignment) and
request.assignment.status in [:accepted, :in_progress] and
not Trust.blocked_between?(scope.user.id, request.requester_id)
if owner or
(matched_participant and request.location_visibility == :exact_for_active_match) do
%Geo.Point{coordinates: {lng, lat}} = request.location
%{latitude: lat, longitude: lng, exact: true}
else
HelpRequest.public_coordinates(request)
end
end
def handover_code(request_id) do
secret = Application.fetch_env!(:who_need_help, :handover_secret)
digest = :crypto.mac(:hmac, :sha256, secret, request_id)
digest
|> :binary.decode_unsigned()
|> rem(1_000_000)
|> Integer.to_string()
|> String.pad_leading(6, "0")
end
defp transition_assignment(user, assignment_id, action) do
with {:ok, assignment_id} <- cast_id(assignment_id),
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), action) do
Repo.transact(fn ->
with %Assignment{} = assignment <- locked_assignment(assignment_id) do
request = Repo.get!(HelpRequest, assignment.request_id)
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
now = DateTime.utc_now(:second)
if Trust.blocked_between?(assignment.helper_id, request.requester_id) do
{:error, :blocked}
else
case {action, assignment.status, user.id} do
{:start, :accepted, helper_id} when helper_id == assignment.helper_id ->
with {:ok, assignment} <-
assignment
|> Assignment.changeset(%{status: :in_progress, started_at: now})
|> Repo.update(),
{:ok, _} <-
request |> Ecto.Changeset.change(status: :in_progress) |> Repo.update(),
{:ok, _audit} <-
Trust.audit(
user.id,
"assignment.started",
"assignment",
assignment.id,
%{"request_id" => request.id}
) do
{:ok, assignment}
end
{:confirm, status, user_id} when status in [:accepted, :in_progress] ->
attrs =
cond do
user_id == assignment.helper_id -> %{helper_confirmed_at: now}
user_id == request.requester_id -> %{requester_confirmed_at: now}
true -> nil
end
if attrs do
assignment
|> Assignment.changeset(attrs)
|> maybe_complete(request)
|> audit_assignment_transition(user.id, "assignment.confirmed", request.id)
else
{:error, :forbidden}
end
_ ->
{:error, :invalid_transition}
end
end
else
nil -> {:error, :not_found}
end
end)
else
:error -> {:error, :not_found}
end
|> after_transition()
end
defp maybe_complete(changeset, request) do
assignment = Ecto.Changeset.apply_changes(changeset)
if assignment.handover_verified_at && assignment.requester_confirmed_at &&
assignment.helper_confirmed_at do
now = DateTime.utc_now(:second)
Multi.new()
|> Multi.update(
:assignment,
Ecto.Changeset.change(changeset, status: :completed, completed_at: now)
)
|> Multi.update(
:request,
Ecto.Changeset.change(request, status: :completed, completed_at: now)
)
|> Repo.transaction()
|> case do
{:ok, %{assignment: assignment}} -> {:ok, assignment}
{:error, _step, reason, _changes} -> {:error, reason}
end
else
Repo.update(changeset)
end
end
defp locked_assignment(id) do
Assignment
|> where([a], a.id == ^id)
|> lock("FOR UPDATE")
|> Repo.one()
end
defp valid_handover_code?(code) when is_binary(code),
do: Regex.match?(~r/^\d{6}$/, code)
defp valid_handover_code?(_code), do: false
defp code_hash(code) when is_binary(code), do: :crypto.hash(:sha256, code)
defp after_transition({:ok, assignment}) do
request = get_request!(assignment.request_id)
broadcast({:request_updated, request})
if assignment.status == :completed, do: Trust.record_completion_signals(assignment)
{:ok,
Repo.preload(
assignment,
[helper: Accounts.public_user_query(), request: []],
force: true
)}
end
defp after_transition(other), do: other
defp audit_assignment_transition({:ok, assignment}, actor_id, action, request_id) do
with {:ok, _audit} <-
Trust.audit(actor_id, action, "assignment", assignment.id, %{
"request_id" => request_id,
"status" => to_string(assignment.status)
}),
{:ok, _completion_audit} <-
maybe_audit_completion(actor_id, assignment, request_id) do
{:ok, assignment}
end
end
defp audit_assignment_transition(other, _actor_id, _action, _request_id), do: other
defp maybe_audit_completion(actor_id, %Assignment{status: :completed} = assignment, request_id) do
Trust.audit(actor_id, "request.completed", "request", request_id, %{
"assignment_id" => assignment.id
})
end
defp maybe_audit_completion(_actor_id, _assignment, _request_id), do: {:ok, :not_completed}
defp cancel_assignment(nil), do: {:ok, :no_assignment}
defp cancel_assignment(assignment) do
assignment
|> Assignment.changeset(%{status: :cancelled})
|> Repo.update()
end
defp broadcast(event) do
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, event)
request =
case event do
{_, %HelpRequest{} = request} -> request
end
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "help:request:#{request.id}", event)
end
defp filter_open_requests(query, filters) do
query
|> maybe_filter(:category_id, filters["category_id"] || filters[:category_id])
|> maybe_filter(:urgency, filters["urgency"] || filters[:urgency])
end
defp request_filter_matches?(request, filters) do
category_id = filters["category_id"] || filters[:category_id]
urgency = filters["urgency"] || filters[:urgency]
(category_id in [nil, ""] or to_string(request.category_id) == to_string(category_id)) and
(urgency in [nil, ""] or to_string(request.urgency) == to_string(urgency))
end
defp maybe_filter(query, _field, value) when value in [nil, ""], do: query
defp maybe_filter(query, :category_id, value) do
case Ecto.UUID.cast(value) do
{:ok, category_id} -> where(query, [request], request.category_id == ^category_id)
:error -> where(query, [request], false)
end
end
defp maybe_filter(query, :urgency, value) do
case normalize_urgency(value) do
{:ok, urgency} -> where(query, [request], request.urgency == ^urgency)
:error -> where(query, [request], false)
end
end
defp normalize_urgency(value) when value in [:now, "now"], do: {:ok, :now}
defp normalize_urgency(value) when value in [:today, "today"], do: {:ok, :today}
defp normalize_urgency(value) when value in [:scheduled, "scheduled"], do: {:ok, :scheduled}
defp normalize_urgency(_value), do: :error
defp cast_id(id) do
case Ecto.UUID.cast(id) do
{:ok, id} -> {:ok, id}
:error -> :error
end
end
defp validate_structured_data(changeset) do
category_id = Ecto.Changeset.get_field(changeset, :category_id)
case category_id && Repo.get(Category, category_id) do
%Category{mode: :help, active: true} = category ->
data = Ecto.Changeset.get_field(changeset, :structured_data)
case Catalog.validate_structured_data(category, data) do
{:ok, clean} ->
Ecto.Changeset.put_change(changeset, :structured_data, clean)
{:error, errors} ->
Ecto.Changeset.add_error(
changeset,
:structured_data,
Enum.join(errors, "; ")
)
end
_ ->
Ecto.Changeset.add_error(changeset, :category_id, "select an active help category")
end
end
end