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