1033 lines
33 KiB
Elixir
1033 lines
33 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.Mail.{
|
|
SupportConfirmationWorker,
|
|
SupportOperatorAlertWorker,
|
|
SupportUpdateWorker
|
|
}
|
|
|
|
alias WhoNeedHelp.Notifications
|
|
alias WhoNeedHelp.Pagination
|
|
alias WhoNeedHelp.PublicAccess
|
|
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)
|
|
|
|
if is_nil(user) and anonymous_account_lifecycle_kind?(attrs["kind"]) do
|
|
create_anonymous_account_lifecycle_request(attrs, client_scope)
|
|
else
|
|
create_standard_request(user, attrs, client_scope)
|
|
end
|
|
end
|
|
|
|
defp create_standard_request(user, attrs, client_scope) do
|
|
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
|
|
),
|
|
{:ok, _job} <- enqueue_created_email(request) do
|
|
{:ok, request}
|
|
end
|
|
end)
|
|
|
|
result
|
|
|> resolve_duplicate_submission(fingerprint)
|
|
|> broadcast_request_update()
|
|
end
|
|
end
|
|
|
|
defp create_anonymous_account_lifecycle_request(attrs, client_scope) do
|
|
contact_email = attrs["contact_email"]
|
|
|
|
with :ok <- validate_anonymous_account_lifecycle_attrs(attrs),
|
|
{:ok, _limits} <-
|
|
RateLimiter.check_many(rate_scopes(nil, contact_email, client_scope)) do
|
|
case Accounts.get_user_by_normalized_email(contact_email || "") do
|
|
{:ok, user} -> create_pending_account_lifecycle_request(user, attrs)
|
|
{:error, reason} when reason in [:not_found, :ambiguous] -> {:ok, :accepted}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp validate_anonymous_account_lifecycle_attrs(attrs) do
|
|
changeset =
|
|
%SupportRequest{reference: "SUP-VALIDATION"}
|
|
|> SupportRequest.submission_changeset(attrs)
|
|
|
|
if changeset.valid?, do: :ok, else: {:error, changeset}
|
|
end
|
|
|
|
defp create_pending_account_lifecycle_request(%User{} = user, attrs) do
|
|
attrs = Map.put(attrs, "contact_email", user.email)
|
|
kind = account_lifecycle_kind(attrs["kind"])
|
|
|
|
result =
|
|
Repo.transact(fn ->
|
|
case pending_account_lifecycle_request(user.id, kind, lock: true) do
|
|
%SupportRequest{} = request ->
|
|
with {:ok, _job} <- enqueue_created_email(request) do
|
|
{:ok, request}
|
|
end
|
|
|
|
nil ->
|
|
insert_pending_account_lifecycle_request(user, attrs)
|
|
end
|
|
end)
|
|
|
|
result
|
|
|> resolve_pending_account_lifecycle_conflict(user.id, kind)
|
|
|> broadcast_request_update()
|
|
end
|
|
|
|
defp insert_pending_account_lifecycle_request(%User{} = user, attrs) do
|
|
with {:ok, request} <-
|
|
%SupportRequest{
|
|
reference: unique_reference("SUP"),
|
|
requester_id: user.id,
|
|
status: :pending_verification
|
|
}
|
|
|> SupportRequest.submission_changeset(attrs)
|
|
|> Repo.insert(),
|
|
{:ok, _audit} <-
|
|
Trust.audit(
|
|
nil,
|
|
"support_request.created",
|
|
"support_request",
|
|
request.id,
|
|
%{"kind" => to_string(request.kind)}
|
|
),
|
|
{:ok, _event} <-
|
|
record_status_event(request, nil, request.status, nil, :requester),
|
|
{:ok, _job} <- enqueue_created_email(request) do
|
|
{:ok, request}
|
|
end
|
|
end
|
|
|
|
defp pending_account_lifecycle_request(user_id, kind, options \\ []) do
|
|
query =
|
|
from request in SupportRequest,
|
|
where:
|
|
request.requester_id == ^user_id and request.kind == ^kind and
|
|
request.status == :pending_verification and
|
|
is_nil(request.contact_verified_at)
|
|
|
|
query = if Keyword.get(options, :lock, false), do: lock(query, "FOR UPDATE"), else: query
|
|
Repo.one(query)
|
|
end
|
|
|
|
defp resolve_pending_account_lifecycle_conflict(
|
|
{:error, %Ecto.Changeset{} = changeset} = error,
|
|
user_id,
|
|
kind
|
|
) do
|
|
if pending_account_lifecycle_constraint_error?(changeset) do
|
|
case pending_account_lifecycle_request(user_id, kind) do
|
|
%SupportRequest{} = request -> {:ok, request}
|
|
nil -> error
|
|
end
|
|
else
|
|
error
|
|
end
|
|
end
|
|
|
|
defp resolve_pending_account_lifecycle_conflict(result, _user_id, _kind), do: result
|
|
|
|
defp pending_account_lifecycle_constraint_error?(changeset) do
|
|
Enum.any?(changeset.errors, fn
|
|
{_field, {_message, metadata}} ->
|
|
metadata[:constraint_name] ==
|
|
"support_requests_one_pending_account_lifecycle_per_user_index"
|
|
|
|
_other ->
|
|
false
|
|
end)
|
|
end
|
|
|
|
defp anonymous_account_lifecycle_kind?(kind),
|
|
do: kind in ["account_access", "account_deletion"]
|
|
|
|
defp account_lifecycle_kind("account_access"), do: :account_access
|
|
defp account_lifecycle_kind("account_deletion"), do: :account_deletion
|
|
|
|
def create_inbound_request(attrs, external_source, external_id)
|
|
when is_map(attrs) and is_binary(external_source) and is_binary(external_id) do
|
|
attrs = normalize_keys(attrs)
|
|
contact_email = attrs["contact_email"]
|
|
fingerprint = public_submission_fingerprint(attrs)
|
|
|
|
case Repo.get_by(SupportRequest,
|
|
external_source: external_source,
|
|
external_id: external_id
|
|
) do
|
|
%SupportRequest{} = request ->
|
|
{:ok, request, :duplicate}
|
|
|
|
nil ->
|
|
with {:ok, _limit} <-
|
|
RateLimiter.check(:support_request, rate_scope(nil, contact_email)) do
|
|
result = insert_inbound_request(attrs, external_source, external_id)
|
|
|
|
result
|
|
|> resolve_duplicate_inbound_request(external_source, external_id, fingerprint)
|
|
|> broadcast_inbound_request_update()
|
|
end
|
|
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} <-
|
|
PublicAccess.verify(@access_salt, token, max_age: case_access_max_age_seconds()),
|
|
%SupportRequest{contact_verified_at: verified_at} = request
|
|
when not is_nil(verified_at) <- Repo.get(SupportRequest, id) do
|
|
{:ok, preload_conversation(request)}
|
|
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} <-
|
|
PublicAccess.verify(@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
|
|
PublicAccess.sign(@access_salt, id, max_age: case_access_max_age_seconds())
|
|
end
|
|
|
|
def confirmation_token(%SupportRequest{id: id}) do
|
|
PublicAccess.sign(@confirmation_salt, id, max_age: contact_verification_max_age_seconds())
|
|
end
|
|
|
|
def status_url(%SupportRequest{} = request) do
|
|
token = access_token(request)
|
|
|
|
PublicAccess.url("/support/cases/#{request.id}?token=#{URI.encode_www_form(token)}")
|
|
end
|
|
|
|
def confirmation_url(%SupportRequest{} = request) do
|
|
token = confirmation_token(request)
|
|
|
|
PublicAccess.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
|
|
}
|
|
),
|
|
update = %{
|
|
audit_id: audit.id,
|
|
message_added: match?(%ConversationMessage{}, message),
|
|
status_changed: previous_status != request.status
|
|
},
|
|
{:ok, _job} <- maybe_enqueue_support_email(request, update) do
|
|
{:ok, {preload_conversation(request), update}}
|
|
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)
|
|
|> 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)
|
|
|> 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 enqueue_created_email(%SupportRequest{contact_verified_at: nil} = request) do
|
|
request.id
|
|
|> then(&SupportConfirmationWorker.new(%{request_id: &1}))
|
|
|> Oban.insert()
|
|
end
|
|
|
|
defp enqueue_created_email(%SupportRequest{} = request), do: enqueue_operator_alert(request)
|
|
|
|
defp notify_decision({:ok, {%SupportRequest{contact_verified_at: nil} = request, _update}}),
|
|
do: {:ok, request}
|
|
|
|
defp notify_decision({:ok, {%SupportRequest{} = request, update}}) do
|
|
if update.status_changed or update.message_added do
|
|
notify_requester_in_app(request, update)
|
|
end
|
|
|
|
{:ok, request}
|
|
end
|
|
|
|
defp notify_decision(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
|
|
),
|
|
{:ok, _job} <- enqueue_operator_alert(verified) 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
|
|
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 notify_requester_in_app(%SupportRequest{requester_id: nil}, _update), do: :ok
|
|
|
|
defp notify_requester_in_app(%SupportRequest{} = request, update) do
|
|
Notifications.notify_user(request.requester_id, %{
|
|
kind: :support_update,
|
|
title: "Support request updated",
|
|
body: "Open Who Need Help to review the latest update.",
|
|
path: "/support/cases/#{request.id}",
|
|
idempotency_key: "support-request-update:#{update.audit_id}:#{request.requester_id}",
|
|
data: %{
|
|
"kind" => "support_request_updated",
|
|
"request_id" => request.id,
|
|
"status" => to_string(request.status),
|
|
"response_added" => update.message_added
|
|
}
|
|
})
|
|
end
|
|
|
|
defp support_email_needed?(request, update) do
|
|
update.status_changed or (update.message_added and is_nil(request.response_sent_at))
|
|
end
|
|
|
|
defp maybe_enqueue_support_email(request, update) do
|
|
if support_email_needed?(request, update) do
|
|
enqueue_support_email(request)
|
|
else
|
|
{:ok, :not_needed}
|
|
end
|
|
end
|
|
|
|
defp enqueue_support_email(request) do
|
|
request.id
|
|
|> then(&SupportUpdateWorker.new(%{request_id: &1}))
|
|
|> Oban.insert()
|
|
end
|
|
|
|
defp enqueue_operator_alert(request) do
|
|
if Notifier.operator_alerts_enabled?() do
|
|
request.id
|
|
|> then(&SupportOperatorAlertWorker.new(%{request_id: &1}))
|
|
|> Oban.insert()
|
|
else
|
|
{:ok, :disabled}
|
|
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
|
|
{:ok, request}
|
|
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}
|
|
nil -> error
|
|
end
|
|
else
|
|
error
|
|
end
|
|
end
|
|
|
|
defp resolve_duplicate_submission(result, _fingerprint), do: result
|
|
|
|
defp insert_inbound_request(attrs, external_source, external_id) do
|
|
Repo.transact(fn ->
|
|
with {:ok, request} <-
|
|
%SupportRequest{
|
|
reference: unique_reference("SUP"),
|
|
external_source: external_source,
|
|
external_id: external_id,
|
|
public_submission_fingerprint: public_submission_fingerprint(attrs),
|
|
status: :pending_verification
|
|
}
|
|
|> SupportRequest.inbound_changeset(attrs, external_source, external_id)
|
|
|> Repo.insert(),
|
|
{:ok, _audit} <-
|
|
Trust.audit(
|
|
nil,
|
|
"support_request.created",
|
|
"support_request",
|
|
request.id,
|
|
%{
|
|
"kind" => to_string(request.kind),
|
|
"source" => external_source
|
|
}
|
|
),
|
|
{:ok, _event} <-
|
|
record_status_event(request, nil, request.status, nil, :requester),
|
|
{:ok, _job} <- enqueue_created_email(request) do
|
|
{:ok, request}
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp resolve_duplicate_inbound_request({:ok, request}, _source, _id, _fingerprint),
|
|
do: {:ok, request, :created}
|
|
|
|
defp resolve_duplicate_inbound_request(
|
|
{:error, %Ecto.Changeset{} = changeset} = error,
|
|
source,
|
|
id,
|
|
fingerprint
|
|
) do
|
|
cond do
|
|
duplicate_external_identity_error?(changeset) ->
|
|
resolve_existing_external_identity(source, id, error)
|
|
|
|
duplicate_fingerprint_error?(changeset) ->
|
|
attach_external_identity(fingerprint, source, id, error)
|
|
|
|
true ->
|
|
error
|
|
end
|
|
end
|
|
|
|
defp resolve_duplicate_inbound_request(result, _source, _id, _fingerprint), do: result
|
|
|
|
defp resolve_existing_external_identity(source, id, error) do
|
|
case Repo.get_by(SupportRequest, external_source: source, external_id: id) do
|
|
%SupportRequest{} = request -> {:ok, request, :duplicate}
|
|
nil -> error
|
|
end
|
|
end
|
|
|
|
defp attach_external_identity(fingerprint, source, id, error) do
|
|
case Repo.get_by(SupportRequest, public_submission_fingerprint: fingerprint) do
|
|
%SupportRequest{external_source: nil, external_id: nil} = request ->
|
|
request
|
|
|> SupportRequest.external_identity_changeset(source, id)
|
|
|> Repo.update()
|
|
|> case do
|
|
{:ok, updated} ->
|
|
{:ok, updated, :duplicate}
|
|
|
|
{:error, changeset} ->
|
|
if duplicate_external_identity_error?(changeset),
|
|
do: resolve_existing_external_identity(source, id, error),
|
|
else: error
|
|
end
|
|
|
|
%SupportRequest{external_source: ^source, external_id: ^id} = request ->
|
|
{:ok, request, :duplicate}
|
|
|
|
%SupportRequest{} = request ->
|
|
{:ok, request, :duplicate}
|
|
|
|
nil ->
|
|
error
|
|
end
|
|
end
|
|
|
|
defp broadcast_inbound_request_update({:ok, request, :created} = result) do
|
|
_ = broadcast_request_update({:ok, request})
|
|
result
|
|
end
|
|
|
|
defp broadcast_inbound_request_update({:ok, _request, :duplicate} = result), do: result
|
|
|
|
defp broadcast_inbound_request_update(result), 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 duplicate_external_identity_error?(changeset) do
|
|
Enum.any?(changeset.errors, fn
|
|
{field, {_message, metadata}} when field in [:external_source, :external_id] ->
|
|
metadata[:constraint] == :unique and
|
|
metadata[:constraint_name] == "support_requests_external_identity_index"
|
|
|
|
_other ->
|
|
false
|
|
end)
|
|
end
|
|
|
|
defp normalize_keys(attrs), do: Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
|
|
end
|