diff --git a/config/.env.test b/config/.env.test index 42a1e8552634..953a1d24069e 100644 --- a/config/.env.test +++ b/config/.env.test @@ -19,6 +19,7 @@ HELP_SCOUT_APP_ID=fake_app_id HELP_SCOUT_APP_SECRET=fake_app_secret HELP_SCOUT_SIGNATURE_KEY=fake_signature_key HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM= +PLAIN_CUSTOMER_CARD_TOKEN=fake_plain_token S3_DISABLED=false S3_ACCESS_KEY_ID=minioadmin diff --git a/config/runtime.exs b/config/runtime.exs index 0ecd2d04d558..946fa53cdd93 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -887,6 +887,14 @@ config :plausible, :hcaptcha, nolt_sso_secret = get_var_from_path_or_env(config_dir, "NOLT_SSO_SECRET") config :joken, default_signer: nolt_sso_secret +# Plain customer support integration. +# hmac_secret - used for signing data in the chat widget +# token - used for authenticating customer card requests (Bearer token in Authorization header) +config :plausible, :plain, + app_id: get_var_from_path_or_env(config_dir, "PLAIN_APP_ID"), + hmac_secret: get_var_from_path_or_env(config_dir, "PLAIN_HMAC_SECRET"), + customer_card_token: get_var_from_path_or_env(config_dir, "PLAIN_CUSTOMER_CARD_TOKEN") + config :plausible, Plausible.Sentry.Client, finch_request_opts: [ pool_timeout: get_int_from_path_or_env(config_dir, "SENTRY_FINCH_POOL_TIMEOUT", 5000), diff --git a/extra/lib/plausible/plain_customer_cards.ex b/extra/lib/plausible/plain_customer_cards.ex new file mode 100644 index 000000000000..a882bc01ba8d --- /dev/null +++ b/extra/lib/plausible/plain_customer_cards.ex @@ -0,0 +1,402 @@ +defmodule Plausible.PlainCustomerCards do + @moduledoc """ + Plain customer cards API logic. + + Plain sends a POST request with the customer's email and we respond + with structured card components showing subscription status, plan, etc. + """ + + import Ecto.Query + + alias Plausible.Billing + alias Plausible.Billing.Subscription + alias Plausible.Repo + alias Plausible.Teams + + alias PlausibleWeb.Router.Helpers, as: Routes + + require Plausible.Billing.Subscription.Status + + @spec build_cards(String.t() | nil, [String.t()]) :: [map()] + def build_cards(email, card_keys) do + case get_customer_data(email || "") do + {:ok, details} -> + card = build_card(details) + Enum.map(card_keys, fn _key -> card end) + + {:error, _} -> + Enum.map(card_keys, fn key -> + %{ + key: key, + timeToLiveSeconds: 60, + components: [ + %{componentText: %{text: "Customer not found", textSize: "M", textColor: "MUTED"}} + ] + } + end) + end + end + + @spec get_customer_data(String.t()) :: {:ok, map()} | {:error, any()} + defp get_customer_data(email) do + user = + users_query() + |> where([user: u], u.email == ^email) + |> Repo.one() + + if user do + teams = Teams.Users.owned_teams(user) + + if length(teams) > 1 do + multiple_teams_details(user, teams) + else + single_team_details(user, List.first(teams)) + end + else + {:error, {:user_not_found, email}} + end + end + + defp multiple_teams_details(user, teams) do + teams = + Enum.map(teams, 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 + }} + end + + defp single_team_details(user, nil) do + status_link = + Routes.customer_support_user_url(PlausibleWeb.Endpoint, :show, user.id) + + {:ok, + %{ + multiple_teams?: false, + team_setup?: false, + team_name: nil, + email: user.email, + notes: notes(user, nil), + status_label: status_label(nil, nil), + status_link: status_link, + plan_label: plan_label(nil, nil), + plan_link: plan_link(nil), + sites_count: 0 + }} + end + + defp single_team_details(user, team) do + team = Teams.with_subscription(team) + plan = Billing.Plans.get_subscription_plan(team.subscription) + + status_link = + Routes.customer_support_team_url(PlausibleWeb.Endpoint, :show, team.id) + + {: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, team.subscription), + status_link: status_link, + plan_label: plan_label(team.subscription, plan), + plan_link: plan_link(team.subscription), + sites_count: Teams.owned_sites_count(team) + }} + end + + @spec build_card(map()) :: map() + defp build_card(%{multiple_teams?: true} = details) do + %{ + key: "customer-details", + timeToLiveSeconds: 60, + components: + [ + %{ + componentText: %{ + text: "Multiple teams (#{length(details.teams)})", + textSize: "L", + textColor: "NORMAL" + } + } + ] ++ + Enum.map(details.teams, fn team -> + %{ + componentRow: %{ + rowMainContent: [ + %{componentText: %{text: team.name, textSize: "M", textColor: "NORMAL"}} + ], + rowAsideContent: [ + %{ + componentText: %{ + text: "#{team.sites_count} sites", + textSize: "S", + textColor: "MUTED" + } + } + ] + } + } + end) ++ + [ + %{componentDivider: %{dividerSpacingSize: "M"}}, + %{ + componentLinkButton: %{ + linkButtonLabel: "View user in CRM", + linkButtonUrl: details.user_link + } + } + ] + } + end + + defp build_card(%{multiple_teams?: false} = details) do + components = + [ + %{ + componentRow: %{ + rowMainContent: [ + %{componentText: %{text: "Status", textSize: "S", textColor: "MUTED"}} + ], + rowAsideContent: [ + %{ + componentBadge: %{ + badgeLabel: details.status_label, + badgeColor: status_badge_color(details.status_label) + } + } + ] + } + }, + %{ + componentRow: %{ + rowMainContent: [ + %{componentText: %{text: "Plan", textSize: "S", textColor: "MUTED"}} + ], + rowAsideContent: [ + %{componentText: %{text: details.plan_label, textSize: "S", textColor: "NORMAL"}} + ] + } + }, + %{ + componentRow: %{ + rowMainContent: [ + %{componentText: %{text: "Sites", textSize: "S", textColor: "MUTED"}} + ], + rowAsideContent: [ + %{ + componentText: %{ + text: to_string(details.sites_count), + textSize: "S", + textColor: "NORMAL" + } + } + ] + } + } + ] ++ + if details.team_setup? do + [ + %{ + componentRow: %{ + rowMainContent: [ + %{componentText: %{text: "Team", textSize: "S", textColor: "MUTED"}} + ], + rowAsideContent: [ + %{ + componentText: %{ + text: details.team_name, + textSize: "S", + textColor: "NORMAL" + } + } + ] + } + } + ] + else + [] + end + + components = + if details.notes do + components ++ + [ + %{componentText: %{text: details.notes, textSize: "S", textColor: "MUTED"}} + ] + else + components + end + + components = + components ++ + [ + %{componentDivider: %{dividerSpacingSize: "M"}}, + %{ + componentLinkButton: %{ + linkButtonLabel: "View in CRM", + linkButtonUrl: details.status_link + } + } + ] + + components = + if details.plan_link != "#" do + components ++ + [ + %{ + componentLinkButton: %{ + linkButtonLabel: "Manage in Paddle", + linkButtonUrl: details.plan_link + } + } + ] + else + components + end + + %{ + key: "customer-details", + timeToLiveSeconds: 60, + components: components + } + end + + defp status_badge_color(status_label) do + case status_label do + "Paid" -> "GREEN" + "Trial" -> "GREEN" + "Pending cancellation" -> "YELLOW" + "Paused" -> "YELLOW" + "Expired trial" -> "RED" + "Canceled" -> "RED" + _ -> "GREY" + end + 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 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 notes(user, team) do + notes = + [ + user.notes, + team && team.notes + ] + |> Enum.reject(&is_nil/1) + |> Enum.join("\n") + + if notes != "", do: notes + end +end diff --git a/extra/lib/plausible_web/controllers/plain_controller.ex b/extra/lib/plausible_web/controllers/plain_controller.ex new file mode 100644 index 000000000000..f8d06e63070b --- /dev/null +++ b/extra/lib/plausible_web/controllers/plain_controller.ex @@ -0,0 +1,18 @@ +defmodule PlausibleWeb.PlainController do + use PlausibleWeb, :controller + + alias Plausible.PlainCustomerCards + + def customer_cards(conn, params) do + token = Application.get_env(:plausible, :plain)[:customer_card_token] + auth = get_req_header(conn, "authorization") |> List.first() + + if Plug.Crypto.secure_compare(auth || "", "Bearer #{token}") do + email = get_in(params, ["customer", "email"]) + card_keys = Map.get(params, "cardKeys", ["customer-details"]) + json(conn, %{cards: PlainCustomerCards.build_cards(email, card_keys)}) + else + conn |> put_status(401) |> json(%{error: "Unauthorized"}) + end + end +end diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 157e4bb41498..435e5c6cc979 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -540,6 +540,12 @@ defmodule PlausibleWeb.Router do get "/helpscout/show", HelpScoutController, :show get "/helpscout/search", HelpScoutController, :search end + + scope "/", PlausibleWeb do + pipe_through [:external_api] + + post "/plain/customer-cards", PlainController, :customer_cards + end end scope "/", PlausibleWeb do diff --git a/lib/plausible_web/templates/layout/app.html.heex b/lib/plausible_web/templates/layout/app.html.heex index dc217ba23fa9..12606439c8f4 100644 --- a/lib/plausible_web/templates/layout/app.html.heex +++ b/lib/plausible_web/templates/layout/app.html.heex @@ -59,5 +59,28 @@ <% end %> + <%= case plain_chat_config(assigns[:current_user]) do %> + <% %{app_id: app_id, email: email, email_hash: email_hash, full_name: full_name} -> %> + + <% nil -> %> + <% end %> diff --git a/lib/plausible_web/views/layout_view.ex b/lib/plausible_web/views/layout_view.ex index f224da978726..f11b63430eff 100644 --- a/lib/plausible_web/views/layout_view.ex +++ b/lib/plausible_web/views/layout_view.ex @@ -35,6 +35,23 @@ defmodule PlausibleWeb.LayoutView do end end + def plain_chat_config(nil), do: nil + + def plain_chat_config(user) do + with true <- FunWithFlags.enabled?(:plain_chat, for: user), + config when is_list(config) <- Application.get_env(:plausible, :plain), + app_id when not is_nil(app_id) <- config[:app_id], + secret when not is_nil(secret) <- config[:hmac_secret] do + email_hash = + :crypto.mac(:hmac, :sha256, secret, user.email) + |> Base.encode16(case: :lower) + + %{app_id: app_id, email: user.email, email_hash: email_hash, full_name: user.name} + else + _ -> nil + end + end + def home_dest(conn) do if conn.assigns[:current_user] do "/sites" diff --git a/test/plausible/plain_customer_cards_test.exs b/test/plausible/plain_customer_cards_test.exs new file mode 100644 index 000000000000..7f16bd5677d3 --- /dev/null +++ b/test/plausible/plain_customer_cards_test.exs @@ -0,0 +1,111 @@ +defmodule Plausible.PlainCustomerCardsTest do + use Plausible.DataCase, async: true + + @moduletag :ee_only + + on_ee do + alias Plausible.PlainCustomerCards + + describe "build_cards/2" do + test "returns not found card when user does not exist" do + [card] = PlainCustomerCards.build_cards("notfound@example.com", ["customer-details"]) + + assert card.key == "customer-details" + + texts = text_components(card) + assert "Customer not found" in texts + end + + test "returns not found card for nil email" do + [card] = PlainCustomerCards.build_cards(nil, ["customer-details"]) + + texts = text_components(card) + assert "Customer not found" in texts + end + + test "returns card with status, plan and sites for a known user" do + user = insert(:user, email: "plain.test@example.com") + + [card] = PlainCustomerCards.build_cards(user.email, ["customer-details"]) + + assert card.key == "customer-details" + assert card.timeToLiveSeconds == 60 + + row_labels = row_main_labels(card) + assert "Status" in row_labels + assert "Plan" in row_labels + assert "Sites" in row_labels + end + + test "includes notes when present" do + user = insert(:user, email: "plain.notes@example.com", notes: "VIP customer") + + [card] = PlainCustomerCards.build_cards(user.email, ["customer-details"]) + + assert "VIP customer" in text_components(card) + end + + test "includes View in CRM link button" do + user = insert(:user, email: "plain.link@example.com") + + [card] = PlainCustomerCards.build_cards(user.email, ["customer-details"]) + + assert "View in CRM" in link_button_labels(card) + end + + test "returns one card per requested key" do + user = insert(:user, email: "plain.keys@example.com") + + cards = PlainCustomerCards.build_cards(user.email, ["customer-details", "other-key"]) + + assert length(cards) == 2 + end + + test "returns multiple teams card for user in multiple teams" do + user = new_user(email: "plain.multi@example.com") + other_user = new_user() + _site1 = new_site(owner: user) + _site2 = new_site(owner: other_user) + + team2 = team_of(other_user) + + team2 = + team2 + |> Plausible.Teams.complete_setup() + |> Ecto.Changeset.change(name: "Plain Test Team") + |> Plausible.Repo.update!() + + add_member(team2, user: user, role: :owner) + + [card] = PlainCustomerCards.build_cards(user.email, ["customer-details"]) + + assert Enum.any?(text_components(card), &String.contains?(&1, "Multiple teams")) + assert "View user in CRM" in link_button_labels(card) + end + end + + defp text_components(card) do + Enum.flat_map(card.components, fn + %{componentText: %{text: t}} -> [t] + _ -> [] + end) + end + + defp row_main_labels(card) do + Enum.flat_map(card.components, fn + %{componentRow: %{rowMainContent: content}} -> + Enum.map(content, fn %{componentText: %{text: t}} -> t end) + + _ -> + [] + end) + end + + defp link_button_labels(card) do + Enum.flat_map(card.components, fn + %{componentLinkButton: %{linkButtonLabel: label}} -> [label] + _ -> [] + end) + end + end +end diff --git a/test/plausible_web/controllers/plain_controller_test.exs b/test/plausible_web/controllers/plain_controller_test.exs new file mode 100644 index 000000000000..aec82f479c50 --- /dev/null +++ b/test/plausible_web/controllers/plain_controller_test.exs @@ -0,0 +1,70 @@ +defmodule PlausibleWeb.PlainControllerTest do + use PlausibleWeb.ConnCase, async: true + + @moduletag :ee_only + + on_ee do + @token Application.compile_env(:plausible, :plain)[:customer_card_token] + + defp auth_header(conn) do + put_req_header(conn, "authorization", "Bearer #{@token}") + end + + describe "customer_cards/2" do + test "returns customer card for known user", %{conn: conn} do + user = insert(:user, email: "plain.ctrl@example.com") + + body = %{ + cardKeys: ["customer-details"], + customer: %{id: "c_123", email: user.email, externalId: nil}, + thread: %{id: "t_123", externalId: nil} + } + + conn = conn |> auth_header() |> post("/plain/customer-cards", body) + + assert %{"cards" => [card]} = json_response(conn, 200) + assert card["key"] == "customer-details" + assert is_list(card["components"]) + end + + test "returns not found card for unknown user", %{conn: conn} do + body = %{ + cardKeys: ["customer-details"], + customer: %{id: "c_456", email: "nobody@example.com", externalId: nil}, + thread: %{id: "t_456", externalId: nil} + } + + conn = conn |> auth_header() |> post("/plain/customer-cards", body) + + assert %{"cards" => [card]} = json_response(conn, 200) + assert card["key"] == "customer-details" + + texts = + card["components"] + |> Enum.flat_map(fn + %{"componentText" => %{"text" => t}} -> [t] + _ -> [] + end) + + assert "Customer not found" in texts + end + + test "returns 401 when authorization header is missing", %{conn: conn} do + body = %{cardKeys: ["customer-details"], customer: %{email: "test@example.com"}} + conn = post(conn, "/plain/customer-cards", body) + assert json_response(conn, 401) + end + + test "returns 401 when authorization token is invalid", %{conn: conn} do + body = %{cardKeys: ["customer-details"], customer: %{email: "test@example.com"}} + + conn = + conn + |> put_req_header("authorization", "Bearer wrong-token") + |> post("/plain/customer-cards", body) + + assert json_response(conn, 401) + end + end + end +end