File size: 8,816 Bytes
8da2481
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
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