| defmodule Plausible.Segments do |
| @moduledoc """ |
| Module for accessing Segments. |
| """ |
| alias Plausible.Segments.Segment |
| alias Plausible.Repo |
| import Ecto.Query |
|
|
| @roles_with_personal_segments [:billing, :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} |
| @type error_segment_limit_reached() :: {:error, :segment_limit_reached} |
| @type error_invalid_segment() :: {:error, {:invalid_segment, Keyword.t()}} |
| @type unknown_error() :: {:error, any()} |
|
|
| @max_segments 500 |
|
|
| def get_all_for_site(%Plausible.Site{} = site, site_role) do |
| fields = [:id, :name, :type, :inserted_at, :updated_at, :segment_data] |
|
|
| cond do |
| site_role in [:public] -> |
| {:ok, |
| Repo.all( |
| from(segment in Segment, |
| select: ^fields, |
| where: segment.site_id == ^site.id, |
| order_by: [desc: segment.updated_at, desc: segment.id] |
| ) |
| )} |
|
|
| site_role in roles_with_personal_segments() or site_role in roles_with_maybe_site_segments() -> |
| fields = fields ++ [:owner_id] |
|
|
| {:ok, |
| Repo.all( |
| from(segment in Segment, |
| select: ^fields, |
| where: segment.site_id == ^site.id, |
| order_by: [desc: segment.updated_at, desc: segment.id], |
| preload: [:owner] |
| ) |
| )} |
|
|
| 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 segment_ids == [] do |
| {:ok, []} |
| end |
|
|
| 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 |
|
|
| def search_by_name(%Plausible.Site{} = site, input) do |
| type = :site |
| fields = [:id, :name] |
|
|
| input_empty? = is_nil(input) or (is_binary(input) and String.trim(input) == "") |
|
|
| base_query = |
| from(segment in Segment, |
| where: segment.site_id == ^site.id, |
| where: segment.type == ^type, |
| limit: 20 |
| ) |
|
|
| query = |
| if input_empty? do |
| from([segment] in base_query, |
| select: ^fields, |
| order_by: [desc: segment.updated_at] |
| ) |
| else |
| from([segment] in base_query, |
| select: %{ |
| id: segment.id, |
| name: segment.name, |
| match_rank: |
| fragment( |
| "CASE |
| WHEN lower(?) = lower(?) THEN 0 -- exact match |
| WHEN lower(?) LIKE lower(?) || '%' THEN 1 -- starts with |
| WHEN lower(?) LIKE '% ' || lower(?) || '%' THEN 2 -- after a space |
| WHEN lower(?) ILIKE ? THEN 3 -- anywhere |
| END AS match_rank", |
| segment.name, |
| ^input, |
| segment.name, |
| ^input, |
| segment.name, |
| ^input, |
| segment.name, |
| ^"%#{input}%" |
| ), |
| pos: |
| fragment( |
| "position(lower(?) IN lower(?)) AS pos", |
| ^input, |
| segment.name |
| ), |
| len_diff: fragment("abs(length(?) - length(?)) AS len_diff", segment.name, ^input) |
| }, |
| where: fragment("? ilike ?", segment.name, ^"%#{input}%"), |
| order_by: fragment("match_rank asc, pos asc, len_diff asc, updated_at desc") |
| ) |
| end |
|
|
| {: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 |
|
|
| @spec insert_one(pos_integer(), Plausible.Site.t(), atom(), map()) :: |
| {:ok, Segment.t()} |
| | error_not_enough_permissions() |
| | error_invalid_segment() |
| | error_segment_limit_reached() |
| | unknown_error() |
|
|
| 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{site_id: site.id}, |
| Map.merge(params, %{"owner_id" => user_id}) |
| ), |
| :ok <- |
| Segment.validate_segment_data(site, params["segment_data"], true) do |
| {:ok, changeset |> Repo.insert!() |> Repo.preload(:owner)} |
| 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 |
|
|
| @spec update_one(pos_integer(), Plausible.Site.t(), atom(), pos_integer(), map()) :: |
| {:ok, Segment.t()} |
| | error_not_enough_permissions() |
| | error_invalid_segment() |
| | unknown_error() |
|
|
| 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 |
| Repo.update!(changeset) |
|
|
| {:ok, Repo.reload!(segment) |> Repo.preload(:owner)} |
| 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_goal_in_segments( |
| %Plausible.Site{} = site, |
| %Plausible.Goal{} = stale_goal, |
| %Plausible.Goal{} = updated_goal |
| ) do |
| |
| |
| goal_filter_regex = |
| ~s(.*?\\[\s*"is",\s*"event:goal",\s*\\[.*?"#{Regex.escape(stale_goal.display_name)}".*?\\]\s*\\].*?) |
|
|
| segments_to_update = |
| from( |
| s in Segment, |
| where: s.site_id == ^site.id, |
| where: fragment("?['filters']::text ~ ?", s.segment_data, ^goal_filter_regex) |
| ) |
|
|
| stale_goal_name = stale_goal.display_name |
|
|
| for segment <- Repo.all(segments_to_update) do |
| updated_filters = |
| Plausible.Stats.Filters.transform_filters(segment.segment_data["filters"], fn |
| ["is", "event:goal", clauses] -> |
| new_clauses = |
| Enum.map(clauses, fn |
| ^stale_goal_name -> updated_goal.display_name |
| clause -> clause |
| end) |
|
|
| [["is", "event:goal", new_clauses]] |
|
|
| _ -> |
| nil |
| end) |
|
|
| updated_segment_data = Map.put(segment.segment_data, "filters", updated_filters) |
|
|
| from( |
| s in Segment, |
| where: s.id == ^segment.id, |
| update: [set: [segment_data: ^updated_segment_data]] |
| ) |
| |> Repo.update_all([]) |
| end |
|
|
| :ok |
| end |
|
|
| def after_user_removed_from_site(site, user) do |
| Repo.delete_all( |
| from segment in Segment, |
| where: segment.site_id == ^site.id, |
| where: segment.owner_id == ^user.id, |
| where: segment.type == :personal |
| ) |
|
|
| Repo.update_all( |
| from(segment in Segment, |
| where: segment.site_id == ^site.id, |
| where: segment.owner_id == ^user.id, |
| where: segment.type == :site, |
| update: [set: [owner_id: nil]] |
| ), |
| [] |
| ) |
| end |
|
|
| def after_user_removed_from_team(team, user) do |
| team_sites_q = |
| from( |
| site in Plausible.Site, |
| where: site.team_id == ^team.id, |
| where: parent_as(:segment).site_id == site.id |
| ) |
|
|
| Repo.delete_all( |
| from segment in Segment, |
| as: :segment, |
| where: segment.owner_id == ^user.id, |
| where: segment.type == :personal, |
| where: exists(team_sites_q) |
| ) |
|
|
| Repo.update_all( |
| from(segment in Segment, |
| as: :segment, |
| where: segment.owner_id == ^user.id, |
| where: segment.type == :site, |
| where: exists(team_sites_q), |
| update: [set: [owner_id: nil]] |
| ), |
| [] |
| ) |
| end |
|
|
| def user_removed(user) do |
| Repo.delete_all( |
| from segment in Segment, |
| as: :segment, |
| where: segment.owner_id == ^user.id, |
| where: segment.type == :personal |
| ) |
|
|
| |
| 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 |
|
|
| def get_related_shared_links( |
| _site, |
| _site_role, |
| nil |
| ) do |
| {:error, :segment_not_found} |
| end |
|
|
| def get_related_shared_links( |
| %Plausible.Site{} = site, |
| site_role, |
| segment_id |
| ) do |
| if site_role in roles_with_personal_segments() do |
| {:ok, |
| Repo.all( |
| from(shared_link in Plausible.Site.SharedLink, |
| select: [:name], |
| where: shared_link.segment_id == ^segment_id, |
| where: shared_link.site_id == ^site.id |
| ) |
| )} |
| else |
| {:error, :not_enough_permissions} |
| 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, |
| preload: [:owner] |
| ) |
|
|
| 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 |
| count_segments(site.id) >= @max_segments -> |
| {:error, :segment_limit_reached} |
|
|
| 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 |
|
|
| defp count_segments(site_id) do |
| from(segment in Segment, |
| where: segment.site_id == ^site_id |
| ) |
| |> Repo.aggregate(:count, :id) |
| end |
|
|
| def to_response_map(%Segment{} = segment, %Plausible.Site{} = site) do |
| %{ |
| id: segment.id, |
| name: segment.name, |
| type: segment.type, |
| segment_data: segment.segment_data, |
| owner_id: segment.owner_id, |
| owner_name: owner_name(segment), |
| inserted_at: shift_to_site_tz(segment.inserted_at, site.timezone), |
| updated_at: shift_to_site_tz(segment.updated_at, site.timezone) |
| } |
| end |
|
|
| defp owner_name(%Segment{owner_id: nil}), do: nil |
| defp owner_name(%Segment{owner: %Plausible.Auth.User{name: name}}), do: name |
| defp owner_name(%Segment{}), do: nil |
|
|
| defp shift_to_site_tz(%NaiveDateTime{} = naive_utc, timezone) do |
| naive_utc |
| |> DateTime.from_naive!("Etc/UTC") |
| |> Plausible.Times.to_naive_datetime!(timezone) |
| end |
|
|
| def roles_with_personal_segments(), do: @roles_with_personal_segments |
| def roles_with_maybe_site_segments(), do: @roles_with_maybe_site_segments |
|
|
| def site_segments_available?(%Plausible.Site{} = site), |
| do: Plausible.Billing.Feature.SiteSegments.check_availability(site.team) == :ok |
| end |
|
|