| defmodule Plausible.Goal do |
| @moduledoc """ |
| Goal schema. |
| """ |
|
|
| use Plausible |
| use Ecto.Schema |
|
|
| import Ecto.Changeset |
|
|
| @type t() :: %__MODULE__{} |
|
|
| on_ee do |
| @journey_end_event Plausible.Stats.Exploration.Journey.Step.journey_end_event() |
| else |
| @journey_end_event nil |
| end |
|
|
| schema "goals" do |
| field :event_name, :string |
| field :page_path, :string |
| field :scroll_threshold, :integer, default: -1 |
| field :display_name, :string |
|
|
| on_ee do |
| field :currency, Ecto.Enum, values: Money.Currency.known_current_currencies() |
| many_to_many :funnels, Plausible.Funnel, join_through: Plausible.Funnel.Step |
| else |
| field :currency, :string, virtual: true, default: nil |
| field :funnels, {:array, :map}, virtual: true, default: [] |
| end |
|
|
| field :custom_props, :map, default: %{} |
|
|
| belongs_to :site, Plausible.Site |
|
|
| timestamps() |
| end |
|
|
| @fields [ |
| :event_name, |
| :page_path, |
| :scroll_threshold, |
| :display_name, |
| :custom_props |
| ] ++ |
| on_ee(do: [:currency], else: []) |
|
|
| @max_event_name_length 120 |
|
|
| def max_event_name_length(), do: @max_event_name_length |
|
|
| @max_custom_props_per_goal 3 |
|
|
| def max_custom_props_per_goal(), do: @max_custom_props_per_goal |
|
|
| @special_goals [ |
| "404", |
| "Outbound Link: Click", |
| "Cloaked Link: Click", |
| "File Download", |
| "Form: Submission", |
| "WP Search Queries", |
| "WP Form Completions" |
| ] |
|
|
| def special_goals(), do: @special_goals |
|
|
| @spec special_goal?(t() | String.t() | nil) :: boolean() |
| def special_goal?(%__MODULE__{event_name: event_name}), do: special_goal?(event_name) |
| def special_goal?(event_name) when is_binary(event_name), do: event_name in @special_goals |
| def special_goal?(_), do: false |
|
|
| def changeset(goal, attrs \\ %{}) do |
| goal |
| |> cast(attrs, @fields) |
| |> update_leading_slash() |
| |> validate_event_name_and_page_path() |
| |> validate_page_path_for_scroll_goal() |
| |> maybe_put_display_name() |
| |> validate_change(:custom_props, &validate_custom_props/2) |
| |> unique_constraint(:display_name, name: :goals_display_name_unique) |
| |> unique_constraint(:event_name, name: :goals_event_config_unique) |
| |> unique_constraint([:page_path, :scroll_threshold], |
| name: :goals_pageview_config_unique |
| ) |
| |> validate_length(:event_name, max: @max_event_name_length) |
| |> validate_number(:scroll_threshold, |
| greater_than_or_equal_to: -1, |
| less_than_or_equal_to: 100, |
| message: "Should be -1 (missing) or in range [0, 100]" |
| ) |
| |> check_constraint(:event_name, |
| name: :check_event_name_or_page_path, |
| message: "cannot co-exist with page_path" |
| ) |
| |> maybe_drop_currency() |
| |> prevent_currency_change() |
| |> prevent_special_goal_renames() |
| end |
|
|
| @spec display_name(t()) :: String.t() |
| def display_name(goal) do |
| goal.display_name |
| end |
|
|
| @spec type(t()) :: :event | :scroll | :page |
| def type(goal) do |
| cond do |
| is_binary(goal.event_name) -> :event |
| is_binary(goal.page_path) && goal.scroll_threshold > -1 -> :scroll |
| is_binary(goal.page_path) -> :page |
| end |
| end |
|
|
| @spec has_custom_props?(t()) :: boolean() |
| def has_custom_props?(%__MODULE__{custom_props: custom_props}) |
| when map_size(custom_props) > 0 do |
| true |
| end |
|
|
| def has_custom_props?(_), do: false |
|
|
| defp update_leading_slash(changeset) do |
| case get_field(changeset, :page_path) do |
| "/" <> _ -> |
| changeset |
|
|
| page_path when is_binary(page_path) -> |
| put_change(changeset, :page_path, "/" <> page_path) |
|
|
| _ -> |
| changeset |
| end |
| end |
|
|
| defp validate_event_name_and_page_path(changeset) do |
| case {validate_page_path(changeset), validate_event_name(changeset)} do |
| {:ok, _} -> |
| update_change(changeset, :page_path, &String.trim/1) |
|
|
| {_, :ok} -> |
| update_change(changeset, :event_name, &String.trim/1) |
|
|
| {{:error, page_path_error}, {:error, event_name_error}} -> |
| changeset |
| |> add_error(:event_name, event_name_error) |
| |> add_error(:page_path, page_path_error) |
| end |
| end |
|
|
| defp validate_page_path_for_scroll_goal(changeset) do |
| scroll_threshold = get_field(changeset, :scroll_threshold) |
| page_path = get_field(changeset, :page_path) |
|
|
| if scroll_threshold > -1 and is_nil(page_path) do |
| changeset |
| |> add_error(:scroll_threshold, "page_path field missing for page scroll goal") |
| else |
| changeset |
| end |
| end |
|
|
| defp validate_page_path(changeset) do |
| value = get_field(changeset, :page_path) |
|
|
| if value && String.match?(value, ~r/^\/.*/) do |
| :ok |
| else |
| {:error, "this field is required and must start with a /"} |
| end |
| end |
|
|
| defp validate_event_name(changeset) do |
| value = get_field(changeset, :event_name) |
|
|
| cond do |
| value == "engagement" -> |
| {:error, "The event name 'engagement' is reserved and cannot be used as a goal"} |
|
|
| journey_end_event?(value) -> |
| {:error, |
| "The event name '#{@journey_end_event}' is reserved and cannot be used as a goal"} |
|
|
| value && String.match?(value, ~r/^.+/) -> |
| :ok |
|
|
| true -> |
| {:error, "this field is required and cannot be blank"} |
| end |
| end |
|
|
| defp maybe_drop_currency(changeset) do |
| if ee?() and get_field(changeset, :page_path) do |
| delete_change(changeset, :currency) |
| else |
| changeset |
| end |
| end |
|
|
| defp prevent_currency_change(changeset) do |
| if (ee?() and changeset.data.id) && Map.has_key?(changeset.changes, :currency) do |
| add_error(changeset, :currency, "cannot change currency of existing goal") |
| else |
| changeset |
| end |
| end |
|
|
| defp maybe_put_display_name(changeset) do |
| clause = |
| Enum.map([:display_name, :page_path, :event_name], &get_field(changeset, &1)) |
|
|
| case clause do |
| [nil, path, _] when is_binary(path) -> |
| put_change(changeset, :display_name, "Visit " <> path) |
|
|
| [nil, _, event_name] when is_binary(event_name) -> |
| put_change(changeset, :display_name, event_name) |
|
|
| _ -> |
| changeset |
| end |
| |> update_change(:display_name, &String.trim/1) |
| |> validate_required(:display_name) |
| end |
|
|
| defp validate_custom_props(:custom_props, custom_props) when is_map(custom_props) do |
| cond do |
| map_size(custom_props) > @max_custom_props_per_goal -> |
| [custom_props: "use at most #{@max_custom_props_per_goal} properties per goal"] |
|
|
| not Enum.all?(custom_props, fn {k, v} -> |
| is_binary(k) and is_binary(v) |
| end) -> |
| [custom_props: "must be a map with string keys and string values"] |
|
|
| Enum.any?(custom_props, fn {k, _v} -> |
| String.length(k) not in 1..Plausible.Props.max_prop_key_length() |
| end) -> |
| [ |
| custom_props: "key length is 1..#{Plausible.Props.max_prop_key_length()} characters" |
| ] |
|
|
| Enum.any?(custom_props, fn {_k, v} -> |
| String.length(v) not in 1..Plausible.Props.max_prop_value_length() |
| end) -> |
| [ |
| custom_props: "value length is 1..#{Plausible.Props.max_prop_value_length()} characters" |
| ] |
|
|
| true -> |
| [] |
| end |
| end |
|
|
| defp prevent_special_goal_renames(changeset) do |
| if changeset.data.id && special_goal?(changeset.data.event_name) do |
| changeset |
| |> reject_special_goal_field_change(:event_name) |
| |> reject_special_goal_field_change(:display_name) |
| else |
| changeset |
| end |
| end |
|
|
| defp reject_special_goal_field_change(changeset, :event_name) do |
| if Map.has_key?(changeset.changes, :event_name) do |
| add_error(changeset, :event_name, "cannot be changed for an automated goal") |
| else |
| changeset |
| end |
| end |
|
|
| defp reject_special_goal_field_change(changeset, :display_name) do |
| new_value = get_change(changeset, :display_name) |
| canonical = changeset.data.event_name |
|
|
| if new_value && new_value != canonical do |
| add_error(changeset, :display_name, "cannot be changed for an automated goal") |
| else |
| changeset |
| end |
| end |
|
|
| on_ee do |
| defp journey_end_event?(name) do |
| name == @journey_end_event |
| end |
| else |
| defp journey_end_event?(_name), do: always(false) |
| end |
| end |
|
|
| defimpl Jason.Encoder, for: Plausible.Goal do |
| def encode(value, opts) do |
| domain = value.site.domain |
|
|
| value |
| |> Map.put(:goal_type, Plausible.Goal.type(value)) |
| |> Map.take([:id, :goal_type, :event_name, :page_path, :custom_props]) |
| |> Map.put(:domain, domain) |
| |> Map.put(:display_name, value.display_name) |
| |> Jason.Encode.map(opts) |
| end |
| end |
|
|
| defimpl String.Chars, for: Plausible.Goal do |
| def to_string(goal) do |
| goal.display_name |
| end |
| end |
|
|
| defimpl Phoenix.HTML.Safe, for: Plausible.Goal do |
| def to_iodata(data) do |
| data |> to_string() |> Phoenix.HTML.Engine.html_escape() |
| end |
| end |
|
|