Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion assets/js/types/query-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export type CustomPropertyFilterDimensions = string;
export type GoalDimension = "event:goal";
export type TimeDimensions = "time" | "time:month" | "time:week" | "time:day" | "time:hour";
export type FilterTree = FilterEntry | FilterAndOr | FilterNot;
export type FilterEntry = FilterWithoutGoals | FilterWithGoals | FilterWithPattern;
export type FilterEntry = FilterWithoutGoals | FilterWithGoals | FilterWithPattern | FilterForSegment;
/**
* @minItems 3
* @maxItems 4
Expand Down Expand Up @@ -115,6 +115,11 @@ export type FilterWithPattern = [
* filter operation
*/
export type FilterOperationRegex = "matches" | "matches_not";
/**
* @minItems 3
* @maxItems 3
*/
export type FilterForSegment = ["is", "segment", number[]];
/**
* @minItems 2
* @maxItems 2
Expand Down
108 changes: 108 additions & 0 deletions lib/plausible/segments/filters.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
defmodule Plausible.Segments.Filters do
@moduledoc """
This module contains functions that enable resolving segments in filters.
"""
alias Plausible.Segments
alias Plausible.Stats.Filters

@max_segment_filters_count 10

@doc """
Finds unique segment IDs used in query filters.

## Examples
iex> get_segment_ids([[:not, [:is, "segment", [10, 20]]], [:contains, "visit:entry_page", ["blog"]]])
{:ok, [10, 20]}

iex> get_segment_ids([[:and, [[:is, "segment", Enum.to_list(1..6)], [:is, "segment", Enum.to_list(1..6)]]]])
{:error, "Invalid filters. You can only use up to 10 segment filters in a query."}
"""
def get_segment_ids(filters) do
ids =
filters
|> Filters.traverse()
|> Enum.flat_map(fn
{[_operation, "segment", clauses], _depth} -> clauses
_ -> []
end)

if length(ids) > @max_segment_filters_count do
{:error,
"Invalid filters. You can only use up to #{@max_segment_filters_count} segment filters in a query."}
else
{:ok, Enum.uniq(ids)}
end
end

def preload_needed_segments(%Plausible.Site{} = site, filters) do
with {:ok, segment_ids} <- get_segment_ids(filters),
{:ok, segments} <-
Segments.get_many(
site,
segment_ids,
fields: [:id, :segment_data]
),
{:ok, segments_by_id} <-
{:ok,
Enum.into(
segments,
%{},
fn %Segments.Segment{id: id, segment_data: segment_data} ->
case Filters.QueryParser.parse_filters(segment_data["filters"]) do
{:ok, filters} -> {id, filters}
_ -> {id, nil}
end
end
)},
:ok <-
if(Enum.any?(segment_ids, fn id -> is_nil(Map.get(segments_by_id, id)) end),
do: {:error, "Invalid filters. Some segments don't exist or aren't accessible."},
else: :ok
) do
{:ok, segments_by_id}
end
end

defp replace_segment_with_filter_tree([_, "segment", clauses], preloaded_segments) do
if length(clauses) === 1 do
[[:and, Map.get(preloaded_segments, Enum.at(clauses, 0))]]
else
[[:or, Enum.map(clauses, fn id -> [:and, Map.get(preloaded_segments, id)] end)]]
end
end

defp replace_segment_with_filter_tree(_filter, _preloaded_segments) do
nil
end

@doc """
## Examples

iex> resolve_segments([[:is, "visit:entry_page", ["/home"]]], %{})
{:ok, [[:is, "visit:entry_page", ["/home"]]]}

iex> resolve_segments([[:is, "visit:entry_page", ["/home"]], [:is, "segment", [1]]], %{1 => [[:contains, "visit:entry_page", ["blog"]], [:is, "visit:country", ["PL"]]]})
{:ok, [
[:is, "visit:entry_page", ["/home"]],
[:and, [[:contains, "visit:entry_page", ["blog"]], [:is, "visit:country", ["PL"]]]]
]}

iex> resolve_segments([[:is, "segment", [1, 2]]], %{1 => [[:contains, "event:goal", ["Singup"]], [:is, "visit:country", ["PL"]]], 2 => [[:contains, "event:goal", ["Sauna"]], [:is, "visit:country", ["EE"]]]})
{:ok, [
[:or, [
[:and, [[:contains, "event:goal", ["Singup"]], [:is, "visit:country", ["PL"]]]],
[:and, [[:contains, "event:goal", ["Sauna"]], [:is, "visit:country", ["EE"]]]]]
]
]}
"""
def resolve_segments(original_filters, preloaded_segments) do
if map_size(preloaded_segments) > 0 do
{:ok,
Filters.transform_filters(original_filters, fn f ->
replace_segment_with_filter_tree(f, preloaded_segments)
end)}
else
{:ok, original_filters}
end
end
end
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule Plausible.Segment do
defmodule Plausible.Segments.Segment do
@moduledoc """
Schema for segments. Segments are saved filter combinations.
"""
Expand Down
246 changes: 246 additions & 0 deletions lib/plausible/segments/segments.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
defmodule Plausible.Segments do
@moduledoc """
Module for accessing Segments.
"""
alias Plausible.Segments.Segment
alias Plausible.Repo
import Ecto.Query

@roles_with_personal_segments [:viewer, :editor, :admin, :owner, :super_admin]
@roles_with_maybe_site_segments [:editor, :admin, :owner, :super_admin]

@type error_not_enough_permissions() :: {:error, :not_enough_permissions}
@type error_segment_not_found() :: {:error, :segment_not_found}

@spec index(pos_integer() | nil, Plausible.Site.t(), atom()) ::
{:ok, [Segment.t()]} | error_not_enough_permissions()
def index(user_id, %Plausible.Site{} = site, site_role) do
fields = [:id, :name, :type, :inserted_at, :updated_at, :owner_id]

site_segments_available? =
site_segments_available?(site)

cond do
site_role in [:public] and
site_segments_available? ->
{:ok, get_public_site_segments(site.id, fields -- [:owner_id])}

site_role in @roles_with_maybe_site_segments and
site_segments_available? ->
{:ok, get_segments(user_id, site.id, fields)}

site_role in @roles_with_personal_segments ->
{:ok, get_segments(user_id, site.id, fields, only: :personal)}

true ->
{:error, :not_enough_permissions}
end
end

@spec get_many(Plausible.Site.t(), list(pos_integer()), Keyword.t()) ::
{:ok, [Segment.t()]}
def get_many(%Plausible.Site{} = site, segment_ids, opts) when is_list(segment_ids) do
fields = Keyword.get(opts, :fields, [:id])

query =
from(segment in Segment,
select: ^fields,
where: segment.site_id == ^site.id,
where: segment.id in ^segment_ids
)

{:ok, Repo.all(query)}
end

@spec get_one(pos_integer(), Plausible.Site.t(), atom(), pos_integer() | nil) ::
{:ok, Segment.t()}
| error_not_enough_permissions()
| error_segment_not_found()
def get_one(user_id, site, site_role, segment_id) do
if site_role in roles_with_personal_segments() do
case do_get_one(user_id, site.id, segment_id) do
%Segment{} = segment -> {:ok, segment}
nil -> {:error, :segment_not_found}
end
else
{:error, :not_enough_permissions}
end
end

def insert_one(
user_id,
%Plausible.Site{} = site,
site_role,
%{} = params
) do
with :ok <- can_insert_one?(site, site_role, params),
%{valid?: true} = changeset <-
Segment.changeset(
%Segment{},
Map.merge(params, %{"site_id" => site.id, "owner_id" => user_id})
),
:ok <-
Segment.validate_segment_data(site, params["segment_data"], true) do
{:ok, Repo.insert!(changeset)}
else
%{valid?: false, errors: errors} ->
{:error, {:invalid_segment, errors}}

{:error, {:invalid_filters, message}} ->
{:error, {:invalid_segment, segment_data: {message, []}}}

{:error, _type} = error ->
error
end
end

def update_one(
user_id,
%Plausible.Site{} = site,
site_role,
segment_id,
%{} = params
) do
with {:ok, segment} <- get_one(user_id, site, site_role, segment_id),
:ok <- can_update_one?(site, site_role, params, segment.type),
%{valid?: true} = changeset <-
Segment.changeset(
segment,
Map.merge(params, %{"owner_id" => user_id})
),
:ok <-
Segment.validate_segment_data_if_exists(
site,
params["segment_data"],
true
) do
{:ok,
Repo.update!(
changeset,
returning: true
)}
else
%{valid?: false, errors: errors} ->
{:error, {:invalid_segment, errors}}

{:error, {:invalid_filters, message}} ->
{:error, {:invalid_segment, segment_data: {message, []}}}

{:error, _type} = error ->
error
end
end

def delete_one(user_id, %Plausible.Site{} = site, site_role, segment_id) do
with {:ok, segment} <- get_one(user_id, site, site_role, segment_id) do
cond do
segment.type == :site and site_role in roles_with_maybe_site_segments() ->
{:ok, do_delete_one(segment)}

segment.type == :personal and site_role in roles_with_personal_segments() ->
{:ok, do_delete_one(segment)}

true ->
{:error, :not_enough_permissions}
end
end
end

@spec do_get_one(pos_integer(), pos_integer(), pos_integer() | nil) ::
Segment.t() | nil
defp do_get_one(user_id, site_id, segment_id)

defp do_get_one(_user_id, _site_id, nil) do
nil
end

defp do_get_one(user_id, site_id, segment_id) do
query =
from(segment in Segment,
where: segment.site_id == ^site_id,
where: segment.id == ^segment_id,
where: segment.type == :site or segment.owner_id == ^user_id
)

Repo.one(query)
end

defp do_delete_one(segment) do
Repo.delete!(segment)
segment
end

defp can_update_one?(%Plausible.Site{} = site, site_role, params, existing_segment_type) do
updating_to_site_segment? = params["type"] == "site"

cond do
(existing_segment_type == :site or
updating_to_site_segment?) and site_role in roles_with_maybe_site_segments() and
site_segments_available?(site) ->
:ok

existing_segment_type == :personal and not updating_to_site_segment? and
site_role in roles_with_personal_segments() ->
:ok

true ->
{:error, :not_enough_permissions}
end
end

defp can_insert_one?(%Plausible.Site{} = site, site_role, params) do
cond do
params["type"] == "site" and site_role in roles_with_maybe_site_segments() and
site_segments_available?(site) ->
:ok

params["type"] == "personal" and
site_role in roles_with_personal_segments() ->
:ok

true ->
{:error, :not_enough_permissions}
end
end

def roles_with_personal_segments(), do: [:viewer, :editor, :admin, :owner, :super_admin]
def roles_with_maybe_site_segments(), do: [:editor, :admin, :owner, :super_admin]

def site_segments_available?(%Plausible.Site{} = site),
do: Plausible.Billing.Feature.Props.check_availability(site.team) == :ok

@spec get_public_site_segments(pos_integer(), list(atom())) :: [Segment.t()]
defp get_public_site_segments(site_id, fields) do
Repo.all(
from(segment in Segment,
select: ^fields,
where: segment.site_id == ^site_id,
where: segment.type == :site,
order_by: [desc: segment.updated_at, desc: segment.id]
)
)
end

@spec get_segments(pos_integer(), pos_integer(), list(atom()), Keyword.t()) :: [Segment.t()]
defp get_segments(user_id, site_id, fields, opts \\ []) do
query =
from(segment in Segment,
select: ^fields,
where: segment.site_id == ^site_id,
order_by: [desc: segment.updated_at, desc: segment.id]
)

query =
if Keyword.get(opts, :only) == :personal do
where(query, [segment], segment.type == :personal and segment.owner_id == ^user_id)
else
where(
query,
[segment],
segment.type == :site or (segment.type == :personal and segment.owner_id == ^user_id)
)
end

Repo.all(query)
end
end
Loading
Loading