Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor Segments controller to be thinner #4981

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

apata
Copy link
Contributor

@apata apata commented Jan 16, 2025

Changes

Refactors Segments controller to be thinner.

Tests

  • Automated tests are still in effect on the model through controller tests

Changelog

  • This PR does not make a user-facing change

Documentation

  • This change does not need a documentation update

Dark mode

  • This PR does not change the UI

@apata apata requested a review from RobertJoonas January 16, 2025 09:17
@apata apata changed the title Refactor segments model Refactor segments model Jan 16, 2025
@apata apata changed the title Refactor segments model Refactor Segments controller to be thinner Jan 16, 2025
Comment on lines +196 to +197
def roles_with_personal_segments(), do: [:viewer, :editor, :admin, :owner, :super_admin]
def roles_with_maybe_site_segments(), do: [:editor, :admin, :owner, :super_admin]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move these to the top of the file, as they're pretty significant in this module.

I would also write them as module attributes instead of functions, since this way they'd only be evaluated once at compile time (instead of every time this function is called), e.g.:

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

Currently I don't see them being used elsewhere, other than in this file, so perhaps a module attribute should be enough?

But if we do need to expose them to other modules:

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

def roles_with_personal_segments, do: @roles_with_personal_segments

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering the "site segments" feature equivalent to Custom Props, a large proportion of grandfathered customers on legacy plans (any plan before the plans_v4.json generation) will have access to it.

If we want to make it a new "Business feature", Funnels for example would be more appropriate at this stage. In long term though, I guess we're planning to introduce Feature.SiteSegments too right?

Comment on lines +14 to +21
fields_in_index_query = [
:id,
:name,
:type,
:inserted_at,
:updated_at,
:owner_id
]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like a good candidate for a module attribute, so that it could be referenced by the query functions directly without the need to pass it around.

@index_query_fields [:id, :name, :type, :inserted_at, :updated_at, :owner_id]

site_role in [:public] and
site_segments_available? ->
{:ok,
Repo.all(get_site_segments_only_query(site.id, fields_in_index_query -- [:owner_id]))}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a clear benefit of having functions that return an Ecto.Query here. WDYT about creating two functions that execute Repo.all straight away?

I also feel like making a clear separation between getting the "public site segments" vs all other segments is a good idea, due to the difference in the fields we want to fetch.

Proposal:

@spec get_public_site_segments(pos_integer()) :: [Segments.Segment.t()]
defp get_public_site_segments(site_id) do
  fields = @index_query_fields -- [:owner_id]

  Repo.all(
    from(s in Plausible.Segments.Segment,
      select: ^fields,
      where: s.site_id == ^site_id,
      where: s.type == :site,
      order_by: [desc: s.updated_at, desc: s.id]
    )
  )
end

@spec get_segments(pos_integer(), pos_integer()) :: [Segments.Segment.t()]
defp get_segments(user_id, site_id, opts \\ []) do
  q =
    from(s in Plausible.Segments.Segment,
      select: ^@index_query_fields,
      where: s.site_id == ^site_id,
      order_by: [desc: s.updated_at, desc: s.id]
    )

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

  Repo.all(q)
end

This would make the index function much cleaner IMO:

def index(user_id, %Plausible.Site{} = site, site_role) do
  site_segments_available? =
    site_segments_available?(site)

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

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

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

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

@moduledoc """
Module for accessing Segments.
"""
alias __MODULE__
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks redundant. Any function we want to reference from this module can be called without specifying any module in front of it.

I understand this currently makes it possible to call Segments.Segment, but I think that's redundant too. In this context module, the name Segment alone gives a perfectly clear idea of what it is, and doesn't need any prefix, I think.

Proposal: let's set alias Plausible.Segments.Segment here, and replace all occurrences of Segments.Segment and Plausible.Segments.Segment with just Segment.

Module for accessing Segments.
"""
alias __MODULE__
use Plausible.Repo
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
use Plausible.Repo
alias Plausible.Repo
import Ecto.Query

I left a comment below with some code suggestions that require Ecto.Query.where/3 as well as from/2 that is currently imported through use Plausible.Repo. This change is required for those functions to work.

user_id,
site,
site_role,
segment_id
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If segment_id is missing or invalid, shouldn't we respond with a "400 bad request" here already? Unlike current_user_id it's something that is definitely required for operations like get / delete / update.

I'd suggest using a with statement here (and also in update and delete actions). Something like:

user_id = normalize_current_user_id(conn)

with {:ok, segment_id} <- get_segment_id_param(params["segment_id"]),
     {:ok, segment} <- Segments.get_one(user_id, site, site_role, segment_id) do
  json(conn, segment)
else
  {:error, :missing_or_invalid_segment_id} ->
    invalid_request(conn)

  {:error, :not_enough_permissions} ->
    H.not_enough_permissions(conn, "Not enough permissions to get segment data")

  {:error, :segment_not_found} ->
    segment_not_found(conn, params["segment_id"])
end

@@ -12,13 +12,16 @@ defmodule PlausibleWeb.Plugs.FeatureFlagCheckPlug do
do: raise(ArgumentError, "The first argument must be a non-empty list of feature flags")

def call(%Plug.Conn{} = conn, flags) do
if validate(conn.assigns.current_user, conn.assigns.site, flags) do
if validate(conn.assigns[:current_user], conn.assigns.site, flags) do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! 😁

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants