Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to lookup by email and site domain in HelpScout integration #4377

Merged
merged 6 commits into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 107 additions & 9 deletions extra/lib/plausible/help_scout.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ defmodule Plausible.HelpScout do
@base_api_url "https://api.helpscout.net"
@signature_field "X-HelpScout-Signature"

@signature_errors [:missing_signature, :bad_signature]

@type signature_error() :: unquote(Enum.reduce(@signature_errors, &{:|, [], [&1, &2]}))

def signature_errors(), do: @signature_errors

@doc """
Validates signature against secret key configured for the
HelpScout application.
Expand All @@ -30,7 +36,7 @@ defmodule Plausible.HelpScout do
params to JSON using wrapper struct, informing Jason to put the values
in the serialized object in this particular order matching query string.
"""
@spec validate_signature(Plug.Conn.t()) :: :ok | {:error, :missing_signature | :bad_signature}
@spec validate_signature(Plug.Conn.t()) :: :ok | {:error, signature_error()}
def validate_signature(conn) do
params = conn.params

Expand Down Expand Up @@ -65,15 +71,23 @@ defmodule Plausible.HelpScout do
end
end

@spec get_customer_details(String.t()) :: {:ok, map()} | {:error, any()}
def get_customer_details(customer_id) do
with {:ok, emails} <- get_customer_emails(customer_id),
{:ok, user} <- get_user(emails) do
@spec get_details_for_customer(String.t()) :: {:ok, map()} | {:error, any()}
def get_details_for_customer(customer_id) do
with {:ok, emails} <- get_customer_emails(customer_id) do
get_details_for_emails(emails, customer_id)
end
end

@spec get_details_for_emails([String.t()], String.t()) :: {:ok, map()} | {:error, any()}
def get_details_for_emails(emails, customer_id) do
with {:ok, user} <- get_user(emails) do
set_mapping(customer_id, user.email)
user = Plausible.Users.with_subscription(user.id)
plan = Billing.Plans.get_subscription_plan(user.subscription)

