Cleanup files batch 360
Browse files- extra/lib/plausible/installation_support/check.ex +0 -61
- extra/lib/plausible/installation_support/check_runner.ex +0 -65
- extra/lib/plausible/installation_support/checks/detection.ex +0 -162
- extra/lib/plausible/installation_support/checks/url.ex +0 -103
- extra/lib/plausible/installation_support/checks/verify_installation.ex +0 -189
- extra/lib/plausible/installation_support/checks/verify_installation_cache_bust.ex +0 -42
- extra/lib/plausible/installation_support/detection/checks.ex +0 -114
- extra/lib/plausible/installation_support/detection/diagnostics.ex +0 -108
- extra/lib/plausible/installation_support/installation_support.ex +0 -20
- extra/lib/plausible/installation_support/result.ex +0 -18
- extra/lib/plausible/installation_support/state.ex +0 -40
- extra/lib/plausible/installation_support/url.ex +0 -25
- extra/lib/plausible/installation_support/verification/checks.ex +0 -100
- extra/lib/plausible/installation_support/verification/diagnostics.ex +0 -389
- extra/lib/plausible/plugins/api/funnels.ex +0 -61
- extra/lib/plausible/site/tracker_script_id_cache.ex +0 -50
- extra/lib/plausible/stats/exploration.ex +0 -613
- extra/lib/plausible/stats/funnel.ex +0 -171
- extra/lib/plausible/stats/goal/revenue.ex +0 -94
- extra/lib/plausible/stats/journey/step.ex +0 -53
- extra/lib/plausible/stats/sampling.ex +0 -105
- extra/lib/plausible/stats/sampling_cache.ex +0 -59
- extra/lib/plausible/workers/score_trial_prospects.ex +0 -101
- extra/lib/plausible_web/controllers/api/external_sites_controller.ex +0 -626
- extra/lib/plausible_web/controllers/api/revenue.ex +0 -27
- extra/lib/plausible_web/controllers/help_scout_controller.ex +0 -121
- extra/lib/plausible_web/controllers/sso_controller.ex +0 -140
- extra/lib/plausible_web/dogfood.ex +0 -96
- extra/lib/plausible_web/live/customer_support.ex +0 -74
- extra/lib/plausible_web/live/customer_support/components/layout.ex +0 -124
- extra/lib/plausible_web/live/customer_support/components/search.ex +0 -125
- extra/lib/plausible_web/live/customer_support/components/search_result.ex +0 -105
- extra/lib/plausible_web/live/customer_support/live.ex +0 -133
- extra/lib/plausible_web/live/customer_support/site.ex +0 -115
- extra/lib/plausible_web/live/customer_support/site/components/overview.ex +0 -104
- extra/lib/plausible_web/live/customer_support/site/components/people.ex +0 -70
- extra/lib/plausible_web/live/customer_support/site/components/rescue_zone.ex +0 -89
- extra/lib/plausible_web/live/customer_support/team.ex +0 -381
- extra/lib/plausible_web/live/customer_support/team/components/audit.ex +0 -245
- 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 |
-
← 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 |
-
← 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} <{@resource.object.email}> <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 |
-
← 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 |
-
← 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 →
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|