analytics/lib/plausible/goals.ex

257 lines
6.8 KiB
Elixir

defmodule Plausible.Goals do
use Plausible
use Plausible.Repo
use Plausible.Funnel.Const
import Ecto.Query
alias Plausible.Goal
alias Ecto.Multi
@spec create(Plausible.Site.t(), map(), Keyword.t()) ::
{:ok, Goal.t()} | {:error, Ecto.Changeset.t()} | {:error, :upgrade_required}
@doc """
Creates a Goal for a site.
If the created goal is a revenue goal, it sets site.updated_at to be
refreshed by the sites cache, as revenue goals are used during ingestion.
"""
def create(site, params, opts \\ []) do
upsert? = Keyword.get(opts, :upsert?, false)
Repo.transaction(fn ->
case insert_goal(site, params, upsert?) do
{:ok, :insert, goal} ->
on_ee do
now = Keyword.get(opts, :now, DateTime.utc_now())
# credo:disable-for-next-line Credo.Check.Refactor.Nesting
if Plausible.Goal.Revenue.revenue?(goal) do
Plausible.Site.Cache.touch_site!(site, now)
end
end
Repo.preload(goal, :site)
{:ok, :upsert, goal} ->
Repo.preload(goal, :site)
{:error, cause} ->
Repo.rollback(cause)
end
end)
end
def find_or_create(site, %{
"goal_type" => "event",
"event_name" => event_name,
"currency" => currency
})
when is_binary(event_name) and is_binary(currency) do
params = %{"event_name" => event_name, "currency" => currency}
with {:ok, goal} <- create(site, params, upsert?: true) do
if to_string(goal.currency) == currency do
{:ok, goal}
else
# we must disallow creation of the same goal name with different currency
changeset =
goal
|> Goal.changeset()
|> Ecto.Changeset.add_error(
:event_name,
"'#{goal.event_name}' (with currency: #{goal.currency}) has already been taken"
)
{:error, changeset}
end
end
end
def find_or_create(site, %{"goal_type" => "event", "event_name" => event_name})
when is_binary(event_name) do
create(site, %{"event_name" => event_name}, upsert?: true)
end
def find_or_create(_, %{"goal_type" => "event"}), do: {:missing, "event_name"}
def find_or_create(site, %{"goal_type" => "page", "page_path" => page_path}) do
create(site, %{"page_path" => page_path}, upsert?: true)
end
def find_or_create(_, %{"goal_type" => "page"}), do: {:missing, "page_path"}
def list_revenue_goals(site) do
from(g in Plausible.Goal,
where: g.site_id == ^site.id and not is_nil(g.currency),
select: %{event_name: g.event_name, currency: g.currency}
)
|> Plausible.Repo.all()
end
def for_site(site, opts \\ []) do
site
|> for_site_query(opts)
|> Repo.all()
|> Enum.map(&maybe_trim/1)
end
def for_site_query(site, opts \\ []) do
query =
from g in Goal,
inner_join: assoc(g, :site),
where: g.site_id == ^site.id,
order_by: [desc: g.id],
preload: [:site]
if opts[:preload_funnels?] == true and ee?() do
from(g in query,
left_join: assoc(g, :funnels),
group_by: g.id,
preload: [:funnels]
)
else
query
end
end
@doc """
If a goal belongs to funnel(s), we need to inspect their number of steps.
If it exceeds the minimum allowed (defined via `Plausible.Funnel.min_steps/0`),
the funnel will be reduced (i.e. a step associated with the goal to be deleted
is removed), so that the minimum number of steps is preserved. This is done
implicitly, by postgres, as per on_delete: :delete_all.
Otherwise, for associated funnel(s) consisting of minimum number steps only,
funnel record(s) are removed completely along with the targeted goal.
"""
def delete(id, %Plausible.Site{id: site_id}) do
delete(id, site_id)
end
def delete(id, site_id) do
goal_query =
from(g in Goal,
where: g.id == ^id,
where: g.site_id == ^site_id
)
goal_query = on_ee(do: preload(goal_query, funnels: :steps), else: goal_query)
result =
Multi.new()
|> Multi.one(
:goal,
goal_query
)
|> Multi.run(:funnel_ids_to_wipe, fn
_, %{goal: nil} ->
{:error, :not_found}
_, %{goal: %{funnels: []}} ->
{:ok, []}
_, %{goal: %{funnels: funnels}} ->
funnels_to_wipe =
funnels
|> Enum.filter(&(Enum.count(&1.steps) == Funnel.Const.min_steps()))
|> Enum.map(& &1.id)
{:ok, funnels_to_wipe}
end)
|> Multi.merge(fn
%{funnel_ids_to_wipe: []} ->
Ecto.Multi.new()
%{funnel_ids_to_wipe: [_ | _] = funnel_ids} ->
Ecto.Multi.new()
|> Multi.delete_all(
:delete_funnels,
from(f in "funnels",
where: f.id in ^funnel_ids
)
)
end)
|> Multi.delete_all(
:delete_goals,
fn _ ->
from g in Goal,
where: g.id == ^id,
where: g.site_id == ^site_id
end
)
|> Repo.transaction()
case result do
{:ok, _} -> :ok
{:error, _step, reason, _context} -> {:error, reason}
end
end
@spec count(Plausible.Site.t()) :: non_neg_integer()
def count(site) do
Repo.aggregate(
from(
g in Goal,
where: g.site_id == ^site.id
),
:count
)
end
defp insert_goal(site, params, upsert?) do
params = Map.delete(params, "site_id")
insert_opts =
if upsert? do
[on_conflict: :nothing]
else
[]
end
changeset = Goal.changeset(%Goal{site_id: site.id}, params)
with :ok <- maybe_check_feature_access(site, changeset),
{:ok, goal} <- Repo.insert(changeset, insert_opts) do
# Upsert with `on_conflict: :nothing` strategy
# will result in goal struct missing primary key field
# which is generated by the database.
if goal.id do
{:ok, :insert, goal}
else
get_params =
goal
|> Map.take([:site_id, :event_name, :page_path])
|> Enum.reject(fn {_, value} -> is_nil(value) end)
{:ok, :upsert, Repo.get_by!(Goal, get_params)}
end
end
end
defp maybe_check_feature_access(site, changeset) do
if Ecto.Changeset.get_field(changeset, :currency) do
site = Plausible.Repo.preload(site, :owner)
Plausible.Billing.Feature.RevenueGoals.check_availability(site.owner)
else
:ok
end
end
defp maybe_trim(%Goal{} = goal) do
# we make sure that even if we saved goals erroneously with trailing
# space, it's removed during fetch
goal
|> Map.update!(:event_name, &maybe_trim/1)
|> Map.update!(:page_path, &maybe_trim/1)
end
defp maybe_trim(s) when is_binary(s) do
String.trim(s)
end
defp maybe_trim(other) do
other
end
end