773 lines
24 KiB
Elixir
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
|