From fea7a5b1519ae88901d2f2eb0991a5ecbfd39d5d Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Tue, 3 Mar 2026 18:59:22 +0200 Subject: [PATCH 1/8] Plain integration --- config/runtime.exs | 4 ++++ .../templates/layout/app.html.heex | 23 +++++++++++++++++++ lib/plausible_web/views/layout_view.ex | 15 ++++++++++++ 3 files changed, 42 insertions(+) diff --git a/config/runtime.exs b/config/runtime.exs index 0ecd2d04d558..0ea4526468c3 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -887,6 +887,10 @@ config :plausible, :hcaptcha, nolt_sso_secret = get_var_from_path_or_env(config_dir, "NOLT_SSO_SECRET") config :joken, default_signer: nolt_sso_secret +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") + 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/lib/plausible_web/templates/layout/app.html.heex b/lib/plausible_web/templates/layout/app.html.heex index dc217ba23fa9..29ab10a5da23 100644 --- a/lib/plausible_web/templates/layout/app.html.heex +++ b/lib/plausible_web/templates/layout/app.html.heex @@ -59,5 +59,28 @@ <% end %> + <%= if (user = assigns[:current_user]) && FunWithFlags.enabled?(:plain_chat, for: user) do %> + <%= if {plain_app_id, plain_secret} = plain_config() do %> + + <% end %> + <% end %> diff --git a/lib/plausible_web/views/layout_view.ex b/lib/plausible_web/views/layout_view.ex index f224da978726..ce935262707f 100644 --- a/lib/plausible_web/views/layout_view.ex +++ b/lib/plausible_web/views/layout_view.ex @@ -35,6 +35,21 @@ defmodule PlausibleWeb.LayoutView do end end + def plain_config() do + case Application.get_env(:plausible, :plain) do + [app_id: app_id, hmac_secret: secret] when is_binary(app_id) and is_binary(secret) -> + {app_id, secret} + + _ -> + nil + end + end + + def plain_email_hash(secret, email) do + :crypto.mac(:hmac, :sha256, secret, email) + |> Base.encode16(case: :lower) + end + def home_dest(conn) do if conn.assigns[:current_user] do "/sites" From bede1569c263fce6491b31ea52490d839e5ccf7b Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Tue, 3 Mar 2026 19:04:32 +0200 Subject: [PATCH 2/8] Remove unnecessary guard --- lib/plausible_web/views/layout_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plausible_web/views/layout_view.ex b/lib/plausible_web/views/layout_view.ex index ce935262707f..c9889840b69d 100644 --- a/lib/plausible_web/views/layout_view.ex +++ b/lib/plausible_web/views/layout_view.ex @@ -37,7 +37,7 @@ defmodule PlausibleWeb.LayoutView do def plain_config() do case Application.get_env(:plausible, :plain) do - [app_id: app_id, hmac_secret: secret] when is_binary(app_id) and is_binary(secret) -> + [app_id: app_id, hmac_secret: secret] -> {app_id, secret} _ -> From b62ba4dc760cfcd13e439c3701927ed0c6af847f Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Tue, 3 Mar 2026 19:54:59 +0200 Subject: [PATCH 3/8] Consolidate plain_config logic --- .../templates/layout/app.html.heex | 14 ++++++------ lib/plausible_web/views/layout_view.ex | 22 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/plausible_web/templates/layout/app.html.heex b/lib/plausible_web/templates/layout/app.html.heex index 29ab10a5da23..12606439c8f4 100644 --- a/lib/plausible_web/templates/layout/app.html.heex +++ b/lib/plausible_web/templates/layout/app.html.heex @@ -59,20 +59,20 @@ <% end %> - <%= if (user = assigns[:current_user]) && FunWithFlags.enabled?(:plain_chat, for: user) do %> - <%= if {plain_app_id, plain_secret} = plain_config() do %> + <%= case plain_chat_config(assigns[:current_user]) do %> + <% %{app_id: app_id, email: email, email_hash: email_hash, full_name: full_name} -> %> - <% end %> + <% nil -> %> <% end %> diff --git a/lib/plausible_web/views/layout_view.ex b/lib/plausible_web/views/layout_view.ex index c9889840b69d..e6c3cde8c745 100644 --- a/lib/plausible_web/views/layout_view.ex +++ b/lib/plausible_web/views/layout_view.ex @@ -35,19 +35,19 @@ defmodule PlausibleWeb.LayoutView do end end - def plain_config() do - case Application.get_env(:plausible, :plain) do - [app_id: app_id, hmac_secret: secret] -> - {app_id, secret} + def plain_chat_config(nil), do: nil - _ -> - nil - end - end + def plain_chat_config(user) do + with true <- FunWithFlags.enabled?(:plain_chat, for: user), + [app_id: app_id, hmac_secret: secret] <- Application.get_env(:plausible, :plain) do + email_hash = + :crypto.mac(:hmac, :sha256, secret, user.email) + |> Base.encode16(case: :lower) - def plain_email_hash(secret, email) do - :crypto.mac(:hmac, :sha256, secret, 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 From 7ebdd97482eb1707f81ba9dd784a153d1a31cab9 Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Wed, 4 Mar 2026 02:07:58 +0200 Subject: [PATCH 4/8] Replicate helpscout customer cards --- config/.env.test | 1 + config/runtime.exs | 6 +- extra/lib/plausible/plain_customer_cards.ex | 388 ++++++++++++++++++ .../controllers/plain_controller.ex | 43 ++ lib/plausible_web/router.ex | 6 + test/plausible/plain_customer_cards_test.exs | 158 +++++++ .../controllers/plain_controller_test.exs | 70 ++++ 7 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 extra/lib/plausible/plain_customer_cards.ex create mode 100644 extra/lib/plausible_web/controllers/plain_controller.ex create mode 100644 test/plausible/plain_customer_cards_test.exs create mode 100644 test/plausible_web/controllers/plain_controller_test.exs diff --git a/config/.env.test b/config/.env.test index 42a1e8552634..00dfe39db395 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_TOKEN=fake_plain_token S3_DISABLED=false S3_ACCESS_KEY_ID=minioadmin diff --git a/config/runtime.exs b/config/runtime.exs index 0ea4526468c3..b7d027b25e6c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -887,9 +887,13 @@ 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") + hmac_secret: get_var_from_path_or_env(config_dir, "PLAIN_HMAC_SECRET"), + token: get_var_from_path_or_env(config_dir, "PLAIN_TOKEN") config :plausible, Plausible.Sentry.Client, finch_request_opts: [ diff --git a/extra/lib/plausible/plain_customer_cards.ex b/extra/lib/plausible/plain_customer_cards.ex new file mode 100644 index 000000000000..10f2365212dc --- /dev/null +++ b/extra/lib/plausible/plain_customer_cards.ex @@ -0,0 +1,388 @@ +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 + + @doc """ + Looks up customer data by email address. + """ + @spec get_customer_data(String.t()) :: {:ok, map()} | {:error, any()} + def get_customer_data(email) do + user = + users_query() + |> where([user: u], u.email == ^email) + |> limit(1) + |> Repo.one() + + if user do + teams = Teams.Users.owned_teams(user) + + 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 + else + {:error, {:user_not_found, email}} + end + end + + @doc """ + Builds Plain card components from customer data. + """ + @spec build_card(map()) :: map() + def 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 + + def 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..9018e4a29ec3 --- /dev/null +++ b/extra/lib/plausible_web/controllers/plain_controller.ex @@ -0,0 +1,43 @@ +defmodule PlausibleWeb.PlainController do + use PlausibleWeb, :controller + + alias Plausible.PlainCustomerCards + + def customer_cards(conn, params) do + token = Application.get_env(:plausible, :plain)[: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"]) + + cards = + case PlainCustomerCards.get_customer_data(email) do + {:ok, details} -> + card = PlainCustomerCards.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 + + json(conn, %{cards: cards}) + 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/test/plausible/plain_customer_cards_test.exs b/test/plausible/plain_customer_cards_test.exs new file mode 100644 index 000000000000..763e383158a7 --- /dev/null +++ b/test/plausible/plain_customer_cards_test.exs @@ -0,0 +1,158 @@ +defmodule Plausible.PlainCustomerCardsTest do + use Plausible.DataCase, async: true + + @moduletag :ee_only + + on_ee do + alias Plausible.PlainCustomerCards + + describe "get_customer_data/1" do + test "returns error when user not found" do + assert {:error, {:user_not_found, "notfound@example.com"}} = + PlainCustomerCards.get_customer_data("notfound@example.com") + end + + test "returns single team details for a user with one team" do + user = insert(:user, email: "plain.test@example.com") + + {:ok, details} = PlainCustomerCards.get_customer_data(user.email) + + assert details.multiple_teams? == false + assert details.email == user.email + end + + test "returns multiple teams details 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) + + {:ok, details} = PlainCustomerCards.get_customer_data(user.email) + + assert details.multiple_teams? == true + assert length(details.teams) == 2 + end + + test "includes notes when present" do + user = insert(:user, email: "plain.notes@example.com", notes: "Important customer") + + {:ok, details} = PlainCustomerCards.get_customer_data(user.email) + + assert details.notes == "Important customer" + end + end + + describe "build_card/1 for single team" do + test "builds card with status, plan, sites, and admin link" do + user = insert(:user, email: "plain.card@example.com") + + {:ok, details} = PlainCustomerCards.get_customer_data(user.email) + card = PlainCustomerCards.build_card(details) + + assert card.key == "customer-details" + assert card.timeToLiveSeconds == 60 + assert is_list(card.components) + + labels = + card.components + |> Enum.flat_map(fn + %{componentRow: %{rowMainContent: content}} -> + Enum.map(content, fn %{componentText: %{text: t}} -> t end) + + _ -> + [] + end) + + assert "Status" in labels + assert "Plan" in labels + assert "Sites" in labels + end + + test "includes notes as text component when present" do + user = insert(:user, email: "plain.card2@example.com", notes: "VIP customer") + + {:ok, details} = PlainCustomerCards.get_customer_data(user.email) + card = PlainCustomerCards.build_card(details) + + texts = + card.components + |> Enum.flat_map(fn + %{componentText: %{text: t}} -> [t] + _ -> [] + end) + + assert "VIP customer" in texts + end + + test "includes View in admin link button" do + user = insert(:user, email: "plain.card3@example.com") + + {:ok, details} = PlainCustomerCards.get_customer_data(user.email) + card = PlainCustomerCards.build_card(details) + + link_buttons = + card.components + |> Enum.flat_map(fn + %{componentLinkButton: btn} -> [btn] + _ -> [] + end) + + labels = Enum.map(link_buttons, & &1.linkButtonLabel) + assert "View in CRM" in labels + end + end + + describe "build_card/1 for multiple teams" do + test "builds card with team list" do + user = new_user(email: "plain.multi2@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 Multi Team") + |> Plausible.Repo.update!() + + add_member(team2, user: user, role: :owner) + + {:ok, details} = PlainCustomerCards.get_customer_data(user.email) + card = PlainCustomerCards.build_card(details) + + assert card.key == "customer-details" + + texts = + card.components + |> Enum.flat_map(fn + %{componentText: %{text: t}} -> [t] + _ -> [] + end) + + assert Enum.any?(texts, &String.contains?(&1, "Multiple teams")) + + link_buttons = + card.components + |> Enum.flat_map(fn + %{componentLinkButton: btn} -> [btn] + _ -> [] + end) + + labels = Enum.map(link_buttons, & &1.linkButtonLabel) + assert "View user in CRM" in labels + 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..2fd6fc3fae71 --- /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)[: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 From 91d2c13c59eb163be8ba97b20c89dd73759fde5c Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Wed, 4 Mar 2026 02:21:04 +0200 Subject: [PATCH 5/8] Consolidate card building logic in service module instead of controller --- extra/lib/plausible/plain_customer_cards.ex | 33 ++-- .../controllers/plain_controller.ex | 27 +-- test/plausible/plain_customer_cards_test.exs | 163 +++++++----------- 3 files changed, 82 insertions(+), 141 deletions(-) diff --git a/extra/lib/plausible/plain_customer_cards.ex b/extra/lib/plausible/plain_customer_cards.ex index 10f2365212dc..09bb7e45a2d8 100644 --- a/extra/lib/plausible/plain_customer_cards.ex +++ b/extra/lib/plausible/plain_customer_cards.ex @@ -17,15 +17,31 @@ defmodule Plausible.PlainCustomerCards do require Plausible.Billing.Subscription.Status - @doc """ - Looks up customer data by email address. - """ + @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()} - def get_customer_data(email) do + defp get_customer_data(email) do user = users_query() |> where([user: u], u.email == ^email) - |> limit(1) |> Repo.one() if user do @@ -103,11 +119,8 @@ defmodule Plausible.PlainCustomerCards do end end - @doc """ - Builds Plain card components from customer data. - """ @spec build_card(map()) :: map() - def build_card(%{multiple_teams?: true} = details) do + defp build_card(%{multiple_teams?: true} = details) do %{ key: "customer-details", timeToLiveSeconds: 60, @@ -151,7 +164,7 @@ defmodule Plausible.PlainCustomerCards do } end - def build_card(%{multiple_teams?: false} = details) do + defp build_card(%{multiple_teams?: false} = details) do components = [ %{ diff --git a/extra/lib/plausible_web/controllers/plain_controller.ex b/extra/lib/plausible_web/controllers/plain_controller.ex index 9018e4a29ec3..504af8608064 100644 --- a/extra/lib/plausible_web/controllers/plain_controller.ex +++ b/extra/lib/plausible_web/controllers/plain_controller.ex @@ -10,32 +10,7 @@ defmodule PlausibleWeb.PlainController do if Plug.Crypto.secure_compare(auth || "", "Bearer #{token}") do email = get_in(params, ["customer", "email"]) card_keys = Map.get(params, "cardKeys", ["customer-details"]) - - cards = - case PlainCustomerCards.get_customer_data(email) do - {:ok, details} -> - card = PlainCustomerCards.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 - - json(conn, %{cards: cards}) + json(conn, %{cards: PlainCustomerCards.build_cards(email, card_keys)}) else conn |> put_status(401) |> json(%{error: "Unauthorized"}) end diff --git a/test/plausible/plain_customer_cards_test.exs b/test/plausible/plain_customer_cards_test.exs index 763e383158a7..7f16bd5677d3 100644 --- a/test/plausible/plain_customer_cards_test.exs +++ b/test/plausible/plain_customer_cards_test.exs @@ -6,115 +6,63 @@ defmodule Plausible.PlainCustomerCardsTest do on_ee do alias Plausible.PlainCustomerCards - describe "get_customer_data/1" do - test "returns error when user not found" do - assert {:error, {:user_not_found, "notfound@example.com"}} = - PlainCustomerCards.get_customer_data("notfound@example.com") - end - - test "returns single team details for a user with one team" do - user = insert(:user, email: "plain.test@example.com") - - {:ok, details} = PlainCustomerCards.get_customer_data(user.email) - - assert details.multiple_teams? == false - assert details.email == user.email - end - - test "returns multiple teams details 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) + 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"]) - {:ok, details} = PlainCustomerCards.get_customer_data(user.email) + assert card.key == "customer-details" - assert details.multiple_teams? == true - assert length(details.teams) == 2 + texts = text_components(card) + assert "Customer not found" in texts end - test "includes notes when present" do - user = insert(:user, email: "plain.notes@example.com", notes: "Important customer") + test "returns not found card for nil email" do + [card] = PlainCustomerCards.build_cards(nil, ["customer-details"]) - {:ok, details} = PlainCustomerCards.get_customer_data(user.email) - - assert details.notes == "Important customer" + texts = text_components(card) + assert "Customer not found" in texts end - end - describe "build_card/1 for single team" do - test "builds card with status, plan, sites, and admin link" do - user = insert(:user, email: "plain.card@example.com") + test "returns card with status, plan and sites for a known user" do + user = insert(:user, email: "plain.test@example.com") - {:ok, details} = PlainCustomerCards.get_customer_data(user.email) - card = PlainCustomerCards.build_card(details) + [card] = PlainCustomerCards.build_cards(user.email, ["customer-details"]) assert card.key == "customer-details" assert card.timeToLiveSeconds == 60 - assert is_list(card.components) - labels = - card.components - |> Enum.flat_map(fn - %{componentRow: %{rowMainContent: content}} -> - Enum.map(content, fn %{componentText: %{text: t}} -> t end) + row_labels = row_main_labels(card) + assert "Status" in row_labels + assert "Plan" in row_labels + assert "Sites" in row_labels + end - _ -> - [] - end) + test "includes notes when present" do + user = insert(:user, email: "plain.notes@example.com", notes: "VIP customer") - assert "Status" in labels - assert "Plan" in labels - assert "Sites" in labels - end + [card] = PlainCustomerCards.build_cards(user.email, ["customer-details"]) - test "includes notes as text component when present" do - user = insert(:user, email: "plain.card2@example.com", notes: "VIP customer") + assert "VIP customer" in text_components(card) + end - {:ok, details} = PlainCustomerCards.get_customer_data(user.email) - card = PlainCustomerCards.build_card(details) + test "includes View in CRM link button" do + user = insert(:user, email: "plain.link@example.com") - texts = - card.components - |> Enum.flat_map(fn - %{componentText: %{text: t}} -> [t] - _ -> [] - end) + [card] = PlainCustomerCards.build_cards(user.email, ["customer-details"]) - assert "VIP customer" in texts + assert "View in CRM" in link_button_labels(card) end - test "includes View in admin link button" do - user = insert(:user, email: "plain.card3@example.com") + test "returns one card per requested key" do + user = insert(:user, email: "plain.keys@example.com") - {:ok, details} = PlainCustomerCards.get_customer_data(user.email) - card = PlainCustomerCards.build_card(details) + cards = PlainCustomerCards.build_cards(user.email, ["customer-details", "other-key"]) - link_buttons = - card.components - |> Enum.flat_map(fn - %{componentLinkButton: btn} -> [btn] - _ -> [] - end) - - labels = Enum.map(link_buttons, & &1.linkButtonLabel) - assert "View in CRM" in labels + assert length(cards) == 2 end - end - describe "build_card/1 for multiple teams" do - test "builds card with team list" do - user = new_user(email: "plain.multi2@example.com") + 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) @@ -124,35 +72,40 @@ defmodule Plausible.PlainCustomerCardsTest do team2 = team2 |> Plausible.Teams.complete_setup() - |> Ecto.Changeset.change(name: "Plain Multi Team") + |> Ecto.Changeset.change(name: "Plain Test Team") |> Plausible.Repo.update!() add_member(team2, user: user, role: :owner) - {:ok, details} = PlainCustomerCards.get_customer_data(user.email) - card = PlainCustomerCards.build_card(details) + [card] = PlainCustomerCards.build_cards(user.email, ["customer-details"]) - assert card.key == "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 - texts = - card.components - |> Enum.flat_map(fn - %{componentText: %{text: t}} -> [t] - _ -> [] - end) + defp text_components(card) do + Enum.flat_map(card.components, fn + %{componentText: %{text: t}} -> [t] + _ -> [] + end) + end - assert Enum.any?(texts, &String.contains?(&1, "Multiple teams")) + defp row_main_labels(card) do + Enum.flat_map(card.components, fn + %{componentRow: %{rowMainContent: content}} -> + Enum.map(content, fn %{componentText: %{text: t}} -> t end) - link_buttons = - card.components - |> Enum.flat_map(fn - %{componentLinkButton: btn} -> [btn] - _ -> [] - end) + _ -> + [] + end) + end - labels = Enum.map(link_buttons, & &1.linkButtonLabel) - assert "View user in CRM" in labels - end + defp link_button_labels(card) do + Enum.flat_map(card.components, fn + %{componentLinkButton: %{linkButtonLabel: label}} -> [label] + _ -> [] + end) end end end From 8d7e64649eeab86517412d4e5a2ff7059c2fab5b Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Wed, 4 Mar 2026 02:27:44 +0200 Subject: [PATCH 6/8] Fix credo --- extra/lib/plausible/plain_customer_cards.ex | 129 ++++++++++---------- 1 file changed, 65 insertions(+), 64 deletions(-) diff --git a/extra/lib/plausible/plain_customer_cards.ex b/extra/lib/plausible/plain_customer_cards.ex index 09bb7e45a2d8..a882bc01ba8d 100644 --- a/extra/lib/plausible/plain_customer_cards.ex +++ b/extra/lib/plausible/plain_customer_cards.ex @@ -48,77 +48,78 @@ defmodule Plausible.PlainCustomerCards do teams = Teams.Users.owned_teams(user) 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 - }} + multiple_teams_details(user, teams) 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) - }} + 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 %{ From 805dc3f8e605ce5386fdb23b939ca95ebb0c653d Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Wed, 4 Mar 2026 14:37:53 +0200 Subject: [PATCH 7/8] Update pattern match now that token is also in config --- lib/plausible_web/views/layout_view.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/plausible_web/views/layout_view.ex b/lib/plausible_web/views/layout_view.ex index e6c3cde8c745..f11b63430eff 100644 --- a/lib/plausible_web/views/layout_view.ex +++ b/lib/plausible_web/views/layout_view.ex @@ -39,7 +39,9 @@ defmodule PlausibleWeb.LayoutView do def plain_chat_config(user) do with true <- FunWithFlags.enabled?(:plain_chat, for: user), - [app_id: app_id, hmac_secret: secret] <- Application.get_env(:plausible, :plain) do + 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) From b4a21a84d2150a4e5f9c4e6ea083c952bb7388bf Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Wed, 4 Mar 2026 16:37:20 +0200 Subject: [PATCH 8/8] Rename PLAIN_TOKEN -> PLAIN_CUSTOMER_CARD_TOKEN --- config/.env.test | 2 +- config/runtime.exs | 2 +- extra/lib/plausible_web/controllers/plain_controller.ex | 2 +- test/plausible_web/controllers/plain_controller_test.exs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/.env.test b/config/.env.test index 00dfe39db395..953a1d24069e 100644 --- a/config/.env.test +++ b/config/.env.test @@ -19,7 +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_TOKEN=fake_plain_token +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 b7d027b25e6c..946fa53cdd93 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -893,7 +893,7 @@ config :joken, default_signer: nolt_sso_secret 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"), - token: get_var_from_path_or_env(config_dir, "PLAIN_TOKEN") + customer_card_token: get_var_from_path_or_env(config_dir, "PLAIN_CUSTOMER_CARD_TOKEN") config :plausible, Plausible.Sentry.Client, finch_request_opts: [ diff --git a/extra/lib/plausible_web/controllers/plain_controller.ex b/extra/lib/plausible_web/controllers/plain_controller.ex index 504af8608064..f8d06e63070b 100644 --- a/extra/lib/plausible_web/controllers/plain_controller.ex +++ b/extra/lib/plausible_web/controllers/plain_controller.ex @@ -4,7 +4,7 @@ defmodule PlausibleWeb.PlainController do alias Plausible.PlainCustomerCards def customer_cards(conn, params) do - token = Application.get_env(:plausible, :plain)[:token] + 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 diff --git a/test/plausible_web/controllers/plain_controller_test.exs b/test/plausible_web/controllers/plain_controller_test.exs index 2fd6fc3fae71..aec82f479c50 100644 --- a/test/plausible_web/controllers/plain_controller_test.exs +++ b/test/plausible_web/controllers/plain_controller_test.exs @@ -4,7 +4,7 @@ defmodule PlausibleWeb.PlainControllerTest do @moduletag :ee_only on_ee do - @token Application.compile_env(:plausible, :plain)[:token] + @token Application.compile_env(:plausible, :plain)[:customer_card_token] defp auth_header(conn) do put_req_header(conn, "authorization", "Bearer #{@token}")