| defmodule Plausible.HelpScout do |
| @moduledoc """ |
| HelpScout callback API logic. |
| """ |
|
|
| import Ecto.Query |
|
|
| alias Plausible.Billing |
| alias Plausible.Billing.Subscription |
| alias Plausible.HelpScout.Vault |
| alias Plausible.Repo |
| alias Plausible.Teams |
|
|
| alias PlausibleWeb.Router.Helpers, as: Routes |
|
|
| require Plausible.Billing.Subscription.Status |
|
|
| @base_api_url "https://api.helpscout.net" |
| @signature_field "X-HelpScout-Signature" |
|
|
| @signature_errors [:missing_signature, :bad_signature] |
|
|
| @excluded_email_domains ["paddle.com"] |
|
|
| @type signature_error() :: unquote(Enum.reduce(@signature_errors, &{:|, [], [&1, &2]})) |
|
|
| def signature_errors(), do: @signature_errors |
|
|
| def excluded_email_domains(), do: @excluded_email_domains |
|
|
| @doc """ |
| Validates signature against secret key configured for the |
| HelpScout application. |
| |
| NOTE: HelpScout signature generation procedure at |
| https://developer.helpscout.com/apps/guides/signature-validation/ |
| fails to mention that it's implicitly dependent on request params |
| order getting preserved. PHP arrays are ordered maps, so they provide |
| this guarantee. Here, on the other hand, we have to determine the original |
| order of the keys directly from the query string and serialize |
| 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, signature_error()} |
| def validate_signature(conn) do |
| params = conn.params |
|
|
| keys = |
| conn.query_string |
| |> String.split("&") |
| |> Enum.map(fn part -> |
| part |> String.split("=") |> List.first() |
| end) |
| |> Enum.reject(&(&1 == @signature_field)) |
|
|
| signature = params[@signature_field] |
|
|
| if is_binary(signature) do |
| signature_key = Keyword.fetch!(config(), :signature_key) |
|
|
| ordered_data = Enum.map(keys, fn key -> {key, params[key]} end) |
| data = Jason.encode!(%Jason.OrderedObject{values: ordered_data}) |
|
|
| calculated = |
| :hmac |
| |> :crypto.mac(:sha, signature_key, data) |
| |> Base.encode64() |
|
|
| if Plug.Crypto.secure_compare(signature, calculated) do |
| :ok |
| else |
| {:error, :bad_signature} |
| end |
| else |
| {:error, :missing_signature} |
| end |
| end |
|
|
| @spec get_details_for_customer(String.t(), String.t()) :: {:ok, map()} | {:error, any()} |
| def get_details_for_customer(customer_id, conversation_id) do |
| with {:ok, emails} <- get_customer_emails(customer_id, conversation_id) do |
| get_details_for_emails(emails, customer_id, conversation_id, nil) |
| end |
| end |
|
|
| @spec get_details_for_emails([String.t()], String.t(), String.t(), String.t() | nil) :: |
| {:ok, map()} | {:error, any()} |
| def get_details_for_emails(emails, customer_id, conversation_id, team_identifier) do |
| with {:ok, user} <- get_user(emails) do |
| set_customer_mapping(customer_id, user.email) |
| set_conversation_mapping(conversation_id, user.email) |
|
|
| teams = Teams.Users.owned_teams(user) |
|
|
| teams = |
| if team_identifier do |
| Enum.filter(teams, &(&1.identifier == team_identifier)) |
| else |
| teams |
| end |
|
|
| if length(teams) > 1 do |
| teams = |
| teams |
| |> Enum.map(fn team -> |
| %{ |
| name: Teams.name(team), |
| identifier: team.identifier, |
| sites_count: Teams.owned_sites_count(team) |
| } |
| end) |
|
|
| user_link = |
| Routes.customer_support_user_url( |
| PlausibleWeb.Endpoint, |
| :show, |
| user.id |
| ) |
|
|
| {:ok, |
| %{ |
| multiple_teams?: true, |
| email: user.email, |
| notes: notes(user, nil), |
| teams: teams, |
| user_link: user_link |
| }} |
| else |
| team = List.first(teams) |
|
|
| {subscription, plan} = |
| if team do |
| team = Teams.with_subscription(team) |
| plan = Billing.Plans.get_subscription_plan(team.subscription) |
| {team.subscription, plan} |
| else |
| {nil, nil} |
| end |
|
|
| status_link = |
| if team do |
| Routes.customer_support_team_url( |
| PlausibleWeb.Endpoint, |
| :show, |
| team.id |
| ) |
| else |
| Routes.customer_support_user_url( |
| PlausibleWeb.Endpoint, |
| :show, |
| user.id |
| ) |
| end |
|
|
| {:ok, |
| %{ |
| multiple_teams?: false, |
| team_setup?: Plausible.Teams.setup?(team), |
| team_name: Plausible.Teams.name(team), |
| email: user.email, |
| notes: notes(user, team), |
| status_label: status_label(team, subscription), |
| status_link: status_link, |
| plan_label: plan_label(subscription, plan), |
| plan_link: plan_link(subscription), |
| sites_count: Teams.owned_sites_count(team) |
| }} |
| end |
| end |
| end |
|
|
| @spec search_users(String.t(), String.t(), String.t()) :: [map()] |
| def search_users(term, customer_id, conversation_id) do |
| clear_mappings(customer_id, conversation_id) |
|
|
| search_term = "%#{term}%" |
|
|
| domain_query = |
| from(s in Plausible.Site.regular(), |
| inner_join: t in assoc(s, :team), |
| inner_join: tm in assoc(t, :team_memberships), |
| where: tm.user_id == parent_as(:user).id and tm.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, sites: s], %{email: u.email, sites_count: count(s.id)}) |
| |> Repo.all() |
| end |
|
|
| defp plan_link(nil), do: "#" |
|
|
| defp plan_link(%{paddle_subscription_id: paddle_id}) do |
| Path.join([ |
| Billing.PaddleApi.vendors_domain(), |
| "/subscriptions/customers/manage/", |
| paddle_id |
| ]) |
| end |
|
|
| defp status_label(team, subscription) do |
| subscription_active? = Billing.Subscriptions.active?(subscription) |
| trial? = Teams.on_trial?(team) |
|
|
| cond do |
| not subscription_active? and not trial? and (is_nil(team) or is_nil(team.trial_expiry_date)) -> |
| "None" |
|
|
| is_nil(subscription) and not trial? -> |
| "Expired trial" |
|
|
| trial? -> |
| "Trial" |
|
|
| subscription.status == Subscription.Status.deleted() -> |
| if subscription_active? do |
| "Pending cancellation" |
| else |
| "Canceled" |
| end |
|
|
| subscription.status == Subscription.Status.paused() -> |
| "Paused" |
|
|
| Teams.locked?(team) -> |
| "Dashboard locked" |
|
|
| subscription_active? -> |
| "Paid" |
| end |
| end |
|
|
| defp plan_label(_, nil) do |
| "None" |
| end |
|
|
| defp plan_label(_, :free_10k) do |
| "Free 10k" |
| end |
|
|
| defp plan_label(subscription, %Billing.Plan{} = plan) do |
| [plan] = Billing.Plans.with_prices([plan]) |
| interval = Billing.Plans.subscription_interval(subscription) |
| quota = PlausibleWeb.AuthView.subscription_quota(subscription, []) |
|
|
| price = |
| cond do |
| interval == "monthly" && plan.monthly_cost -> |
| Billing.format_price(plan.monthly_cost) |
|
|
| interval == "yearly" && plan.yearly_cost -> |
| Billing.format_price(plan.yearly_cost) |
|
|
| true -> |
| "N/A" |
| end |
|
|
| "#{quota} Plan (#{price} #{interval})" |
| end |
|
|
| defp plan_label(subscription, %Billing.EnterprisePlan{} = plan) do |
| quota = PlausibleWeb.AuthView.subscription_quota(subscription, []) |
| price_amount = Billing.Plans.get_price_for(plan, "127.0.0.1") |
|
|
| price = |
| if price_amount do |
| Billing.format_price(price_amount) |
| else |
| "N/A" |
| end |
|
|
| "#{quota} Enterprise Plan (#{price} #{plan.billing_interval})" |
| end |
|
|
| defp get_user(emails) do |
| user = |
| users_query() |
| |> where([user: u], u.email in ^emails) |
| |> limit(1) |
| |> Repo.one() |
|
|
| if user do |
| {:ok, user} |
| else |
| {:error, {:user_not_found, emails}} |
| end |
| end |
|
|
| defp users_query() do |
| from(u in Plausible.Auth.User, |
| as: :user, |
| left_join: tm in assoc(u, :team_memberships), |
| on: tm.role == :owner, |
| as: :team_memberships, |
| left_join: t in assoc(tm, :team), |
| left_join: s in assoc(t, :sites), |
| as: :sites, |
| where: is_nil(s) or not s.consolidated, |
| group_by: u.id, |
| order_by: [desc: count(s.id)] |
| ) |
| end |
|
|
| defp get_customer_emails(customer_id, conversation_id) do |
| get_emails_with_conversation_mapping(conversation_id, customer_id) |
| end |
|
|
| defp get_emails_with_conversation_mapping(conversation_id, customer_id) do |
| case lookup_conversation_mapping(conversation_id) do |
| {:ok, mapped_emails} -> |
| {:ok, mapped_emails} |
|
|
| {:error, :not_found} -> |
| get_emails_with_customer_mapping(customer_id) |
| end |
| end |
|
|
| defp get_emails_with_customer_mapping(customer_id) do |
| |
| |
| |
| case fetch_customer_emails(customer_id) do |
| {:ok, emails} -> |
| case lookup_customer_mapping(customer_id) do |
| {:ok, mapped_emails} -> |
| {:ok, mapped_emails} |
|
|
| {:error, :not_found} -> |
| {:ok, emails} |
| end |
|
|
| {:error, error} when error in [:not_found, :no_emails] -> |
| lookup_customer_mapping(customer_id) |
|
|
| {:error, _} = error -> |
| error |
| end |
| end |
|
|
| defp fetch_customer_emails(customer_id, opts \\ []) do |
| refresh? = Keyword.get(opts, :refresh?, true) |
| token = get_token!() |
|
|
| url = Path.join([@base_api_url, "/v2/customers/", customer_id]) |
|
|
| extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || [] |
| opts = Keyword.merge([auth: {:bearer, token}], extra_opts) |
|
|
| case Req.get(url, opts) do |
| {:ok, %{body: %{"_embedded" => %{"emails" => [_ | _] = emails}}}} -> |
| emails = Enum.map(emails, & &1["value"]) |
|
|
| if Enum.any?(emails, &email_excluded?/1) do |
| {:error, :excluded_email} |
| else |
| {:ok, emails} |
| end |
|
|
| {:ok, %{status: 200}} -> |
| {:error, :no_emails} |
|
|
| {:ok, %{status: 404}} -> |
| {:error, :not_found} |
|
|
| {:ok, %{status: 401}} -> |
| if refresh? do |
| refresh_token!() |
| fetch_customer_emails(customer_id, refresh?: false) |
| else |
| {:error, :auth_failed} |
| end |
|
|
| error -> |
| Sentry.capture_message("Failed to obtain customer data from HelpScout API", |
| extra: %{error: inspect(error), customer_id: customer_id} |
| ) |
|
|
| {:error, :unknown} |
| end |
| end |
|
|
| |
| @doc false |
| def lookup_customer_mapping(customer_id) do |
| email_row = |
| "SELECT email FROM help_scout_mappings WHERE customer_id = $1" |
| |> Repo.query!([customer_id]) |
| |> Map.get(:rows) |
| |> List.first() |
|
|
| case email_row do |
| [email] -> |
| {:ok, [email]} |
|
|
| _ -> |
| {:error, :not_found} |
| end |
| end |
|
|
| |
| @doc false |
| def lookup_conversation_mapping(conversation_id) do |
| email_row = |
| "SELECT email FROM help_scout_mappings WHERE conversation_id = $1" |
| |> Repo.query!([conversation_id]) |
| |> Map.get(:rows) |
| |> List.first() |
|
|
| case email_row do |
| [email] -> |
| {:ok, [email]} |
|
|
| _ -> |
| {:error, :not_found} |
| end |
| end |
|
|
| |
| @doc false |
| def set_customer_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 |
|
|
| |
| @doc false |
| def set_conversation_mapping(conversation_id, email) do |
| now = NaiveDateTime.utc_now(:second) |
|
|
| Repo.insert_all( |
| "help_scout_mappings", |
| [[conversation_id: conversation_id, email: email, inserted_at: now, updated_at: now]], |
| conflict_target: :conversation_id, |
| on_conflict: [set: [email: email, updated_at: now]] |
| ) |
| end |
|
|
| defp email_excluded?(email) when is_binary(email) do |
| case String.split(email, "@") do |
| [_, domain] -> String.trim(domain) in @excluded_email_domains |
| _ -> false |
| end |
| end |
|
|
| defp email_excluded?(_), do: false |
|
|
| defp clear_mappings(customer_id, conversation_id) do |
| Repo.query!( |
| "DELETE FROM help_scout_mappings WHERE customer_id = $1 or conversation_id = $2", |
| [customer_id, conversation_id] |
| ) |
| end |
|
|
| defp get_token!() do |
| token = |
| "SELECT access_token FROM help_scout_credentials ORDER BY id DESC LIMIT 1" |
| |> Repo.query!() |
| |> Map.get(:rows) |
| |> List.first() |
|
|
| case token do |
| [token] when is_binary(token) -> |
| Vault.decrypt!(token) |
|
|
| _ -> |
| refresh_token!() |
| end |
| end |
|
|
| defp refresh_token!() do |
| url = Path.join(@base_api_url, "/v2/oauth2/token") |
| credentials = config() |
|
|
| params = [ |
| grant_type: "client_credentials", |
| client_id: Keyword.fetch!(credentials, :app_id), |
| client_secret: Keyword.fetch!(credentials, :app_secret) |
| ] |
|
|
| extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || [] |
| opts = Keyword.merge([form: params], extra_opts) |
|
|
| %{status: 200, body: %{"access_token" => token}} = Req.post!(url, opts) |
| now = NaiveDateTime.utc_now(:second) |
|
|
| Repo.insert_all("help_scout_credentials", [ |
| [access_token: Vault.encrypt!(token), inserted_at: now, updated_at: now] |
| ]) |
|
|
| token |
| end |
|
|
| defp config() do |
| Application.fetch_env!(:plausible, __MODULE__) |
| end |
|
|
| defp notes(user, team) do |
| notes = |
| [ |
| user.notes, |
| team && team.notes |
| ] |
| |> Enum.reject(&is_nil/1) |
| |> Enum.join("\n") |
|
|
| if notes != "", do: notes |
| end |
| end |
|
|