{:ok,
%{
email: user.email,
status_label: status_label(user),
status_link:
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id),
Expand All @@ -88,6 +102,30 @@ defmodule Plausible.HelpScout do
end
end

@spec search_users(String.t(), String.t()) :: [map()]
def search_users(term, customer_id) do
clear_mapping(customer_id)

search_term = "%#{term}%"

domain_query =
from(s in Plausible.Site,
inner_join: sm in assoc(s, :memberships),
where: sm.user_id == parent_as(:user).id and sm.role == :owner,
where: ilike(s.domain, ^search_term) or ilike(s.domain_changed_from, ^search_term),
select: 1
)

users_query()
|> where(
[user: u],
like(u.email, ^search_term) or exists(domain_query)
)
|> limit(5)
|> select([user: u, site_membership: sm], %{email: u.email, sites_count: count(sm.id)})
|> Repo.all()
end

defp plan_link(nil), do: "#"

defp plan_link(%{paddle_subscription_id: paddle_id}) do
Expand Down Expand Up @@ -174,17 +212,42 @@ defmodule Plausible.HelpScout do

defp get_user(emails) do
user =
from(u in Plausible.Auth.User, where: u.email in ^emails, limit: 1)
users_query()
|> where([user: u], u.email in ^emails)
|> limit(1)
|> Repo.one()

if user do
{:ok, user}
else
{:error, :not_found}
{:error, {:user_not_found, emails}}
end
end

defp users_query() do
from(u in Plausible.Auth.User,
as: :user,
left_join: sm in assoc(u, :site_memberships),
on: sm.role == :owner,
as: :site_membership,
left_join: s in assoc(sm, :site),
as: :site,
group_by: u.id,
order_by: [desc: count(sm.id)]
)
end

defp get_customer_emails(customer_id) do
case lookup_mapping(customer_id) do
{:ok, email} ->
{:ok, [email]}

{:error, :mapping_not_found} ->
fetch_customer_emails(customer_id)
end
end

defp get_customer_emails(customer_id, opts \\ []) do
defp fetch_customer_emails(customer_id, opts \\ []) do
refresh? = Keyword.get(opts, :refresh?, true)
token = get_token!()

Expand All @@ -206,7 +269,7 @@ defmodule Plausible.HelpScout do
{:ok, %{status: 401}} ->
if refresh? do
refresh_token!()
get_customer_emails(customer_id, refresh?: false)
fetch_customer_emails(customer_id, refresh?: false)
else
{:error, :auth_failed}
end
Expand All @@ -220,6 +283,41 @@ defmodule Plausible.HelpScout do
end
end

# Exposed for testing
@doc false
def lookup_mapping(customer_id) do
email =
"SELECT email FROM help_scout_mappings WHERE customer_id = $1"
|> Repo.query!([customer_id])
|> Map.get(:rows)
|> List.first()

case email do
[email] ->
{:ok, email}

_ ->
{:error, :mapping_not_found}
end
end

# Exposed for testing
@doc false
def set_mapping(customer_id, email) do
now = NaiveDateTime.utc_now(:second)

Repo.insert_all(
"help_scout_mappings",
[[customer_id: customer_id, email: email, inserted_at: now, updated_at: now]],
conflict_target: :customer_id,
on_conflict: [set: [email: email, updated_at: now]]
)
end

defp clear_mapping(customer_id) do
Repo.query!("DELETE FROM help_scout_mappings WHERE customer_id = $1", [customer_id])
end

defp get_token!() do
token =
"SELECT access_token FROM help_scout_credentials ORDER BY id DESC LIMIT 1"
Expand Down
127 changes: 119 additions & 8 deletions extra/lib/plausible_web/controllers/help_scout_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,129 @@ defmodule PlausibleWeb.HelpScoutController do

alias Plausible.HelpScout

def callback(conn, %{"customer-id" => customer_id}) do
conn =
conn
|> delete_resp_header("x-frame-options")
|> put_layout(false)
@conversation_cookie "helpscout_conversation"
@conversation_cookie_seconds 8 * 60 * 60
@signature_errors HelpScout.signature_errors()

plug :make_iframe_friendly

def callback(conn, %{"customer-id" => customer_id, "conversation-id" => conversation_id}) do
assigns = %{conversation_id: conversation_id, customer_id: customer_id}

with :ok <- HelpScout.validate_signature(conn),
{:ok, details} <- HelpScout.get_customer_details(customer_id) do
render(conn, "callback.html", details)
{:ok, details} <- HelpScout.get_details_for_customer(customer_id) do
conn
|> set_cookie(conversation_id)
|> render("callback.html", Map.merge(assigns, details))
else
{:error, {:user_not_found, [email | _]}} ->
conn
|> set_cookie(conversation_id)
|> render("callback.html", Map.merge(assigns, %{error: ":user_not_found", email: email}))

{:error, error} ->
render(conn, "callback.html", error: inspect(error))
conn
|> maybe_set_cookie(error, conversation_id)
|> render("callback.html", Map.put(assigns, :error, inspect(error)))
end
end

def callback(conn, _) do
render(conn, "bad_request.html")
end

def show(
conn,
%{"email" => email, "conversation_id" => conversation_id, "customer_id" => customer_id} =
params
) do
assigns = %{
xhr?: params["xhr"] == "true",
conversation_id: conversation_id,
customer_id: customer_id
}

with :ok <- match_conversation(conn, conversation_id),
{:ok, details} <- HelpScout.get_details_for_emails([email], customer_id) do
render(conn, "callback.html", Map.merge(assigns, details))
else
{:error, :invalid_conversation = error} ->
conn
|> clear_cookie()
|> render("callback.html", Map.put(assigns, :error, inspect(error)))

{:error, error} ->
render(conn, "callback.html", Map.put(assigns, :error, inspect(error)))
end
end

def search(conn, %{
"term" => term,
"conversation_id" => conversation_id,
"customer_id" => customer_id
}) do
assigns = %{
conversation_id: conversation_id,
customer_id: customer_id
}

case match_conversation(conn, conversation_id) do
:ok ->
users = HelpScout.search_users(term, customer_id)
render(conn, "search.html", Map.merge(assigns, %{users: users, term: term}))

{:error, :invalid_conversation = error} ->
conn
|> clear_cookie()
|> render("search.html", Map.put(assigns, :error, inspect(error)))
end
end

defp match_conversation(conn, conversation_id) do
conn = fetch_cookies(conn, encrypted: [@conversation_cookie])
cookie_conversation = conn.cookies[@conversation_cookie][:conversation_id]

if cookie_conversation && conversation_id == cookie_conversation do
:ok
else
{:error, :invalid_conversation}
end
end

defp maybe_set_cookie(conn, error, conversation_id)
when error not in @signature_errors do
set_cookie(conn, conversation_id)
end

defp maybe_set_cookie(conn, _error, _conversation_id) do
clear_cookie(conn)
end

# Exposed for testing
@doc false
def set_cookie(conn, conversation_id) do
put_resp_cookie(conn, @conversation_cookie, %{conversation_id: conversation_id},
domain: PlausibleWeb.Endpoint.host(),
secure: true,
encrypt: true,
max_age: @conversation_cookie_seconds,
same_site: "None"
)
end

defp clear_cookie(conn) do
delete_resp_cookie(conn, @conversation_cookie,
domain: PlausibleWeb.Endpoint.host(),
secure: true,
encrypt: true,
max_age: @conversation_cookie_seconds,
same_site: "None"
)
end

defp make_iframe_friendly(conn, _opts) do
conn
|> delete_resp_header("x-frame-options")
|> put_layout(false)
end
end
Loading
Loading