590 lines
18 KiB
Elixir
590 lines
18 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))
|
|
|> preload([:category, :requester, assignment: :helper])
|
|
|> Repo.all()
|
|
|> Pagination.page(limit, &{&1.expires_at, &1.id})
|
|
end
|
|
|
|
def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required")
|
|
|
|
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))
|
|
|> preload([:category, :requester, assignment: :helper])
|
|
|> Repo.all()
|
|
|> 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)
|
|
|> Repo.preload([
|
|
:category,
|
|
requester: :social_identities,
|
|
assignment: [helper: :social_identities, messages: :sender]
|
|
])
|
|
end
|
|
|
|
def get_request(%Scope{user: user} = scope, id) do
|
|
case Repo.get(HelpRequest, id) do
|
|
nil ->
|
|
{:error, :not_found}
|
|
|
|
request ->
|
|
request = get_request!(request.id)
|
|
|
|
cond do
|
|
Accounts.moderator_authorized?(user) ->
|
|
{:ok, request}
|
|
|
|
request.requester_id == user.id ->
|
|
{:ok, request}
|
|
|
|
request.assignment && participant?(scope, request.assignment) ->
|
|
{:ok, request}
|
|
|
|
not is_nil(request.hidden_at) ->
|
|
{:error, :not_found}
|
|
|
|
Trust.blocked_between?(user.id, request.requester_id) ->
|
|
{:error, :not_found}
|
|
|
|
true ->
|
|
{:ok, request}
|
|
end
|
|
end
|
|
end
|
|
|
|
def get_assignment_for_participant(%Scope{} = scope, id) do
|
|
with {:ok, id} <- Ecto.UUID.cast(id),
|
|
%Assignment{} = assignment <- Repo.get(Assignment, id),
|
|
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
|
|
now = DateTime.utc_now(:second)
|
|
code = handover_code(request_id)
|
|
|
|
result =
|
|
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(helper), :accept_request) do
|
|
Repo.transact(fn ->
|
|
request =
|
|
HelpRequest
|
|
|> where([r], r.id == ^request_id)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.one!()
|
|
|
|
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
|
|
end)
|
|
end
|
|
|
|
case result do
|
|
{:ok, assignment} ->
|
|
request = get_request!(assignment.request_id)
|
|
broadcast({:request_updated, request})
|
|
{:ok, Repo.preload(assignment, [:helper, :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, _limit} <- Trust.authorize_action(Scope.for_user(user), :verify_handover) do
|
|
Repo.transact(fn ->
|
|
assignment = locked_assignment(assignment_id)
|
|
request = Repo.get!(HelpRequest, assignment.request_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
|
|
end)
|
|
end
|
|
|> after_transition()
|
|
end
|
|
|
|
def cancel_request(%Scope{user: user}, request_id) do
|
|
with {: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!()
|
|
|
|
if request.requester_id == user.id and request.status in [:open, :matched] do
|
|
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
|
|
else
|
|
{:error, :forbidden}
|
|
end
|
|
end)
|
|
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, _limit} <- Trust.authorize_action(Scope.for_user(user), :withdraw_assignment) do
|
|
Repo.transact(fn ->
|
|
assignment = locked_assignment(assignment_id)
|
|
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
|
|
end)
|
|
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
|
|
|
|
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
|
|
participant =
|
|
request.requester_id == scope.user.id or
|
|
(not is_nil(request.assignment) and participant?(scope, request.assignment))
|
|
|
|
if participant and request.location_visibility in [:hidden, :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, _limit} <- Trust.authorize_action(Scope.for_user(user), action) do
|
|
Repo.transact(fn ->
|
|
assignment = locked_assignment(assignment_id)
|
|
request = Repo.get!(HelpRequest, assignment.request_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
|
|
end)
|
|
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 code_hash(code), do: :crypto.hash(:sha256, to_string(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, :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 maybe_filter(query, _field, value) when value in [nil, ""], do: query
|
|
|
|
defp maybe_filter(query, field, value),
|
|
do: where(query, [request], field(request, ^field) == ^value)
|
|
|
|
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{} = 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
|
|
|
|
_ ->
|
|
changeset
|
|
end
|
|
end
|
|
end
|