who_need_help/lib/who_need_help/support.ex

773 lines
24 KiB
Elixir

defmodule WhoNeedHelp.Support do
@moduledoc "Public and authenticated support, privacy, and account-lifecycle requests."
import Ecto.Query
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.DataLifecycle
alias WhoNeedHelp.Accounts.{Scope, User}
alias WhoNeedHelp.Pagination
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Support.{ConversationMessage, Notifier, StatusEvent, SupportRequest}
alias WhoNeedHelp.Trust
alias WhoNeedHelp.Trust.RateLimiter
@access_salt "support-request-access"
@confirmation_salt "support-request-confirmation"
@staff_topic "support:staff"
def subscribe_request(id),
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, request_topic(id))
def subscribe_staff,
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @staff_topic)
def change_request(%SupportRequest{} = request, attrs \\ %{}) do
SupportRequest.submission_changeset(request, attrs)
end
def create_request(scope, attrs) when is_map(attrs), do: create_request(scope, attrs, nil)
def create_request(scope, attrs, client_scope) when is_map(attrs) do
user = scope_user(scope)
attrs = normalize_keys(attrs)
contact_email = if user, do: user.email, else: attrs["contact_email"]
attrs = Map.put(attrs, "contact_email", contact_email)
fingerprint = if user, do: nil, else: public_submission_fingerprint(attrs)
status = if user, do: :open, else: :pending_verification
with {:ok, _limits} <-
RateLimiter.check_many(rate_scopes(user, contact_email, client_scope)) do
result =
Repo.transact(fn ->
with {:ok, request} <-
%SupportRequest{
reference: unique_reference("SUP"),
requester_id: user && user.id,
contact_verified_at: user && DateTime.utc_now(:second),
public_submission_fingerprint: fingerprint,
status: status
}
|> SupportRequest.submission_changeset(attrs)
|> Repo.insert(),
{:ok, _audit} <-
Trust.audit(
user && user.id,
"support_request.created",
"support_request",
request.id,
%{
"kind" => to_string(request.kind)
}
),
{:ok, _event} <-
record_status_event(
request,
nil,
request.status,
user && user.id,
:requester
) do
{:ok, request}
end
end)
result
|> resolve_duplicate_submission(fingerprint)
|> notify_created()
|> broadcast_request_update()
end
end
def create_account_deletion_request(%Scope{user: %User{}} = scope, details \\ nil) do
create_request(scope, %{
"kind" => "account_deletion",
"subject" => "Delete my Who Need Help account",
"details" => details || "Please delete my account and associated personal data."
})
end
def list_for_user(%Scope{user: %User{id: user_id}}) do
SupportRequest
|> where([request], request.requester_id == ^user_id)
|> order_by([request], desc: request.inserted_at, desc: request.id)
|> Repo.all()
end
def get_for_user(%Scope{user: %User{id: user_id}}, id) do
with {:ok, id} <- Ecto.UUID.cast(id),
%SupportRequest{} = request <-
Repo.get_by(SupportRequest, id: id, requester_id: user_id) do
{:ok, preload_conversation(request)}
else
_ -> {:error, :not_found}
end
end
def get_by_access_token(id, token) when is_binary(token) do
with {:ok, id} <- Ecto.UUID.cast(id),
{:ok, ^id} <-
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
max_age: case_access_max_age_seconds()
),
%SupportRequest{} = request <- Repo.get(SupportRequest, id) do
if request.contact_verified_at do
{:ok, preload_conversation(request)}
else
verify_legacy_confirmation(request, token)
end
else
_ -> {:error, :not_found}
end
end
def get_by_access_token(_id, _token), do: {:error, :not_found}
def verify_by_confirmation_token(id, token) when is_binary(token) do
with {:ok, id} <- Ecto.UUID.cast(id),
{:ok, ^id} <-
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @confirmation_salt, token,
max_age: contact_verification_max_age_seconds()
),
%SupportRequest{} = request <- Repo.get(SupportRequest, id) do
verify_contact(request)
else
_ -> {:error, :not_found}
end
end
def verify_by_confirmation_token(_id, _token), do: {:error, :not_found}
def get_for_viewer(scope, id, token \\ nil)
def get_for_viewer(%Scope{} = scope, id, token) do
case get_for_user(scope, id) do
{:ok, request} -> {:ok, request}
_error -> get_by_access_token(id, token)
end
end
def get_for_viewer(nil, id, token), do: get_by_access_token(id, token)
def access_token(%SupportRequest{id: id}) do
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id,
max_age: case_access_max_age_seconds()
)
end
def confirmation_token(%SupportRequest{id: id}) do
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @confirmation_salt, id,
max_age: contact_verification_max_age_seconds()
)
end
def status_url(%SupportRequest{} = request) do
token = access_token(request)
WhoNeedHelpWeb.Endpoint.url() <>
"/support/cases/#{request.id}?token=#{URI.encode_www_form(token)}"
end
def confirmation_url(%SupportRequest{} = request) do
token = confirmation_token(request)
WhoNeedHelpWeb.Endpoint.url() <>
"/support/cases/#{request.id}/verify?token=#{URI.encode_www_form(token)}"
end
def paginate_for_staff(%Scope{user: user}, options \\ []) do
if Accounts.authorized?(user, :support_view) do
limit = Pagination.limit(options)
cursor = Pagination.cursor(options)
SupportRequest
|> where([request], not is_nil(request.contact_verified_at))
|> maybe_kind(Keyword.get(options, :kind))
|> maybe_status(Keyword.get(options, :status))
|> maybe_assignee(Keyword.get(options, :assigned_to_id), user.id)
|> maybe_search(Keyword.get(options, :search))
|> before(cursor)
|> order_by([request], desc: request.inserted_at, desc: request.id)
|> limit(^(limit + 1))
|> preload([:requester, :assigned_to])
|> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
else
%Pagination.Page{}
end
end
def get_for_staff(%Scope{user: user}, id) do
with true <- Accounts.authorized?(user, :support_view),
{:ok, id} <- Ecto.UUID.cast(id),
%SupportRequest{} = request <-
SupportRequest
|> where([request], request.id == ^id and not is_nil(request.contact_verified_at))
|> Repo.one() do
{:ok, preload_conversation(request)}
else
false -> {:error, :forbidden}
_ -> {:error, :not_found}
end
end
def moderate(%Scope{user: moderator}, id, attrs) do
with {:ok, id} <- Ecto.UUID.cast(id),
true <- Accounts.authorized?(moderator, :support_manage) do
attrs = normalize_keys(attrs)
response = normalize_message(attrs["response"] || attrs["resolution_note"])
with {:ok, attrs} <- normalize_assignment(attrs, :support_manage) do
attrs =
attrs
|> Map.delete("response")
|> maybe_put_resolution(response)
|> Map.merge(%{
"reviewed_at" => DateTime.utc_now(:second),
"reviewed_by_id" => moderator.id
})
Repo.transact(fn ->
request =
SupportRequest
|> where([request], request.id == ^id and not is_nil(request.contact_verified_at))
|> lock("FOR UPDATE")
|> Repo.one()
if request do
previous_status = request.status
response_to_record =
if response && response != request.resolution_note, do: response
with {:ok, request} <-
request |> SupportRequest.moderation_changeset(attrs) |> Repo.update(),
{:ok, _event} <-
maybe_record_status_event(
request,
previous_status,
request.status,
moderator.id,
:staff
),
{:ok, _message} <-
maybe_record_message(request, moderator.id, :staff, response_to_record),
{:ok, _audit} <-
Trust.audit(
moderator.id,
"support_request.moderated",
"support_request",
request.id,
%{
"status" => to_string(request.status),
"assigned_to_id" => request.assigned_to_id
}
) do
{:ok, preload_conversation(request)}
end
else
{:error, :not_found}
end
end)
|> notify_decision()
|> broadcast_request_update()
end
else
false -> {:error, :forbidden}
_ -> {:error, :not_found}
end
end
def add_requester_message(scope, id, token, attrs) when is_map(attrs) do
with {:ok, request} <- get_for_viewer(scope, id, token),
body when is_binary(body) <- attrs["body"] || attrs[:body],
{:ok, _limit} <-
RateLimiter.check(
:support_message,
rate_scope(scope_user(scope), request.contact_email)
) do
actor_id = requester_actor_id(scope, request)
Repo.transact(fn ->
request =
SupportRequest
|> where([record], record.id == ^request.id)
|> lock("FOR UPDATE")
|> Repo.one()
if request do
previous_status = request.status
next_status =
if previous_status in [:waiting_for_requester, :resolved, :closed],
do: :open,
else: previous_status
with {:ok, message} <-
%ConversationMessage{}
|> ConversationMessage.changeset(%{
support_request_id: request.id,
sender_id: actor_id,
sender_role: :requester,
body: body
})
|> Repo.insert(),
{:ok, request} <- maybe_update_status(request, next_status),
{:ok, _event} <-
maybe_record_status_event(
request,
previous_status,
next_status,
actor_id,
:requester
),
{:ok, _audit} <-
Trust.audit(
actor_id,
"support_request.requester_message_added",
"support_request",
request.id,
%{"status" => to_string(request.status)}
) do
{:ok, {request, message}}
end
else
{:error, :not_found}
end
end)
|> notify_operator_update()
|> then(fn
{:ok, {updated, _message}} -> {:ok, preload_conversation(updated)}
error -> error
end)
|> broadcast_request_update()
else
nil -> {:error, :invalid_message}
{:error, _reason} = error -> error
end
end
def add_requester_message(_scope, _id, _token, _attrs), do: {:error, :invalid_message}
def reopen_request(scope, id, token) do
with {:ok, request} <- get_for_viewer(scope, id, token),
true <- request.status in [:resolved, :closed] do
actor_id = requester_actor_id(scope, request)
Repo.transact(fn ->
request =
SupportRequest
|> where([record], record.id == ^request.id)
|> lock("FOR UPDATE")
|> Repo.one()
if request && request.status in [:resolved, :closed] do
previous_status = request.status
with {:ok, request} <- maybe_update_status(request, :open),
{:ok, _event} <-
record_status_event(request, previous_status, :open, actor_id, :requester),
{:ok, _audit} <-
Trust.audit(
actor_id,
"support_request.reopened",
"support_request",
request.id
) do
{:ok, request}
end
else
{:error, :invalid_status}
end
end)
|> notify_operator_update()
|> then(fn
{:ok, updated} -> {:ok, preload_conversation(updated)}
error -> error
end)
|> broadcast_request_update()
else
false -> {:error, :invalid_status}
{:error, _reason} = error -> error
end
end
def deletion_assessment(%Scope{user: moderator} = scope, id) do
with true <- Accounts.authorized?(moderator, :support_manage),
{:ok, id} <- Ecto.UUID.cast(id),
%SupportRequest{} = request <-
Repo.one(
from request in SupportRequest,
where: request.id == ^id and not is_nil(request.contact_verified_at)
) do
DataLifecycle.deletion_assessment(scope, request)
else
false -> {:error, :forbidden}
_ -> {:error, :not_found}
end
end
defp notify_created({:ok, {request, :pending_verification}}) do
_ = Notifier.deliver_confirmation(request, confirmation_url(request))
{:ok, request}
end
defp notify_created({:ok, {request, :verified}}) do
_ = Notifier.deliver_received(request, status_url(request))
_ = Notifier.deliver_operator_alert(request)
{:ok, request}
end
defp notify_created({:ok, {request, :duplicate}}), do: {:ok, request}
defp notify_created(result), do: result
defp notify_decision({:ok, %SupportRequest{contact_verified_at: nil} = request}),
do: {:ok, request}
defp notify_decision({:ok, request}) do
case Notifier.deliver_decision(request, status_url(request)) do
{:ok, _metadata} ->
request
|> Ecto.Changeset.change(response_sent_at: DateTime.utc_now(:second))
|> Repo.update()
|> then(fn
{:ok, updated} -> {:ok, preload_conversation(updated)}
error -> error
end)
_error ->
{:ok, request}
end
end
defp notify_decision(result), do: result
defp notify_operator_update({:ok, {%SupportRequest{} = request, _message}} = result) do
_ = Notifier.deliver_requester_update(request)
result
end
defp notify_operator_update({:ok, %SupportRequest{} = request} = result) do
_ = Notifier.deliver_requester_update(request)
result
end
defp notify_operator_update(result), do: result
defp broadcast_request_update({:ok, %SupportRequest{} = request} = result) do
event = {:support_request_updated, request.id}
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, request_topic(request.id), event)
if request.contact_verified_at do
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @staff_topic, event)
end
result
end
defp broadcast_request_update(result), do: result
defp request_topic(id), do: "support:request:#{id}"
defp verify_contact(%SupportRequest{contact_verified_at: nil} = request) do
Repo.transact(fn ->
current =
SupportRequest
|> where([record], record.id == ^request.id)
|> lock("FOR UPDATE")
|> Repo.one()
cond do
is_nil(current) ->
{:error, :not_found}
current.contact_verified_at ->
{:ok, {current, :already_verified}}
true ->
previous_status = current.status
verified_at = DateTime.utc_now(:second)
with {:ok, verified} <-
current
|> Ecto.Changeset.change(
contact_verified_at: verified_at,
public_submission_fingerprint: nil,
status: :open
)
|> Repo.update(),
{:ok, _event} <-
maybe_record_status_event(
verified,
previous_status,
:open,
nil,
:requester
),
{:ok, _audit} <-
Trust.audit(
nil,
"support_request.contact_verified",
"support_request",
verified.id
) do
{:ok, {verified, :newly_verified}}
end
end
end)
|> notify_verified_request()
end
defp verify_contact(%SupportRequest{} = request), do: {:ok, request}
defp notify_verified_request({:ok, {request, :newly_verified}}) do
_ = Notifier.deliver_received(request, status_url(request))
_ = Notifier.deliver_operator_alert(request)
event = {:support_request_updated, request.id}
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, request_topic(request.id), event)
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @staff_topic, event)
{:ok, request}
end
defp notify_verified_request({:ok, {request, :already_verified}}), do: {:ok, request}
defp notify_verified_request(result), do: result
defp verify_legacy_confirmation(%SupportRequest{id: id} = request, token) do
case Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
max_age: contact_verification_max_age_seconds()
) do
{:ok, ^id} ->
with {:ok, verified} <- verify_contact(request) do
{:ok, preload_conversation(verified)}
end
_error ->
{:error, :not_found}
end
end
defp contact_verification_max_age_seconds do
Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds)
end
defp case_access_max_age_seconds do
Application.fetch_env!(:who_need_help, :public_case_access_max_age_seconds)
end
defp preload_conversation(%SupportRequest{} = request) do
Repo.preload(
request,
[:assigned_to, conversation_messages: :sender, status_events: :actor],
force: true
)
end
defp requester_actor_id(
%Scope{user: %User{id: user_id}},
%SupportRequest{requester_id: user_id}
),
do: user_id
defp requester_actor_id(_scope, _request), do: nil
defp normalize_message(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
body -> body
end
end
defp normalize_message(_value), do: nil
defp maybe_put_resolution(attrs, nil), do: Map.delete(attrs, "resolution_note")
defp maybe_put_resolution(attrs, response), do: Map.put(attrs, "resolution_note", response)
defp maybe_update_status(%SupportRequest{status: status} = request, status),
do: {:ok, request}
defp maybe_update_status(%SupportRequest{} = request, status) do
request
|> Ecto.Changeset.change(status: status)
|> Repo.update()
end
defp maybe_record_message(_request, _sender_id, _sender_role, nil),
do: {:ok, :not_added}
defp maybe_record_message(request, sender_id, sender_role, body) do
%ConversationMessage{}
|> ConversationMessage.changeset(%{
support_request_id: request.id,
sender_id: sender_id,
sender_role: sender_role,
body: body
})
|> Repo.insert()
end
defp maybe_record_status_event(_request, status, status, _actor_id, _actor_role),
do: {:ok, :unchanged}
defp maybe_record_status_event(request, from_status, to_status, actor_id, actor_role),
do: record_status_event(request, from_status, to_status, actor_id, actor_role)
defp record_status_event(request, from_status, to_status, actor_id, actor_role) do
%StatusEvent{}
|> StatusEvent.changeset(%{
support_request_id: request.id,
actor_id: actor_id,
actor_role: actor_role,
from_status: from_status,
to_status: to_status
})
|> Repo.insert()
end
defp before(query, nil), do: query
defp before(query, {inserted_at, id}) do
where(
query,
[request],
request.inserted_at < ^inserted_at or
(request.inserted_at == ^inserted_at and request.id < ^id)
)
end
defp maybe_kind(query, nil), do: query
defp maybe_kind(query, ""), do: query
defp maybe_kind(query, kind), do: where(query, [request], request.kind == ^kind)
defp maybe_status(query, nil), do: query
defp maybe_status(query, ""), do: query
defp maybe_status(query, status), do: where(query, [request], request.status == ^status)
defp maybe_assignee(query, nil, _current_user_id), do: query
defp maybe_assignee(query, "", _current_user_id), do: query
defp maybe_assignee(query, "unassigned", _current_user_id),
do: where(query, [r], is_nil(r.assigned_to_id))
defp maybe_assignee(query, "mine", current_user_id),
do: where(query, [r], r.assigned_to_id == ^current_user_id)
defp maybe_assignee(query, assignee_id, _current_user_id),
do: where(query, [r], r.assigned_to_id == ^assignee_id)
defp maybe_search(query, value) when value in [nil, ""], do: query
defp maybe_search(query, value) do
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
if String.length(term) < 3 do
where(query, [request], false)
else
pattern = "%#{String.downcase(term)}%"
where(
query,
[request],
fragment(
"lower(coalesce(?, '') || ' ' || coalesce(?, '') || ' ' || coalesce(?, '')) LIKE ?",
request.reference,
request.contact_email,
request.subject,
^pattern
)
)
end
end
defp normalize_assignment(attrs, permission) do
case Map.fetch(attrs, "assigned_to_id") do
:error ->
{:ok, attrs}
{:ok, value} when value in [nil, ""] ->
{:ok, Map.put(attrs, "assigned_to_id", nil)}
{:ok, user_id} ->
if Accounts.authorized_user_id?(user_id, permission),
do: {:ok, attrs},
else: {:error, :invalid_assignee}
end
end
defp rate_scopes(user, contact_email, client_scope) do
[{:support_request, rate_scope(user, contact_email)}]
|> maybe_add_client_rate_scope(client_scope)
end
defp maybe_add_client_rate_scope(scopes, client_scope)
when is_binary(client_scope) and client_scope != "",
do: [{:support_request_ip, client_scope} | scopes]
defp maybe_add_client_rate_scope(scopes, _client_scope), do: scopes
defp rate_scope(%User{id: id}, _email), do: "user:#{id}"
defp rate_scope(nil, email) when is_binary(email),
do: "email:#{String.downcase(String.trim(email))}"
defp rate_scope(nil, _email), do: "missing-email"
defp scope_user(%Scope{user: %User{} = user}), do: user
defp scope_user(_scope), do: nil
defp unique_reference(prefix) do
suffix = 8 |> :crypto.strong_rand_bytes() |> Base.encode32(case: :upper, padding: false)
"#{prefix}-#{suffix}"
end
defp public_submission_fingerprint(attrs) do
["contact_email", "kind", "subject", "details"]
|> Enum.map_join("\u0000", fn key ->
attrs |> Map.get(key, "") |> normalize_fingerprint_part()
end)
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)
end
defp normalize_fingerprint_part(value) do
value
|> to_string()
|> String.trim()
|> String.downcase()
|> String.replace(~r/\s+/u, " ")
end
defp resolve_duplicate_submission({:ok, request}, _fingerprint) do
outcome = if request.contact_verified_at, do: :verified, else: :pending_verification
{:ok, {request, outcome}}
end
defp resolve_duplicate_submission({:error, %Ecto.Changeset{} = changeset} = error, fingerprint)
when is_binary(fingerprint) do
if duplicate_fingerprint_error?(changeset) do
case Repo.get_by(SupportRequest, public_submission_fingerprint: fingerprint) do
%SupportRequest{} = request -> {:ok, {request, :duplicate}}
nil -> error
end
else
error
end
end
defp resolve_duplicate_submission(result, _fingerprint), do: result
defp duplicate_fingerprint_error?(changeset) do
Enum.any?(changeset.errors, fn
{:public_submission_fingerprint, {_message, metadata}} ->
metadata[:constraint] == :unique
_other ->
false
end)
end
defp normalize_keys(attrs), do: Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
end