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 # Looks for a pattern like ["is", "event:goal", [......]] in the filters structure. # Added a bunch of whitespace matchers to make sure it's tolerant of valid JSON formatting 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 ) # Site segments are set to owner=null via ON DELETE SET NULL 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