Leon4gr45 commited on
Commit
d12d11b
·
verified ·
1 Parent(s): a57a925

Cleanup files batch 360

Browse files
Files changed (40) hide show
  1. extra/lib/plausible/installation_support/check.ex +0 -61
  2. extra/lib/plausible/installation_support/check_runner.ex +0 -65
  3. extra/lib/plausible/installation_support/checks/detection.ex +0 -162
  4. extra/lib/plausible/installation_support/checks/url.ex +0 -103
  5. extra/lib/plausible/installation_support/checks/verify_installation.ex +0 -189
  6. extra/lib/plausible/installation_support/checks/verify_installation_cache_bust.ex +0 -42
  7. extra/lib/plausible/installation_support/detection/checks.ex +0 -114
  8. extra/lib/plausible/installation_support/detection/diagnostics.ex +0 -108
  9. extra/lib/plausible/installation_support/installation_support.ex +0 -20
  10. extra/lib/plausible/installation_support/result.ex +0 -18
  11. extra/lib/plausible/installation_support/state.ex +0 -40
  12. extra/lib/plausible/installation_support/url.ex +0 -25
  13. extra/lib/plausible/installation_support/verification/checks.ex +0 -100
  14. extra/lib/plausible/installation_support/verification/diagnostics.ex +0 -389
  15. extra/lib/plausible/plugins/api/funnels.ex +0 -61
  16. extra/lib/plausible/site/tracker_script_id_cache.ex +0 -50
  17. extra/lib/plausible/stats/exploration.ex +0 -613
  18. extra/lib/plausible/stats/funnel.ex +0 -171
  19. extra/lib/plausible/stats/goal/revenue.ex +0 -94
  20. extra/lib/plausible/stats/journey/step.ex +0 -53
  21. extra/lib/plausible/stats/sampling.ex +0 -105
  22. extra/lib/plausible/stats/sampling_cache.ex +0 -59
  23. extra/lib/plausible/workers/score_trial_prospects.ex +0 -101
  24. extra/lib/plausible_web/controllers/api/external_sites_controller.ex +0 -626
  25. extra/lib/plausible_web/controllers/api/revenue.ex +0 -27
  26. extra/lib/plausible_web/controllers/help_scout_controller.ex +0 -121
  27. extra/lib/plausible_web/controllers/sso_controller.ex +0 -140
  28. extra/lib/plausible_web/dogfood.ex +0 -96
  29. extra/lib/plausible_web/live/customer_support.ex +0 -74
  30. extra/lib/plausible_web/live/customer_support/components/layout.ex +0 -124
  31. extra/lib/plausible_web/live/customer_support/components/search.ex +0 -125
  32. extra/lib/plausible_web/live/customer_support/components/search_result.ex +0 -105
  33. extra/lib/plausible_web/live/customer_support/live.ex +0 -133
  34. extra/lib/plausible_web/live/customer_support/site.ex +0 -115
  35. extra/lib/plausible_web/live/customer_support/site/components/overview.ex +0 -104
  36. extra/lib/plausible_web/live/customer_support/site/components/people.ex +0 -70
  37. extra/lib/plausible_web/live/customer_support/site/components/rescue_zone.ex +0 -89
  38. extra/lib/plausible_web/live/customer_support/team.ex +0 -381
  39. extra/lib/plausible_web/live/customer_support/team/components/audit.ex +0 -245
  40. extra/lib/plausible_web/live/customer_support/team/components/billing.ex +0 -461
extra/lib/plausible/installation_support/check.ex DELETED
@@ -1,61 +0,0 @@
1
- defmodule Plausible.InstallationSupport.Check do
2
- @moduledoc """
3
- Behaviour to be implemented by a specific installation support check.
4
-
5
- `report_progress_as()` doesn't necessarily reflect the actual check
6
- description, it serves as a user-facing message grouping mechanism,
7
- to prevent frequent message flashing when checks rotate often.
8
-
9
- Each check operates on `%Plausible.InstallationSupport.State{}` and is
10
- expected to return it, optionally modified, by all means. `perform_safe/1`
11
- is used to guarantee no exceptions are thrown by faulty implementations,
12
- not to interrupt LiveView.
13
- """
14
- @type state() :: Plausible.InstallationSupport.State.t()
15
- @callback report_progress_as() :: String.t()
16
- @callback perform(state(), Keyword.t()) :: state()
17
-
18
- defmacro __using__(_) do
19
- quote do
20
- import Plausible.InstallationSupport.State
21
- alias Plausible.InstallationSupport.State
22
-
23
- require Logger
24
-
25
- @behaviour Plausible.InstallationSupport.Check
26
-
27
- def perform_safe(state, opts) do
28
- timeout = Keyword.get(opts, :timeout, 10_000)
29
-
30
- task =
31
- Task.async(fn ->
32
- try do
33
- perform(state, opts)
34
- catch
35
- _, e ->
36
- Logger.error(
37
- "Error running check #{inspect(__MODULE__)} on #{state.url}: #{inspect(e)}"
38
- )
39
-
40
- put_diagnostics(state, service_error: %{code: :internal_check_error, extra: e})
41
- end
42
- end)
43
-
44
- try do
45
- Task.await(task, timeout)
46
- catch
47
- :exit, {:timeout, _} ->
48
- Task.shutdown(task, :brutal_kill)
49
- check_name = __MODULE__ |> Atom.to_string() |> String.split(".") |> List.last()
50
-
51
- put_diagnostics(state,
52
- service_error: %{
53
- code: :internal_check_timeout,
54
- extra: "#{check_name} timed out after #{timeout}ms"
55
- }
56
- )
57
- end
58
- end
59
- end
60
- end
61
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/installation_support/check_runner.ex DELETED
@@ -1,65 +0,0 @@
1
- defmodule Plausible.InstallationSupport.CheckRunner do
2
- @moduledoc """
3
- Takes two arguments:
4
-
5
- 1. A `%Plausible.InstallationSupport.State{}` struct - the `diagnostics`
6
- field is a struct representing the set of diagnostics shared between
7
- all the checks in this flow.
8
-
9
- 2. A list of modules implementing `Plausible.InstallationSupport.Check`
10
- behaviour.
11
-
12
- Checks are normally run asynchronously, except when synchronous
13
- execution is optionally required for tests. Slowdowns can be optionally
14
- added, the user doesn't benefit from running the checks too quickly.
15
- """
16
-
17
- def run(state, checks, opts) do
18
- async? = Keyword.get(opts, :async?, true)
19
- slowdown = Keyword.get(opts, :slowdown, 500)
20
-
21
- if async? do
22
- Task.start_link(fn -> do_run(state, checks, slowdown) end)
23
- else
24
- do_run(state, checks, slowdown)
25
- end
26
- end
27
-
28
- defp do_run(state, checks, slowdown) do
29
- state =
30
- Enum.reduce_while(
31
- checks,
32
- state,
33
- fn {check, check_opts}, state ->
34
- if state.skip_further_checks? do
35
- {:halt, state}
36
- else
37
- {:cont,
38
- state
39
- |> notify_check_start(check, slowdown)
40
- |> check.perform_safe(check_opts)}
41
- end
42
- end
43
- )
44
-
45
- notify_all_checks_done(state, slowdown)
46
- end
47
-
48
- defp notify_check_start(state, check, slowdown) do
49
- if is_pid(state.report_to) do
50
- if is_integer(slowdown) and slowdown > 0, do: :timer.sleep(slowdown)
51
- send(state.report_to, {:check_start, {check, state}})
52
- end
53
-
54
- state
55
- end
56
-
57
- defp notify_all_checks_done(state, slowdown) do
58
- if is_pid(state.report_to) do
59
- if is_integer(slowdown) and slowdown > 0, do: :timer.sleep(slowdown)
60
- send(state.report_to, {:all_checks_done, state})
61
- end
62
-
63
- state
64
- end
65
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/installation_support/checks/detection.ex DELETED
@@ -1,162 +0,0 @@
1
- defmodule Plausible.InstallationSupport.Checks.Detection do
2
- @moduledoc """
3
- Calls the browserless.io service (local instance can be spawned with `make browserless`)
4
- and runs detector script via the [function API](https://docs.browserless.io/HTTP-APIs/function).
5
-
6
- * v1_detected (optional - detection can take up to @plausible_window_check_timeout_ms)
7
- * gtm_likely
8
- * wordpress_likely
9
- * wordpress_plugin
10
-
11
- These diagnostics are used to determine what installation type to recommend,
12
- and whether to provide a notice for upgrading an existing v1 integration to v2.
13
- """
14
-
15
- require Logger
16
- use Plausible.InstallationSupport.Check
17
- alias Plausible.InstallationSupport.BrowserlessConfig
18
-
19
- @detector_code_path "priv/tracker/installation_support/detector.js"
20
- @external_resource @detector_code_path
21
-
22
- # On CI, the file might not be present for static checks so we default to empty string
23
- @detector_code (case File.read(Application.app_dir(:plausible, @detector_code_path)) do
24
- {:ok, content} -> content
25
- {:error, _} -> ""
26
- end)
27
-
28
- # Puppeteer wrapper function that executes the vanilla JS detector code
29
- @puppeteer_wrapper_code """
30
- export default async function({ page, context: { url, userAgent, ...functionContext } }) {
31
- try {
32
- await page.setUserAgent(userAgent);
33
- await page.goto(url);
34
-
35
- await page.evaluate(() => {
36
- #{@detector_code} // injects window.scanPageBeforePlausibleInstallation
37
- });
38
-
39
- return await page.evaluate(
40
- (c) => window.scanPageBeforePlausibleInstallation(c),
41
- { ...functionContext }
42
- );
43
- } catch (error) {
44
- return {
45
- data: {
46
- completed: false,
47
- error: {
48
- message: error?.message ?? JSON.stringify(error)
49
- }
50
- }
51
- }
52
- }
53
- }
54
- """
55
-
56
- # This timeout determines how long we wait for window.plausible to be initialized on the page, used for detecting whether v1 installed
57
- @plausible_window_check_timeout_ms 1_500
58
-
59
- # To support browserless API being unavailable or overloaded, we retry the endpoint call if it doesn't return a successful response
60
- @max_retries 1
61
-
62
- @impl true
63
- def report_progress_as, do: "We're checking your site to recommend the best installation method"
64
-
65
- @impl true
66
- def perform(%State{url: url, assigns: %{detect_v1?: detect_v1?}} = state, opts) do
67
- check_timeout = Keyword.fetch!(opts, :timeout)
68
-
69
- # We rely on Req's `:receive_timeout` to avoid waiting too long for a response, but
70
- # we also pass the same timeout (+ 1s) via a query param to the Browserless /function API
71
- # to not waste resources and leave a redundant process running there.
72
- req_timeout = check_timeout - 1000
73
- browserless_api_timeout = check_timeout
74
-
75
- opts =
76
- [
77
- headers: %{content_type: "application/json"},
78
- body:
79
- Jason.encode!(%{
80
- code: @puppeteer_wrapper_code,
81
- context: %{
82
- url: Plausible.InstallationSupport.URL.bust_url(url),
83
- userAgent: Plausible.InstallationSupport.user_agent(),
84
- detectV1: detect_v1?,
85
- timeoutMs: @plausible_window_check_timeout_ms,
86
- debug: Application.get_env(:plausible, :environment) == "dev"
87
- }
88
- }),
89
- params: %{timeout: browserless_api_timeout},
90
- retry: fn _request, response_or_error ->
91
- case response_or_error do
92
- %{status: status} -> Map.get(BrowserlessConfig.retry_policy(), status, false)
93
- _ -> false
94
- end
95
- end,
96
- receive_timeout: req_timeout,
97
- retry_log_level: :warning,
98
- max_retries: @max_retries
99
- ]
100
- |> Keyword.merge(Application.get_env(:plausible, __MODULE__)[:req_opts] || [])
101
-
102
- case Req.post(BrowserlessConfig.browserless_function_api_endpoint(), opts) do
103
- {:ok, %{body: body, status: status}} ->
104
- handle_browserless_response(state, body, status)
105
-
106
- {:error, %Req.TransportError{reason: :timeout}} ->
107
- put_diagnostics(state, service_error: %{code: :browserless_timeout})
108
-
109
- {:error, %{reason: reason}} ->
110
- Logger.warning(warning_message("Browserless request error: #{inspect(reason)}", state))
111
-
112
- put_diagnostics(state, service_error: %{code: :req_error, extra: reason})
113
- end
114
- end
115
-
116
- defp handle_browserless_response(
117
- state,
118
- %{"data" => %{"completed" => completed} = data},
119
- _status
120
- ) do
121
- if completed do
122
- put_diagnostics(
123
- state,
124
- parse_to_diagnostics(data)
125
- )
126
- else
127
- Logger.warning(
128
- warning_message(
129
- "Browserless function returned with completed: false, error.message: #{inspect(data["error"]["message"])}",
130
- state
131
- )
132
- )
133
-
134
- put_diagnostics(state,
135
- service_error: %{code: :browserless_client_error, extra: data["error"]["message"]}
136
- )
137
- end
138
- end
139
-
140
- defp handle_browserless_response(state, body, status) do
141
- error = "Unhandled browserless response with status: #{status}"
142
-
143
- warning_message("#{error}; body: #{inspect(body)}", state)
144
- |> Logger.warning()
145
-
146
- put_diagnostics(state, service_error: %{code: :bad_browserless_response, extra: status})
147
- end
148
-
149
- defp warning_message(message, state) do
150
- "[DETECTION] #{message} (data_domain='#{state.data_domain}')"
151
- end
152
-
153
- defp parse_to_diagnostics(data),
154
- do: [
155
- v1_detected: data["v1Detected"],
156
- gtm_likely: data["gtmLikely"],
157
- npm: data["npm"],
158
- wordpress_likely: data["wordpressLikely"],
159
- wordpress_plugin: data["wordpressPlugin"],
160
- service_error: nil
161
- ]
162
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/installation_support/checks/url.ex DELETED
@@ -1,103 +0,0 @@
1
- defmodule Plausible.InstallationSupport.Checks.Url do
2
- @moduledoc """
3
- Checks if site domain has an A record.
4
- If not, checks if prepending `www.` helps,
5
- because we have specifically requested customers to register the domain with `www.` prefix.
6
- If not, skips all further checks.
7
- """
8
-
9
- use Plausible.InstallationSupport.Check
10
-
11
- @impl true
12
- def report_progress_as, do: "We're trying to reach your website"
13
-
14
- @impl true
15
- @spec perform(State.t(), Keyword.t()) :: State.t()
16
- def perform(%State{url: url} = state, _opts) when is_binary(url) do
17
- with {:ok, %URI{scheme: scheme} = uri} when scheme in ["http", "https"] <- URI.new(url),
18
- :ok <- check_domain(uri.host) do
19
- stripped_url = URI.to_string(%URI{uri | query: nil, fragment: nil})
20
- %State{state | url: stripped_url}
21
- else
22
- {:error, :no_a_record} ->
23
- put_diagnostics(%State{state | skip_further_checks?: true},
24
- service_error: %{code: :domain_not_found}
25
- )
26
-
27
- _ ->
28
- put_diagnostics(%State{state | skip_further_checks?: true},
29
- service_error: %{code: :invalid_url}
30
- )
31
- end
32
- end
33
-
34
- def perform(%State{data_domain: domain} = state, _opts) when is_binary(domain) do
35
- case find_working_url(domain) do
36
- {:ok, working_url} ->
37
- %State{state | url: working_url}
38
-
39
- {:error, :domain_not_found} ->
40
- put_diagnostics(%State{state | url: nil, skip_further_checks?: true},
41
- service_error: %{code: :domain_not_found}
42
- )
43
- end
44
- end
45
-
46
- # Check A records of the the domains [domain, "www.#{domain}"]
47
- # at this point, domain can contain path
48
- @spec find_working_url(String.t()) :: {:ok, String.t()} | {:error, :domain_not_found}
49
- defp find_working_url(domain) do
50
- [domain_without_path | rest] = split_domain(domain)
51
-
52
- [
53
- domain_without_path,
54
- "www.#{domain_without_path}"
55
- ]
56
- |> Enum.reduce_while({:error, :domain_not_found}, fn d, _acc ->
57
- case dns_lookup(d) do
58
- :ok -> {:halt, {:ok, "https://" <> unsplit_domain(d, rest)}}
59
- {:error, :no_a_record} -> {:cont, {:error, :domain_not_found}}
60
- end
61
- end)
62
- end
63
-
64
- @spec dns_lookup(String.t()) :: :ok | {:error, :no_a_record}
65
- defp dns_lookup(domain) do
66
- lookup_timeout = 1_000
67
- resolve_timeout = 1_000
68
-
69
- case Plausible.DnsLookup.impl().lookup(
70
- to_charlist(domain),
71
- :in,
72
- :a,
73
- [timeout: resolve_timeout],
74
- lookup_timeout
75
- ) do
76
- [{a, b, c, d} | _]
77
- when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) ->
78
- :ok
79
-
80
- # this may mean timeout or no DNS record
81
- [] ->
82
- {:error, :no_a_record}
83
- end
84
- end
85
-
86
- defp check_domain(domain) do
87
- if Application.get_env(:plausible, :environment) == "dev" and domain == "localhost" do
88
- :ok
89
- else
90
- dns_lookup(domain)
91
- end
92
- end
93
-
94
- @spec split_domain(String.t()) :: [String.t()]
95
- defp split_domain(domain) do
96
- String.split(domain, "/", parts: 2)
97
- end
98
-
99
- @spec unsplit_domain(String.t(), [String.t()]) :: String.t()
100
- defp unsplit_domain(domain_without_path, rest) do
101
- Enum.join([domain_without_path] ++ rest, "/")
102
- end
103
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/installation_support/checks/verify_installation.ex DELETED
@@ -1,189 +0,0 @@
1
- defmodule Plausible.InstallationSupport.Checks.VerifyInstallation do
2
- @moduledoc """
3
- Calls the browserless.io service (local instance can be spawned with `make browserless`)
4
- and runs verifier script via the [function API](https://docs.browserless.io/HTTP-APIs/function).
5
- """
6
-
7
- require Logger
8
- use Plausible.InstallationSupport.Check
9
- alias Plausible.InstallationSupport.BrowserlessConfig
10
-
11
- @verifier_code_path "priv/tracker/installation_support/verifier.js"
12
- @external_resource @verifier_code_path
13
-
14
- # On CI, the file might not be present for static checks so we default to empty string
15
- @verifier_code (case File.read(Application.app_dir(:plausible, @verifier_code_path)) do
16
- {:ok, content} -> content
17
- {:error, _} -> ""
18
- end)
19
-
20
- # Puppeteer wrapper function that executes the vanilla JS verifier code
21
- @puppeteer_wrapper_code """
22
- export default async function({ page, context: { url, userAgent, maxAttempts, timeoutBetweenAttemptsMs, ...functionContext } }) {
23
- try {
24
- await page.setUserAgent(userAgent)
25
- const response = await page.goto(url)
26
- const responseStatus = response.status()
27
- const responseHeaders = response.headers()
28
-
29
- async function verify() {
30
- await page.evaluate(() => {#{@verifier_code}}) // injects window.verifyPlausibleInstallation
31
- return await page.evaluate(
32
- (c) => window.verifyPlausibleInstallation(c),
33
- { ...functionContext, responseHeaders }
34
- );
35
- }
36
-
37
- let lastError;
38
- for (let attempts = 1; attempts <= maxAttempts; attempts++) {
39
- try {
40
- const output = await verify();
41
- return {
42
- data: {
43
- ...output.data,
44
- attempts,
45
- responseStatus
46
- },
47
- };
48
- } catch (error) {
49
- lastError = error;
50
- if (
51
- typeof error?.message === "string" &&
52
- error.message.toLowerCase().includes("execution context")
53
- ) {
54
- await new Promise((resolve) => setTimeout(resolve, timeoutBetweenAttemptsMs));
55
- continue;
56
- }
57
- throw error
58
- }
59
- }
60
- throw lastError;
61
- } catch (error) {
62
- return {
63
- data: {
64
- completed: false,
65
- error: {
66
- message: error?.message ?? JSON.stringify(error)
67
- }
68
- }
69
- }
70
- }
71
- }
72
- """
73
-
74
- # To support browserless API being unavailable or overloaded, we retry the endpoint call if it doesn't return a successful response
75
- @max_retries 1
76
-
77
- # We rely on Req's `:receive_timeout` to avoid waiting too long for a response. We also pass the same timeout (+ 1s) via a query
78
- # param to the Browserless /function API to not waste resources.
79
- @req_timeout 15_000
80
-
81
- # This timeout determines how long we wait for window.plausible to be initialized on the page, including sending the test event
82
- @plausible_window_check_timeout_ms 4_000
83
-
84
- # To handle navigation that happens immediately on the page, we attempt to verify the installation multiple times _within a single browserless endpoint call_
85
- @max_attempts 2
86
- @timeout_between_attempts_ms 500
87
-
88
- @impl true
89
- def report_progress_as, do: "We're verifying that your visitors are being counted correctly"
90
-
91
- @impl true
92
- def perform(%State{url: url} = state, _opts) do
93
- opts = [
94
- headers: %{content_type: "application/json"},
95
- body:
96
- JSON.encode!(%{
97
- code: @puppeteer_wrapper_code,
98
- context: %{
99
- maxAttempts: @max_attempts,
100
- timeoutMs: @plausible_window_check_timeout_ms,
101
- timeoutBetweenAttemptsMs: @timeout_between_attempts_ms,
102
- cspHostToCheck: PlausibleWeb.Endpoint.host(),
103
- url: url,
104
- userAgent: Plausible.InstallationSupport.user_agent(),
105
- debug: Application.get_env(:plausible, :environment) == "dev"
106
- }
107
- }),
108
- params: %{timeout: @req_timeout + 1000},
109
- retry: fn _request, response_or_error ->
110
- case response_or_error do
111
- %{status: status} -> Map.get(BrowserlessConfig.retry_policy(), status, false)
112
- %Req.TransportError{reason: :timeout} -> {:delay, 500}
113
- _ -> false
114
- end
115
- end,
116
- retry_log_level: :warning,
117
- max_retries: @max_retries,
118
- receive_timeout: @req_timeout
119
- ]
120
-
121
- extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
122
- opts = Keyword.merge(opts, extra_opts)
123
-
124
- case Req.post(BrowserlessConfig.browserless_function_api_endpoint(), opts) do
125
- {:ok, %{body: body, status: status}} ->
126
- handle_browserless_response(state, body, status)
127
-
128
- {:error, %Req.TransportError{reason: :timeout}} ->
129
- put_diagnostics(state, service_error: %{code: :browserless_timeout})
130
-
131
- {:error, %{reason: reason}} ->
132
- Logger.warning(warning_message("Browserless request error: #{inspect(reason)}", state))
133
-
134
- put_diagnostics(state, service_error: %{code: :req_error, extra: reason})
135
- end
136
- end
137
-
138
- defp handle_browserless_response(
139
- state,
140
- %{"data" => %{"completed" => completed} = data},
141
- _status
142
- ) do
143
- if completed do
144
- put_diagnostics(
145
- state,
146
- parse_to_diagnostics(data)
147
- )
148
- else
149
- Logger.warning(
150
- warning_message(
151
- "Browserless function returned with completed: false, error.message: #{inspect(data["error"]["message"])}",
152
- state
153
- )
154
- )
155
-
156
- put_diagnostics(state,
157
- service_error: %{code: :browserless_client_error, extra: data["error"]["message"]}
158
- )
159
- end
160
- end
161
-
162
- defp handle_browserless_response(state, body, status) do
163
- error = "Unhandled browserless response with status: #{status}"
164
-
165
- warning_message("#{error}; body: #{inspect(body)}", state)
166
- |> Logger.warning()
167
-
168
- put_diagnostics(state, service_error: %{code: :bad_browserless_response, extra: status})
169
- end
170
-
171
- defp warning_message(message, state) do
172
- "[VERIFICATION] #{message} (data_domain='#{state.data_domain}')"
173
- end
174
-
175
- defp parse_to_diagnostics(data),
176
- do: [
177
- disallowed_by_csp: data["disallowedByCsp"],
178
- tracker_is_in_html: data["trackerIsInHtml"],
179
- plausible_is_on_window: data["plausibleIsOnWindow"],
180
- plausible_is_initialized: data["plausibleIsInitialized"],
181
- plausible_version: data["plausibleVersion"],
182
- plausible_variant: data["plausibleVariant"],
183
- test_event: data["testEvent"],
184
- cookies_consent_result: data["cookiesConsentResult"],
185
- attempts: data["attempts"],
186
- response_status: data["responseStatus"],
187
- service_error: nil
188
- ]
189
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/installation_support/checks/verify_installation_cache_bust.ex DELETED
@@ -1,42 +0,0 @@
1
- defmodule Plausible.InstallationSupport.Checks.VerifyInstallationCacheBust do
2
- @moduledoc """
3
- If the output of previous checks can not be interpreted as successful,
4
- as a last resort, we try to bust the cache of the site under test by adding a query parameter to the URL,
5
- and running VerifyInstallation again.
6
-
7
- Whatever the result from the rerun, that is what we use to interpret the installation.
8
-
9
- The idea is to make sure that any issues we detect will be about the latest version of their website.
10
-
11
- We also want to avoid reporting a successful installation if it took a special cache-busting action to make it work.
12
- """
13
-
14
- require Logger
15
- alias Plausible.InstallationSupport
16
- use Plausible.InstallationSupport.Check
17
-
18
- @impl true
19
- def report_progress_as, do: "We're verifying that your visitors are being counted correctly"
20
-
21
- @impl true
22
- def perform(%State{url: url} = state, _opts) do
23
- case InstallationSupport.Verification.Checks.interpret_diagnostics(state, telemetry?: false) do
24
- %InstallationSupport.Result{ok?: true} ->
25
- state
26
-
27
- %InstallationSupport.Result{data: %{unhandled: true}} ->
28
- state
29
-
30
- _known_installation_failure ->
31
- reset_diagnostics = %InstallationSupport.Verification.Diagnostics{
32
- selected_installation_type: state.diagnostics.selected_installation_type
33
- }
34
-
35
- state
36
- |> struct!(diagnostics: reset_diagnostics)
37
- |> struct!(url: InstallationSupport.URL.bust_url(url))
38
- |> InstallationSupport.Checks.VerifyInstallation.perform([])
39
- |> put_diagnostics(diagnostics_are_from_cache_bust: true)
40
- end
41
- end
42
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/installation_support/detection/checks.ex DELETED
@@ -1,114 +0,0 @@
1
- defmodule Plausible.InstallationSupport.Detection.Checks do
2
- @moduledoc """
3
- Checks that are performed pre-installation, providing recommended installation
4
- methods and whether v1 is used on the site.
5
-
6
- In async execution, each check notifies the caller by sending a message to it.
7
- """
8
- alias Plausible.InstallationSupport.Detection
9
- alias Plausible.InstallationSupport.{State, CheckRunner, Checks}
10
-
11
- require Logger
12
-
13
- @detection_check_timeout 6000
14
-
15
- def run(url, data_domain, opts \\ []) do
16
- detection_check_timeout =
17
- case Keyword.get(opts, :detection_check_timeout) do
18
- int when is_integer(int) -> int
19
- _ -> @detection_check_timeout
20
- end
21
-
22
- report_to = Keyword.get(opts, :report_to, self())
23
- async? = Keyword.get(opts, :async?, true)
24
- slowdown = Keyword.get(opts, :slowdown, 500)
25
- detect_v1? = Keyword.get(opts, :detect_v1?, false)
26
-
27
- init_state =
28
- %State{
29
- url: url,
30
- data_domain: data_domain,
31
- report_to: report_to,
32
- diagnostics: %Detection.Diagnostics{},
33
- assigns: %{detect_v1?: detect_v1?}
34
- }
35
-
36
- checks = [
37
- {Checks.Url, []},
38
- {Checks.Detection, [timeout: detection_check_timeout]}
39
- ]
40
-
41
- CheckRunner.run(init_state, checks,
42
- async?: async?,
43
- report_to: report_to,
44
- slowdown: slowdown
45
- )
46
- end
47
-
48
- def telemetry_event_success(), do: [:plausible, :detection, :success]
49
- def telemetry_event_failure(), do: [:plausible, :detection, :failure]
50
-
51
- def interpret_diagnostics(%State{
52
- diagnostics: diagnostics,
53
- data_domain: data_domain,
54
- url: url
55
- }) do
56
- result = Detection.Diagnostics.interpret(diagnostics, url)
57
-
58
- {failed?, trigger_sentry?, msg} =
59
- case result do
60
- %{ok?: true} ->
61
- {false, false, nil}
62
-
63
- %{data: %{failure: :customer_website_issue}} ->
64
- {true, false, "Failed due to an issue with the customer website"}
65
-
66
- %{data: %{failure: :browserless_issue}} ->
67
- {true, true, "Failed due to a Browserless issue"}
68
-
69
- _unknown_failure ->
70
- {true, true, "Unknown failure"}
71
- end
72
-
73
- if failed? do
74
- :telemetry.execute(telemetry_event_failure(), %{})
75
- Logger.warning("[DETECTION] #{msg} (data_domain='#{data_domain}'): #{inspect(diagnostics)}")
76
- else
77
- :telemetry.execute(telemetry_event_success(), %{})
78
- end
79
-
80
- if trigger_sentry? do
81
- Sentry.capture_message("[DETECTION] #{msg}",
82
- extra: %{
83
- message: inspect(diagnostics),
84
- url: url,
85
- hash: :erlang.phash2(diagnostics)
86
- }
87
- )
88
- end
89
-
90
- result
91
- end
92
-
93
- @unthrottled_checks 3
94
- @first_slowdown_ms 1000
95
- def run_with_rate_limit(url, data_domain, opts \\ []) do
96
- case Plausible.RateLimit.check_rate(
97
- "site_detection:#{data_domain}",
98
- :timer.minutes(60),
99
- 10
100
- ) do
101
- {:allow, count} when count <= @unthrottled_checks ->
102
- {:ok, run(url, data_domain, opts)}
103
-
104
- {:allow, count} when count > @unthrottled_checks ->
105
- # slowdown steps 1x, 4x, 9x, 16x, ...
106
- slowdown_ms = @first_slowdown_ms * (count - @unthrottled_checks) ** 2
107
- :timer.sleep(slowdown_ms)
108
- {:ok, run(url, data_domain, opts)}
109
-
110
- {:deny, limit} ->
111
- {:error, {:rate_limit_exceeded, limit}}
112
- end
113
- end
114
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/installation_support/detection/diagnostics.ex DELETED
@@ -1,108 +0,0 @@
1
- defmodule Plausible.InstallationSupport.Detection.Diagnostics do
2
- @moduledoc """
3
- Module responsible for translating diagnostics to user-friendly errors and recommendations.
4
- """
5
-
6
- # in this struct, nil means indeterminate
7
- defstruct v1_detected: nil,
8
- gtm_likely: nil,
9
- wordpress_likely: nil,
10
- wordpress_plugin: nil,
11
- npm: nil,
12
- service_error: nil
13
-
14
- @type t :: %__MODULE__{}
15
-
16
- alias Plausible.InstallationSupport.Result
17
-
18
- @spec interpret(t(), String.t()) :: Result.t()
19
- def interpret(
20
- %__MODULE__{
21
- gtm_likely: true,
22
- service_error: nil
23
- } = diagnostics,
24
- _url
25
- ) do
26
- success("gtm", diagnostics)
27
- end
28
-
29
- def interpret(
30
- %__MODULE__{
31
- wordpress_likely: true,
32
- service_error: nil
33
- } = diagnostics,
34
- _url
35
- ) do
36
- success(
37
- "wordpress",
38
- diagnostics
39
- )
40
- end
41
-
42
- def interpret(
43
- %__MODULE__{
44
- npm: true,
45
- service_error: nil
46
- } = diagnostics,
47
- _url
48
- ) do
49
- success("npm", diagnostics)
50
- end
51
-
52
- def interpret(
53
- %__MODULE__{
54
- service_error: nil
55
- } = diagnostics,
56
- _url
57
- ) do
58
- success(PlausibleWeb.Tracker.fallback_installation_type(), diagnostics)
59
- end
60
-
61
- def interpret(%__MODULE__{service_error: %{code: code}}, _url)
62
- when code in [:domain_not_found, :invalid_url] do
63
- failure(:customer_website_issue)
64
- end
65
-
66
- def interpret(%__MODULE__{service_error: %{code: code}}, _url)
67
- when code in [:bad_browserless_response, :browserless_timeout, :internal_check_timeout] do
68
- failure(:browserless_issue)
69
- end
70
-
71
- def interpret(
72
- %__MODULE__{service_error: %{code: :browserless_client_error, extra: extra}},
73
- _url
74
- ) do
75
- cond do
76
- String.contains?(extra, "net::") ->
77
- failure(:customer_website_issue)
78
-
79
- String.contains?(String.downcase(extra), "execution context") ->
80
- failure(:customer_website_issue)
81
-
82
- true ->
83
- failure(:unknown_issue)
84
- end
85
- end
86
-
87
- def interpret(%__MODULE__{} = _diagnostics, _url), do: failure(:unknown_issue)
88
-
89
- defp failure(reason) do
90
- %Result{
91
- ok?: false,
92
- data: %{failure: reason},
93
- errors: [reason]
94
- }
95
- end
96
-
97
- defp success(suggested_technology, diagnostics) do
98
- %Result{
99
- ok?: true,
100
- data: %{
101
- v1_detected: diagnostics.v1_detected,
102
- wordpress_plugin: diagnostics.wordpress_plugin,
103
- npm: diagnostics.npm,
104
- suggested_technology: suggested_technology
105
- }
106
- }
107
- end
108
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/installation_support/installation_support.ex DELETED
@@ -1,20 +0,0 @@
1
- defmodule Plausible.InstallationSupport do
2
- @moduledoc """
3
- This top level module is the middle ground between pre-installation
4
- site scans and verification of whether Plausible has been installed
5
- correctly.
6
-
7
- Defines the user-agent used with checks.
8
- """
9
- use Plausible
10
-
11
- on_ee do
12
- def user_agent() do
13
- "Plausible Verification Agent - if abused, contact support@plausible.io"
14
- end
15
- else
16
- def user_agent() do
17
- "Plausible Community Edition"
18
- end
19
- end
20
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/installation_support/result.ex DELETED
@@ -1,18 +0,0 @@
1
- defmodule Plausible.InstallationSupport.Result do
2
- @moduledoc """
3
- Diagnostics interpretation result.
4
-
5
- ## Example
6
- ok?: false,
7
- data: nil,
8
- errors: [error.message],
9
- recommendations: [%{text: error.recommendation, url: error.url}]
10
-
11
- ok?: true,
12
- data: %{},
13
- errors: [],
14
- recommendations: []
15
- """
16
- defstruct ok?: false, errors: [], recommendations: [], data: nil
17
- @type t :: %__MODULE__{}
18
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/installation_support/state.ex DELETED
@@ -1,40 +0,0 @@
1
- defmodule Plausible.InstallationSupport.State do
2
- @moduledoc """
3
- The state to be shared across check during site installation support.
4
-
5
- Assigns are meant to be used to communicate between checks, while
6
- `diagnostics` are specific to the check group being executed.
7
- """
8
-
9
- defstruct url: nil,
10
- data_domain: nil,
11
- report_to: nil,
12
- assigns: %{},
13
- diagnostics: %{},
14
- skip_further_checks?: false
15
-
16
- @type diagnostics_type ::
17
- Plausible.InstallationSupport.Verification.Diagnostics.t()
18
- | Plausible.InstallationSupport.Detection.Diagnostics.t()
19
-
20
- @type t :: %__MODULE__{
21
- url: String.t() | nil,
22
- data_domain: String.t() | nil,
23
- report_to: pid() | nil,
24
- assigns: map(),
25
- diagnostics: diagnostics_type(),
26
- skip_further_checks?: boolean()
27
- }
28
-
29
- def assign(%__MODULE__{} = state, assigns) do
30
- %{state | assigns: Map.merge(state.assigns, Enum.into(assigns, %{}))}
31
- end
32
-
33
- def put_diagnostics(%__MODULE__{} = state, diagnostics) when is_list(diagnostics) do
34
- %{state | diagnostics: struct!(state.diagnostics, diagnostics)}
35
- end
36
-
37
- def put_diagnostics(%__MODULE__{} = state, diagnostics) do
38
- put_diagnostics(state, List.wrap(diagnostics))
39
- end
40
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/installation_support/url.ex DELETED
@@ -1,25 +0,0 @@
1
- defmodule Plausible.InstallationSupport.URL do
2
- @moduledoc """
3
- URL utilities for installation support, including cache busting functionality.
4
- """
5
-
6
- def bust_url(url) do
7
- cache_invalidator = abs(:erlang.unique_integer())
8
- update_url(url, cache_invalidator)
9
- end
10
-
11
- defp update_url(url, invalidator) do
12
- url
13
- |> URI.parse()
14
- |> then(fn uri ->
15
- updated_query =
16
- (uri.query || "")
17
- |> URI.decode_query()
18
- |> Map.put("plausible_verification", invalidator)
19
- |> URI.encode_query()
20
-
21
- struct!(uri, query: updated_query)
22
- end)
23
- |> to_string()
24
- end
25
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/installation_support/verification/checks.ex DELETED
@@ -1,100 +0,0 @@
1
- defmodule Plausible.InstallationSupport.Verification.Checks do
2
- @moduledoc """
3
- Checks that are performed during tracker script installation verification.
4
-
5
- In async execution, each check notifies the caller by sending a message to it.
6
- """
7
- alias Plausible.InstallationSupport.Verification
8
- alias Plausible.InstallationSupport.{State, CheckRunner, Checks}
9
-
10
- require Logger
11
-
12
- @verify_installation_check_timeout 20_000
13
-
14
- @spec run(String.t(), String.t(), String.t(), Keyword.t()) :: {:ok, pid()} | State.t()
15
- def run(url, data_domain, installation_type, opts \\ []) do
16
- # Timeout option for testing purposes
17
- verify_installation_check_timeout =
18
- case Keyword.get(opts, :verify_installation_check_timeout) do
19
- int when is_integer(int) -> int
20
- _ -> @verify_installation_check_timeout
21
- end
22
-
23
- report_to = Keyword.get(opts, :report_to, self())
24
- async? = Keyword.get(opts, :async?, true)
25
- slowdown = Keyword.get(opts, :slowdown, 500)
26
-
27
- init_state =
28
- %State{
29
- url: url,
30
- data_domain: data_domain,
31
- report_to: report_to,
32
- diagnostics: %Verification.Diagnostics{
33
- selected_installation_type: installation_type
34
- }
35
- }
36
-
37
- checks = [
38
- {Checks.Url, []},
39
- {Checks.VerifyInstallation, [timeout: verify_installation_check_timeout]},
40
- {Checks.VerifyInstallationCacheBust, [timeout: verify_installation_check_timeout]}
41
- ]
42
-
43
- CheckRunner.run(init_state, checks,
44
- async?: async?,
45
- report_to: report_to,
46
- slowdown: slowdown
47
- )
48
- end
49
-
50
- def telemetry_event_handled(), do: [:plausible, :verification, :handled]
51
- def telemetry_event_unhandled(), do: [:plausible, :verification, :unhandled]
52
-
53
- def interpret_diagnostics(
54
- %State{
55
- diagnostics: diagnostics,
56
- data_domain: data_domain,
57
- url: url
58
- },
59
- opts \\ []
60
- ) do
61
- telemetry? = Keyword.get(opts, :telemetry?, true)
62
-
63
- result =
64
- Verification.Diagnostics.interpret(
65
- diagnostics,
66
- data_domain,
67
- url
68
- )
69
-
70
- case {telemetry?, result.data} do
71
- {false, _} ->
72
- :skip
73
-
74
- {_, %{unhandled: true, browserless_issue: browserless_issue}} ->
75
- sentry_msg =
76
- if browserless_issue,
77
- do: "Browserless failure in verification",
78
- else: "Unhandled case for site verification"
79
-
80
- Sentry.capture_message(sentry_msg,
81
- extra: %{
82
- message: inspect(diagnostics),
83
- url: url,
84
- hash: :erlang.phash2(diagnostics)
85
- }
86
- )
87
-
88
- Logger.warning(
89
- "[VERIFICATION] Unhandled case (data_domain='#{data_domain}'): #{inspect(diagnostics)}"
90
- )
91
-
92
- :telemetry.execute(telemetry_event_unhandled(), %{})
93
-
94
- _ ->
95
- :telemetry.execute(telemetry_event_handled(), %{})
96
- end
97
-
98
- result
99
- end
100
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/installation_support/verification/diagnostics.ex DELETED
@@ -1,389 +0,0 @@
1
- defmodule Plausible.InstallationSupport.Verification.Diagnostics do
2
- @moduledoc """
3
- Module responsible for translating diagnostics to user-friendly errors and recommendations.
4
- """
5
-
6
- # In this struct
7
- # - the default nil value for each field means that the value is indeterminate (e.g. we didn't even get to the part where response_status is set)
8
- defstruct [
9
- :selected_installation_type,
10
- :disallowed_by_csp,
11
- :tracker_is_in_html,
12
- :plausible_is_on_window,
13
- :plausible_is_initialized,
14
- :plausible_version,
15
- :plausible_variant,
16
- :diagnostics_are_from_cache_bust,
17
- :test_event,
18
- :cookies_consent_result,
19
- :response_status,
20
- :service_error,
21
- :attempts
22
- ]
23
-
24
- @type t :: %__MODULE__{}
25
-
26
- @verify_manually_url "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration"
27
-
28
- alias Plausible.InstallationSupport.Result
29
-
30
- defmodule Error do
31
- @moduledoc """
32
- Error that has compile-time enforced checks for the attributes.
33
- """
34
-
35
- @enforce_keys [:message, :recommendation]
36
- defstruct [:message, :recommendation, :url]
37
-
38
- def new!(attrs) do
39
- message = Map.fetch!(attrs, :message)
40
-
41
- if String.ends_with?(message, ".") do
42
- raise ArgumentError, "Error message must not end with a period: #{inspect(message)}"
43
- end
44
-
45
- if String.ends_with?(attrs[:recommendation], ".") do
46
- raise ArgumentError,
47
- "Error recommendation must not end with a period: #{inspect(attrs[:recommendation])}"
48
- end
49
-
50
- if is_binary(attrs[:url]) and not String.starts_with?(attrs[:url], "https://plausible.io") do
51
- raise ArgumentError,
52
- "Recommendation url must start with 'https://plausible.io': #{inspect(attrs[:url])}"
53
- end
54
-
55
- struct!(__MODULE__, attrs)
56
- end
57
- end
58
-
59
- @error_succeeds_only_after_cache_bust Error.new!(%{
60
- message: "We detected an issue with your site's cache",
61
- recommendation:
62
- "Please clear the cache for your site to ensure that your visitors will load the latest version of your site that has Plausible correctly installed",
63
- url:
64
- "https://plausible.io/docs/troubleshoot-integration#have-you-cleared-the-cache-of-your-site"
65
- })
66
-
67
- @spec interpret(t(), String.t(), String.t()) :: Result.t()
68
- def interpret(
69
- %__MODULE__{
70
- test_event: %{
71
- "normalizedBody" => %{
72
- "domain" => domain
73
- },
74
- "responseStatus" => response_status
75
- },
76
- service_error: nil,
77
- diagnostics_are_from_cache_bust: true
78
- },
79
- expected_domain,
80
- _url
81
- )
82
- when response_status in [200, 202] and
83
- domain == expected_domain,
84
- do: handled_error(@error_succeeds_only_after_cache_bust)
85
-
86
- def interpret(
87
- %__MODULE__{
88
- test_event: %{
89
- "normalizedBody" => %{
90
- "domain" => domain
91
- },
92
- "responseStatus" => response_status
93
- },
94
- service_error: nil
95
- },
96
- expected_domain,
97
- _url
98
- )
99
- when response_status in [200, 202] and
100
- domain == expected_domain,
101
- do: success()
102
-
103
- def interpret(
104
- %__MODULE__{
105
- test_event: %{
106
- "normalizedBody" => %{
107
- "domain" => domain
108
- },
109
- "responseStatus" => response_status
110
- },
111
- service_error: nil,
112
- selected_installation_type: selected_installation_type
113
- },
114
- expected_domain,
115
- _url
116
- )
117
- when response_status in [200, 202] and
118
- domain != expected_domain do
119
- error_unexpected_domain(selected_installation_type)
120
- |> handled_error()
121
- end
122
-
123
- @error_proxy_network_error Error.new!(%{
124
- message:
125
- "We got an unexpected response from the proxy you are using for Plausible",
126
- recommendation:
127
- "Please check that you've configured the proxied /event route correctly",
128
- url: "https://plausible.io/docs/proxy/introduction"
129
- })
130
- @error_plausible_network_error Error.new!(%{
131
- message: "We couldn't verify your website",
132
- recommendation:
133
- "Please try verifying again in a few minutes, or verify your installation manually",
134
- url: @verify_manually_url
135
- })
136
-
137
- def interpret(
138
- %__MODULE__{
139
- test_event: %{
140
- "requestUrl" => request_url,
141
- "responseStatus" => response_status
142
- },
143
- service_error: nil
144
- },
145
- _expected_domain,
146
- _url
147
- )
148
- when response_status not in [200, 202] and is_binary(request_url) do
149
- proxying? = not String.starts_with?(request_url, PlausibleWeb.Endpoint.url())
150
-
151
- if proxying? do
152
- handled_error(@error_proxy_network_error)
153
- else
154
- handled_error(@error_plausible_network_error)
155
- end
156
- end
157
-
158
- def interpret(
159
- %__MODULE__{
160
- tracker_is_in_html: false,
161
- selected_installation_type: "manual",
162
- plausible_is_on_window: plausible_is_on_window,
163
- plausible_is_initialized: plausible_is_initialized,
164
- service_error: nil
165
- },
166
- _expected_domain,
167
- _url
168
- )
169
- when plausible_is_on_window != true and
170
- plausible_is_initialized != true do
171
- error_plausible_not_found("manual")
172
- |> handled_error()
173
- end
174
-
175
- @error_csp_disallowed Error.new!(%{
176
- message:
177
- "We encountered an issue with your site's Content Security Policy (CSP)",
178
- recommendation:
179
- "Please add plausible.io domain specifically to the allowed list of domains in your site's CSP",
180
- url:
181
- "https://plausible.io/docs/troubleshoot-integration#does-your-site-use-a-content-security-policy-csp"
182
- })
183
- def interpret(
184
- %__MODULE__{
185
- disallowed_by_csp: true,
186
- service_error: nil
187
- },
188
- _expected_domain,
189
- _url
190
- ),
191
- do: handled_error(@error_csp_disallowed)
192
-
193
- @error_domain_not_found Error.new!(%{
194
- message: "We couldn't find your website at <%= @attempted_url %>",
195
- recommendation:
196
- "Please check that the domain you entered is correct and reachable publicly. If it's intentionally private, you'll need to verify that Plausible works manually",
197
- url: @verify_manually_url
198
- })
199
-
200
- def interpret(%__MODULE__{service_error: %{code: code}}, expected_domain, url)
201
- when code in [:domain_not_found, :invalid_url] do
202
- attempted_url = if url, do: url, else: "https://#{expected_domain}"
203
-
204
- @error_domain_not_found
205
- |> handled_error(attempted_url: attempted_url)
206
- |> struct!(data: %{offer_custom_url_input: true})
207
- end
208
-
209
- @error_browserless_network Error.new!(%{
210
- message:
211
- "We couldn't verify your website at <%= @attempted_url %>",
212
- recommendation:
213
- "Accessing the website resulted in a network error. Please verify your installation manually",
214
- url: @verify_manually_url
215
- })
216
-
217
- def interpret(
218
- %__MODULE__{service_error: %{code: :browserless_client_error, extra: "net::" <> _}},
219
- _expected_domain,
220
- url
221
- )
222
- when is_binary(url) do
223
- attempted_url = shorten_url(url)
224
-
225
- @error_browserless_network
226
- |> handled_error(attempted_url: attempted_url)
227
- |> struct!(data: %{offer_custom_url_input: true})
228
- end
229
-
230
- @error_browserless_temporary Error.new!(%{
231
- message:
232
- "Our verification tool encountered a temporary service error",
233
- recommendation:
234
- "Please try again in a few minutes or verify your installation manually",
235
- url: @verify_manually_url
236
- })
237
-
238
- def interpret(%__MODULE__{service_error: %{code: code}}, _expected_domain, _url)
239
- when code in [:bad_browserless_response, :browserless_timeout, :internal_check_timeout] do
240
- unhandled_error(@error_browserless_temporary, browserless_issue: true)
241
- end
242
-
243
- @error_unexpected_page_response Error.new!(%{
244
- message:
245
- "We couldn't verify your website at <%= @attempted_url %>",
246
- recommendation:
247
- "Accessing the website resulted in an unexpected status code <%= @page_response_status %>. Please check for anything that might be blocking us from reaching your site, like a firewall, authentication requirements, or CDN rules. If you'd prefer, you can skip this and verify your installation manually",
248
- url: @verify_manually_url
249
- })
250
-
251
- def interpret(
252
- %__MODULE__{
253
- plausible_is_on_window: plausible_is_on_window,
254
- plausible_is_initialized: plausible_is_initialized,
255
- response_status: page_response_status
256
- },
257
- _expected_domain,
258
- url
259
- )
260
- when is_binary(url) and not is_nil(page_response_status) and
261
- (page_response_status < 200 or page_response_status >= 300) and
262
- plausible_is_on_window != true and
263
- plausible_is_initialized != true do
264
- attempted_url = shorten_url(url)
265
-
266
- @error_unexpected_page_response
267
- |> handled_error(attempted_url: attempted_url, page_response_status: page_response_status)
268
- |> struct!(data: %{offer_custom_url_input: true})
269
- end
270
-
271
- def interpret(
272
- %__MODULE__{
273
- selected_installation_type: selected_installation_type,
274
- plausible_is_on_window: false,
275
- service_error: nil
276
- },
277
- _expected_domain,
278
- _url
279
- ) do
280
- error_plausible_not_found(selected_installation_type)
281
- |> handled_error()
282
- end
283
-
284
- def interpret(%__MODULE__{} = diagnostics, _expected_domain, _url) do
285
- error_plausible_not_found(diagnostics.selected_installation_type)
286
- |> unhandled_error()
287
- end
288
-
289
- @message_plausible_not_found "We couldn't detect Plausible on your site"
290
- @error_plausible_not_found_for_manual Error.new!(%{
291
- message: @message_plausible_not_found,
292
- recommendation:
293
- "Please make sure you've copied the snippet to the head of your site, or verify your installation manually",
294
- url: @verify_manually_url
295
- })
296
- @error_plausible_not_found_for_npm Error.new!(%{
297
- message: @message_plausible_not_found,
298
- recommendation:
299
- "Please make sure you've initialized Plausible on your site, or verify your installation manually",
300
- url: @verify_manually_url
301
- })
302
- @error_plausible_not_found_for_gtm Error.new!(%{
303
- message: @message_plausible_not_found,
304
- recommendation:
305
- "Please make sure you've configured the GTM template correctly, or verify your installation manually",
306
- url: @verify_manually_url
307
- })
308
- @error_plausible_not_found_for_wordpress Error.new!(%{
309
- message: @message_plausible_not_found,
310
- recommendation:
311
- "Please make sure you've enabled the plugin, or verify your installation manually",
312
- url: @verify_manually_url
313
- })
314
- defp error_plausible_not_found(selected_installation_type) do
315
- case selected_installation_type do
316
- "npm" -> @error_plausible_not_found_for_npm
317
- "gtm" -> @error_plausible_not_found_for_gtm
318
- "wordpress" -> @error_plausible_not_found_for_wordpress
319
- _ -> @error_plausible_not_found_for_manual
320
- end
321
- end
322
-
323
- @unexpected_domain_message "Plausible test event is not for this site"
324
- @error_unexpected_domain_for_manual Error.new!(%{
325
- message: @unexpected_domain_message,
326
- recommendation:
327
- "Please check that the snippet on your site matches the installation instructions exactly, or verify your installation manually",
328
- url: @verify_manually_url
329
- })
330
-
331
- @error_unexpected_domain_for_npm Error.new!(%{
332
- message: @unexpected_domain_message,
333
- recommendation:
334
- "Please check that you've initialized Plausible with the correct domain, or verify your installation manually",
335
- url: @verify_manually_url
336
- })
337
-
338
- @error_unexpected_domain_for_gtm Error.new!(%{
339
- message: @unexpected_domain_message,
340
- recommendation:
341
- "Please check that you've entered the ID in the GTM template correctly, or verify your installation manually",
342
- url: @verify_manually_url
343
- })
344
-
345
- @error_unexpected_domain_for_wordpress Error.new!(%{
346
- message: @unexpected_domain_message,
347
- recommendation:
348
- "Please check that you've installed the WordPress plugin correctly, or verify your installation manually",
349
- url: @verify_manually_url
350
- })
351
- defp error_unexpected_domain(selected_installation_type) do
352
- case selected_installation_type do
353
- "npm" -> @error_unexpected_domain_for_npm
354
- "gtm" -> @error_unexpected_domain_for_gtm
355
- "wordpress" -> @error_unexpected_domain_for_wordpress
356
- _ -> @error_unexpected_domain_for_manual
357
- end
358
- end
359
-
360
- defp shorten_url(url) do
361
- String.split(url, "?") |> List.first()
362
- end
363
-
364
- defp success() do
365
- %Result{ok?: true}
366
- end
367
-
368
- defp handled_error(%Error{} = error, assigns \\ []) do
369
- message = EEx.eval_string(error.message, assigns: assigns)
370
- recommendation = EEx.eval_string(error.recommendation, assigns: assigns)
371
-
372
- %Result{
373
- ok?: false,
374
- errors: [message],
375
- recommendations: [%{text: recommendation, url: error.url}]
376
- }
377
- end
378
-
379
- defp unhandled_error(%Error{} = error, opts \\ []) do
380
- browserless_issue = Keyword.get(opts, :browserless_issue, false)
381
-
382
- %Result{
383
- ok?: false,
384
- data: %{unhandled: true, browserless_issue: browserless_issue},
385
- errors: [error.message],
386
- recommendations: [%{text: error.recommendation, url: error.url}]
387
- }
388
- end
389
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/plugins/api/funnels.ex DELETED
@@ -1,61 +0,0 @@
1
- defmodule Plausible.Plugins.API.Funnels do
2
- @moduledoc """
3
- Plugins API context module for Funnels.
4
- All high level Funnel operations should be implemented here.
5
- """
6
- use Plausible
7
-
8
- import Plausible.Pagination
9
-
10
- alias Plausible.Repo
11
- alias PlausibleWeb.Plugins.API.Schemas.Funnel.CreateRequest
12
-
13
- @type create_request() :: CreateRequest.t()
14
-
15
- @spec create(
16
- Plausible.Site.t(),
17
- create_request()
18
- ) ::
19
- {:ok, Plausible.Funnel.t()}
20
- | {:error, Ecto.Changeset.t()}
21
- | {:error, :upgrade_required}
22
- def create(site, create_request) do
23
- Repo.transaction(fn ->
24
- with {:ok, goals} <- Plausible.Plugins.API.Goals.create(site, create_request.funnel.steps),
25
- {:ok, funnel} <- get_or_create(site, create_request.funnel.name, goals) do
26
- funnel
27
- else
28
- {:error, error} ->
29
- Repo.rollback(error)
30
- end
31
- end)
32
- end
33
-
34
- defp get_or_create(site, name, goals) do
35
- case get(site, name) do
36
- %Plausible.Funnel{} = funnel ->
37
- {:ok, funnel}
38
-
39
- nil ->
40
- case Plausible.Funnels.create(site, name, goals) do
41
- {:ok, funnel} ->
42
- # reload result with steps included
43
- {:ok, get(site, funnel.id)}
44
-
45
- error ->
46
- error
47
- end
48
- end
49
- end
50
-
51
- @spec get_funnels(Plausible.Site.t(), map()) :: {:ok, Paginator.Page.t()}
52
- def get_funnels(site, params) do
53
- query = Plausible.Funnels.with_goals_query(site)
54
- {:ok, paginate(query, params, cursor_fields: [{:id, :desc}])}
55
- end
56
-
57
- @spec get(Plausible.Site.t(), pos_integer() | String.t()) :: nil | Plausible.Funnel.t()
58
- def get(site, by) when is_integer(by) or is_binary(by) do
59
- Plausible.Funnels.get(site, by)
60
- end
61
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/site/tracker_script_id_cache.ex DELETED
@@ -1,50 +0,0 @@
1
- defmodule Plausible.Site.TrackerScriptIdCache do
2
- @moduledoc """
3
- Cache for tracker script IDs to avoid excessive database lookups when the
4
- script API endpoint is bombarded with random IDs.
5
- """
6
- alias Plausible.Site.TrackerScriptConfiguration
7
- alias PlausibleWeb.Tracker
8
-
9
- import Ecto.Query
10
- use Plausible
11
- use Plausible.Cache
12
-
13
- @cache_name :tracker_script_id_cache
14
-
15
- @impl true
16
- def name(), do: @cache_name
17
-
18
- @impl true
19
- def child_id(), do: :cache_tracker_script_id
20
-
21
- @impl true
22
- def count_all() do
23
- Plausible.Repo.aggregate(TrackerScriptConfiguration, :count)
24
- end
25
-
26
- @impl true
27
- def base_db_query(), do: Tracker.get_tracker_script_configuration_base_query()
28
-
29
- @impl true
30
- def get_from_source(id) do
31
- case Tracker.get_tracker_script_configuration_by_id(id) do
32
- %TrackerScriptConfiguration{site: %{domain: _domain}} ->
33
- true
34
-
35
- _ ->
36
- nil
37
- end
38
- end
39
-
40
- @impl true
41
- def unwrap_cache_keys(items) do
42
- Enum.reduce(items, [], fn
43
- tracker_script_configuration, acc ->
44
- [
45
- {tracker_script_configuration.id, true}
46
- | acc
47
- ]
48
- end)
49
- end
50
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/stats/exploration.ex DELETED
@@ -1,613 +0,0 @@
1
- defmodule Plausible.Stats.Exploration do
2
- @moduledoc """
3
- Query logic for user journey exploration.
4
- """
5
-
6
- import Ecto.Query
7
- import Plausible.Stats.SQL.Fragments
8
- import Plausible.Stats.Util, only: [percentage: 2]
9
-
10
- alias Plausible.ClickhouseRepo
11
- alias Plausible.Stats.Base
12
- alias Plausible.Stats.Exploration.Journey
13
- alias Plausible.Stats.Filters
14
- alias Plausible.Stats.Query
15
-
16
- @type journey() :: [Journey.Step.t()]
17
- @type direction() :: :forward | :backward
18
-
19
- defguard is_direction(value) when value in [:forward, :backward]
20
-
21
- @type next_step() :: %{
22
- step: Journey.Step.t(),
23
- visitors: pos_integer()
24
- }
25
-
26
- @type funnel_step() :: %{
27
- step: Journey.Step.t(),
28
- visitors: non_neg_integer(),
29
- dropoff: non_neg_integer(),
30
- dropoff_percentage: String.t(),
31
- conversion_rate: String.t(),
32
- conversion_rate_step: String.t()
33
- }
34
-
35
- @max_steps 20
36
- @max_candidates 50
37
-
38
- @next_steps_defaults [
39
- search_term: "",
40
- direction: :forward,
41
- max_candidates: 10,
42
- include_wildcard?: true
43
- ]
44
-
45
- @spec max_steps() :: pos_integer()
46
- def max_steps, do: @max_steps
47
-
48
- @spec next_steps(Plausible.Site.t(), Query.t(), journey(), keyword()) ::
49
- {:ok, [next_step()]} | {:error, :journey_too_long}
50
- def next_steps(site, query, journey, opts \\ [])
51
-
52
- def next_steps(_site, _query, journey, _opts) when length(journey) >= @max_steps do
53
- {:error, :journey_too_long}
54
- end
55
-
56
- def next_steps(site, query, journey, opts) do
57
- opts = Keyword.merge(@next_steps_defaults, opts)
58
- direction = Keyword.fetch!(opts, :direction)
59
- search_term = Keyword.fetch!(opts, :search_term)
60
- max_candidates = min(Keyword.fetch!(opts, :max_candidates), @max_candidates)
61
- include_wilcard? = Keyword.fetch!(opts, :include_wildcard?)
62
-
63
- goals =
64
- site
65
- |> Plausible.Goals.for_site(include_goals_with_custom_props?: false)
66
- |> filter_eligible_goals()
67
-
68
- query
69
- |> Base.base_event_query()
70
- |> next_steps_query(journey, search_term, direction, max_candidates, include_wilcard?, goals)
71
- # We pass the query struct to record query metadata for
72
- # the CH debug console.
73
- |> ClickhouseRepo.all(query: query)
74
- |> then(&{:ok, &1})
75
- end
76
-
77
- @spec journey_funnel(Query.t(), journey(), direction()) ::
78
- {:ok, [funnel_step()]} | {:error, :empty_journey | :journey_too_long}
79
- def journey_funnel(query, journey, direction \\ :forward)
80
-
81
- def journey_funnel(_query, [], _direction), do: {:error, :empty_journey}
82
-
83
- def journey_funnel(_query, journey, _direction) when length(journey) > @max_steps do
84
- {:error, :journey_too_long}
85
- end
86
-
87
- def journey_funnel(query, journey, direction) when is_direction(direction) do
88
- query
89
- |> Base.base_event_query()
90
- |> journey_funnel_query(journey, direction)
91
- # We pass the query struct to record query metadata for
92
- # the CH debug console.
93
- |> ClickhouseRepo.all(query: query)
94
- |> to_funnel(journey)
95
- |> then(&{:ok, &1})
96
- end
97
-
98
- defp filter_eligible_goals(goals) do
99
- Enum.reject(goals, fn g ->
100
- Plausible.Goal.Revenue.revenue?(g) or g.scroll_threshold > -1 or
101
- Plausible.Goal.has_custom_props?(g)
102
- end)
103
- end
104
-
105
- defp next_steps_query(
106
- query,
107
- steps,
108
- search_term,
109
- direction,
110
- max_candidates,
111
- include_wildcard?,
112
- goals
113
- )
114
- when is_direction(direction) do
115
- next_step_idx = length(steps) + 1
116
- q_steps = steps_query(query, next_step_idx, direction)
117
-
118
- next_name = :"name#{next_step_idx}"
119
- next_pathname = :"pathname#{next_step_idx}"
120
-
121
- q_matches =
122
- from(s in subquery(q_steps),
123
- select: %{
124
- user_id: s.user_id,
125
- name: selected_as(field(s, ^next_name), :name),
126
- pathname: selected_as(field(s, ^next_pathname), :pathname),
127
- _sample_factor: fragment("any(?)", s._sample_factor)
128
- },
129
- group_by: [selected_as(:name), selected_as(:pathname), s.user_id]
130
- )
131
-
132
- q_matches =
133
- steps
134
- |> Enum.with_index()
135
- |> Enum.reduce(q_matches, fn {step, idx}, q ->
136
- step_condition = step_condition(step, idx + 1)
137
-
138
- from(s in q, where: ^step_condition)
139
- end)
140
- |> maybe_exclude_step_matches(List.last(steps))
141
-
142
- # Fan out each q_combined row into up to two output rows (exact + wildcard)
143
- # using ARRAY JOIN over a small boolean array.
144
- #
145
- # For each row we build [false, true] and filter it down to just [false]
146
- # when the wildcard row should be suppressed (non-pageview, only one distinct
147
- # subpath, or same visitor count as exact). ARRAY JOIN then emits one or more
148
- # rows per group. The joined boolean `is_wildcard` selects which values to
149
- # use for visitors / includes_subpaths / subpaths_count.
150
- q_wildcard_combined_matches =
151
- q_matches
152
- |> combined_wildcard_query(include_wildcard?)
153
- |> combined_wildcard_matches_query()
154
-
155
- q_all_combined_matches =
156
- if q_goal_matches = goals_query(q_matches, goals) do
157
- q_wildcard_combined_matches
158
- |> exclude_goal_matches(goals)
159
- |> union_all(^q_goal_matches)
160
- else
161
- q_wildcard_combined_matches
162
- end
163
-
164
- from(m in subquery(q_all_combined_matches),
165
- select: %{
166
- step: %Journey.Step{
167
- label:
168
- selected_as(
169
- fragment("if(? != '', ?, ?)", m.name, m.label, ^Journey.Step.journey_end_label()),
170
- :label
171
- ),
172
- name: fragment("if(? != '', ?, ?)", m.name, m.name, ^Journey.Step.journey_end_event()),
173
- pathname: m.pathname,
174
- includes_subpaths: m.includes_subpaths,
175
- subpaths_count: m.subpaths_count,
176
- is_goal: m.is_goal
177
- },
178
- visitors: m.visitors
179
- },
180
- order_by: [
181
- desc: m.visitors,
182
- asc: m.pathname,
183
- asc: m.name
184
- ],
185
- limit: ^max_candidates
186
- )
187
- |> maybe_search(search_term)
188
- end
189
-
190
- defp goals_query(_, []), do: nil
191
-
192
- defp goals_query(q_matches, goals) do
193
- values =
194
- Enum.map(goals, fn g ->
195
- pathname = g.page_path || ""
196
-
197
- regex_pathname =
198
- if String.contains?(pathname, "*") do
199
- Filters.Utils.page_regex(pathname)
200
- else
201
- ""
202
- end
203
-
204
- %{
205
- label: g.display_name,
206
- name: g.event_name || "pageview",
207
- pathname: pathname,
208
- regex_pathname: regex_pathname
209
- }
210
- end)
211
-
212
- types = %{label: :string, name: :string, pathname: :string, regex_pathname: :string}
213
-
214
- query =
215
- from(m in subquery(q_matches),
216
- inner_join: g in values(values, types),
217
- on:
218
- g.name == m.name and
219
- (g.name != "pageview" or
220
- (g.name == "pageview" and
221
- fragment(
222
- "if(? != '', match(?, ?), ? = ?)",
223
- g.regex_pathname,
224
- m.pathname,
225
- g.regex_pathname,
226
- m.pathname,
227
- g.pathname
228
- ))),
229
- select: %{
230
- label: selected_as(g.label, :label),
231
- name: selected_as(g.name, :name),
232
- pathname: selected_as(g.pathname, :pathname),
233
- visitors: scale_sample(fragment("uniq(?)", m.user_id)),
234
- includes_subpaths: fragment("CAST(?, 'Bool')", false),
235
- subpaths_count: 0,
236
- is_goal: fragment("CAST(?, 'Bool')", true)
237
- },
238
- group_by: [selected_as(:label), selected_as(:name), selected_as(:pathname)]
239
- )
240
-
241
- from(m in subquery(query),
242
- where: m.visitors > 0
243
- )
244
- end
245
-
246
- defp maybe_exclude_step_matches(query, %{includes_subpaths: true} = step) do
247
- pattern = wildcard_pattern(step.pathname)
248
-
249
- from m in query,
250
- where:
251
- selected_as(:name) != ^step.name or
252
- not fragment("match(?, ?)", selected_as(:pathname), ^pattern)
253
- end
254
-
255
- defp maybe_exclude_step_matches(query, %{is_goal: true, name: "pageview"} = step) do
256
- if String.contains?(step.pathname, "*") do
257
- pattern = Filters.Utils.page_regex(step.pathname)
258
-
259
- from m in query,
260
- where:
261
- selected_as(:name) != ^step.name or
262
- not fragment("match(?, ?)", selected_as(:pathname), ^pattern)
263
- else
264
- query
265
- end
266
- end
267
-
268
- defp maybe_exclude_step_matches(query, _), do: query
269
-
270
- defp exclude_goal_matches(query, goals) do
271
- to_exclude =
272
- goals
273
- |> Enum.filter(fn g -> is_nil(g.page_path) or not String.contains?(g.page_path, "*") end)
274
- |> Enum.map(fn g ->
275
- %{
276
- name: g.event_name || "pageview",
277
- pathname: g.page_path || ""
278
- }
279
- end)
280
-
281
- if to_exclude != [] do
282
- types = %{name: :string, pathname: :string}
283
-
284
- from m in subquery(query),
285
- left_join: g in values(to_exclude, types),
286
- on: g.name == m.name and g.pathname == m.pathname,
287
- where: g.name == "" or m.includes_subpaths
288
- else
289
- query
290
- end
291
- end
292
-
293
- # Expand each (name, pathname, user_id) row into all prefix paths via
294
- # ARRAY JOIN, then aggregate once to get both exact and wildcard visitor
295
- # counts in a single scan of events_v2.
296
- #
297
- # The arrayFold expansion includes the original pathname as the last
298
- # element, so uniqIf(user_id, original_pathname = prefix_pathname) gives the
299
- # exact-match count for free, alongside the wildcard uniq(user_id) and the
300
- # uniq(original_pathname) subpath count — all in one GROUP BY.
301
- #
302
- # Non-pageview events are included in the expansion but produce only a
303
- # single prefix (their exact pathname), so they naturally get
304
- # subpaths_count = 1 and are only emitted as exact rows.
305
- @wildcard_array_join """
306
- if(? = 'pageview', arrayFold(
307
- acc, x -> arrayPushBack(acc, concat(acc[-1], '/', x)),
308
- arraySlice(splitByChar('/', ?) AS split_pathname, 2),
309
- arraySlice(split_pathname, 1, 1)), [?])
310
- """
311
-
312
- defp combined_wildcard_query(q_matches, true = _include_wildcard?) do
313
- from(em in subquery(q_matches),
314
- join: pname in fragment(@wildcard_array_join, em.name, em.pathname, em.pathname),
315
- on: true,
316
- hints: "ARRAY",
317
- where: em.name != "pageview" or selected_as(:pathname) != "",
318
- select: %{
319
- name: em.name,
320
- pathname: selected_as(fragment("?", pname), :pathname),
321
- exact_visitors:
322
- scale_sample(fragment("uniqIf(?, ? = ?)", em.user_id, em.pathname, pname)),
323
- wildcard_visitors:
324
- selected_as(scale_sample(fragment("uniq(?)", em.user_id)), :wildcard_visitors),
325
- subpaths_count: scale_sample(fragment("uniq(?)", em.pathname))
326
- },
327
- group_by: [em.name, selected_as(:pathname)]
328
- )
329
- end
330
-
331
- defp combined_wildcard_query(q_matches, false = _include_wildcard?) do
332
- from(em in subquery(q_matches),
333
- where: em.name != "pageview" or selected_as(:pathname) != "",
334
- select: %{
335
- name: em.name,
336
- pathname: selected_as(em.pathname, :pathname),
337
- exact_visitors:
338
- selected_as(scale_sample(fragment("uniq(?)", em.user_id)), :exact_visitors),
339
- wildcard_visitors: selected_as(:exact_visitors),
340
- subpaths_count: 1
341
- },
342
- group_by: [em.name, selected_as(:pathname)]
343
- )
344
- end
345
-
346
- defp combined_wildcard_matches_query(q_wildcard_combined) do
347
- from(m in subquery(q_wildcard_combined),
348
- join:
349
- is_wildcard in fragment(
350
- """
351
- arrayFilter(
352
- x -> x = false OR (? = 'pageview' AND ? != '/' AND ? > 1 AND ? != ?),
353
- [false, true]
354
- )
355
- """,
356
- m.name,
357
- m.pathname,
358
- m.subpaths_count,
359
- m.wildcard_visitors,
360
- m.exact_visitors
361
- ),
362
- on: true,
363
- hints: "ARRAY",
364
- where: selected_as(:visitors) > 0,
365
- select: %{
366
- label:
367
- selected_as(
368
- fragment(
369
- "if(? != 'pageview', ?, ?)",
370
- m.name,
371
- m.name,
372
- m.pathname
373
- ),
374
- :label
375
- ),
376
- name: selected_as(m.name, :name),
377
- pathname: selected_as(m.pathname, :pathname),
378
- visitors:
379
- selected_as(
380
- fragment("if(?, ?, ?)", is_wildcard, m.wildcard_visitors, m.exact_visitors),
381
- :visitors
382
- ),
383
- includes_subpaths:
384
- selected_as(fragment("CAST(?, 'Bool')", is_wildcard), :includes_subpaths),
385
- subpaths_count: fragment("if(?, ?, 0)", is_wildcard, m.subpaths_count),
386
- is_goal: fragment("CAST(?, 'Bool')", false)
387
- }
388
- )
389
- end
390
-
391
- defp journey_funnel_query(query, steps, direction) do
392
- q_steps = steps_query(query, length(steps), direction)
393
-
394
- q_funnel = from(s in subquery(q_steps), select: %{})
395
-
396
- steps
397
- |> Enum.with_index()
398
- |> Enum.reduce(q_funnel, fn
399
- {step, 0}, q ->
400
- step_condition = step_condition(step, 1)
401
-
402
- from(e in q,
403
- select_merge: %{
404
- 1 => scale_sample(fragment("uniq(?)", e.user_id))
405
- },
406
- where: ^step_condition
407
- )
408
-
409
- {_step, idx}, q ->
410
- current_steps = Enum.take(steps, idx + 1)
411
-
412
- step_conditions =
413
- current_steps
414
- |> Enum.with_index()
415
- |> Enum.reduce(dynamic(true), fn {step, idx}, acc ->
416
- step_condition = step_condition(step, idx + 1)
417
- dynamic([q], fragment("? and ?", ^acc, ^step_condition))
418
- end)
419
-
420
- step_count =
421
- dynamic(
422
- [e],
423
- scale_sample(
424
- fragment(
425
- "uniqIf(?, ?)",
426
- e.user_id,
427
- ^step_conditions
428
- )
429
- )
430
- )
431
-
432
- from(e in q, select_merge: ^%{(idx + 1) => step_count})
433
- end)
434
- end
435
-
436
- defp steps_query(query, steps, direction) when is_integer(steps) do
437
- q_pairs =
438
- from(e in query,
439
- windows: [
440
- session_window: [
441
- partition_by: e.user_id,
442
- order_by: [asc: e.timestamp]
443
- ]
444
- ],
445
- select: %{
446
- site_id: e.site_id,
447
- user_id: e.user_id,
448
- _sample_factor: e._sample_factor,
449
- row_number: row_number() |> over(:session_window),
450
- name: e.name,
451
- pathname: fragment("if(? = 'pageview', ?, '')", e.name, e.pathname),
452
- timestamp: e.timestamp
453
- },
454
- where: e.name != "engagement" and e.revenue_reporting_currency == ""
455
- )
456
- |> select_previous(direction)
457
-
458
- q_steps =
459
- from(e in subquery(q_pairs),
460
- windows: [
461
- step_window: [partition_by: e.user_id, order_by: [asc: e.timestamp, asc: e.row_number]]
462
- ],
463
- select: %{
464
- user_id: e.user_id,
465
- _sample_factor: e._sample_factor,
466
- name1: e.name,
467
- pathname1: e.pathname
468
- },
469
- where: e.prev_name != e.name or e.prev_pathname != e.pathname
470
- )
471
-
472
- if steps > 1 do
473
- Enum.reduce(1..(steps - 1), q_steps, fn idx, q ->
474
- select_next(q, idx, direction)
475
- end)
476
- else
477
- q_steps
478
- end
479
- end
480
-
481
- defp select_previous(query, :forward) do
482
- from(e in query,
483
- select_merge: %{
484
- prev_pathname:
485
- lag(fragment("if(? = 'pageview', ?, '')", e.name, e.pathname)) |> over(:session_window),
486
- prev_name: lag(e.name) |> over(:session_window)
487
- }
488
- )
489
- end
490
-
491
- defp select_previous(query, :backward) do
492
- from(e in query,
493
- select_merge: %{
494
- prev_pathname:
495
- lead(fragment("if(? = 'pageview', ?, '')", e.name, e.pathname)) |> over(:session_window),
496
- prev_name: lead(e.name) |> over(:session_window)
497
- }
498
- )
499
- end
500
-
501
- defp select_next(query, idx, :forward) do
502
- from(e in query,
503
- select_merge: %{
504
- ^:"name#{idx + 1}" => lead(e.name, ^idx) |> over(:step_window),
505
- ^:"pathname#{idx + 1}" => lead(e.pathname, ^idx) |> over(:step_window)
506
- }
507
- )
508
- end
509
-
510
- defp select_next(query, idx, :backward) do
511
- from(e in query,
512
- select_merge: %{
513
- ^:"name#{idx + 1}" => lag(e.name, ^idx) |> over(:step_window),
514
- ^:"pathname#{idx + 1}" => lag(e.pathname, ^idx) |> over(:step_window)
515
- }
516
- )
517
- end
518
-
519
- defp step_condition(step, count) when count <= @max_steps do
520
- cond do
521
- step.includes_subpaths ->
522
- pattern = wildcard_pattern(step.pathname)
523
-
524
- dynamic(
525
- [s],
526
- field(s, ^:"name#{count}") == ^step.name and
527
- fragment("match(?, ?)", field(s, ^:"pathname#{count}"), ^pattern)
528
- )
529
-
530
- step.is_goal and step.name == "pageview" and String.contains?(step.pathname, "*") ->
531
- pattern = Filters.Utils.page_regex(step.pathname)
532
-
533
- dynamic(
534
- [s],
535
- field(s, ^:"name#{count}") == ^step.name and
536
- fragment("match(?, ?)", field(s, ^:"pathname#{count}"), ^pattern)
537
- )
538
-
539
- step.name == Journey.Step.journey_end_event() ->
540
- dynamic(
541
- [s],
542
- field(s, ^:"name#{count}") == ""
543
- )
544
-
545
- true ->
546
- dynamic(
547
- [s],
548
- field(s, ^:"name#{count}") == ^step.name and
549
- field(s, ^:"pathname#{count}") == ^step.pathname
550
- )
551
- end
552
- end
553
-
554
- defp wildcard_pattern(pathname) when is_binary(pathname) do
555
- escaped = Regex.escape(pathname)
556
-
557
- "^#{escaped}(/.*)?$"
558
- end
559
-
560
- defp maybe_search(query, search_term) do
561
- case String.trim(search_term) do
562
- term when byte_size(term) > 2 ->
563
- from(s in query,
564
- where:
565
- ilike(selected_as(:label), ^"%#{term}%") or
566
- ilike(s.pathname, ^"%#{term}%") or
567
- (s.name != "pageview" and ilike(s.name, ^"%#{term}%"))
568
- )
569
-
570
- _ ->
571
- query
572
- end
573
- end
574
-
575
- defp to_funnel([result], journey) do
576
- journey
577
- |> Enum.with_index()
578
- |> Enum.reduce(%{funnel: [], visitors_at_previous: nil, total_visitors: nil}, fn {step, idx},
579
- acc ->
580
- step = Journey.Step.from(step)
581
- current_visitors = Map.get(result, idx + 1, 0)
582
- total_visitors = acc.total_visitors || current_visitors
583
-
584
- dropoff =
585
- if acc.visitors_at_previous, do: acc.visitors_at_previous - current_visitors, else: 0
586
-
587
- dropoff_percentage = percentage(dropoff, acc.visitors_at_previous)
588
- conversion_rate = percentage(current_visitors, total_visitors)
589
- conversion_rate_step = percentage(current_visitors, acc.visitors_at_previous)
590
-
591
- funnel = [
592
- %{
593
- step: step,
594
- visitors: current_visitors,
595
- dropoff: dropoff,
596
- dropoff_percentage: dropoff_percentage,
597
- conversion_rate: conversion_rate,
598
- conversion_rate_step: conversion_rate_step
599
- }
600
- | acc.funnel
601
- ]
602
-
603
- %{
604
- acc
605
- | funnel: funnel,
606
- visitors_at_previous: current_visitors,
607
- total_visitors: total_visitors
608
- }
609
- end)
610
- |> Map.fetch!(:funnel)
611
- |> Enum.reverse()
612
- end
613
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/stats/funnel.ex DELETED
@@ -1,171 +0,0 @@
1
- defmodule Plausible.Stats.Funnel do
2
- @moduledoc """
3
- Module responsible for funnel evaluation, i.e. building and executing
4
- ClickHouse funnel query based on `Plausible.Funnel` definition.
5
- """
6
-
7
- @funnel_window_duration 86_400
8
-
9
- alias Plausible.Funnel
10
- alias Plausible.Funnels
11
-
12
- import Ecto.Query
13
- import Plausible.Stats.SQL.Fragments
14
- import Plausible.Stats.Util, only: [percentage: 2]
15
-
16
- alias Plausible.ClickhouseRepo
17
- alias Plausible.Stats.{Base, Query}
18
-
19
- @spec funnel(Plausible.Site.t(), Plausible.Stats.Query.t(), Funnel.t() | pos_integer()) ::
20
- {:ok, map()} | {:error, :funnel_not_found}
21
- def funnel(site, query, funnel_id) when is_integer(funnel_id) do
22
- case Funnels.get(site.id, funnel_id) do
23
- %Funnel{} = funnel ->
24
- funnel(site, query, funnel)
25
-
26
- nil ->
27
- {:error, :funnel_not_found}
28
- end
29
- end
30
-
31
- def funnel(_site, query, %Funnel{} = funnel) do
32
- goals = Enum.map(funnel.steps, & &1.goal)
33
-
34
- funnel_data =
35
- query
36
- |> Query.set(preloaded_goals: %{all: [], matching_toplevel_filters: goals})
37
- |> Base.base_event_query()
38
- |> funnel_query(funnel)
39
- # We pass the query struct to record query metadata for
40
- # the CH debug console.
41
- |> ClickhouseRepo.all(query: query)
42
-
43
- # Funnel definition steps are 1-indexed, if there's index 0 in the resulting query,
44
- # it signifies the number of visitors that haven't entered the funnel.
45
- not_entering_visitors =
46
- case funnel_data do
47
- [{0, count} | _] -> count
48
- _ -> 0
49
- end
50
-
51
- all_visitors = Enum.reduce(funnel_data, 0, fn {_, n}, acc -> acc + n end)
52
- steps = backfill_steps(funnel_data, funnel)
53
-
54
- visitors_at_first_step = List.first(steps).visitors
55
-
56
- {:ok,
57
- %{
58
- name: funnel.name,
59
- steps: steps,
60
- all_visitors: all_visitors,
61
- entering_visitors: visitors_at_first_step,
62
- entering_visitors_percentage: percentage(visitors_at_first_step, all_visitors),
63
- never_entering_visitors: all_visitors - visitors_at_first_step,
64
- never_entering_visitors_percentage: percentage(not_entering_visitors, all_visitors)
65
- }}
66
- end
67
-
68
- defp funnel_query(query, funnel_definition) do
69
- q_events =
70
- from(e in query,
71
- select: %{user_id: e.user_id, _sample_factor: fragment("any(_sample_factor)")},
72
- where: e.site_id == ^funnel_definition.site_id,
73
- group_by: e.user_id,
74
- order_by: [desc: fragment("step")]
75
- )
76
- |> select_funnel(funnel_definition)
77
-
78
- from(f in subquery(q_events),
79
- select: {f.step, total()},
80
- group_by: f.step
81
- )
82
- end
83
-
84
- defp select_funnel(db_query, funnel_definition) do
85
- window_funnel_steps =
86
- Enum.reduce(funnel_definition.steps, nil, fn step, acc ->
87
- goal_condition = Plausible.Stats.Goals.goal_condition(step.goal)
88
-
89
- if acc do
90
- dynamic([q], fragment("?, ?", ^acc, ^goal_condition))
91
- else
92
- dynamic([q], fragment("?", ^goal_condition))
93
- end
94
- end)
95
-
96
- dynamic_window_funnel =
97
- if funnel_definition.strict_order do
98
- dynamic(
99
- [q],
100
- fragment(
101
- "windowFunnel(?, 'strict_order')(timestamp, ?)",
102
- @funnel_window_duration,
103
- ^window_funnel_steps
104
- )
105
- )
106
- else
107
- dynamic(
108
- [q],
109
- fragment("windowFunnel(?)(timestamp, ?)", @funnel_window_duration, ^window_funnel_steps)
110
- )
111
- end
112
-
113
- from(q in db_query,
114
- select_merge:
115
- ^%{
116
- step: dynamic_window_funnel
117
- }
118
- )
119
- end
120
-
121
- defp backfill_steps(funnel_result, funnel) do
122
- # Directly from ClickHouse we only get {step_idx(), visitor_count()} tuples.
123
- # but no totals including previous steps are aggregated.
124
- # Hence we need to perform the appropriate backfill
125
- # and also calculate dropoff and conversion rate for each step.
126
- # In case ClickHouse returns 0-index funnel result, we're going to ignore it
127
- # anyway, since we fold over steps as per definition, that are always
128
- # indexed starting from 1.
129
- funnel_result = Enum.into(funnel_result, %{})
130
- max_step = Enum.max_by(funnel.steps, & &1.step_order).step_order
131
-
132
- funnel
133
- |> Map.fetch!(:steps)
134
- |> Enum.reduce({nil, nil, []}, fn step, {total_visitors, visitors_at_previous, acc} ->
135
- # first step contains the total number of all visitors qualifying for the funnel,
136
- # with each subsequent step needing to accumulate sum of the previous one(s)
137
- visitors_at_step =
138
- step.step_order..max_step
139
- |> Enum.map(&Map.get(funnel_result, &1, 0))
140
- |> Enum.sum()
141
-
142
- # accumulate current_visitors for the next iteration
143
- current_visitors = visitors_at_step
144
-
145
- # First step contains the total number of visitors that we base percentage dropoff on
146
- total_visitors =
147
- total_visitors ||
148
- current_visitors
149
-
150
- # Dropoff is 0 for the first step, otherwise we subtract current from previous
151
- dropoff = if visitors_at_previous, do: visitors_at_previous - current_visitors, else: 0
152
-
153
- dropoff_percentage = percentage(dropoff, visitors_at_previous)
154
- conversion_rate = percentage(current_visitors, total_visitors)
155
- conversion_rate_step = percentage(current_visitors, visitors_at_previous)
156
-
157
- step = %{
158
- dropoff: dropoff,
159
- dropoff_percentage: dropoff_percentage,
160
- conversion_rate: conversion_rate,
161
- conversion_rate_step: conversion_rate_step,
162
- visitors: visitors_at_step,
163
- label: to_string(step.goal)
164
- }
165
-
166
- {total_visitors, current_visitors, [step | acc]}
167
- end)
168
- |> elem(2)
169
- |> Enum.reverse()
170
- end
171
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/stats/goal/revenue.ex DELETED
@@ -1,94 +0,0 @@
1
- defmodule Plausible.Stats.Goal.Revenue do
2
- @moduledoc """
3
- Revenue specific functions for the stats scope
4
- """
5
-
6
- @revenue_metrics [:average_revenue, :total_revenue]
7
-
8
- def revenue_metrics() do
9
- @revenue_metrics
10
- end
11
-
12
- @doc """
13
- Preloads revenue currencies for a query. Used when parsing the query.
14
-
15
- Returns tuple containing revenue warning (if set, no revenue metrics should be calculated) and
16
- revenue currencies map.
17
-
18
- Assumptions and business logic:
19
- 1. Goals are already filtered according to query filters and dimensions
20
- 2. If there's a single currency involved, return map containing the default
21
- 3. If there's a breakdown by event:goal we return all the relevant currencies as a map
22
- 4. If filtering by multiple different currencies without event:goal breakdown empty map is returned
23
- 5. If user has no access or preloading is not needed, empty map is returned
24
-
25
- The resulting data structure is attached to a `Query` and used below in `format_revenue_metric/3`.
26
- """
27
- def preload(site, preloaded_goals, metrics, dimensions) do
28
- cond do
29
- not requested?(metrics) -> {nil, %{}}
30
- not available?(site) -> {:revenue_goals_unavailable, %{}}
31
- true -> preload(preloaded_goals.matching_toplevel_filters, dimensions)
32
- end
33
- end
34
-
35
- defp preload(goals, dimensions) do
36
- goal_currency_map =
37
- goals
38
- |> Map.new(fn goal -> {Plausible.Goal.display_name(goal), goal.currency} end)
39
- |> Map.reject(fn {_goal, currency} -> is_nil(currency) end)
40
-
41
- currencies = goal_currency_map |> Map.values() |> Enum.uniq()
42
- goal_dimension? = "event:goal" in dimensions
43
-
44
- case {currencies, goal_dimension?} do
45
- {[currency], false} -> {nil, %{default: currency}}
46
- {[], _} -> {:no_revenue_goals_matching, %{}}
47
- {_, true} -> {nil, goal_currency_map}
48
- _ -> {:no_single_revenue_currency, %{}}
49
- end
50
- end
51
-
52
- def format_revenue_metric(_, query, _) when not is_nil(query.revenue_warning), do: nil
53
-
54
- def format_revenue_metric(value, query, dimension_values) do
55
- currency =
56
- query.revenue_currencies[:default] ||
57
- get_goal_dimension_revenue_currency(query, dimension_values)
58
-
59
- format_revenue_metric(value, currency)
60
- end
61
-
62
- def format_revenue_metric(value, currency) do
63
- if currency do
64
- money = Money.new!(value || 0, currency)
65
-
66
- %{
67
- short: Money.to_string!(money, format: :short, fractional_digits: 1),
68
- long: Money.to_string!(money),
69
- value: Decimal.to_float(money.amount),
70
- currency: currency
71
- }
72
- else
73
- value
74
- end
75
- end
76
-
77
- def available?(site) do
78
- site = Plausible.Repo.preload(site, :team)
79
- Plausible.Billing.Feature.RevenueGoals.check_availability(site.team) == :ok
80
- end
81
-
82
- # :NOTE: Legacy queries don't have metrics associated with them so work around the issue by assuming
83
- # revenue metric was requested.
84
- def requested?([]), do: true
85
- def requested?(metrics), do: Enum.any?(metrics, &(&1 in @revenue_metrics))
86
-
87
- defp get_goal_dimension_revenue_currency(query, dimension_values) do
88
- Enum.zip(query.dimensions, dimension_values)
89
- |> Enum.find_value(fn
90
- {"event:goal", goal_label} -> Map.get(query.revenue_currencies, goal_label)
91
- _ -> nil
92
- end)
93
- end
94
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/stats/journey/step.ex DELETED
@@ -1,53 +0,0 @@
1
- defmodule Plausible.Stats.Exploration.Journey.Step do
2
- @moduledoc false
3
-
4
- @type t() :: %__MODULE__{}
5
-
6
- @derive {Jason.Encoder,
7
- only: [:name, :pathname, :label, :includes_subpaths, :subpaths_count, :is_goal]}
8
- defstruct name: nil,
9
- pathname: "",
10
- label: nil,
11
- includes_subpaths: false,
12
- subpaths_count: 0,
13
- is_goal: false
14
-
15
- @journey_end_event "__journey_end__"
16
- @journey_end_label "No further action"
17
-
18
- @spec journey_end_event() :: String.t()
19
- def journey_end_event, do: @journey_end_event
20
-
21
- @spec journey_end_label() :: String.t()
22
- def journey_end_label, do: @journey_end_label
23
-
24
- @spec from(map()) :: t()
25
- def from(step) do
26
- new(step.name, step.pathname, step.includes_subpaths, step.subpaths_count, step.is_goal)
27
- end
28
-
29
- @spec new(String.t(), String.t(), boolean(), non_neg_integer(), boolean()) :: t()
30
- def new(name, pathname, includes_subpaths \\ false, subpaths_count \\ 0, is_goal \\ false)
31
- when is_boolean(includes_subpaths) and is_integer(subpaths_count) do
32
- label =
33
- cond do
34
- name == @journey_end_event ->
35
- @journey_end_label
36
-
37
- name != "pageview" ->
38
- name
39
-
40
- true ->
41
- pathname
42
- end
43
-
44
- %__MODULE__{
45
- label: label,
46
- name: name,
47
- pathname: pathname,
48
- includes_subpaths: includes_subpaths,
49
- subpaths_count: subpaths_count,
50
- is_goal: is_goal
51
- }
52
- end
53
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/stats/sampling.ex DELETED
@@ -1,105 +0,0 @@
1
- defmodule Plausible.Stats.Sampling do
2
- @moduledoc """
3
- Sampling related functions
4
- """
5
- @default_sample_threshold 10_000_000
6
-
7
- import Ecto.Query
8
-
9
- alias Plausible.Stats.{Query, SamplingCache}
10
-
11
- def default_sample_threshold(), do: @default_sample_threshold
12
-
13
- @spec add_query_hint(Ecto.Query.t(), Plausible.Stats.Query.t()) :: Ecto.Query.t()
14
- def add_query_hint(%Ecto.Query{} = db_query, %Plausible.Stats.Query{} = query) do
15
- case query.sample_threshold do
16
- :no_sampling ->
17
- db_query
18
-
19
- nil ->
20
- db_query
21
-
22
- threshold ->
23
- add_query_hint(db_query, threshold)
24
- end
25
- end
26
-
27
- @spec add_query_hint(Ecto.Query.t(), pos_integer() | float()) :: Ecto.Query.t()
28
- def add_query_hint(%Ecto.Query{} = query, threshold) when is_number(threshold) do
29
- from(x in query, hints: unsafe_fragment(^"SAMPLE #{threshold}"))
30
- end
31
-
32
- @spec add_query_hint(Ecto.Query.t()) :: Ecto.Query.t()
33
- def add_query_hint(%Ecto.Query{} = query) do
34
- add_query_hint(query, @default_sample_threshold)
35
- end
36
-
37
- @spec put_threshold(Plausible.Stats.Query.t(), Plausible.Site.t(), map()) ::
38
- Plausible.Stats.Query.t()
39
- def put_threshold(query, site, params) do
40
- sample_threshold =
41
- case params["sample_threshold"] do
42
- nil ->
43
- decide_sample_rate(site, query)
44
-
45
- "infinite" ->
46
- :no_sampling
47
-
48
- value_string ->
49
- {value, _} = Float.parse(value_string)
50
- value
51
- end
52
-
53
- Map.put(query, :sample_threshold, sample_threshold)
54
- end
55
-
56
- defp decide_sample_rate(site, query) do
57
- if Plausible.Sites.consolidated?(site) and not Enum.empty?(query.consolidated_site_ids) do
58
- query.consolidated_site_ids
59
- |> SamplingCache.consolidated_get()
60
- |> fractional_sample_rate(query)
61
- else
62
- site.id
63
- |> SamplingCache.get()
64
- |> fractional_sample_rate(query)
65
- end
66
- end
67
-
68
- def fractional_sample_rate(nil = _traffic_30_day, _query),
69
- do: :no_sampling
70
-
71
- def fractional_sample_rate(traffic_30_day, query) do
72
- date_range = Query.date_range(query)
73
- duration = Date.diff(date_range.last, date_range.first)
74
-
75
- estimated_traffic = estimate_traffic(traffic_30_day, duration, query)
76
-
77
- fraction =
78
- if(estimated_traffic > 0,
79
- do: Float.round(@default_sample_threshold / estimated_traffic, 2),
80
- else: 1.0
81
- )
82
-
83
- cond do
84
- # Don't sample small time ranges
85
- duration < 1 -> :no_sampling
86
- # If sampling doesn't have a significant effect, don't sample
87
- fraction > 0.4 -> :no_sampling
88
- true -> max(fraction, min_sample_rate())
89
- end
90
- end
91
-
92
- defp min_sample_rate(), do: 0.013
93
-
94
- defp estimate_traffic(traffic_30_day, duration, query) do
95
- duration_adjusted_traffic = traffic_30_day / 30.0 * duration
96
-
97
- estimate_by_filters(duration_adjusted_traffic, query.filters)
98
- end
99
-
100
- @filter_traffic_multiplier 1 / 4.0
101
- @max_filters 2
102
-
103
- defp estimate_by_filters(estimation, filters),
104
- do: estimation * @filter_traffic_multiplier ** min(length(filters), @max_filters)
105
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/stats/sampling_cache.ex DELETED
@@ -1,59 +0,0 @@
1
- defmodule Plausible.Stats.SamplingCache do
2
- @moduledoc """
3
- Cache storing estimation for events ingested by a site in the past month.
4
-
5
- Used for sampling rate calculations in Plausible.Stats.Sampling.
6
- """
7
- alias Plausible.Ingestion
8
-
9
- import Ecto.Query
10
- use Plausible.Cache
11
-
12
- @cache_name :stats_sampling_cache
13
-
14
- @impl true
15
- def name(), do: @cache_name
16
-
17
- @impl true
18
- def child_id(), do: :cache_stats_sampling
19
-
20
- @impl true
21
- def repo(), do: Plausible.ClickhouseRepo
22
-
23
- @impl true
24
- def count_all() do
25
- base_db_query()
26
- |> repo().all()
27
- |> length()
28
- end
29
-
30
- @impl true
31
- def base_db_query() do
32
- from(r in Ingestion.Counters.Record,
33
- select: {
34
- r.site_id,
35
- selected_as(fragment("sumIf(value, metric = 'buffered')"), :events_ingested)
36
- },
37
- where: fragment("toDate(event_timebucket) >= ?", ^thirty_days_ago()),
38
- group_by: r.site_id
39
- )
40
- end
41
-
42
- @impl true
43
- def get_from_source(site_id) do
44
- base_db_query()
45
- |> repo().all()
46
- |> Map.new()
47
- |> Map.get(site_id)
48
- end
49
-
50
- @spec consolidated_get(list(pos_integer()), Keyword.t()) :: pos_integer() | nil
51
- def consolidated_get(site_ids, opts \\ []) when is_list(site_ids) do
52
- events_ingested = Enum.sum_by(site_ids, &(get(&1, opts) || 0))
53
- if events_ingested > 0, do: events_ingested
54
- end
55
-
56
- defp thirty_days_ago() do
57
- Date.shift(Date.utc_today(), day: -30)
58
- end
59
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible/workers/score_trial_prospects.ex DELETED
@@ -1,101 +0,0 @@
1
- defmodule Plausible.Workers.ScoreTrialProspects do
2
- @moduledoc """
3
- Daily worker that scores the revenue potential of every team on (or just off)
4
- a trial and caches the result in the `trial_prospects` table.
5
- """
6
- use Plausible.Repo
7
-
8
- use Oban.Worker,
9
- queue: :score_trial_prospects,
10
- max_attempts: 1
11
-
12
- require Logger
13
-
14
- alias Plausible.Teams
15
- alias Plausible.CustomerSupport.{TrialProspect, TrialProspects}
16
-
17
- @max_expired_days 30
18
-
19
- @impl Oban.Worker
20
- def perform(_job) do
21
- Date.utc_today()
22
- |> trial_population()
23
- |> Enum.each(&score_and_persist/1)
24
-
25
- :ok
26
- end
27
-
28
- defp score_and_persist(team) do
29
- try do
30
- team
31
- |> score_team()
32
- |> upsert()
33
- rescue
34
- error ->
35
- Logger.error(
36
- "ScoreTrialProspects: skipping team #{team.id}: " <>
37
- Exception.format(:error, error, __STACKTRACE__)
38
- )
39
- end
40
- end
41
-
42
- defp trial_population(today) do
43
- cutoff = Date.add(today, -@max_expired_days)
44
-
45
- Repo.all(
46
- from t in Teams.Team,
47
- left_join: s in assoc(t, :subscription),
48
- where: not is_nil(t.trial_expiry_date),
49
- where: is_nil(s.id),
50
- where: t.trial_expiry_date >= ^cutoff
51
- )
52
- end
53
-
54
- defp score_team(team) do
55
- site_ids = Teams.owned_sites_ids(team)
56
-
57
- traffic = Plausible.Stats.Clickhouse.trial_traffic(site_ids)
58
-
59
- case traffic do
60
- %{events_in_window: 0} -> nil
61
- traffic -> build_row(team, traffic, site_ids)
62
- end
63
- end
64
-
65
- defp build_row(team, traffic, site_ids) do
66
- score =
67
- TrialProspects.score(
68
- traffic.estimated_monthly,
69
- Teams.Billing.features_usage(team, site_ids),
70
- length(site_ids),
71
- Teams.Billing.team_member_usage(team)
72
- )
73
-
74
- now = DateTime.utc_now(:second)
75
- naive_now = DateTime.to_naive(now)
76
-
77
- %{
78
- team_id: team.id,
79
- estimated_monthly: traffic.estimated_monthly,
80
- observed_days: traffic.observed_days,
81
- first_data_day: traffic.first_data_day,
82
- kind: score.kind,
83
- forced_by: score.forced_by,
84
- pageview_limit: score.pageview_limit,
85
- over_top_tier: score.over_top_tier,
86
- estimated_mrr: score.estimated_mrr,
87
- computed_at: now,
88
- inserted_at: naive_now,
89
- updated_at: naive_now
90
- }
91
- end
92
-
93
- defp upsert(nil), do: :ok
94
-
95
- defp upsert(row) do
96
- Repo.insert_all(TrialProspect, [row],
97
- on_conflict: {:replace_all_except, [:id, :team_id, :inserted_at]},
98
- conflict_target: :team_id
99
- )
100
- end
101
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/controllers/api/external_sites_controller.ex DELETED
@@ -1,626 +0,0 @@
1
- defmodule PlausibleWeb.Api.ExternalSitesController do
2
- use PlausibleWeb, :controller
3
- use Plausible.Repo
4
- use PlausibleWeb.Plugs.ErrorHandler
5
-
6
- import Plausible.Pagination
7
-
8
- alias Plausible.Sites
9
- alias Plausible.Goal
10
- alias Plausible.Goals
11
- alias Plausible.Props
12
- alias Plausible.Teams
13
- alias PlausibleWeb.Api.Helpers, as: H
14
-
15
- @pagination_opts [cursor_fields: [{:id, :desc}], limit: 100, maximum_limit: 1000]
16
-
17
- def index(conn, params) do
18
- user = conn.assigns.current_user
19
- team = conn.assigns.current_team || Teams.get(params["team_id"])
20
-
21
- page =
22
- user
23
- |> Sites.for_user_query(team)
24
- |> paginate(params, @pagination_opts)
25
-
26
- json(conn, %{
27
- sites: page.entries |> Enum.map(&get_site_response_for_index/1),
28
- meta: pagination_meta(page.metadata)
29
- })
30
- end
31
-
32
- def guests_index(conn, params) do
33
- user = conn.assigns.current_user
34
- team = conn.assigns.current_team
35
-
36
- with {:ok, site_id} <- expect_param_key(params, "site_id"),
37
- {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do
38
- opts = [cursor_fields: [sort_index: :asc], limit: 100, maximum_limit: 1000]
39
- page = site |> Sites.list_guests_query() |> paginate(params, opts)
40
-
41
- json(conn, %{
42
- guests:
43
- Enum.map(page.entries, fn entry ->
44
- Map.take(entry, [:email, :role, :status])
45
- end),
46
- meta: pagination_meta(page.metadata)
47
- })
48
- else
49
- {:missing, "site_id"} ->
50
- H.bad_request(conn, "Parameter `site_id` is required to list goals")
51
-
52
- {:error, :site_not_found} ->
53
- H.not_found(conn, "Site could not be found")
54
- end
55
- end
56
-
57
- def teams_index(conn, params) do
58
- user = conn.assigns.current_user
59
- team = conn.assigns.current_team
60
-
61
- page =
62
- user
63
- |> Teams.Users.teams_query(identifier: team && team.identifier, order_by: :id_desc)
64
- |> paginate(params, @pagination_opts)
65
-
66
- json(conn, %{
67
- teams:
68
- Enum.map(page.entries, fn team ->
69
- api_available? =
70
- Plausible.Billing.Feature.StatsAPI.check_availability(team) == :ok
71
-
72
- %{
73
- id: team.identifier,
74
- name: Teams.name(team),
75
- api_available: api_available?
76
- }
77
- end),
78
- meta: pagination_meta(page.metadata)
79
- })
80
- end
81
-
82
- def goals_index(conn, params) do
83
- user = conn.assigns.current_user
84
- team = conn.assigns.current_team
85
-
86
- with {:ok, site_id} <- expect_param_key(params, "site_id"),
87
- {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do
88
- page =
89
- site
90
- |> Plausible.Goals.for_site_query()
91
- |> paginate(params, @pagination_opts)
92
-
93
- json(conn, %{
94
- goals:
95
- Enum.map(page.entries, fn goal ->
96
- %{
97
- id: goal.id,
98
- display_name: goal.display_name,
99
- goal_type: Goal.type(goal),
100
- event_name: goal.event_name,
101
- page_path: goal.page_path,
102
- custom_props: goal.custom_props
103
- }
104
- end),
105
- meta: pagination_meta(page.metadata)
106
- })
107
- else
108
- {:missing, "site_id"} ->
109
- H.bad_request(conn, "Parameter `site_id` is required to list goals")
110
-
111
- {:error, :site_not_found} ->
112
- H.not_found(conn, "Site could not be found")
113
- end
114
- end
115
-
116
- def create_site(conn, params) do
117
- user = conn.assigns.current_user
118
- team = conn.assigns.current_team || Teams.get(params["team_id"])
119
-
120
- case Repo.transact(fn ->
121
- with {:ok, %{site: site}} <- Sites.create(user, params, team),
122
- {:ok, tracker_script_configuration} <-
123
- get_or_create_config(site, params["tracker_script_configuration"] || %{}) do
124
- {:ok,
125
- struct(site,
126
- tracker_script_configuration: tracker_script_configuration
127
- )}
128
- else
129
- # Translates Multi error format to Repo.transact error format
130
- {:error, step_id, output, context} ->
131
- {:error, {step_id, output, context}}
132
-
133
- # already in Repo.transact error format
134
- {:error, reason} ->
135
- {:error, reason}
136
- end
137
- end) do
138
- {:ok, site} ->
139
- json(conn, get_site_response(site))
140
-
141
- {:error, {_, {:over_limit, limit}, _}} ->
142
- conn
143
- |> put_status(402)
144
- |> json(%{
145
- error:
146
- "Your account has reached the limit of #{limit} sites. To unlock more sites, please upgrade your subscription."
147
- })
148
-
149
- {:error, {_, :permission_denied, _}} ->
150
- conn
151
- |> put_status(403)
152
- |> json(%{
153
- error: "You can't add sites to the selected team."
154
- })
155
-
156
- {:error, {_, :multiple_teams, _}} ->
157
- conn
158
- |> put_status(400)
159
- |> json(%{
160
- error: "You must select a team with 'team_id' parameter."
161
- })
162
-
163
- {:error, {:tracker_script_configuration_invalid, %Ecto.Changeset{} = changeset}} ->
164
- conn
165
- |> put_status(400)
166
- |> json(serialize_errors(changeset, "tracker_script_configuration."))
167
-
168
- {:error, {_, %Ecto.Changeset{} = changeset, _}} ->
169
- conn
170
- |> put_status(400)
171
- |> json(serialize_errors(changeset))
172
- end
173
- end
174
-
175
- def get_site(conn, %{"site_id" => site_id}) do
176
- user = conn.assigns.current_user
177
- team = conn.assigns.current_team
178
-
179
- with {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]),
180
- {:ok, tracker_script_configuration} <- get_or_create_config(site, %{}) do
181
- site = struct(site, tracker_script_configuration: tracker_script_configuration)
182
-
183
- json(conn, get_site_response(site))
184
- else
185
- {:error, :site_not_found} ->
186
- H.not_found(conn, "Site could not be found")
187
- end
188
- end
189
-
190
- def delete_site(conn, %{"site_id" => site_id}) do
191
- user = conn.assigns.current_user
192
- team = conn.assigns.current_team
193
-
194
- case find_site(user, team, site_id, [:owner]) do
195
- {:ok, site} ->
196
- {:ok, _} = Plausible.Site.Removal.run(site)
197
- json(conn, %{"deleted" => true})
198
-
199
- {:error, :site_not_found} ->
200
- H.not_found(conn, "Site could not be found")
201
- end
202
- end
203
-
204
- def update_site(conn, %{"site_id" => site_id} = params) do
205
- user = conn.assigns.current_user
206
- team = conn.assigns.current_team
207
-
208
- with {:ok, params} <- validate_update_payload(params),
209
- {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]),
210
- {:ok, site} <- do_update_site(site, params) do
211
- json(conn, get_site_response(site))
212
- else
213
- {:error, :site_not_found} ->
214
- H.not_found(conn, "Site could not be found")
215
-
216
- {:error, :no_changes} ->
217
- H.bad_request(
218
- conn,
219
- "Payload must contain at least one of the parameters 'domain', 'tracker_script_configuration'"
220
- )
221
-
222
- {:error, {:domain_change_invalid, %Ecto.Changeset{} = changeset}} ->
223
- conn
224
- |> put_status(400)
225
- |> json(serialize_errors(changeset))
226
-
227
- {:error, {:tracker_script_configuration_invalid, %Ecto.Changeset{} = changeset}} ->
228
- conn
229
- |> put_status(400)
230
- |> json(serialize_errors(changeset, "tracker_script_configuration."))
231
- end
232
- end
233
-
234
- defp validate_update_payload(params) do
235
- params = params |> Map.take(["domain", "tracker_script_configuration"]) |> Map.drop([nil])
236
-
237
- if map_size(params) == 0 do
238
- {:error, :no_changes}
239
- else
240
- {:ok, params}
241
- end
242
- end
243
-
244
- defp do_update_site(site, params) do
245
- Repo.transact(fn ->
246
- with {:ok, site} <-
247
- if(params["domain"],
248
- do: change_domain(site, params["domain"]),
249
- else: {:ok, site}
250
- ),
251
- {:ok, tracker_script_configuration} <-
252
- if(params["tracker_script_configuration"],
253
- do: update_config(site, params["tracker_script_configuration"]),
254
- else: get_or_create_config(site, %{})
255
- ) do
256
- {:ok, struct(site, tracker_script_configuration: tracker_script_configuration)}
257
- end
258
- end)
259
- end
260
-
261
- defp change_domain(site, domain) do
262
- case Plausible.Site.Domain.change(site, domain) do
263
- {:ok, site} ->
264
- {:ok, site}
265
-
266
- {:error, %Ecto.Changeset{} = changeset} ->
267
- {:error, {:domain_change_invalid, changeset}}
268
- end
269
- end
270
-
271
- def find_or_create_guest(conn, params) do
272
- user = conn.assigns.current_user
273
- team = conn.assigns.current_team
274
-
275
- with {:ok, site_id} <- expect_param_key(params, "site_id"),
276
- {:ok, email} <- expect_param_key(params, "email"),
277
- {:ok, role} <- expect_param_key(params, "role", ["viewer", "editor"]),
278
- {:ok, site} <- find_site(user, team, site_id, [:owner, :admin]) do
279
- existing = Repo.one(Sites.list_guests_query(site, email: email))
280
-
281
- if existing do
282
- json(conn, %{
283
- role: existing.role,
284
- email: existing.email,
285
- status: existing.status
286
- })
287
- else
288
- case Teams.Invitations.InviteToSite.invite(
289
- site,
290
- conn.assigns.current_user,
291
- email,
292
- role
293
- ) do
294
- {:ok, invitation} ->
295
- json(conn, %{
296
- role: invitation.role,
297
- email: invitation.team_invitation.email,
298
- status: "invited"
299
- })
300
- end
301
- end
302
- else
303
- {:error, :site_not_found} ->
304
- H.not_found(conn, "Site could not be found")
305
-
306
- {:missing, "role"} ->
307
- H.bad_request(
308
- conn,
309
- "Parameter `role` is required to create guest. Possible values: `viewer` or `editor`"
310
- )
311
-
312
- {:missing, param} ->
313
- H.bad_request(conn, "Parameter `#{param}` is required to create guest")
314
- end
315
- end
316
-
317
- def delete_guest(conn, params) do
318
- user = conn.assigns.current_user
319
- team = conn.assigns.current_team
320
-
321
- with {:ok, site_id} <- expect_param_key(params, "site_id"),
322
- {:ok, email} <- expect_param_key(params, "email"),
323
- {:ok, site} <- find_site(user, team, site_id, [:owner, :admin]) do
324
- existing = Repo.one(Sites.list_guests_query(site, email: email))
325
-
326
- case existing do
327
- %{status: "invited", id: id} ->
328
- with guest_invitation when not is_nil(guest_invitation) <-
329
- Repo.get(Teams.GuestInvitation, id) do
330
- Teams.Invitations.remove_guest_invitation(guest_invitation)
331
- end
332
-
333
- %{status: "accepted", email: email} ->
334
- with %{} = user <- Repo.get_by(Plausible.Auth.User, email: email) do
335
- Teams.Memberships.remove(site, user)
336
- end
337
-
338
- _ ->
339
- :ignore
340
- end
341
-
342
- json(conn, %{"deleted" => true})
343
- else
344
- {:error, :site_not_found} ->
345
- H.not_found(conn, "Site could not be found")
346
-
347
- {:missing, param} ->
348
- H.bad_request(conn, "Parameter `#{param}` is required to delete a guest")
349
- end
350
- end
351
-
352
- def find_or_create_shared_link(conn, params) do
353
- user = conn.assigns.current_user
354
- team = conn.assigns.current_team
355
-
356
- with {:ok, site_id} <- expect_param_key(params, "site_id"),
357
- {:ok, link_name} <- expect_param_key(params, "name"),
358
- {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]) do
359
- shared_link = Repo.get_by(Plausible.Site.SharedLink, site_id: site.id, name: link_name)
360
-
361
- shared_link =
362
- case shared_link do
363
- nil -> Sites.create_shared_link(site, link_name)
364
- link -> {:ok, link}
365
- end
366
-
367
- case shared_link do
368
- {:ok, link} ->
369
- json(conn, %{
370
- name: link.name,
371
- url: Sites.shared_link_url(site, link)
372
- })
373
-
374
- {:error, %Ecto.Changeset{} = changeset} ->
375
- {msg, _} = changeset.errors[:name]
376
- H.bad_request(conn, msg)
377
-
378
- {:error, :upgrade_required} ->
379
- H.payment_required(conn, "Your current subscription plan does not include Shared Links")
380
- end
381
- else
382
- {:error, :site_not_found} ->
383
- H.not_found(conn, "Site could not be found")
384
-
385
- {:missing, "site_id"} ->
386
- H.bad_request(conn, "Parameter `site_id` is required to create a shared link")
387
-
388
- {:missing, "name"} ->
389
- H.bad_request(conn, "Parameter `name` is required to create a shared link")
390
-
391
- e ->
392
- H.bad_request(conn, "Something went wrong: #{inspect(e)}")
393
- end
394
- end
395
-
396
- def find_or_create_goal(conn, params) do
397
- user = conn.assigns.current_user
398
- team = conn.assigns.current_team
399
-
400
- with {:ok, site_id} <- expect_param_key(params, "site_id"),
401
- {:ok, _} <- expect_param_key(params, "goal_type"),
402
- {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]),
403
- {:ok, goal} <- Goals.find_or_create(site, params) do
404
- json(conn, goal)
405
- else
406
- {:error, :site_not_found} ->
407
- H.not_found(conn, "Site could not be found")
408
-
409
- {:missing, param} ->
410
- H.bad_request(conn, "Parameter `#{param}` is required to create a goal")
411
-
412
- {:error, %Ecto.Changeset{} = changeset} ->
413
- message = Enum.map_join(changeset.errors, ", ", &translate_error/1)
414
-
415
- H.bad_request(conn, message)
416
-
417
- {:error, :upgrade_required} ->
418
- H.payment_required(
419
- conn,
420
- "Your current subscription plan does not include Custom Properties"
421
- )
422
-
423
- e ->
424
- H.bad_request(conn, "Something went wrong: #{inspect(e)}")
425
- end
426
- end
427
-
428
- def delete_goal(conn, params) do
429
- user = conn.assigns.current_user
430
- team = conn.assigns.current_team
431
-
432
- with {:ok, site_id} <- expect_param_key(params, "site_id"),
433
- {:ok, goal_id} <- expect_param_key(params, "goal_id"),
434
- {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]),
435
- :ok <- Goals.delete(goal_id, site) do
436
- json(conn, %{"deleted" => true})
437
- else
438
- {:error, :site_not_found} ->
439
- H.not_found(conn, "Site could not be found")
440
-
441
- {:error, :not_found} ->
442
- H.not_found(conn, "Goal could not be found")
443
-
444
- {:missing, "site_id"} ->
445
- H.bad_request(conn, "Parameter `site_id` is required to delete a goal")
446
-
447
- {:missing, "goal_id"} ->
448
- H.bad_request(conn, "Parameter `goal_id` is required to delete a goal")
449
-
450
- e ->
451
- H.bad_request(conn, "Something went wrong: #{inspect(e)}")
452
- end
453
- end
454
-
455
- def custom_props_index(conn, params) do
456
- user = conn.assigns.current_user
457
- team = conn.assigns.current_team
458
-
459
- with {:ok, site_id} <- expect_param_key(params, "site_id"),
460
- {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do
461
- properties =
462
- (site.allowed_event_props || [])
463
- |> Enum.sort()
464
- |> Enum.map(fn prop -> %{property: prop} end)
465
-
466
- json(conn, %{custom_properties: properties})
467
- else
468
- {:error, :site_not_found} ->
469
- H.not_found(conn, "Site could not be found")
470
-
471
- {:missing, "site_id"} ->
472
- H.bad_request(conn, "Parameter `site_id` is required to list custom properties")
473
- end
474
- end
475
-
476
- def add_custom_prop(conn, params) do
477
- user = conn.assigns.current_user
478
- team = conn.assigns.current_team
479
-
480
- with {:ok, site_id} <- expect_param_key(params, "site_id"),
481
- {:ok, property} <- expect_param_key(params, "property"),
482
- {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]),
483
- {:ok, _} <- Props.allow(site, property) do
484
- json(conn, %{"created" => true})
485
- else
486
- {:error, :site_not_found} ->
487
- H.not_found(conn, "Site could not be found")
488
-
489
- {:missing, param} ->
490
- H.bad_request(conn, "Parameter `#{param}` is required to create a custom property")
491
-
492
- {:error, :upgrade_required} ->
493
- H.payment_required(
494
- conn,
495
- "Your current subscription plan does not include Custom Properties"
496
- )
497
-
498
- {:error, changeset} ->
499
- %{allowed_event_props: [error | _]} =
500
- Ecto.Changeset.traverse_errors(changeset, fn {_msg, opts} ->
501
- cond do
502
- opts[:type] == :list and opts[:validation] == :length ->
503
- "Can't add any more custom properties"
504
-
505
- opts[:type] == :string and opts[:validation] == :length ->
506
- "Parameter `property` is too long"
507
-
508
- true ->
509
- "Parameter `property` is invalid"
510
- end
511
- end)
512
-
513
- H.bad_request(conn, error)
514
- end
515
- end
516
-
517
- def delete_custom_prop(conn, params) do
518
- user = conn.assigns.current_user
519
- team = conn.assigns.current_team
520
-
521
- with {:ok, site_id} <- expect_param_key(params, "site_id"),
522
- {:ok, property} <- expect_param_key(params, "property"),
523
- # Property name is extracted from route URL via wildcard,
524
- # which returns a list.
525
- property = Path.join(property),
526
- {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]),
527
- {:ok, _} <- Props.disallow(site, property) do
528
- json(conn, %{"deleted" => true})
529
- else
530
- {:error, :site_not_found} ->
531
- H.not_found(conn, "Site could not be found")
532
-
533
- {:missing, param} ->
534
- H.bad_request(conn, "Parameter `#{param}` is required to delete a custom property")
535
-
536
- e ->
537
- H.bad_request(conn, "Something went wrong: #{inspect(e)}")
538
- end
539
- end
540
-
541
- defp pagination_meta(meta) do
542
- %{
543
- after: meta.after,
544
- before: meta.before,
545
- limit: meta.limit
546
- }
547
- end
548
-
549
- defp find_site(user, team, site_id, roles) do
550
- case Plausible.Sites.get_for_user(user, site_id, roles: roles) do
551
- nil ->
552
- {:error, :site_not_found}
553
-
554
- site ->
555
- site = Repo.preload(site, :team)
556
-
557
- if team && team.id != site.team_id do
558
- {:error, :site_not_found}
559
- else
560
- {:ok, site}
561
- end
562
- end
563
- end
564
-
565
- defp serialize_errors(changeset, field_prefix \\ "") do
566
- {field, {msg, _opts}} = List.first(changeset.errors)
567
- error_msg = field_prefix <> Atom.to_string(field) <> ": " <> msg
568
- %{"error" => error_msg}
569
- end
570
-
571
- defp expect_param_key(params, key, inclusion \\ [])
572
-
573
- defp expect_param_key(params, key, []) do
574
- case Map.fetch(params, key) do
575
- :error -> {:missing, key}
576
- res -> res
577
- end
578
- end
579
-
580
- defp expect_param_key(params, key, inclusion) do
581
- case expect_param_key(params, key, []) do
582
- {:ok, value} = ok ->
583
- if value in inclusion, do: ok, else: {:missing, key}
584
-
585
- _ ->
586
- {:missing, key}
587
- end
588
- end
589
-
590
- defp get_or_create_config(site, params) do
591
- case PlausibleWeb.Tracker.get_or_create_tracker_script_configuration(site, params) do
592
- {:ok, tracker_script_configuration} ->
593
- {:ok, tracker_script_configuration}
594
-
595
- {:error, %Ecto.Changeset{} = changeset} ->
596
- {:error, {:tracker_script_configuration_invalid, changeset}}
597
- end
598
- end
599
-
600
- defp update_config(site, params) do
601
- case PlausibleWeb.Tracker.update_script_configuration(site, params, :installation) do
602
- {:ok, tracker_script_configuration} ->
603
- {:ok, tracker_script_configuration}
604
-
605
- {:error, %Ecto.Changeset{} = changeset} ->
606
- {:error, {:tracker_script_configuration_invalid, changeset}}
607
- end
608
- end
609
-
610
- defp get_site_response_for_index(site) do
611
- site |> Map.take([:domain, :timezone])
612
- end
613
-
614
- defp get_site_response(site) do
615
- site
616
- |> Map.take([:domain, :timezone, :tracker_script_configuration])
617
- # remap to `custom_properties`
618
- |> Map.put(:custom_properties, site.allowed_event_props || [])
619
- end
620
-
621
- defp translate_error({field, {msg, opts}}) do
622
- Enum.reduce(opts, "#{field}: #{msg}", fn {key, value}, acc ->
623
- String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
624
- end)
625
- end
626
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/controllers/api/revenue.ex DELETED
@@ -1,27 +0,0 @@
1
- defmodule PlausibleWeb.Controllers.API.Revenue do
2
- @moduledoc """
3
- Revenue specific functions for the API scope
4
- """
5
-
6
- @revenue_metrics Plausible.Stats.Goal.Revenue.revenue_metrics()
7
- def format_revenue_metric({metric, value}) do
8
- if metric in @revenue_metrics do
9
- {metric, format_money(value)}
10
- else
11
- {metric, value}
12
- end
13
- end
14
-
15
- def format_money(value) do
16
- case value do
17
- %Money{} ->
18
- %{
19
- short: Money.to_string!(value, format: :short, fractional_digits: 1),
20
- long: Money.to_string!(value)
21
- }
22
-
23
- _any ->
24
- value
25
- end
26
- end
27
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/controllers/help_scout_controller.ex DELETED
@@ -1,121 +0,0 @@
1
- defmodule PlausibleWeb.HelpScoutController do
2
- use PlausibleWeb, :controller
3
-
4
- alias Plausible.HelpScout
5
-
6
- @conversation_token_seconds 8 * 60 * 60
7
-
8
- plug :make_iframe_friendly
9
-
10
- def callback(conn, %{"customer-id" => customer_id, "conversation-id" => conversation_id}) do
11
- assigns = %{conversation_id: conversation_id, customer_id: customer_id, token: ""}
12
-
13
- with :ok <- HelpScout.validate_signature(conn),
14
- {:ok, details} <- HelpScout.get_details_for_customer(customer_id, conversation_id) do
15
- assigns = Map.put(assigns, :token, sign_token(conversation_id))
16
-
17
- conn
18
- |> render("callback.html", Map.merge(assigns, details))
19
- else
20
- {:error, {:user_not_found, [email | _]}} ->
21
- conn
22
- |> render("callback.html", Map.merge(assigns, %{error: ":user_not_found", email: email}))
23
-
24
- {:error, error} ->
25
- conn
26
- |> render("callback.html", Map.put(assigns, :error, inspect(error)))
27
- end
28
- end
29
-
30
- def callback(conn, _) do
31
- render(conn, "bad_request.html")
32
- end
33
-
34
- def show(
35
- conn,
36
- %{
37
- "email" => email,
38
- "token" => token,
39
- "conversation_id" => conversation_id,
40
- "customer_id" => customer_id
41
- } =
42
- params
43
- ) do
44
- assigns = %{
45
- xhr?: params["xhr"] == "true",
46
- conversation_id: conversation_id,
47
- customer_id: customer_id,
48
- token: token
49
- }
50
-
51
- with :ok <- match_conversation(token, conversation_id),
52
- {:ok, details} <-
53
- HelpScout.get_details_for_emails(
54
- [email],
55
- customer_id,
56
- conversation_id,
57
- params["team_identifier"]
58
- ) do
59
- render(conn, "callback.html", Map.merge(assigns, details))
60
- else
61
- {:error, error} ->
62
- render(conn, "callback.html", Map.put(assigns, :error, inspect(error)))
63
- end
64
- end
65
-
66
- def search(conn, %{
67
- "term" => term,
68
- "token" => token,
69
- "conversation_id" => conversation_id,
70
- "customer_id" => customer_id
71
- }) do
72
- assigns = %{
73
- conversation_id: conversation_id,
74
- customer_id: customer_id,
75
- token: token
76
- }
77
-
78
- case match_conversation(token, conversation_id) do
79
- :ok ->
80
- users = HelpScout.search_users(term, customer_id, conversation_id)
81
- render(conn, "search.html", Map.merge(assigns, %{users: users, term: term}))
82
-
83
- {:error, error} ->
84
- render(conn, "search.html", Map.put(assigns, :error, inspect(error)))
85
- end
86
- end
87
-
88
- defp match_conversation(token, conversation_id) do
89
- case verify_token(token) do
90
- {:ok, token_data} ->
91
- if token_data.conversation_id == conversation_id do
92
- :ok
93
- else
94
- {:error, :invalid_conversation}
95
- end
96
-
97
- {:error, _error} ->
98
- {:error, :invalid_token}
99
- end
100
- end
101
-
102
- # Exposed for testing
103
- @doc false
104
- def sign_token(conversation_id) do
105
- Phoenix.Token.sign(PlausibleWeb.Endpoint, "hs-conversation", %{
106
- conversation_id: conversation_id
107
- })
108
- end
109
-
110
- defp verify_token(token) do
111
- Phoenix.Token.verify(PlausibleWeb.Endpoint, "hs-conversation", token,
112
- max_age: @conversation_token_seconds
113
- )
114
- end
115
-
116
- defp make_iframe_friendly(conn, _opts) do
117
- conn
118
- |> delete_resp_header("x-frame-options")
119
- |> put_root_layout(html: {PlausibleWeb.HelpScoutView, :layout})
120
- end
121
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/controllers/sso_controller.ex DELETED
@@ -1,140 +0,0 @@
1
- defmodule PlausibleWeb.SSOController do
2
- use PlausibleWeb, :controller
3
-
4
- require Logger
5
-
6
- alias Plausible.Auth
7
- alias Plausible.Auth.SSO
8
- alias PlausibleWeb.LoginPreference
9
-
10
- alias PlausibleWeb.Router.Helpers, as: Routes
11
-
12
- plug Plausible.Plugs.AuthorizeTeamAccess,
13
- [:owner] when action in [:sso_settings]
14
-
15
- plug Plausible.Plugs.AuthorizeTeamAccess,
16
- [:owner, :admin] when action in [:team_sessions, :delete_session]
17
-
18
- def login_form(conn, params) do
19
- login_preference = LoginPreference.get(conn)
20
- error = Phoenix.Flash.get(conn.assigns.flash, :login_error)
21
-
22
- case {login_preference, params["prefer"], error} do
23
- {nil, nil, nil} ->
24
- redirect(conn, to: Routes.auth_path(conn, :login_form, return_to: params["return_to"]))
25
-
26
- _ ->
27
- render_auth_page(conn, "login_form.html",
28
- autosubmit: params["autosubmit"] != nil,
29
- heading: "Sign in with SSO"
30
- )
31
- end
32
- end
33
-
34
- def login(conn, %{"email" => email} = params) do
35
- with :ok <- Auth.rate_limit(:login_ip, conn),
36
- {:ok, %{sso_integration: integration}} <- SSO.Domains.lookup(email) do
37
- redirect(conn,
38
- to:
39
- Routes.sso_path(
40
- conn,
41
- :saml_signin,
42
- integration.identifier,
43
- email: email,
44
- return_to: params["return_to"]
45
- )
46
- )
47
- else
48
- {:error, :not_found} ->
49
- conn
50
- |> put_flash(
51
- :login_error,
52
- "We couldn't find a Single Sign-On account for that email."
53
- )
54
- |> redirect(to: Routes.sso_path(conn, :login_form))
55
-
56
- {:error, {:rate_limit, _}} ->
57
- Auth.log_failed_login_attempt("too many login attempts for #{email}")
58
-
59
- render_error(
60
- conn,
61
- 429,
62
- "Too many login attempts. Wait a minute before trying again."
63
- )
64
- end
65
- end
66
-
67
- def provision_notice(conn, _params) do
68
- render_auth_page(conn, "provision_notice.html", heading: "Single Sign-On required")
69
- end
70
-
71
- def provision_issue(conn, params) do
72
- issue =
73
- case params["issue"] do
74
- "not_a_member" -> :not_a_member
75
- "multiple_memberships" -> :multiple_memberships
76
- "multiple_memberships_noforce" -> :multiple_memberships_noforce
77
- "active_personal_team" -> :active_personal_team
78
- "active_personal_team_noforce" -> :active_personal_team_noforce
79
- _ -> :unknown
80
- end
81
-
82
- render_auth_page(conn, "provision_issue.html",
83
- heading: "Single Sign-On required",
84
- issue: issue
85
- )
86
- end
87
-
88
- def saml_signin(conn, params) do
89
- saml_adapter().signin(conn, params)
90
- end
91
-
92
- def saml_consume(conn, params) do
93
- saml_adapter().consume(conn, params)
94
- end
95
-
96
- def csp_report(conn, _params) do
97
- {:ok, body, conn} = Plug.Conn.read_body(conn)
98
- Logger.error(body)
99
- conn |> send_resp(200, "OK")
100
- end
101
-
102
- def cta(conn, _params) do
103
- render(conn, :cta, layout: {PlausibleWeb.LayoutView, :settings})
104
- end
105
-
106
- def sso_settings(conn, _params) do
107
- if Plausible.Teams.setup?(conn.assigns.current_team) and
108
- Plausible.Billing.Feature.SSO.check_availability(conn.assigns.current_team) == :ok do
109
- render(conn, :sso_settings,
110
- layout: {PlausibleWeb.LayoutView, :settings},
111
- connect_live_socket: true
112
- )
113
- else
114
- conn
115
- |> redirect(to: Routes.site_path(conn, :index))
116
- end
117
- end
118
-
119
- def team_sessions(conn, _params) do
120
- sso_sessions = Auth.UserSessions.list_sso_for_team(conn.assigns.current_team)
121
-
122
- render(conn, :team_sessions,
123
- layout: {PlausibleWeb.LayoutView, :settings},
124
- sso_sessions: sso_sessions
125
- )
126
- end
127
-
128
- def delete_session(conn, %{"session_id" => session_id}) do
129
- current_team = conn.assigns.current_team
130
- Auth.UserSessions.revoke_sso_by_id(current_team, session_id)
131
-
132
- conn
133
- |> put_flash(:success, "Session logged out successfully")
134
- |> redirect(to: Routes.sso_path(conn, :team_sessions))
135
- end
136
-
137
- defp saml_adapter() do
138
- Application.fetch_env!(:plausible, :sso_saml_adapter)
139
- end
140
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/dogfood.ex DELETED
@@ -1,96 +0,0 @@
1
- defmodule PlausibleWeb.Dogfood do
2
- @moduledoc """
3
- Plausible tracking itself functions
4
- """
5
-
6
- def script_params(assigns) do
7
- %{
8
- script_url: script_url(assigns),
9
- domain_to_replace: domain_to_replace(assigns),
10
- location_override: location_override(assigns),
11
- custom_properties: custom_properties(assigns),
12
- capture_on_localhost: Application.get_env(:plausible, :environment) == "dev"
13
- }
14
- end
15
-
16
- defp script_url(assigns) do
17
- env = Application.get_env(:plausible, :environment)
18
- selfhost? = Application.get_env(:plausible, :is_selfhost)
19
-
20
- tracker_script_config_id =
21
- cond do
22
- env == "prod" and selfhost? ->
23
- "pa-V5OUguy5m04s95qHnmGbH"
24
-
25
- env == "prod" and assigns[:embedded] ->
26
- "pa-Qo3A7Ksnbn-wYQWMijuR3"
27
-
28
- env == "prod" ->
29
- "pa-6_srOGVV9SLMWJ1ZpUAbG"
30
-
31
- env == "staging" ->
32
- "pa-egYOCIzzYzPL9v6GHLc-7"
33
-
34
- env in ["dev", "ce_dev"] ->
35
- # By default we're not letting the app track itself on localhost.
36
- # The requested script will be `.js` and the server will respond with 404.
37
- # If you wish to track the app itself, uncomment the following code
38
- # and replace the site_id if necessary (1 stands for dummy.site).
39
-
40
- # Plausible.Repo.get(Plausible.Site, 1)
41
- # |> PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!()
42
- # |> Map.get(:id)
43
-
44
- "pa-invalid-script-id"
45
-
46
- env in ["test", "ce_test", "e2e_test"] ->
47
- ""
48
- end
49
-
50
- "#{PlausibleWeb.Endpoint.url()}/js/#{tracker_script_config_id}.js"
51
- end
52
-
53
- # TRICKY: The React dashboard uses history-based SPA navigation
54
- # which triggers pageviews on routes that the backend is unaware
55
- # of (e.g.`/:domain/pages`). As opposed to other site paths like
56
- # `/:domain/settings/general` we cannot override the entire URL.
57
- # To still make sure we're not capturing customer domains we run
58
- # a string replace in `payload.u`.
59
- defp domain_to_replace(assigns) do
60
- if not is_nil(assigns[:site]) and assigns[:demo] != true do
61
- URI.encode_www_form(assigns.site.domain)
62
- end
63
- end
64
-
65
- defp location_override(%{dogfood_page_path: path}) when is_binary(path) do
66
- Path.join(PlausibleWeb.Endpoint.url(), path)
67
- end
68
-
69
- defp location_override(_), do: nil
70
-
71
- defp custom_properties(%{current_user: user} = assigns) when is_map(user) do
72
- %{
73
- logged_in: true,
74
- theme: user.theme,
75
- current_plan: current_plan(assigns[:current_team])
76
- }
77
- end
78
-
79
- defp custom_properties(_) do
80
- %{logged_in: false}
81
- end
82
-
83
- defp current_plan(%Plausible.Teams.Team{
84
- subscription: %Plausible.Billing.Subscription{} = subscription
85
- }) do
86
- case Plausible.Billing.Plans.get_subscription_plan(subscription) do
87
- %Plausible.Billing.Plan{kind: kind} -> kind
88
- %Plausible.Billing.EnterprisePlan{} -> :enterprise
89
- other when is_atom(other) -> other
90
- end
91
- end
92
-
93
- defp current_plan(team) do
94
- if Plausible.Teams.on_trial?(team), do: :trial
95
- end
96
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support.ex DELETED
@@ -1,74 +0,0 @@
1
- defmodule PlausibleWeb.Live.CustomerSupport do
2
- @moduledoc """
3
- Customer Support home page (search)
4
- """
5
- use PlausibleWeb, :live_view
6
-
7
- alias PlausibleWeb.CustomerSupport.Components.{Layout, Search}
8
-
9
- @impl true
10
- def mount(params, _session, socket) do
11
- uri =
12
- Routes.customer_support_path(
13
- PlausibleWeb.Endpoint,
14
- :index,
15
- Map.take(params, ["filter_text"])
16
- )
17
- |> URI.new!()
18
-
19
- {:ok,
20
- assign(socket,
21
- uri: uri,
22
- filter_text: params["filter_text"] || ""
23
- )}
24
- end
25
-
26
- @impl true
27
- def handle_params(params, _uri, socket) do
28
- filter_text = params["filter_text"] || ""
29
- socket = assign(socket, filter_text: filter_text)
30
-
31
- send_update(Search, id: "search-component", filter_text: filter_text)
32
-
33
- {:noreply, socket}
34
- end
35
-
36
- @impl true
37
- def render(assigns) do
38
- ~H"""
39
- <.flash_messages flash={@flash} />
40
-
41
- <Layout.layout filter_text={@filter_text}>
42
- <.live_component module={Search} filter_text={@filter_text} id="search-component" />
43
- </Layout.layout>
44
- """
45
- end
46
-
47
- @impl true
48
- def handle_event("filter", %{"filter-text" => input}, socket) do
49
- socket = set_filter_text(socket, input)
50
- {:noreply, socket}
51
- end
52
-
53
- def handle_event("reset-filter-text", _params, socket) do
54
- socket = set_filter_text(socket, "")
55
- {:noreply, socket}
56
- end
57
-
58
- defp set_filter_text(socket, filter_text) do
59
- uri = socket.assigns.uri
60
-
61
- uri_params =
62
- (uri.query || "")
63
- |> URI.decode_query()
64
- |> Map.put("filter_text", filter_text)
65
- |> URI.encode_query()
66
-
67
- uri = %{uri | query: uri_params}
68
-
69
- socket
70
- |> assign(:filter_text, filter_text)
71
- |> assign(:uri, uri)
72
- |> push_patch(to: URI.to_string(uri), replace: true)
73
- end
74
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/components/layout.ex DELETED
@@ -1,124 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Components.Layout do
2
- @moduledoc """
3
- Base layout component for Customer Support UI
4
- Provides common header, filter bar, and content area structure
5
- """
6
- use PlausibleWeb, :component
7
- import PlausibleWeb.Live.Flash
8
-
9
- attr :filter_text, :string, default: ""
10
- attr :show_search, :boolean, default: true
11
- attr :flash, :map, default: %{}
12
- slot :inner_block, required: true
13
- slot :filter_actions, required: false
14
-
15
- def layout(assigns) do
16
- ~H"""
17
- <div x-data="{ openHelp: false }" class="dark:text-gray-400">
18
- <.help_overlay />
19
-
20
- <div class="container pt-6">
21
- <.header filter_text={@filter_text} />
22
-
23
- <div class="py-4">
24
- <.styled_link class="text-sm" onclick="window.history.go(-1); return false;">
25
- &larr; Go back
26
- </.styled_link>
27
- </div>
28
-
29
- <.search_bar :if={@show_search} filter_text={@filter_text}>
30
- {render_slot(@filter_actions)}
31
- </.search_bar>
32
-
33
- <.flash_messages flash={@flash} />
34
-
35
- <div class="mt-4">
36
- {render_slot(@inner_block)}
37
- </div>
38
-
39
- <div class="py-4">
40
- <.styled_link class="text-sm" onclick="window.history.go(-1); return false;">
41
- &larr; Go back
42
- </.styled_link>
43
- </div>
44
- </div>
45
- </div>
46
- """
47
- end
48
-
49
- defp help_overlay(assigns) do
50
- ~H"""
51
- <div
52
- id="help"
53
- x-show="openHelp"
54
- x-cloak
55
- class="p-16 fixed top-0 left-0 w-full h-full bg-gray-800/95 text-gray-300 z-50 flex items-center justify-center"
56
- >
57
- <div @click.away="openHelp = false" @click="openHelp = false">
58
- Prefix your searches with: <br /><br />
59
- <div class="font-mono">
60
- <strong>site:</strong>input<br />
61
- <p class="font-sans pl-2 mb-1">
62
- Search for sites exclusively. Input will be checked against site's domain, team's name, owners' names and e-mails (fuzzy match) or against tracker script ID (exact match).
63
- </p>
64
- <strong>user:</strong>input<br />
65
- <p class="font-sans pl-2 mb-1">
66
- Search for users exclusively. Input will be checked against user's name and e-mail.
67
- </p>
68
- <strong>team:</strong>input<br />
69
- <p class="font-sans pl-2 mb-1">
70
- Search for teams exclusively. Input will be checked against user/team name, e-mail or team identifier. Identifier must be provided complete, as is.
71
- </p>
72
-
73
- <strong>team:</strong>input <strong>+sub</strong>
74
- <br />
75
- <p class="font-sans pl-2 mb-1">
76
- Like above, but only finds team(s) with subscription (in any status).
77
- </p>
78
-
79
- <strong>team:</strong>input <strong>+sso</strong>
80
- <br />
81
- <p class="font-sans pl-2 mb-1">
82
- Like above, but only finds team(s) with SSO integrations (in any status).
83
- </p>
84
- </div>
85
- </div>
86
- </div>
87
- """
88
- end
89
-
90
- attr :filter_text, :string, required: true
91
-
92
- defp header(assigns) do
93
- ~H"""
94
- <div class="group mt-6 pb-5 border-b border-gray-200 dark:border-gray-500 flex items-center justify-between">
95
- <h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-gray-100 sm:text-3xl sm:leading-9 sm:truncate flex-shrink-0">
96
- <.link
97
- replace
98
- patch={
99
- Routes.customer_support_path(PlausibleWeb.Endpoint, :index, %{filter_text: @filter_text})
100
- }
101
- >
102
- 💬 Customer Support
103
- </.link>
104
- </h2>
105
- </div>
106
- """
107
- end
108
-
109
- attr :filter_text, :string, required: true
110
- slot :inner_block, required: false
111
-
112
- defp search_bar(assigns) do
113
- ~H"""
114
- <div class="mb-4 mt-4">
115
- <.filter_bar filter_text={@filter_text} placeholder="Search everything">
116
- <a class="cursor-pointer" @click="openHelp = !openHelp">
117
- <Heroicons.question_mark_circle class="text-indigo-700 dark:text-gray-500 w-5 h-5 hover:stroke-2" />
118
- </a>
119
- {render_slot(@inner_block)}
120
- </.filter_bar>
121
- </div>
122
- """
123
- end
124
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/components/search.ex DELETED
@@ -1,125 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Components.Search do
2
- @moduledoc """
3
- Dedicated search component for Customer Support
4
- Handles search logic independently from other components
5
- """
6
- use PlausibleWeb, :live_component
7
-
8
- alias Plausible.CustomerSupport.Resource
9
- alias PlausibleWeb.CustomerSupport.Components.SearchResult
10
-
11
- @resources [Resource.Team, Resource.User, Resource.Site]
12
-
13
- def update(%{filter_text: filter_text} = assigns, socket) do
14
- socket = assign(socket, assigns)
15
- {:ok, perform_search(socket, filter_text)}
16
- end
17
-
18
- def handle_event("search-updated", %{"filter_text" => filter_text}, socket) do
19
- {:noreply, perform_search(socket, filter_text)}
20
- end
21
-
22
- def render(assigns) do
23
- ~H"""
24
- <div>
25
- <ul :if={@results != []} class="my-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
26
- <li :for={r <- @results} class="group relative">
27
- <.link patch={r.path} data-test-type={r.type} data-test-id={r.id}>
28
- <div class="col-span-1 bg-white dark:bg-gray-800 rounded-lg shadow p-4 group-hover:shadow-lg cursor-pointer">
29
- <div class="text-gray-800 dark:text-gray-500 w-full flex items-center justify-between space-x-4">
30
- <.render_result resource={r} />
31
- </div>
32
- </div>
33
- </.link>
34
- </li>
35
- </ul>
36
-
37
- <div
38
- :if={@results == [] && String.trim(@filter_text) != ""}
39
- class="text-center py-8 text-gray-500"
40
- >
41
- No results found for "{@filter_text}"
42
- </div>
43
-
44
- <div
45
- :if={@results == [] && String.trim(@filter_text) == ""}
46
- class="text-center py-8 text-gray-500"
47
- >
48
- Enter a search term to find teams, users, or sites
49
- </div>
50
- </div>
51
- """
52
- end
53
-
54
- def render_result(assigns) do
55
- SearchResult.render_result(assigns)
56
- end
57
-
58
- defp perform_search(socket, filter_text) do
59
- results = spawn_searches(filter_text)
60
- assign(socket, :results, results)
61
- end
62
-
63
- defp spawn_searches(input) do
64
- input = String.trim(input)
65
-
66
- {resources, input, opts} =
67
- maybe_focus_search(input)
68
-
69
- resources
70
- |> Task.async_stream(fn resource ->
71
- input
72
- |> resource.search(opts)
73
- |> Enum.map(&resource.dump/1)
74
- end)
75
- |> Enum.reduce([], fn {:ok, results}, acc ->
76
- acc ++ results
77
- end)
78
- end
79
-
80
- defp maybe_focus_search(lone_modifier) when lone_modifier in ["site:", "team:", "user:"] do
81
- {[], "", limit: 0}
82
- end
83
-
84
- defp maybe_focus_search("site:" <> rest) do
85
- {[Resource.Site], rest, limit: 90}
86
- end
87
-
88
- defp maybe_focus_search("team:" <> rest) do
89
- [input | mods] = String.split(rest, "+", trim: true)
90
- input = String.trim(input)
91
-
92
- opts =
93
- if "sub" in mods do
94
- [limit: 90, with_subscription_only?: true]
95
- else
96
- [limit: 90]
97
- end
98
-
99
- opts =
100
- if "sso" in mods do
101
- Keyword.merge(opts, with_sso_only?: true)
102
- else
103
- opts
104
- end
105
-
106
- opts =
107
- case Ecto.UUID.cast(input) do
108
- {:ok, _uuid} ->
109
- Keyword.merge(opts, uuid_provided?: true)
110
-
111
- _ ->
112
- opts
113
- end
114
-
115
- {[Resource.Team], input, opts}
116
- end
117
-
118
- defp maybe_focus_search("user:" <> rest) do
119
- {[Resource.User], rest, limit: 90}
120
- end
121
-
122
- defp maybe_focus_search(input) do
123
- {@resources, input, limit: 30}
124
- end
125
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/components/search_result.ex DELETED
@@ -1,105 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Components.SearchResult do
2
- @moduledoc """
3
- Component responsible for rendering search result cards for customer support resources
4
- """
5
- use PlausibleWeb, :live_component
6
-
7
- def favicon(assigns) do
8
- ~H"""
9
- <img src={"/favicon/sources/#{@domain}"} class={@class} />
10
- """
11
- end
12
-
13
- def render_result(%{resource: %{type: "team"}} = assigns) do
14
- ~H"""
15
- <div class="flex-1 -mt-px w-full">
16
- <div class="w-full flex items-center justify-between space-x-4">
17
- <div class="w-5 h-5 rounded-full bg-blue-500 flex items-center justify-center">
18
- <span class="text-white text-xs font-bold">T</span>
19
- </div>
20
- <h3
21
- class="text-gray-900 font-medium text-lg truncate dark:text-gray-100"
22
- style="width: calc(100% - 4rem)"
23
- >
24
- {@resource.object.name}
25
- </h3>
26
-
27
- <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
28
- Team
29
- </span>
30
- <span
31
- :if={@resource.object.subscription}
32
- class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"
33
- >
34
- $
35
- </span>
36
- </div>
37
-
38
- <hr class="mt-4 mb-4 flex-grow border-t border-gray-200 dark:border-gray-600" />
39
- <div class="text-sm truncate">
40
- Team identifier:
41
- <code class="font-mono">{@resource.object.identifier |> String.slice(0, 8)}</code>
42
- <br />
43
- Owned by: {@resource.object.owners
44
- |> Enum.map(& &1.name)
45
- |> Enum.join(", ")}
46
- </div>
47
- </div>
48
- """
49
- end
50
-
51
- def render_result(%{resource: %{type: "user"}} = assigns) do
52
- ~H"""
53
- <div class="flex-1 -mt-px w-full">
54
- <div class="w-full flex items-center justify-between space-x-4">
55
- <img src={Plausible.Auth.User.profile_img_url(@resource.object)} class="h-5 w-5 rounded-full" />
56
- <h3
57
- class="text-gray-900 font-medium text-lg truncate dark:text-gray-100"
58
- style="width: calc(100% - 4rem)"
59
- >
60
- {@resource.object.name}
61
- </h3>
62
-
63
- <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
64
- User
65
- </span>
66
- </div>
67
-
68
- <hr class="mt-4 mb-4 flex-grow border-t border-gray-200 dark:border-gray-600" />
69
- <div class="text-sm truncate">
70
- {@resource.object.name} &lt;{@resource.object.email}&gt; <br />
71
- Owns {length(@resource.object.owned_teams)} team(s)
72
- </div>
73
- </div>
74
- """
75
- end
76
-
77
- def render_result(%{resource: %{type: "site"}} = assigns) do
78
- ~H"""
79
- <div class="flex-1 -mt-px w-full">
80
- <div class="w-full flex items-center justify-between space-x-4">
81
- <.favicon class="w-5" domain={@resource.object.domain} />
82
- <h3
83
- class="text-gray-900 font-medium text-lg truncate dark:text-gray-100"
84
- style="width: calc(100% - 4rem)"
85
- >
86
- {@resource.object.domain}
87
- </h3>
88
-
89
- <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
90
- Site
91
- </span>
92
- </div>
93
-
94
- <hr class="mt-4 mb-4 flex-grow border-t border-gray-200 dark:border-gray-600" />
95
- <div class="text-sm truncate">
96
- Part of <strong>{@resource.object.team.name}</strong>
97
- <br />
98
- owned by {@resource.object.team.owners
99
- |> Enum.map(& &1.name)
100
- |> Enum.join(", ")}
101
- </div>
102
- </div>
103
- """
104
- end
105
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/live.ex DELETED
@@ -1,133 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Live do
2
- @moduledoc """
3
- Shared module providing common LiveView functionality for Customer Support.
4
-
5
- Provides:
6
- - Standard mount/3 and handle_info/2 implementations
7
- - Tab navigation components and routing utilities
8
- - Common aliases and imports for Customer Support LiveViews
9
- - Convenience API for flashes and redirects
10
- """
11
-
12
- defmacro __using__(_opts) do
13
- quote do
14
- use PlausibleWeb, :live_view
15
-
16
- alias Plausible.CustomerSupport.Resource
17
- alias PlausibleWeb.CustomerSupport.Components.Layout
18
- import PlausibleWeb.CustomerSupport.Live
19
- import PlausibleWeb.Components.Generic
20
- alias PlausibleWeb.Router.Helpers, as: Routes
21
-
22
- def mount(_params, _session, socket) do
23
- {:ok, socket}
24
- end
25
-
26
- def handle_info({:success, msg}, socket) do
27
- {:noreply, put_flash(socket, :success, msg)}
28
- end
29
-
30
- def handle_info({:error, msg}, socket) do
31
- {:noreply, put_flash(socket, :error, msg)}
32
- end
33
-
34
- def handle_info({:navigate, path, success_msg}, socket) do
35
- socket = if success_msg, do: put_flash(socket, :success, success_msg), else: socket
36
- {:noreply, push_navigate(socket, to: path)}
37
- end
38
-
39
- def handle_params(%{"id" => _id} = params, _uri, socket) do
40
- handle_params(Map.put(params, "tab", "overview"), nil, socket)
41
- end
42
-
43
- defoverridable mount: 3, handle_info: 2, handle_params: 3
44
- end
45
- end
46
-
47
- @spec success(String.t()) :: :ok
48
- def success(msg) do
49
- send(self(), {:success, msg})
50
- end
51
-
52
- @spec failure(String.t()) :: :ok
53
- def failure(msg) do
54
- send(self(), {:error, msg})
55
- end
56
-
57
- @spec navigate_with_success(String.t(), String.t()) :: :ok
58
- def navigate_with_success(path, msg) do
59
- send(self(), {:navigate, path, msg})
60
- end
61
-
62
- use Phoenix.Component
63
- import Phoenix.LiveView
64
-
65
- attr :to, :string, required: true
66
- attr :tab, :string, required: true
67
- slot :inner_block, required: true
68
-
69
- def tab(assigns) do
70
- current_class = "text-gray-900 dark:text-gray-100"
71
-
72
- inactive_class =
73
- "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
74
-
75
- assigns =
76
- assign(
77
- assigns,
78
- :link_class,
79
- if(assigns.to == assigns.tab, do: current_class, else: inactive_class)
80
- )
81
-
82
- ~H"""
83
- <.link
84
- patch={"?tab=#{@to}"}
85
- class={[
86
- @link_class,
87
- "group relative min-w-0 flex-1 overflow-hidden bg-white dark:bg-gray-800 py-4 px-6 text-center text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-750 focus:z-10 first:rounded-l-lg last:rounded-r-lg"
88
- ]}
89
- >
90
- {render_slot(@inner_block)}
91
- </.link>
92
- """
93
- end
94
-
95
- attr :tab, :string, required: true
96
- attr :extra_classes, :string, default: ""
97
- slot :tabs, required: true
98
-
99
- def tab_navigation(assigns) do
100
- ~H"""
101
- <div class="mt-4">
102
- <div class="hidden sm:block">
103
- <nav
104
- class={[
105
- "isolate flex divide-x dark:divide-gray-900 divide-gray-200 rounded-lg shadow dark:shadow-1",
106
- @extra_classes
107
- ]}
108
- aria-label="Tabs"
109
- >
110
- {render_slot(@tabs)}
111
- </nav>
112
- </div>
113
- </div>
114
- """
115
- end
116
-
117
- def go_to_tab(socket, tab, params, resource_key, component) do
118
- tab_params = Map.drop(params, ["id", "tab"])
119
- resource = Map.get(socket.assigns, resource_key)
120
-
121
- update_params = [
122
- id: "#{resource_key}-#{resource.id}-#{tab}",
123
- tab: tab,
124
- tab_params: tab_params
125
- ]
126
-
127
- update_params = Keyword.put(update_params, resource_key, resource)
128
-
129
- send_update(component, update_params)
130
-
131
- socket
132
- end
133
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/site.ex DELETED
@@ -1,115 +0,0 @@
1
- defmodule PlausibleWeb.Live.CustomerSupport.Site do
2
- @moduledoc """
3
- Site coordinator LiveView for Customer Support interface.
4
-
5
- Manages tab-based navigation and delegates rendering to specialized
6
- components: Overview, People, and Rescue Zone.
7
- """
8
- use PlausibleWeb.CustomerSupport.Live
9
-
10
- alias PlausibleWeb.CustomerSupport.Site.Components.{
11
- Overview,
12
- People,
13
- RescueZone
14
- }
15
-
16
- def favicon(assigns) do
17
- ~H"""
18
- <img src={"/favicon/sources/#{@domain}"} class={@class} />
19
- """
20
- end
21
-
22
- def handle_params(%{"id" => site_id} = params, _uri, socket) do
23
- tab = params["tab"] || "overview"
24
- site = Resource.Site.get(site_id)
25
-
26
- if site do
27
- socket =
28
- socket
29
- |> assign(:site, site)
30
- |> assign(:tab, tab)
31
-
32
- {:noreply, go_to_tab(socket, tab, params, :site, tab_component(tab))}
33
- else
34
- {:noreply, redirect(socket, to: Routes.customer_support_path(socket, :index))}
35
- end
36
- end
37
-
38
- def render(assigns) do
39
- ~H"""
40
- <Layout.layout show_search={false} flash={@flash}>
41
- <.site_header site={@site} />
42
- <.site_tab_navigation site={@site} tab={@tab} />
43
-
44
- <.live_component
45
- module={tab_component(@tab)}
46
- site={@site}
47
- tab={@tab}
48
- id={"site-#{@site.id}-#{@tab}"}
49
- />
50
- </Layout.layout>
51
- """
52
- end
53
-
54
- defp site_header(assigns) do
55
- ~H"""
56
- <div class="flex items-center">
57
- <div class="rounded-full p-1 mr-4">
58
- <.favicon class="w-8" domain={@site.domain} />
59
- </div>
60
- <div>
61
- <p class="text-xl font-bold sm:text-2xl">
62
- {@site.domain}
63
- <span
64
- :if={
65
- @site.ingest_rate_limit_threshold &&
66
- @site.ingest_rate_limit_threshold < @site.ingest_rate_limit_scale_seconds
67
- }
68
- class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800"
69
- >
70
- Rejecting traffic
71
- </span>
72
-
73
- <span
74
- :if={@site.ingest_rate_limit_threshold == 0}
75
- class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800"
76
- >
77
- Traffic blocked
78
- </span>
79
- </p>
80
- <p class="text-sm font-medium">
81
- Timezone: {@site.timezone}
82
- </p>
83
- <p class="text-sm font-medium">
84
- Team:
85
- <.styled_link patch={
86
- Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, @site.team.id)
87
- }>
88
- {@site.team.name}
89
- </.styled_link>
90
- </p>
91
- <p class="text-sm font-medium">
92
- <span :if={@site.domain_changed_from}>(previously: {@site.domain_changed_from})</span>
93
- </p>
94
- </div>
95
- </div>
96
- """
97
- end
98
-
99
- defp site_tab_navigation(assigns) do
100
- ~H"""
101
- <.tab_navigation tab={@tab}>
102
- <:tabs>
103
- <.tab to="overview" tab={@tab}>Overview</.tab>
104
- <.tab to="people" tab={@tab}>People</.tab>
105
- <.tab to="rescue-zone" tab={@tab}>Rescue Zone</.tab>
106
- </:tabs>
107
- </.tab_navigation>
108
- """
109
- end
110
-
111
- defp tab_component("overview"), do: Overview
112
- defp tab_component("people"), do: People
113
- defp tab_component("rescue-zone"), do: RescueZone
114
- defp tab_component(_), do: Overview
115
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/site/components/overview.ex DELETED
@@ -1,104 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Site.Components.Overview do
2
- @moduledoc """
3
- Site overview component - handles site settings and management
4
- """
5
- use PlausibleWeb, :live_component
6
- import PlausibleWeb.CustomerSupport.Live
7
-
8
- def update(%{site: site}, socket) do
9
- changeset = Plausible.Site.crm_changeset(site, %{})
10
- form = to_form(changeset)
11
- {:ok, assign(socket, site: site, form: form)}
12
- end
13
-
14
- def render(assigns) do
15
- ~H"""
16
- <div class="mt-8">
17
- <.form :let={f} for={@form} phx-target={@myself} phx-submit="save-site">
18
- <div class="flex justify-center items-center gap-x-8 pb-8 mb-8 border-b border-gray-200 dark:border-gray-700 w-full text-sm text-center">
19
- <span>Quick links:</span>
20
-
21
- <.styled_link
22
- new_tab={true}
23
- href={Routes.stats_path(PlausibleWeb.Endpoint, :stats, @site.domain, [])}
24
- >
25
- Dashboard
26
- </.styled_link>
27
-
28
- <.styled_link
29
- new_tab={true}
30
- href={Routes.site_path(PlausibleWeb.Endpoint, :settings_general, @site.domain, [])}
31
- >
32
- Settings
33
- </.styled_link>
34
-
35
- <.styled_link
36
- new_tab={true}
37
- href={"https://plausible.grafana.net/d/BClBG5b4k/ingest-counters-per-domain?orgId=1&from=now-24h&to=now&timezone=browser&var-domain=#{@site.domain}&refresh=10s"}
38
- >
39
- Ingest Overview
40
- </.styled_link>
41
- </div>
42
-
43
- <.input
44
- type="select"
45
- field={f[:timezone]}
46
- label="Timezone"
47
- options={Plausible.Timezones.options()}
48
- />
49
- <.input type="checkbox" field={f[:public]} label="Public?" />
50
- <.input type="datetime-local" field={f[:native_stats_start_at]} label="Native Stats Start At" />
51
- <.input
52
- type="text"
53
- field={f[:ingest_rate_limit_threshold]}
54
- label="Ingest Rate Limit Threshold"
55
- />
56
- <.input
57
- type="text"
58
- field={f[:ingest_rate_limit_scale_seconds]}
59
- label="Ingest Rate Limit Scale Seconds"
60
- />
61
-
62
- <div class="flex justify-between">
63
- <.button phx-target={@myself} type="submit">
64
- Save
65
- </.button>
66
-
67
- <.button
68
- phx-target={@myself}
69
- phx-click="delete-site"
70
- data-confirm="Are you sure you want to delete this site?"
71
- theme="danger"
72
- >
73
- Delete Site
74
- </.button>
75
- </div>
76
- </.form>
77
- </div>
78
- """
79
- end
80
-
81
- def handle_event("save-site", %{"site" => params}, socket) do
82
- site = socket.assigns.site
83
-
84
- case Plausible.Site.crm_changeset(site, params) |> Plausible.Repo.update() do
85
- {:ok, updated_site} ->
86
- form = Plausible.Site.crm_changeset(updated_site, %{}) |> to_form()
87
- success("Site updated successfully")
88
- {:noreply, assign(socket, site: updated_site, form: form)}
89
-
90
- {:error, changeset} ->
91
- form = changeset |> to_form()
92
- failure("Failed to update site")
93
- {:noreply, assign(socket, form: form)}
94
- end
95
- end
96
-
97
- def handle_event("delete-site", _, socket) do
98
- site = socket.assigns.site
99
-
100
- {:ok, _} = Plausible.Site.Removal.run(site)
101
- navigate_with_success(Routes.customer_support_path(socket, :index), "Site deleted")
102
- {:noreply, socket}
103
- end
104
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/site/components/people.ex DELETED
@@ -1,70 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Site.Components.People do
2
- @moduledoc """
3
- Site people component - handles site memberships and invitations
4
- """
5
- use PlausibleWeb, :live_component
6
-
7
- def update(%{site: site}, socket) do
8
- people = Plausible.Sites.list_people(site)
9
-
10
- people =
11
- (people.invitations ++ people.memberships)
12
- |> Enum.map(fn p ->
13
- if Map.has_key?(p, :invitation_id) do
14
- {:invitation, p.email, p.role}
15
- else
16
- {:membership, p.user, p.role}
17
- end
18
- end)
19
-
20
- {:ok, assign(socket, site: site, people: people)}
21
- end
22
-
23
- def render(assigns) do
24
- ~H"""
25
- <div class="mt-8">
26
- <.table rows={@people}>
27
- <:thead>
28
- <.th>User</.th>
29
- <.th>Kind</.th>
30
- <.th>Role</.th>
31
- </:thead>
32
- <:tbody :let={{kind, person, role}}>
33
- <.td :if={kind == :membership}>
34
- <.styled_link
35
- class="flex items-center"
36
- patch={Routes.customer_support_user_path(PlausibleWeb.Endpoint, :show, person.id)}
37
- >
38
- <img
39
- src={Plausible.Auth.User.profile_img_url(person)}
40
- class="w-4 rounded-full bg-gray-300 mr-2"
41
- />
42
- {person.name}
43
- </.styled_link>
44
- </.td>
45
-
46
- <.td :if={kind == :invitation}>
47
- <div class="flex items-center">
48
- <img
49
- src={Plausible.Auth.User.profile_img_url(person)}
50
- class="w-4 rounded-full bg-gray-300 mr-2"
51
- />
52
- {person}
53
- </div>
54
- </.td>
55
-
56
- <.td :if={kind == :membership}>
57
- Membership
58
- </.td>
59
-
60
- <.td :if={kind == :invitation}>
61
- Invitation
62
- </.td>
63
-
64
- <.td>{role}</.td>
65
- </:tbody>
66
- </.table>
67
- </div>
68
- """
69
- end
70
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/site/components/rescue_zone.ex DELETED
@@ -1,89 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Site.Components.RescueZone do
2
- @moduledoc """
3
- Site rescue zone component - handles site transfer functionality
4
- """
5
- use PlausibleWeb, :live_component
6
- import PlausibleWeb.CustomerSupport.Live
7
- import PlausibleWeb.Components.Generic
8
- import Ecto.Query
9
- alias Plausible.Repo
10
- alias PlausibleWeb.Live.Components.ComboBox
11
-
12
- def update(%{site: site}, socket) do
13
- first_owner =
14
- hd(Repo.preload(site, :owners).owners)
15
-
16
- {:ok, assign(socket, site: site, first_owner: first_owner)}
17
- end
18
-
19
- def render(assigns) do
20
- ~H"""
21
- <div class="mt-8">
22
- <h1 class="text-xs font-semibold">Transfer Site</h1>
23
- <form class="mt-4 mb-8" phx-target={@myself} phx-submit="init-transfer">
24
- <.label for="inviter_email">
25
- Initiate transfer as
26
- </.label>
27
- <.live_component
28
- id="inviter_email"
29
- submit_name="inviter_email"
30
- class={[
31
- "mb-4"
32
- ]}
33
- module={ComboBox}
34
- suggest_fun={fn input, _ -> search_email(input) end}
35
- selected={{@first_owner.email, "#{@first_owner.name} <#{@first_owner.email}>"}}
36
- />
37
-
38
- <.label for="invitee_email">
39
- Send transfer invitation to
40
- </.label>
41
- <.live_component
42
- id="invitee_email"
43
- submit_name="invitee_email"
44
- module={ComboBox}
45
- suggest_fun={fn input, _ -> search_email(input) end}
46
- creatable
47
- />
48
- <.button phx-target={@myself} type="submit">
49
- Initiate Site Transfer
50
- </.button>
51
- </form>
52
- </div>
53
- """
54
- end
55
-
56
- def handle_event("init-transfer", params, socket) do
57
- inviter = Plausible.Repo.get_by!(Plausible.Auth.User, email: params["inviter_email"])
58
-
59
- case Plausible.Teams.Invitations.InviteToSite.invite(
60
- socket.assigns.site,
61
- inviter,
62
- params["invitee_email"],
63
- :owner
64
- ) do
65
- {:ok, _transfer} ->
66
- success("Transfer e-mail sent!")
67
- {:noreply, socket}
68
-
69
- error ->
70
- failure("Transfer failed: #{inspect(error)}")
71
- {:noreply, socket}
72
- end
73
- end
74
-
75
- defp search_email(input) do
76
- Repo.all(
77
- from u in Plausible.Auth.User,
78
- where: ilike(u.name, ^"%#{input}%") or ilike(u.email, ^"%#{input}%"),
79
- limit: 20,
80
- order_by: [
81
- desc: fragment("?.name = ?", u, ^input),
82
- desc: fragment("?.email = ?", u, ^input),
83
- asc: u.name
84
- ],
85
- select: {u.email, u.name}
86
- )
87
- |> Enum.map(fn {email, name} -> {email, "#{name} <#{email}>"} end)
88
- end
89
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/team.ex DELETED
@@ -1,381 +0,0 @@
1
- defmodule PlausibleWeb.Live.CustomerSupport.Team do
2
- @moduledoc """
3
- Team coordinator LiveView for Customer Support interface.
4
-
5
- Manages tab-based navigation and delegates rendering to specialized
6
- components: Overview, Members, Sites, Billing, SSO, and Audit.
7
- """
8
- use PlausibleWeb.CustomerSupport.Live
9
-
10
- alias PlausibleWeb.CustomerSupport.Team.Components.{
11
- Overview,
12
- Members,
13
- Sites,
14
- ConsolidatedViews,
15
- Billing,
16
- SSO,
17
- Audit
18
- }
19
-
20
- require Plausible.Billing.Subscription.Status
21
-
22
- def handle_params(%{"id" => id} = params, _uri, socket) do
23
- tab = params["tab"] || "overview"
24
- team_id = String.to_integer(id)
25
- team = Resource.Team.get(team_id)
26
-
27
- if team do
28
- tab_params = Map.drop(params, ["id", "tab"])
29
-
30
- socket =
31
- socket
32
- |> assign(:team, team)
33
- |> assign(:tab, tab)
34
- |> assign(:tab_params, tab_params)
35
-
36
- {:noreply, go_to_tab(socket, tab, params, :team, tab_component(tab))}
37
- else
38
- {:noreply, redirect(socket, to: Routes.customer_support_path(socket, :index))}
39
- end
40
- end
41
-
42
- def handle_event("unlock", _, socket) do
43
- {:noreply, unlock_team(socket)}
44
- end
45
-
46
- def handle_event("lock", _, socket) do
47
- {:noreply, lock_team(socket)}
48
- end
49
-
50
- def handle_event("refund-lock", _, socket) do
51
- team = socket.assigns.team
52
-
53
- {:ok, team} =
54
- Plausible.Repo.transaction(fn ->
55
- yesterday = Date.shift(Date.utc_today(), day: -1)
56
- Plausible.Billing.SiteLocker.set_lock_status_for(team, true)
57
-
58
- Plausible.Repo.update!(
59
- Plausible.Billing.Subscription.changeset(team.subscription, %{next_bill_date: yesterday})
60
- )
61
-
62
- Resource.Team.get(team.id)
63
- end)
64
-
65
- {:noreply, assign(socket, team: team)}
66
- end
67
-
68
- def render(assigns) do
69
- team = assigns.team
70
-
71
- usage = Plausible.Teams.Billing.quota_usage(team, with_features: true)
72
-
73
- limits = %{
74
- monthly_pageviews: Plausible.Teams.Billing.monthly_pageview_limit(team),
75
- sites: Plausible.Teams.Billing.site_limit(team),
76
- team_members: Plausible.Teams.Billing.team_member_limit(team)
77
- }
78
-
79
- assigns = assign(assigns, usage: usage, limits: limits)
80
-
81
- ~H"""
82
- <Layout.layout show_search={false} flash={@flash}>
83
- <.team_header team={@team} />
84
- <.team_tab_navigation team={@team} tab={@tab} limits={@limits} usage={@usage} />
85
- <.subscription_bar team={@team} />
86
-
87
- <.live_component
88
- module={tab_component(@tab)}
89
- team={@team}
90
- tab={@tab}
91
- tab_params={@tab_params}
92
- id={"team-#{@team.id}-#{@tab}"}
93
- />
94
- </Layout.layout>
95
- """
96
- end
97
-
98
- defp team_header(assigns) do
99
- ~H"""
100
- <div>
101
- <div class="sm:flex sm:items-center sm:justify-between">
102
- <div class="sm:flex sm:space-x-5">
103
- <div class="shrink-0">
104
- <div class={[
105
- team_bg(@team.identifier),
106
- "rounded-full p-1 flex items-center justify-center"
107
- ]}>
108
- <Heroicons.user_group class="h-14 w-14 text-white dark:text-gray-300" />
109
- </div>
110
- </div>
111
- <div class="mt-4 text-center sm:mt-0 sm:pt-1 sm:text-left">
112
- <p class="text-xl font-bold dark:text-gray-300 text-gray-900 sm:text-2xl">
113
- {@team.name}
114
- </p>
115
- <p class="text-sm font-medium dark:text-gray-500">
116
- <span :if={@team.setup_complete}>Set up at {@team.setup_at}</span>
117
- <span :if={!@team.setup_complete}>Not set up yet</span>
118
- </p>
119
- </div>
120
- </div>
121
-
122
- <div class="mt-5 flex justify-center sm:mt-0">
123
- <.input_with_clipboard
124
- id="team-identifier"
125
- name="team-identifier"
126
- label="Team Identifier"
127
- value={@team.identifier}
128
- onfocus="this.value = this.value;"
129
- />
130
- </div>
131
- </div>
132
- </div>
133
- """
134
- end
135
-
136
- defp team_tab_navigation(assigns) do
137
- ~H"""
138
- <.tab_navigation tab={@tab}>
139
- <:tabs>
140
- <.tab to="overview" tab={@tab}>Overview</.tab>
141
- <.tab to="members" tab={@tab}>
142
- Members ({number_format(@usage.team_members)}/{number_format(@limits.team_members)})
143
- </.tab>
144
- <.tab to="sites" tab={@tab}>
145
- Sites ({number_format(@usage.sites)}/{number_format(@limits.sites)})
146
- </.tab>
147
- <.tab :if={Plausible.Teams.setup?(@team)} to="consolidated_views" tab={@tab}>
148
- Consolidated Views
149
- </.tab>
150
- <.tab :if={has_sso_integration?(@team)} to="sso" tab={@tab}>SSO</.tab>
151
- <.tab to="billing" tab={@tab}>Billing</.tab>
152
- <.tab to="audit" tab={@tab}>Audit</.tab>
153
- </:tabs>
154
- </.tab_navigation>
155
- """
156
- end
157
-
158
- defp subscription_bar(assigns) do
159
- ~H"""
160
- <div class="grid grid-cols-1 divide-y border-t sm:grid-cols-3 sm:divide-x sm:divide-y-0 dark:bg-gray-850 text-gray-900 dark:text-gray-400 dark:divide-gray-800 dark:border-gray-600">
161
- <div class="px-6 py-5 text-center text-sm font-medium">
162
- <span>
163
- <strong>Subscription status</strong> <br />{subscription_status(@team)}
164
- <div :if={
165
- @team.subscription &&
166
- @team.subscription.status == Plausible.Billing.Subscription.Status.deleted() &&
167
- !@team.grace_period
168
- }>
169
- <span class="flex items-center gap-x-8 justify-center mt-1">
170
- <div :if={not Plausible.Teams.locked?(@team)}>
171
- <Heroicons.lock_open solid class="inline stroke-2 w-4 h-4 text-red-400 mr-1" />
172
- <.styled_link
173
- phx-click="refund-lock"
174
- data-confirm="Are you sure you want to lock? The only way to unlock, is for the user to resubscribe."
175
- >
176
- Refund Lock
177
- </.styled_link>
178
- </div>
179
-
180
- <div :if={Plausible.Teams.locked?(@team)}>
181
- <Heroicons.lock_closed solid class="inline stroke-2 w-4 h-4 text-red-400 mr-1" />
182
- Locked
183
- </div>
184
- </span>
185
- </div>
186
- </span>
187
- </div>
188
- <div class="px-6 py-5 text-center text-sm font-medium">
189
- <span>
190
- <strong>Subscription plan</strong> <br />{subscription_plan(@team)}
191
- </span>
192
- </div>
193
- <div class="px-6 py-5 text-center text-sm font-medium">
194
- <span>
195
- <strong>Grace Period</strong> <br />{grace_period_status(@team)}
196
-
197
- <div :if={@team.grace_period}>
198
- <span class="flex items-center gap-x-8 justify-center mt-1">
199
- <div>
200
- <Heroicons.lock_open solid class="inline stroke-2 w-4 h-4 text-red-400 mr-1" />
201
- <.styled_link phx-click="unlock">Unlock</.styled_link>
202
- </div>
203
-
204
- <div>
205
- <Heroicons.lock_closed solid class="inline stroke-2 w-4 h-4 text-red-400 mr-1" />
206
- <.styled_link phx-click="lock">Lock</.styled_link>
207
- </div>
208
- </span>
209
- </div>
210
- </span>
211
- </div>
212
- </div>
213
- """
214
- end
215
-
216
- defp tab_component("overview"), do: Overview
217
- defp tab_component("members"), do: Members
218
- defp tab_component("sites"), do: Sites
219
- defp tab_component("consolidated_views"), do: ConsolidatedViews
220
- defp tab_component("billing"), do: Billing
221
- defp tab_component("sso"), do: SSO
222
- defp tab_component("audit"), do: Audit
223
- defp tab_component(_), do: Overview
224
-
225
- defp has_sso_integration?(team) do
226
- case Plausible.Auth.SSO.get_integration_for(team) do
227
- {:ok, _} -> true
228
- {:error, :not_found} -> false
229
- end
230
- end
231
-
232
- defp team_bg(term) do
233
- list = [
234
- "bg-blue-500",
235
- "bg-blue-600",
236
- "bg-blue-700",
237
- "bg-blue-800",
238
- "bg-cyan-500",
239
- "bg-cyan-600",
240
- "bg-cyan-700",
241
- "bg-cyan-800",
242
- "bg-red-500",
243
- "bg-red-600",
244
- "bg-red-700",
245
- "bg-red-800",
246
- "bg-green-500",
247
- "bg-green-600",
248
- "bg-green-700",
249
- "bg-green-800",
250
- "bg-yellow-500",
251
- "bg-yellow-600",
252
- "bg-yellow-700",
253
- "bg-yellow-800",
254
- "bg-orange-500",
255
- "bg-orange-600",
256
- "bg-orange-700",
257
- "bg-orange-800",
258
- "bg-purple-500",
259
- "bg-purple-600",
260
- "bg-purple-700",
261
- "bg-purple-800",
262
- "bg-gray-500",
263
- "bg-gray-600",
264
- "bg-gray-700",
265
- "bg-gray-800",
266
- "bg-emerald-500",
267
- "bg-emerald-600",
268
- "bg-emerald-700",
269
- "bg-emerald-800"
270
- ]
271
-
272
- idx = :erlang.phash2(term, length(list))
273
- Enum.at(list, idx)
274
- end
275
-
276
- defp subscription_status(team) do
277
- cond do
278
- team && team.subscription ->
279
- status_str =
280
- PlausibleWeb.Components.Billing.Helpers.present_subscription_status(
281
- team.subscription.status
282
- )
283
-
284
- if team.subscription.paddle_subscription_id do
285
- assigns = %{status_str: status_str, subscription: team.subscription}
286
-
287
- ~H"""
288
- <.styled_link new_tab={true} href={manage_url(@subscription)}>{@status_str}</.styled_link>
289
- """
290
- else
291
- status_str
292
- end
293
-
294
- Plausible.Teams.on_trial?(team) ->
295
- "On trial"
296
-
297
- true ->
298
- "Trial expired"
299
- end
300
- end
301
-
302
- defp manage_url(%{paddle_subscription_id: paddle_id} = _subscription) do
303
- Plausible.Billing.PaddleApi.vendors_domain() <>
304
- "/subscriptions/customers/manage/" <> paddle_id
305
- end
306
-
307
- defp subscription_plan(team) do
308
- subscription = team.subscription
309
-
310
- if Plausible.Billing.Subscription.Status.active?(subscription) &&
311
- subscription.paddle_subscription_id do
312
- quota = PlausibleWeb.AuthView.subscription_quota(subscription)
313
- interval = PlausibleWeb.AuthView.subscription_interval(subscription)
314
-
315
- assigns = %{quota: quota, interval: interval, subscription: subscription}
316
-
317
- ~H"""
318
- <.styled_link new_tab={true} href={manage_url(@subscription)}>
319
- {@quota} ({@interval})
320
- </.styled_link>
321
- """
322
- else
323
- "--"
324
- end
325
- end
326
-
327
- defp grace_period_status(team) do
328
- grace_period = team.grace_period
329
-
330
- case grace_period do
331
- nil ->
332
- "--"
333
-
334
- %{manual_lock: true, is_over: true} ->
335
- "Manually locked"
336
-
337
- %{manual_lock: true, is_over: false} ->
338
- "Waiting for manual lock"
339
-
340
- %{is_over: true} ->
341
- "ended"
342
-
343
- %{end_date: %Date{} = end_date} ->
344
- days_left = Date.diff(end_date, Date.utc_today())
345
- "#{days_left} days left"
346
- end
347
- end
348
-
349
- defp lock_team(socket) do
350
- if socket.assigns.team.grace_period do
351
- team = Plausible.Teams.end_grace_period(socket.assigns.team)
352
- Plausible.Billing.SiteLocker.set_lock_status_for(team, true)
353
-
354
- put_live_flash(socket, :success, "Team locked. Grace period ended.")
355
- assign(socket, team: team)
356
- else
357
- put_live_flash(socket, :error, "No grace period")
358
- socket
359
- end
360
- end
361
-
362
- defp unlock_team(socket) do
363
- if socket.assigns.team.grace_period do
364
- team = Plausible.Teams.remove_grace_period(socket.assigns.team)
365
- Plausible.Billing.SiteLocker.set_lock_status_for(team, false)
366
-
367
- put_live_flash(socket, :success, "Team unlocked. Grace period removed.")
368
- assign(socket, team: team)
369
- else
370
- socket
371
- end
372
- end
373
-
374
- defp number_format(unlimited) when unlimited in [-1, "unlimited", :unlimited] do
375
- "unlimited"
376
- end
377
-
378
- defp number_format(input) do
379
- PlausibleWeb.TextHelpers.number_format(input)
380
- end
381
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/team/components/audit.ex DELETED
@@ -1,245 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Team.Components.Audit do
2
- @moduledoc """
3
- Team audit component - handles audit log viewing
4
- """
5
- use PlausibleWeb, :live_component
6
-
7
- def update(%{team: team, tab_params: tab_params}, socket) do
8
- pagination_params = get_pagination_params(tab_params)
9
- audit_page = Plausible.Audit.list_entries_paginated([team_id: team.id], pagination_params)
10
-
11
- entries = process_audit_entries(audit_page.entries)
12
- audit_page = %{audit_page | entries: entries}
13
- current_limit = pagination_params["limit"]
14
-
15
- {:ok,
16
- assign(socket,
17
- team: team,
18
- audit_page: audit_page,
19
- revealed_audit_entry_id: nil,
20
- current_limit: current_limit
21
- )}
22
- end
23
-
24
- def update(%{team: team}, socket) do
25
- update(%{team: team, tab_params: %{}}, socket)
26
- end
27
-
28
- def render(assigns) do
29
- ~H"""
30
- <div class="mt-4 mb-4 text-gray-900 dark:text-gray-400 relative">
31
- <div :if={Enum.empty?(@audit_page.entries)} class="flex justify-center items-center">
32
- No audit logs yet
33
- </div>
34
-
35
- <div
36
- :if={@revealed_audit_entry_id}
37
- phx-target={@myself}
38
- phx-window-keydown="reveal-audit-entry"
39
- phx-key="escape"
40
- >
41
- <.input_with_clipboard
42
- id="audit-entry-identifier"
43
- name="audit-entry-identifier"
44
- label="Audit Entry Identifier"
45
- value={@revealed_audit_entry_id}
46
- />
47
- <div class="relative">
48
- <.input
49
- rows="16"
50
- type="textarea"
51
- id="audit-entry-change"
52
- name="audit-entry-change"
53
- value={
54
- Jason.encode!(
55
- Enum.find(@audit_page.entries, &(&1.id == @revealed_audit_entry_id)).change,
56
- pretty: true
57
- )
58
- }
59
- >
60
- </.input>
61
- <.styled_link
62
- class="text-sm float-right"
63
- onclick="var textarea = document.getElementById('audit-entry-change'); textarea.focus(); textarea.select(); document.execCommand('copy');"
64
- href="#"
65
- >
66
- <div class="flex items-center absolute top-4 right-4 text-xs gap-x-1">
67
- <Heroicons.document_duplicate class="h-4 w-4 text-indigo-700" /> COPY
68
- </div>
69
- </.styled_link>
70
-
71
- <.styled_link
72
- phx-click="reveal-audit-entry"
73
- phx-target={@myself}
74
- class="float-right pt-4 text-sm"
75
- >
76
- &larr; Return
77
- <kbd class="rounded border border-gray-200 dark:border-gray-600 px-1.5 font-medium text-xs text-gray-400">
78
- ESC
79
- </kbd>
80
- </.styled_link>
81
- </div>
82
- </div>
83
-
84
- <.table :if={is_nil(@revealed_audit_entry_id)} rows={@audit_page.entries}>
85
- <:thead>
86
- <.th invisible></.th>
87
- <.th invisible></.th>
88
- <.th>Name</.th>
89
- <.th>Entity</.th>
90
- <.th>Actor</.th>
91
- <.th invisible>Actions</.th>
92
- </:thead>
93
- <:tbody :let={entry}>
94
- <.td>{Calendar.strftime(entry.datetime, "%Y-%m-%d")}</.td>
95
- <.td>{Calendar.strftime(entry.datetime, "%H:%M:%S")}</.td>
96
- <.td class="font-mono">{entry.name}</.td>
97
- <.td truncate>
98
- <.audit_entity entry={entry} />
99
- </.td>
100
- <.td :if={entry.actor_type == :system}>
101
- <div class="flex items-center gap-x-1">
102
- <Heroicons.cog_6_tooth class="size-4" /> SYSTEM
103
- </div>
104
- </.td>
105
- <.td :if={entry.actor_type == :user} truncate>
106
- <.audit_user user={entry.meta.user} />
107
- </.td>
108
-
109
- <.td actions>
110
- <.edit_button
111
- phx-click="reveal-audit-entry"
112
- phx-value-id={entry.id}
113
- phx-target={@myself}
114
- />
115
- </.td>
116
- </:tbody>
117
- </.table>
118
-
119
- <div
120
- :if={
121
- is_nil(@revealed_audit_entry_id) &&
122
- (@audit_page.metadata.before || @audit_page.metadata.after)
123
- }
124
- class="flex justify-between items-center mt-4"
125
- >
126
- <.button
127
- :if={@audit_page.metadata.before}
128
- id="prev-page"
129
- phx-click="paginate-audit"
130
- phx-value-before={@audit_page.metadata.before}
131
- phx-value-limit={@current_limit}
132
- phx-target={@myself}
133
- theme="secondary"
134
- >
135
- &larr; Prev
136
- </.button>
137
- <div></div>
138
- <.button
139
- :if={@audit_page.metadata.after}
140
- id="next-page"
141
- phx-click="paginate-audit"
142
- phx-value-after={@audit_page.metadata.after}
143
- phx-value-limit={@current_limit}
144
- phx-target={@myself}
145
- theme="secondary"
146
- >
147
- Next &rarr;
148
- </.button>
149
- </div>
150
- </div>
151
- """
152
- end
153
-
154
- def handle_event("reveal-audit-entry", %{"id" => id}, socket) do
155
- {:noreply, assign(socket, revealed_audit_entry_id: id)}
156
- end
157
-
158
- def handle_event("reveal-audit-entry", _, socket) do
159
- {:noreply, assign(socket, revealed_audit_entry_id: nil)}
160
- end
161
-
162
- def handle_event("paginate-audit", params, socket) do
163
- pagination_params = get_pagination_params(params)
164
- team = socket.assigns.team
165
-
166
- query_params = %{"tab" => "audit"} |> Map.merge(pagination_params)
167
-
168
- {:noreply,
169
- push_patch(socket,
170
- to: Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, team.id, query_params)
171
- )}
172
- end
173
-
174
- defp process_audit_entries(entries) do
175
- Enum.map(entries, fn entry ->
176
- meta = entry.meta
177
-
178
- meta =
179
- if entry.user_id && entry.user_id > 0 do
180
- user = Plausible.Repo.get(Plausible.Auth.User, entry.user_id)
181
- Map.put(meta, :user, user)
182
- else
183
- meta
184
- end
185
-
186
- meta =
187
- if entry.entity == "Plausible.Auth.User" do
188
- user = Plausible.Repo.get(Plausible.Auth.User, String.to_integer(entry.entity_id))
189
- Map.put(meta, :entity, user)
190
- else
191
- meta
192
- end
193
-
194
- Map.put(entry, :meta, meta)
195
- end)
196
- end
197
-
198
- attr :entry, Plausible.Audit.Entry
199
-
200
- defp audit_entity(assigns) do
201
- ~H"""
202
- <%= if @entry.entity == "Plausible.Auth.User" do %>
203
- <.audit_user user={@entry.meta.entity} />
204
- <% else %>
205
- {@entry.entity |> String.split(".") |> List.last()} #{String.slice(@entry.entity_id, 0, 8)}
206
- <% end %>
207
- """
208
- end
209
-
210
- attr :user, Plausible.Auth.User
211
-
212
- defp audit_user(%{user: nil} = assigns) do
213
- ~H"""
214
- (N/A)
215
- """
216
- end
217
-
218
- defp audit_user(assigns) do
219
- ~H"""
220
- <div class="flex items-center gap-x-1">
221
- <img
222
- class="w-4"
223
- src={
224
- Plausible.Auth.User.profile_img_url(%Plausible.Auth.User{
225
- email: @user.email
226
- })
227
- }
228
- />
229
-
230
- <.styled_link
231
- patch={Routes.customer_support_user_path(PlausibleWeb.Endpoint, :show, @user.id)}
232
- class="cursor-pointer flex block items-center"
233
- >
234
- {@user.name}
235
- </.styled_link>
236
- </div>
237
- """
238
- end
239
-
240
- defp get_pagination_params(params) do
241
- params
242
- |> Map.take(["after", "before", "limit"])
243
- |> Map.put_new("limit", 15)
244
- end
245
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/team/components/billing.ex DELETED
@@ -1,461 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Team.Components.Billing do
2
- @moduledoc """
3
- Team billing component - handles subscription and custom plans
4
- """
5
- use PlausibleWeb, :live_component
6
- import PlausibleWeb.CustomerSupport.Live
7
-
8
- alias Plausible.Billing.EnterprisePlan
9
- alias Plausible.Billing.Plans
10
- alias Plausible.Teams
11
- import Ecto.Query, only: [from: 2]
12
- import PlausibleWeb.Components.Generic
13
-
14
- def update(%{team: team}, socket) do
15
- usage = Teams.Billing.quota_usage(team, with_features: true)
16
-
17
- limits = %{
18
- monthly_pageviews: Teams.Billing.monthly_pageview_limit(team),
19
- sites: Teams.Billing.site_limit(team),
20
- team_members: Teams.Billing.team_member_limit(team)
21
- }
22
-
23
- plans = get_plans(team.id)
24
- plan = Plans.get_subscription_plan(team.subscription)
25
-
26
- attrs = get_plan_attrs(plan)
27
- plan_form = to_form(EnterprisePlan.changeset(%EnterprisePlan{}, attrs))
28
-
29
- {:ok,
30
- assign(socket,
31
- team: team,
32
- usage: usage,
33
- limits: limits,
34
- plan: plan,
35
- plans: plans,
36
- plan_form: plan_form,
37
- show_plan_form?: false,
38
- editing_plan: nil,
39
- cost_estimate: 0
40
- )}
41
- end
42
-
43
- def render(assigns) do
44
- ~H"""
45
- <div>
46
- <script type="text/javascript">
47
- const featureChangeCallback = function(e) {
48
- const value = e.target.value
49
- const checked = e.target.checked
50
- const form = e.target.closest('form')
51
-
52
- if (value === 'sites_api' && checked) {
53
- form.querySelector('input[value=stats_api]').checked = true
54
- } else if (value === 'stats_api' && !checked) {
55
- form.querySelector('input[value=sites_api]').checked = false
56
- }
57
- }
58
- </script>
59
-
60
- <div class="flex">
61
- <.button
62
- :if={!@show_plan_form?}
63
- id="new-custom-plan"
64
- phx-click="show-plan-form"
65
- phx-target={@myself}
66
- class="ml-auto"
67
- >
68
- New Custom Plan
69
- </.button>
70
- </div>
71
-
72
- <div class="mt-4 mb-4 text-gray-900 dark:text-gray-400">
73
- <h1 class="text-xs font-semibold">Usage</h1>
74
- <.table rows={monthly_pageviews_usage(@usage.monthly_pageviews, @limits.monthly_pageviews)}>
75
- <:thead>
76
- <.th invisible>Cycle</.th>
77
- <.th invisible>Dates</.th>
78
- <.th>Total</.th>
79
- <.th>Limit</.th>
80
- </:thead>
81
- <:tbody :let={{cycle, date, total, limit}}>
82
- <.td>{cycle}</.td>
83
- <.td>{date}</.td>
84
- <.td>
85
- <span class={if total > limit, do: "text-red-600"}>
86
- {number_format(total)}
87
- </span>
88
- </.td>
89
- <.td>{number_format(limit)}</.td>
90
- </:tbody>
91
- </.table>
92
-
93
- <p :if={@usage.features != []} class="mt-6 mb-4">
94
- <h1 class="text-xs font-semibold">Features Used</h1>
95
- <span class="text-sm">
96
- {@usage.features |> Enum.map(& &1.display_name()) |> Enum.join(", ")}
97
- </span>
98
- </p>
99
-
100
- <h1 :if={!@show_plan_form? and @plans != []} class="mt-8 text-xs font-semibold">
101
- Custom Plans
102
- </h1>
103
- <.table :if={!@show_plan_form?} rows={@plans}>
104
- <:thead>
105
- <.th invisible>Interval</.th>
106
- <.th>Paddle Plan ID</.th>
107
- <.th>Limits</.th>
108
- <.th>Features</.th>
109
- <.th invisible>Actions</.th>
110
- </:thead>
111
- <:tbody :let={plan}>
112
- <.td class="align-top">
113
- {plan.billing_interval}
114
- </.td>
115
- <.td class="align-top" data-test-id={"plan-entry-#{plan.paddle_plan_id}"}>
116
- {plan.paddle_plan_id}
117
-
118
- <span
119
- :if={current_plan?(@team, plan.paddle_plan_id)}
120
- class="inline-flex items-center px-2 py-0.5 rounded text-xs font-xs bg-red-100 text-red-800"
121
- >
122
- CURRENT
123
- </span>
124
- </.td>
125
- <.td max_width="max-w-40">
126
- <.table rows={[
127
- {"Pageviews", number_format(plan.monthly_pageview_limit)},
128
- {"Sites", number_format(plan.site_limit)},
129
- {"Members", number_format(plan.team_member_limit)},
130
- {"API Requests", number_format(plan.hourly_api_request_limit)}
131
- ]}>
132
- <:tbody :let={{label, value}}>
133
- <.td>{label}</.td>
134
- <.td>{value}</.td>
135
- </:tbody>
136
- </.table>
137
- </.td>
138
- <.td class="align-top">
139
- <span :for={feat <- plan.features}>{feat.display_name()}<br /></span>
140
- </.td>
141
- <.td class="align-top">
142
- <.edit_button phx-click="edit-plan" phx-value-id={plan.id} phx-target={@myself} />
143
- <.delete_button
144
- :if={not current_plan?(@team, plan.paddle_plan_id)}
145
- data-test-id={"delete-plan-#{plan.paddle_plan_id}"}
146
- data-confirm="Are you sure you want to delete this plan?"
147
- phx-click="delete-plan"
148
- phx-value-id={plan.id}
149
- phx-target={@myself}
150
- />
151
- </.td>
152
- </:tbody>
153
- </.table>
154
-
155
- <.form
156
- :let={f}
157
- :if={@show_plan_form?}
158
- for={@plan_form}
159
- id="save-plan"
160
- phx-submit={if @editing_plan, do: "update-plan", else: "save-plan"}
161
- phx-target={@myself}
162
- phx-change="estimate-cost"
163
- >
164
- <.input field={f[:paddle_plan_id]} label="Paddle Plan ID" autocomplete="off" />
165
- <.input
166
- type="select"
167
- options={["monthly", "yearly"]}
168
- field={f[:billing_interval]}
169
- label="Billing Interval"
170
- autocomplete="off"
171
- />
172
-
173
- <div class="flex items-center gap-x-4">
174
- <.input
175
- field={f[:monthly_pageview_limit]}
176
- label="Monthly Pageview Limit"
177
- autocomplete="off"
178
- width="w-[500]"
179
- />
180
- <.preview for={f[:monthly_pageview_limit]} />
181
- </div>
182
-
183
- <div class="flex items-center gap-x-4">
184
- <.input width="w-[500]" field={f[:site_limit]} label="Site Limit" autocomplete="off" />
185
- <.preview for={f[:site_limit]} />
186
- </div>
187
-
188
- <div class="flex items-center gap-x-4">
189
- <.input
190
- field={f[:team_member_limit]}
191
- label="Team Member Limit"
192
- autocomplete="off"
193
- width="w-[500]"
194
- />
195
- <.preview for={f[:team_member_limit]} />
196
- </div>
197
-
198
- <div class="flex items-center gap-x-4">
199
- <.input
200
- field={f[:hourly_api_request_limit]}
201
- label="Hourly API Request Limit"
202
- autocomplete="off"
203
- width="w-[500]"
204
- />
205
- <.preview for={f[:hourly_api_request_limit]} />
206
- </div>
207
-
208
- <.input
209
- :for={
210
- mod <-
211
- Plausible.Billing.Feature.list()
212
- |> Enum.sort_by(fn item -> if item.name() == :stats_api, do: 0, else: 1 end)
213
- }
214
- :if={mod not in Teams.Billing.free_features()}
215
- x-on:change="featureChangeCallback(event)"
216
- type="checkbox"
217
- value={mod in (f.source.changes[:features] || f.source.data.features || [])}
218
- name={"#{f.name}[features[]][#{mod.name()}]"}
219
- label={mod.display_name()}
220
- />
221
-
222
- <div class="mt-8 flex align-center gap-x-4">
223
- <.input
224
- type="checkbox"
225
- field={f[:managed_proxy_price_modifier]}
226
- label="Managed proxy"
227
- />
228
- </div>
229
-
230
- <div class="mt-8 flex align-center gap-x-4">
231
- <.input_with_clipboard
232
- id="cost-estimate"
233
- name="cost-estimate"
234
- label={"#{(f[:billing_interval].value || "monthly")} cost estimate"}
235
- value={@cost_estimate}
236
- />
237
-
238
- <.button theme="secondary" phx-click="hide-plan-form" phx-target={@myself}>
239
- Cancel
240
- </.button>
241
-
242
- <.button type="submit">
243
- {if @editing_plan, do: "Update Plan", else: "Save Custom Plan"}
244
- </.button>
245
- </div>
246
- </.form>
247
- </div>
248
- </div>
249
- """
250
- end
251
-
252
- def handle_event("show-plan-form", _, socket) do
253
- {:noreply, assign(socket, show_plan_form?: true, editing_plan: nil)}
254
- end
255
-
256
- def handle_event("edit-plan", %{"id" => plan_id}, socket) do
257
- {plan_id, _} = Integer.parse(plan_id)
258
- plan = Enum.find(socket.assigns.plans, &(&1.id == plan_id))
259
-
260
- if plan do
261
- plan_form = to_form(EnterprisePlan.changeset(plan, %{}))
262
- {:noreply, assign(socket, show_plan_form?: true, editing_plan: plan, plan_form: plan_form)}
263
- else
264
- {:noreply, socket}
265
- end
266
- end
267
-
268
- def handle_event("delete-plan", %{"id" => plan_id}, socket) do
269
- plan = Plausible.Repo.get(EnterprisePlan, plan_id)
270
-
271
- if not current_plan?(socket.assigns.team, plan.paddle_plan_id),
272
- do: Plausible.Repo.delete(plan)
273
-
274
- plans = get_plans(socket.assigns.team.id)
275
- {:noreply, assign(socket, plans: plans)}
276
- end
277
-
278
- def handle_event("hide-plan-form", _, socket) do
279
- {:noreply, assign(socket, show_plan_form?: false, editing_plan: nil)}
280
- end
281
-
282
- def handle_event("estimate-cost", %{"enterprise_plan" => params}, socket) do
283
- params = update_features_to_list(params)
284
-
285
- form = to_form(EnterprisePlan.changeset(%EnterprisePlan{}, params))
286
-
287
- params = sanitize_params(params)
288
-
289
- cost_estimate =
290
- Plausible.CustomerSupport.EnterprisePlan.estimate(
291
- billing_interval: params["billing_interval"],
292
- pageviews_per_month: get_int_param(params, "monthly_pageview_limit"),
293
- sites_limit: get_int_param(params, "site_limit"),
294
- team_members_limit: get_int_param(params, "team_member_limit"),
295
- api_calls_limit: get_int_param(params, "hourly_api_request_limit"),
296
- features: params["features"],
297
- managed_proxy_price_modifier: params["managed_proxy_price_modifier"] == "true"
298
- )
299
-
300
- {:noreply, assign(socket, cost_estimate: cost_estimate, plan_form: form)}
301
- end
302
-
303
- def handle_event("save-plan", %{"enterprise_plan" => params}, socket) do
304
- params = params |> update_features_to_list() |> sanitize_params()
305
- changeset = EnterprisePlan.changeset(%EnterprisePlan{team_id: socket.assigns.team.id}, params)
306
-
307
- case Plausible.Repo.insert(changeset) do
308
- {:ok, _plan} ->
309
- success("Plan saved")
310
- plans = get_plans(socket.assigns.team.id)
311
-
312
- {:noreply,
313
- assign(socket,
314
- plans: plans,
315
- plan_form: to_form(changeset),
316
- show_plan_form?: false,
317
- editing_plan: nil
318
- )}
319
-
320
- {:error, changeset} ->
321
- failure("Error saving plan: #{inspect(changeset.errors)}")
322
- {:noreply, assign(socket, plan_form: to_form(changeset))}
323
- end
324
- end
325
-
326
- def handle_event("update-plan", %{"enterprise_plan" => params}, socket) do
327
- params = params |> update_features_to_list() |> sanitize_params()
328
- changeset = EnterprisePlan.changeset(socket.assigns.editing_plan, params)
329
-
330
- case Plausible.Repo.update(changeset) do
331
- {:ok, _plan} ->
332
- success("Plan updated")
333
- plans = get_plans(socket.assigns.team.id)
334
-
335
- {:noreply,
336
- assign(socket,
337
- plans: plans,
338
- plan_form: to_form(changeset),
339
- show_plan_form?: false,
340
- editing_plan: nil
341
- )}
342
-
343
- {:error, changeset} ->
344
- failure("Error updating plan: #{inspect(changeset.errors)}")
345
- {:noreply, assign(socket, plan_form: to_form(changeset))}
346
- end
347
- end
348
-
349
- # Helper functions
350
- defp get_plan_attrs(plan) when is_map(plan) do
351
- Map.take(plan, [
352
- :billing_interval,
353
- :monthly_pageview_limit,
354
- :site_limit,
355
- :team_member_limit,
356
- :hourly_api_request_limit,
357
- :features
358
- ])
359
- |> Map.update(:features, [], fn features ->
360
- Enum.map(features, &to_string(&1.name()))
361
- end)
362
- end
363
-
364
- defp get_plan_attrs(_) do
365
- %{
366
- monthly_pageview_limit: 10_000,
367
- hourly_api_request_limit: 600,
368
- site_limit: 10,
369
- team_member_limit: 10,
370
- features: Plausible.Billing.Feature.list() -- [Plausible.Billing.Feature.SSO]
371
- }
372
- end
373
-
374
- defp monthly_pageviews_usage(usage, limit) do
375
- usage
376
- |> Enum.sort_by(fn {_cycle, usage} -> usage.date_range.first end, :desc)
377
- |> Enum.map(fn {cycle, usage} ->
378
- {cycle, PlausibleWeb.TextHelpers.format_date_range(usage.date_range), usage.total, limit}
379
- end)
380
- end
381
-
382
- defp get_plans(team_id) do
383
- Plausible.Repo.all(
384
- from ep in EnterprisePlan,
385
- where: ep.team_id == ^team_id,
386
- order_by: [desc: :id]
387
- )
388
- end
389
-
390
- defp number_format(unlimited) when unlimited in [-1, "unlimited", :unlimited] do
391
- "unlimited"
392
- end
393
-
394
- defp number_format(input) do
395
- PlausibleWeb.TextHelpers.number_format(input)
396
- end
397
-
398
- defp sanitize_params(params) do
399
- params
400
- |> Enum.map(&clear_param/1)
401
- |> Enum.reject(&(&1 == ""))
402
- |> Map.new()
403
- end
404
-
405
- defp clear_param({key, value}) when is_binary(value) do
406
- {key, String.trim(value)}
407
- end
408
-
409
- defp clear_param(other) do
410
- other
411
- end
412
-
413
- defp get_int_param(params, key) do
414
- param = Map.get(params, key)
415
- param = if param in ["", nil], do: "0", else: param
416
-
417
- case Integer.parse(param) do
418
- {integer, ""} -> integer
419
- _ -> 0
420
- end
421
- end
422
-
423
- defp update_features_to_list(params) do
424
- features =
425
- params["features[]"]
426
- |> Enum.reject(fn {_key, value} -> value == "false" or value == "" end)
427
- |> Enum.map(fn {key, _value} -> key end)
428
-
429
- Map.put(params, "features", features)
430
- end
431
-
432
- defp preview_number(n) do
433
- case Integer.parse("#{n}") do
434
- {n, ""} ->
435
- number_format(n) <>
436
- " (#{PlausibleWeb.StatsView.large_number_format(n)})"
437
-
438
- _ ->
439
- "0"
440
- end
441
- end
442
-
443
- attr :for, :any, required: true
444
-
445
- defp preview(assigns) do
446
- ~H"""
447
- <.input
448
- name={"#{@for.name}-preview"}
449
- label="Preview"
450
- autocomplete="off"
451
- width="w-[500]"
452
- readonly
453
- value={preview_number(@for.value)}
454
- class="bg-transparent border-0 p-0 m-0 text-sm w-full"
455
- />
456
- """
457
- end
458
-
459
- defp current_plan?(%{subscription: %{paddle_plan_id: id}}, id), do: true
460
- defp current_plan?(_, _), do: false
461
- end