From ca3eb242d9ed79e7e447f90731edd0794208b62b Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 9 Feb 2026 13:46:28 +0100 Subject: [PATCH 01/13] Remove invitation handling from /sites --- lib/plausible/sites.ex | 3 - lib/plausible/teams/sites.ex | 260 ++----------------------- lib/plausible_web/live/sites.ex | 228 +--------------------- test/plausible/sites_test.exs | 246 +++++------------------ test/plausible_web/live/sites_test.exs | 131 +------------ 5 files changed, 73 insertions(+), 795 deletions(-) diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index 98b0ffb709f1..7754a05c2750 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -136,9 +136,6 @@ defmodule Plausible.Sites do defdelegate list(user, pagination_params, opts \\ []), to: Plausible.Teams.Sites - defdelegate list_with_invitations(user, pagination_params, opts \\ []), - to: Plausible.Teams.Sites - def list_people(site) do owner_memberships = from( diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex index 552b3ceca3e0..3ffe7136e995 100644 --- a/lib/plausible/teams/sites.ex +++ b/lib/plausible/teams/sites.ex @@ -10,6 +10,8 @@ defmodule Plausible.Teams.Sites do @type list_opt() :: {:filter_by_domain, String.t()} | {:team, Teams.Team.t() | nil} + @role_type Plausible.Teams.Invitation.__schema__(:type, :role) + @spec list(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t() def list(user, pagination_params, opts \\ []) do domain_filter = Keyword.get(opts, :filter_by_domain) @@ -22,7 +24,7 @@ defmodule Plausible.Teams.Sites do inner_join: s in assoc(t, :sites), where: tm.user_id == ^user.id and tm.role != :guest, where: tm.team_id == ^team.id, - select: %{site_id: s.id, entry_type: "site"} + select: %{site_id: s.id, entry_type: "site", role: tm.role} ) else my_team_query = @@ -32,7 +34,7 @@ defmodule Plausible.Teams.Sites do where: tm.user_id == ^user.id and tm.role != :guest, where: tm.is_autocreated == true, where: t.setup_complete == false, - select: %{site_id: s.id, entry_type: "site"} + select: %{site_id: s.id, entry_type: "site", role: tm.role} ) guest_membership_query = @@ -40,7 +42,21 @@ defmodule Plausible.Teams.Sites do inner_join: gm in assoc(tm, :guest_memberships), inner_join: s in assoc(gm, :site), where: tm.user_id == ^user.id and tm.role == :guest, - select: %{site_id: s.id, entry_type: "site"} + select: %{ + site_id: s.id, + entry_type: "site", + role: + fragment( + """ + CASE + WHEN ? = 'editor' THEN 'admin' + ELSE ? + END + """, + gm.role, + gm.role + ) + } from s in my_team_query, union_all: ^guest_membership_query @@ -68,64 +84,6 @@ defmodule Plausible.Teams.Sites do ), :entry_type ), - pinned_at: selected_as(up.pinned_at, :pinned_at) - }, - order_by: [ - asc: selected_as(:entry_type), - desc: selected_as(:pinned_at), - asc: s.domain - ] - ) - |> maybe_filter_by_domain(domain_filter) - |> Repo.paginate(pagination_params) - end - - @role_type Plausible.Teams.Invitation.__schema__(:type, :role) - - @spec list_with_invitations(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t() - def list_with_invitations(user, pagination_params, opts \\ []) do - domain_filter = Keyword.get(opts, :filter_by_domain) - team = Keyword.get(opts, :team) - - union_query = - if Teams.setup?(team) do - list_with_invitations_setup_query(team, user) - else - list_with_invitations_personal_query(team, user) - end - - from(u in subquery(union_query), - inner_join: s in ^Plausible.Site.regular(), - on: u.site_id == s.id, - as: :site, - left_join: up in Site.UserPreference, - on: up.site_id == s.id and up.user_id == ^user.id, - left_join: ti in Teams.Invitation, - on: ti.id == u.team_invitation_id, - left_join: gi in Teams.GuestInvitation, - on: gi.id == u.guest_invitation_id, - left_join: st in Teams.SiteTransfer, - on: st.id == u.transfer_id, - select: %{ - s - | entry_type: - selected_as( - fragment( - """ - CASE - WHEN ? IS NOT NULL THEN 'invitation' - WHEN ? IS NOT NULL THEN 'invitation' - WHEN ? IS NOT NULL THEN 'pinned_site' - ELSE ? - END - """, - gi.id, - st.id, - up.pinned_at, - u.entry_type - ), - :entry_type - ), pinned_at: selected_as(up.pinned_at, :pinned_at), memberships: [ %{ @@ -133,15 +91,6 @@ defmodule Plausible.Teams.Sites do site_id: s.id, site: s } - ], - invitations: [ - %{ - invitation_id: coalesce(gi.invitation_id, st.transfer_id), - email: coalesce(ti.email, st.email), - role: type(u.role, ^@role_type), - site_id: s.id, - site: s - } ] }, order_by: [ @@ -152,177 +101,6 @@ defmodule Plausible.Teams.Sites do ) |> maybe_filter_by_domain(domain_filter) |> Repo.paginate(pagination_params) - |> Map.update!(:entries, fn entries -> - Enum.map(entries, fn - %{invitation: [%{invitation_id: nil}]} = entry -> - %{entry | invitations: []} - - entry -> - entry - end) - end) - end - - defp list_with_invitations_setup_query(team, user) do - team_membership_query = - from(tm in Teams.Membership, - inner_join: t in assoc(tm, :team), - inner_join: u in assoc(tm, :user), - as: :user, - inner_join: s in assoc(t, :sites), - as: :site, - where: tm.user_id == ^user.id and tm.role != :guest, - where: tm.team_id == ^team.id, - select: %{ - site_id: s.id, - entry_type: "site", - guest_invitation_id: 0, - team_invitation_id: 0, - role: tm.role, - transfer_id: 0 - } - ) - - site_transfer_query = - from st in Teams.SiteTransfer, - as: :site_transfer, - inner_join: s in assoc(st, :site), - as: :site, - where: s.team_id != ^team.id, - where: st.email == ^user.email, - where: - exists( - from tm in Teams.Membership, - inner_join: u in assoc(tm, :user), - where: tm.team_id == ^team.id, - where: u.email == parent_as(:site_transfer).email, - where: tm.role in [:owner, :admin], - select: 1 - ), - select: %{ - site_id: s.id, - entry_type: "invitation", - guest_invitation_id: 0, - team_invitation_id: 0, - role: "owner", - transfer_id: st.id - } - - from s in team_membership_query, - union_all: ^site_transfer_query - end - - defp list_with_invitations_personal_query(team, user) do - my_team_query = - from(tm in Teams.Membership, - inner_join: t in assoc(tm, :team), - inner_join: u in assoc(tm, :user), - as: :user, - inner_join: s in assoc(t, :sites), - as: :site, - where: tm.user_id == ^user.id and tm.role == :owner, - where: t.setup_complete == false, - select: %{ - site_id: s.id, - entry_type: "site", - guest_invitation_id: 0, - team_invitation_id: 0, - role: tm.role, - transfer_id: 0 - } - ) - - guest_membership_query = - from(tm in Teams.Membership, - inner_join: u in assoc(tm, :user), - as: :user, - inner_join: gm in assoc(tm, :guest_memberships), - inner_join: s in assoc(gm, :site), - as: :site, - where: tm.user_id == ^user.id and tm.role == :guest, - select: %{ - site_id: s.id, - entry_type: "site", - guest_invitation_id: 0, - team_invitation_id: 0, - role: - fragment( - """ - CASE - WHEN ? = 'editor' THEN 'admin' - ELSE ? - END - """, - gm.role, - gm.role - ), - transfer_id: 0 - } - ) - - guest_invitation_query = - from ti in Teams.Invitation, - as: :team_invitation, - inner_join: gi in assoc(ti, :guest_invitations), - inner_join: s in assoc(gi, :site), - as: :site, - where: - not exists( - from tm in Teams.Membership, - inner_join: u in assoc(tm, :user), - left_join: gm in assoc(tm, :guest_memberships), - on: gm.site_id == parent_as(:site).id, - where: tm.team_id == parent_as(:team_invitation).team_id, - where: u.email == parent_as(:team_invitation).email, - where: not is_nil(gm.id) or tm.role != :guest, - select: 1 - ), - where: ti.email == ^user.email and ti.role == :guest, - select: %{ - site_id: s.id, - entry_type: "invitation", - guest_invitation_id: gi.id, - team_invitation_id: ti.id, - role: - fragment( - """ - CASE - WHEN ? = 'editor' THEN 'admin' - ELSE ? - END - """, - gi.role, - gi.role - ), - transfer_id: 0 - } - - site_transfer_query = - from st in Teams.SiteTransfer, - as: :site_transfer, - inner_join: s in assoc(st, :site), - as: :site, - where: st.email == ^user.email, - select: %{ - site_id: s.id, - entry_type: "invitation", - guest_invitation_id: 0, - team_invitation_id: 0, - role: "owner", - transfer_id: st.id - } - - site_transfer_query = - if team do - where(site_transfer_query, [site: s], s.team_id != ^team.id) - else - site_transfer_query - end - - from s in my_team_query, - union_all: ^guest_membership_query, - union_all: ^guest_invitation_query, - union_all: ^site_transfer_query end defp maybe_filter_by_domain(query, domain) diff --git a/lib/plausible_web/live/sites.ex b/lib/plausible_web/live/sites.ex index 8c71fd335140..d3cc16e5de71 100644 --- a/lib/plausible_web/live/sites.ex +++ b/lib/plausible_web/live/sites.ex @@ -24,10 +24,6 @@ defmodule PlausibleWeb.Live.Sites do socket = socket |> assign(:uri, uri) - |> assign( - :team_invitations, - Teams.Invitations.all(user) - ) |> assign(:hourly_stats, %{}) |> assign(:filter_text, String.trim(params["filter_text"] || "")) |> assign(init_consolidated_view_assigns(user, team)) @@ -84,13 +80,7 @@ defmodule PlausibleWeb.Live.Sites do end def render(assigns) do - assigns = - assign( - assigns, - :invitations_map, - Enum.map(assigns.invitations, &{&1.invitation.invitation_id, &1}) |> Enum.into(%{}) - ) - |> assign(:searching?, String.trim(assigns.filter_text) != "") + assigns = assign(assigns, :searching?, String.trim(assigns.filter_text) != "") ~H""" <.flash_messages flash={@flash} /> @@ -114,8 +104,6 @@ defmodule PlausibleWeb.Live.Sites do - -
<%= for site <- @sites.entries do %> <.site - :if={site.entry_type in ["pinned_site", "site"]} site={site} hourly_stats={Map.get(@hourly_stats, site.domain, :loading)} /> - <.invitation - :if={site.entry_type == "invitation"} - site={site} - invitation={@invitations_map[hd(site.invitations).invitation_id]} - hourly_stats={Map.get(@hourly_stats, site.domain, :loading)} - /> <% end %> @@ -484,41 +465,6 @@ defmodule PlausibleWeb.Live.Sites do """ end - attr(:site, Plausible.Site, required: true) - attr(:invitation, :map, required: true) - attr(:hourly_stats, :map, required: true) - - def invitation(assigns) do - assigns = - assigns - |> assign(:modal_id, "invitation-modal-#{assigns[:invitation].invitation.invitation_id}") - - ~H""" -
  • -
    -
    - <.favicon domain={@site.domain} /> -
    -

    - {@site.domain} -

    -
    - <.pill color={:green}> - Pending invitation - -
    - <.site_stats hourly_stats={@hourly_stats} /> -
    - <.invitation_modal id={@modal_id} site={@site} invitation={@invitation} /> -
  • - """ - end - attr(:site, Plausible.Site, required: true) attr(:hourly_stats, :map, required: true) @@ -720,117 +666,6 @@ defmodule PlausibleWeb.Live.Sites do """ end - attr(:id, :string, required: true) - attr(:site, Plausible.Site, required: true) - attr(:invitation, :map, required: true) - - def invitation_modal(assigns) do - ~H""" - -
    - -
    - - You're invited to {@site.domain} - -
    -

    - You've been added as {@invitation.invitation.role} - to the {@site.domain} analytics dashboard. - <%= if !(Map.get(@invitation, :exceeded_limits) || Map.get(@invitation, :no_plan)) && - @invitation.invitation.role == :owner do %> - If you accept the ownership transfer, you will be responsible for billing going forward. - <% else %> - Welcome aboard! - <% end %> -

    -
    -
    - <.notice - :if={Map.get(@invitation, :missing_features)} - title="Missing features" - class="mt-4 shadow-xs dark:shadow-none" - > -

    - The site uses {Map.get(@invitation, :missing_features)}, - which your current subscription does not support. After accepting ownership of this site, - you will not be able to access them unless you <.styled_link - class="inline-block" - href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)} - > - upgrade to a suitable plan - . -

    - - <.notice - :if={Map.get(@invitation, :exceeded_limits)} - title="Unable to accept site ownership" - class="mt-4 shadow-xs dark:shadow-none" - > -

    - Owning this site would exceed your {Map.get(@invitation, :exceeded_limits)}. Please check your usage in - <.styled_link - class="inline-block" - href={Routes.settings_path(PlausibleWeb.Endpoint, :subscription)} - > - account settings - - and upgrade your subscription to accept the site ownership. -

    - - <.notice - :if={Map.get(@invitation, :no_plan)} - title="No subscription" - class="mt-4 shadow-xs dark:shadow-none" - > - You are unable to accept the ownership of this site because your account does not have a subscription. To become the owner of this site, you should upgrade to a suitable plan. - -
    -
    - <.button - :if={!(Map.get(@invitation, :exceeded_limits) || Map.get(@invitation, :no_plan))} - mt?={false} - class="w-full sm:w-auto sm:text-sm" - data-method="post" - data-csrf={Plug.CSRFProtection.get_csrf_token()} - data-to={"/sites/invitations/#{@invitation.invitation.invitation_id}/accept"} - data-autofocus - > - Accept and continue - - <.button_link - :if={Map.get(@invitation, :exceeded_limits) || Map.get(@invitation, :no_plan)} - mt?={false} - href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)} - class="w-full sm:w-auto sm:text-sm" - data-autofocus - > - Upgrade - - <.button_link - mt?={false} - class="w-full sm:w-auto sm:text-sm" - href="#" - theme="secondary" - data-method="post" - data-csrf={Plug.CSRFProtection.get_csrf_token()} - data-to={"/sites/invitations/#{@invitation.invitation.invitation_id}/reject"} - > - Reject - -
    -
    - """ - end - attr(:filter_text, :string, default: "") attr(:uri, URI, required: true) @@ -965,7 +800,7 @@ defmodule PlausibleWeb.Live.Sites do defp load_sites(%{assigns: assigns} = socket) do sites = - Sites.list_with_invitations(assigns.current_user, assigns.params, + Sites.list(assigns.current_user, assigns.params, filter_by_domain: assigns.filter_text, team: assigns.current_team ) @@ -991,71 +826,14 @@ defmodule PlausibleWeb.Live.Sites do do: load_consolidated_stats(assigns.consolidated_view), else: :loading - invitations = extract_invitations(sites.entries, assigns.current_team) - assign( socket, sites: sites, - invitations: invitations, hourly_stats: hourly_stats, consolidated_stats: consolidated_stats || Map.get(assigns, :consolidated_stats) ) end - defp extract_invitations(sites, team) do - sites - |> Enum.filter(&(&1.entry_type == "invitation")) - |> Enum.flat_map(& &1.invitations) - |> Enum.map(&check_limits(&1, team)) - end - - on_ee do - defp check_limits(%{role: :owner, site: site} = invitation, team) do - case ensure_can_take_ownership(site, team) do - :ok -> - check_features(invitation, team) - - {:error, :no_plan} -> - %{invitation: invitation, no_plan: true} - - {:error, {:over_plan_limits, limits}} -> - limits = PlausibleWeb.TextHelpers.pretty_list(limits) - %{invitation: invitation, exceeded_limits: limits} - end - end - end - - defp check_limits(invitation, _), do: %{invitation: invitation} - - defdelegate ensure_can_take_ownership(site, team), to: Teams.Invitations - - def check_features(%{role: :owner, site: site} = invitation, team) do - case check_feature_access(site, team) do - :ok -> - %{invitation: invitation} - - {:error, {:missing_features, features}} -> - feature_names = - features - |> Enum.map(& &1.display_name()) - |> PlausibleWeb.TextHelpers.pretty_list() - - %{invitation: invitation, missing_features: feature_names} - end - end - - defp check_feature_access(site, new_team) do - missing_features = - Teams.Billing.features_usage(nil, [site.id]) - |> Enum.filter(&(&1.check_availability(new_team) != :ok)) - - if missing_features == [] do - :ok - else - {:error, {:missing_features, missing_features}} - end - end - defp set_filter_text(socket, filter_text) do filter_text = String.trim(filter_text) uri = socket.assigns.uri @@ -1113,7 +891,7 @@ defmodule PlausibleWeb.Live.Sites do end defp init_consolidated_view_assigns(_user, nil) do - # technically this is team not setup, but is also equivalent of having no sites at this moment (can have invitations though), so CTA should not be shown + # technically this is team not setup, but is also equivalent of having no sites at this moment, so CTA should not be shown no_consolidated_view(no_consolidated_view_reason: :no_sites) end diff --git a/test/plausible/sites_test.exs b/test/plausible/sites_test.exs index e9e0ba935cd9..97ca545f5721 100644 --- a/test/plausible/sites_test.exs +++ b/test/plausible/sites_test.exs @@ -269,7 +269,7 @@ defmodule Plausible.SitesTest do end end - describe "list/3 and list_with_invitations/3" do + describe "list/3" do test "returns empty when there are no sites" do user = new_user() _rogue_site = new_site() @@ -289,28 +289,11 @@ defmodule Plausible.SitesTest do total_entries: 0, total_pages: 1 } = Plausible.Teams.Sites.list(user, %{}) - - assert %{ - entries: [], - page_size: 24, - page_number: 1, - total_entries: 0, - total_pages: 1 - } = Sites.list_with_invitations(user, %{}) - - assert %{ - entries: [], - page_size: 24, - page_number: 1, - total_entries: 0, - total_pages: 1 - } = Plausible.Teams.Sites.list_with_invitations(user, %{}) end - test "lists guest sites, site invitations and transfers when no current team set" do + test "lists guest sites when no current team set (no invitations)" do user1 = new_user() user2 = new_user() - user3 = new_user() user4 = new_user() # owned site on a setup team @@ -325,32 +308,21 @@ defmodule Plausible.SitesTest do site2 = new_site(owner: user2, domain: "guest.example.com") add_guest(site2, user: user1, role: :editor) - # site invitation - site3 = new_site(owner: user3, domain: "invitation.example.com") - invite_guest(site3, user1, role: :viewer, inviter: user3) - - # transfer - site4 = new_site(domain: "transfer.example.com", owner: user3) - invite_transfer(site4, user1, inviter: user2) - # other team site access - site5 = new_site(domain: "team.example.com", owner: user4) - add_member(site5.team, user: user1, role: :editor) + site3 = new_site(domain: "team.example.com", owner: user4) + add_member(site3.team, user: user1, role: :editor) assert %{ entries: [ - %{domain: "invitation.example.com"}, - %{domain: "transfer.example.com"}, %{domain: "guest.example.com"} ] } = - Sites.list_with_invitations(user1, %{}) + Sites.list(user1, %{}) end - test "lists guest sites, site invitations, transfers and team sites when current team set but not setup" do + test "lists guest sites and team sites when current team set but not setup (no invitations)" do user1 = new_user() user2 = new_user() - user3 = new_user() user4 = new_user() # owned site on a personal team @@ -360,36 +332,25 @@ defmodule Plausible.SitesTest do site2 = new_site(owner: user2, domain: "guest.example.com") add_guest(site2, user: user1, role: :editor) - # site invitation - site3 = new_site(owner: user3, domain: "invitation.example.com") - invite_guest(site3, user1, role: :viewer, inviter: user3) - - # transfer - site4 = new_site(domain: "transfer.example.com", owner: user3) - invite_transfer(site4, user1, inviter: user2) - # other team site access - site5 = new_site(domain: "team.example.com", owner: user4) - add_member(site5.team, user: user1, role: :editor) + site3 = new_site(domain: "team.example.com", owner: user4) + add_member(site3.team, user: user1, role: :editor) # excluded new_site(owner: user1, domain: "consolidated.example.com", consolidated: true) assert %{ entries: [ - %{domain: "invitation.example.com"}, - %{domain: "transfer.example.com"}, %{domain: "guest.example.com"}, %{domain: "own.example.com"} ] } = - Sites.list_with_invitations(user1, %{}, team: site1.team) + Sites.list(user1, %{}, team: site1.team) end - test "lists team sites and transfers when current team set and setup" do + test "lists team sites when current team set and setup (no invitations)" do user1 = new_user() user2 = new_user() - user3 = new_user() user4 = new_user() # owned site on a personal team @@ -399,37 +360,29 @@ defmodule Plausible.SitesTest do site2 = new_site(owner: user2, domain: "guest.example.com") add_guest(site2, user: user1, role: :editor) - # site invitation - site3 = new_site(owner: user3, domain: "invitation.example.com") - invite_guest(site3, user1, role: :viewer, inviter: user3) - - # transfer - site4 = new_site(domain: "transfer.example.com", owner: user3) - invite_transfer(site4, user1, inviter: user2) - # other team site access - site5 = new_site(domain: "team.example.com", owner: user4) - team5 = Plausible.Teams.complete_setup(site5.team) - add_member(site5.team, user: user1, role: :admin) + site3 = new_site(domain: "team.example.com", owner: user4) + team3 = Plausible.Teams.complete_setup(site3.team) + add_member(site3.team, user: user1, role: :admin) # excluded new_site(owner: user1, domain: "consolidated.example.com", consolidated: true) assert %{ entries: [ - %{domain: "transfer.example.com"}, %{domain: "team.example.com"} ] } = - Sites.list_with_invitations(user1, %{}, team: team5) + Sites.list(user1, %{}, team: team3) end - test "shows both pending transfer and pinned site for user without team with guest membership" do + test "shows pinned site for user without team with guest membership (no invitation)" do owner = new_user() pending_owner = new_user() site = new_site(owner: owner, domain: "one.example.com") add_guest(site, user: pending_owner, role: :editor) + # transfer no longer shown invite_transfer(site, pending_owner, inviter: owner) {:ok, _} = Sites.toggle_pin(pending_owner, site) @@ -440,49 +393,42 @@ defmodule Plausible.SitesTest do assert %{ entries: [ - %{domain: "one.example.com", entry_type: "invitation"}, %{domain: "one.example.com", entry_type: "pinned_site"} ] } = - Sites.list_with_invitations(pending_owner, %{}) + Sites.list(pending_owner, %{}) end - test "shows both pending transfer and site for user without team with guest membership" do + test "shows site for user without team with guest membership (no invitation)" do owner = new_user() pending_owner = new_user() site = new_site(owner: owner, domain: "one.example.com") add_guest(site, user: pending_owner, role: :editor) - invite_transfer(site, pending_owner, inviter: owner) - assert %{ entries: [ - %{domain: "one.example.com", entry_type: "invitation"}, %{domain: "one.example.com", entry_type: "site"} ] } = - Sites.list_with_invitations(pending_owner, %{}) + Sites.list(pending_owner, %{}) end - test "shows both pending transfer and site for user with personal team with guest membership" do + test "shows site for user with personal team with guest membership (no invitation)" do owner = new_user() pending_owner = new_user() |> subscribe_to_growth_plan() pending_team = team_of(pending_owner) site = new_site(owner: owner, domain: "one.example.com") add_guest(site, user: pending_owner, role: :editor) - invite_transfer(site, pending_owner, inviter: owner) - assert %{ entries: [ - %{domain: "one.example.com", entry_type: "invitation"}, %{domain: "one.example.com", entry_type: "site"} ] } = - Sites.list_with_invitations(pending_owner, %{}, team: pending_team) + Sites.list(pending_owner, %{}, team: pending_team) end - test "shows only pending transfer for user with setup team with guest membership" do + test "does not show site for user with setup team with guest membership (no invitation)" do owner = new_user() pending_owner = new_user() |> subscribe_to_growth_plan() @@ -494,48 +440,38 @@ defmodule Plausible.SitesTest do site = new_site(owner: owner, domain: "one.example.com") add_guest(site, user: pending_owner, role: :editor) - invite_transfer(site, pending_owner, inviter: owner) - assert %{ - entries: [ - %{domain: "one.example.com", entry_type: "invitation"} - ] + entries: [] } = - Sites.list_with_invitations(pending_owner, %{}, team: pending_team) + Sites.list(pending_owner, %{}, team: pending_team) end test "does not show transfer for user with site in their personal team" do - owner = new_user() pending_owner = new_user() |> subscribe_to_growth_plan() pending_team = team_of(pending_owner) - site = new_site(owner: pending_owner, domain: "one.example.com") - - invite_transfer(site, pending_owner, inviter: owner) + _site = new_site(owner: pending_owner, domain: "one.example.com") assert %{ entries: [ %{domain: "one.example.com", entry_type: "site"} ] } = - Sites.list_with_invitations(pending_owner, %{}, team: pending_team) + Sites.list(pending_owner, %{}, team: pending_team) end test "does not show transfer for user with site in their setup team" do - owner = new_user() pending_owner = new_user() |> subscribe_to_growth_plan() pending_team = team_of(pending_owner) - site = new_site(owner: pending_owner, domain: "one.example.com") + _site = new_site(owner: pending_owner, domain: "one.example.com") pending_team = Plausible.Teams.complete_setup(pending_team) - invite_transfer(site, pending_owner, inviter: owner) - assert %{ entries: [ %{domain: "one.example.com", entry_type: "site"} ] } = - Sites.list_with_invitations(pending_owner, %{}, team: pending_team) + Sites.list(pending_owner, %{}, team: pending_team) end test "pinned site doesn't matter with membership revoked (no active invitations)" do @@ -552,22 +488,18 @@ defmodule Plausible.SitesTest do revoke_membership(site2, user1) assert %{entries: [%{domain: "one.example.com"}]} = Sites.list(user1, %{}) - assert %{entries: [%{domain: "one.example.com"}]} = Sites.list_with_invitations(user1, %{}) + assert %{entries: [%{domain: "one.example.com"}]} = Sites.list(user1, %{}) assert %{entries: [%{domain: "one.example.com"}]} = Plausible.Teams.Sites.list(user1, %{}) assert %{entries: [%{domain: "one.example.com"}]} = - Plausible.Teams.Sites.list_with_invitations(user1, %{}) + Plausible.Teams.Sites.list(user1, %{}) end - test "pinned site with active invitation" do + test "pinned site (no invitation shown)" do user1 = new_user(email: "user1@example.com") - user2 = new_user(email: "user2@example.com") site1 = new_site(domain: "one.example.com", owner: user1) - site2 = new_site(domain: "two.example.com") - - invite_guest(site2, user1, role: :editor, inviter: user2) {:ok, _} = Sites.toggle_pin(user1, site1) @@ -575,19 +507,18 @@ defmodule Plausible.SitesTest do assert %{ entries: [ - %{domain: "two.example.com", entry_type: "invitation"}, %{domain: "one.example.com", entry_type: "pinned_site"} ] } = - Sites.list_with_invitations(user1, %{}) + Sites.list(user1, %{}) assert %{entries: [%{domain: "one.example.com"}]} = Plausible.Teams.Sites.list(user1, %{}) - assert %{entries: [%{domain: "two.example.com"}, %{domain: "one.example.com"}]} = - Plausible.Teams.Sites.list_with_invitations(user1, %{}) + assert %{entries: [%{domain: "one.example.com"}]} = + Plausible.Teams.Sites.list(user1, %{}) end - test "pinned site on active invitation" do + test "pinned site after invitation revoked (no invitation shown)" do user1 = new_user(email: "user1@example.com") user2 = new_user(email: "user2@example.com") @@ -597,53 +528,36 @@ defmodule Plausible.SitesTest do {:ok, _} = Sites.toggle_pin(user1, site1) revoke_membership(site1, user1) - invite_guest(site1, user1, role: :editor, inviter: user2) - assert %{entries: []} = Sites.list(user1, %{}) - assert %{ - entries: [ - %{domain: "one.example.com", entry_type: "invitation"} - ] - } = - Sites.list_with_invitations(user1, %{}) - assert %{entries: []} = Plausible.Teams.Sites.list(user1, %{}) - assert %{entries: [%{domain: "one.example.com", entry_type: "invitation"}]} = - Plausible.Teams.Sites.list_with_invitations(user1, %{}) + assert %{entries: []} = + Plausible.Teams.Sites.list(user1, %{}) end - test "puts invitations first, pinned sites second, sites last" do + test "puts pinned sites first, sites last (no invitations)" do user1 = new_user() user2 = new_user() - user3 = new_user() site1 = new_site(owner: user1, domain: "one.example.com") site2 = new_site(owner: user2, domain: "two.example.com") - site3 = new_site(owner: user3, domain: "three.example.com") + site3 = new_site(domain: "three.example.com") site4 = new_site(domain: "four.example.com") - site5 = new_site(owner: user3, domain: "five.example.com") # excluded new_site(owner: user1, domain: "consolidated1.example.com", consolidated: true) new_site(owner: user2, domain: "consolidated2.example.com", consolidated: true) - new_site(owner: user3, domain: "consolidated3.example.com", consolidated: true) - invite_guest(site2, user1, role: :editor, inviter: user2) add_guest(site3, user: user1, role: :viewer) add_guest(site4, user: user1, role: :editor) - invite_transfer(site5, user1, inviter: user3) - {:ok, _} = Sites.toggle_pin(user1, site3) {:ok, _pin_to_ignore} = Sites.toggle_pin(user2, site2) site1_id = site1.id - site2_id = site2.id site3_id = site3.id site4_id = site4.id - site5_id = site5.id assert %{ entries: [ @@ -655,16 +569,14 @@ defmodule Plausible.SitesTest do assert %{ entries: [ - %{id: ^site5_id, entry_type: "invitation"}, - %{id: ^site2_id, entry_type: "invitation"}, %{id: ^site3_id, entry_type: "pinned_site"}, %{id: ^site4_id, entry_type: "site"}, %{id: ^site1_id, entry_type: "site"} ] - } = Sites.list_with_invitations(user1, %{}) + } = Sites.list(user1, %{}) end - test "pinned sites are ordered according to the time they were pinned at" do + test "pinned sites are ordered according to the time they were pinned at (no invitations)" do user1 = new_user() user2 = new_user() user3 = new_user() @@ -675,6 +587,7 @@ defmodule Plausible.SitesTest do site4 = new_site(domain: "four.example.com") site5 = new_site(owner: user3, domain: "five.example.com") + # invitations no longer shown invite_guest(site2, user1, role: :editor, inviter: user2) add_guest(site3, user: user1, role: :viewer) add_guest(site4, user: user1, role: :editor) @@ -684,10 +597,8 @@ defmodule Plausible.SitesTest do {:ok, _} = Sites.toggle_pin(user1, site3) site1_id = site1.id - site2_id = site2.id site3_id = site3.id site4_id = site4.id - site5_id = site5.id Sites.set_option(user1, site1, :pinned_at, ~N[2023-10-22 12:00:00]) {:ok, _} = Sites.toggle_pin(user1, site3) @@ -702,32 +613,21 @@ defmodule Plausible.SitesTest do assert %{ entries: [ - %{id: ^site5_id, entry_type: "invitation"}, - %{id: ^site2_id, entry_type: "invitation"}, %{id: ^site3_id, entry_type: "pinned_site"}, %{id: ^site1_id, entry_type: "pinned_site"}, %{id: ^site4_id, entry_type: "site"} ] - } = Sites.list_with_invitations(user1, %{}) + } = Sites.list(user1, %{}) end - test "filters by domain" do + test "filters by domain (no invitations)" do user1 = new_user() - user2 = new_user() - user3 = new_user() site1 = new_site(owner: user1, domain: "first.example.com") - site2 = new_site(owner: user2, domain: "first-transfer.example.com") - site3 = new_site(owner: user3, domain: "first-invitation.example.com") - _site4 = new_site(owner: user1, domain: "another.example.com") + _site2 = new_site(owner: user1, domain: "another.example.com") new_site(owner: user1, domain: "consolidated.example.com", consolidated: true) - invite_guest(site3, user1, role: :viewer, inviter: user3) - invite_transfer(site2, user1, inviter: user2) - site1_id = site1.id - site2_id = site2.id - site3_id = site3.id assert %{ entries: [ @@ -737,25 +637,17 @@ defmodule Plausible.SitesTest do assert %{ entries: [ - %{id: ^site3_id}, - %{id: ^site2_id}, %{id: ^site1_id} ] - } = Sites.list_with_invitations(user1, %{}, filter_by_domain: "first") + } = Sites.list(user1, %{}, filter_by_domain: "first") end - test "scopes by team when provided" do + test "scopes by team when provided (no invitations)" do user1 = new_user() - user2 = new_user() - user3 = new_user() site1 = new_site(owner: user1, domain: "first.example.com") - site2 = new_site(owner: user2, domain: "first-transfer.example.com") - site3 = new_site(owner: user3, domain: "first-invitation.example.com") site4 = new_site(domain: "zzzsitefromanotherteam.com") - invite_guest(site3, user1, role: :viewer, inviter: user3) - invite_transfer(site2, user1, inviter: user2) team4 = Plausible.Teams.complete_setup(site4.team) add_member(team4, user: user1, role: :admin) @@ -767,11 +659,9 @@ defmodule Plausible.SitesTest do assert_matches %{ entries: [ - %{id: ^site3.id}, - %{id: ^site2.id}, %{id: ^site1.id} ] - } = Sites.list_with_invitations(user1, %{}) + } = Sites.list(user1, %{}) assert_matches %{ entries: [ @@ -781,36 +671,26 @@ defmodule Plausible.SitesTest do assert_matches %{ entries: [ - %{id: ^site2.id}, %{id: ^site4.id} ] - } = Sites.list_with_invitations(user1, %{}, team: team4) + } = Sites.list(user1, %{}, team: team4) end - test "handles pagination correctly" do + test "handles pagination correctly (no invitations)" do user1 = new_user() - user2 = new_user() - user3 = new_user() site1 = new_site(owner: user1, domain: "one.example.com") - site2 = new_site(owner: user2, domain: "two.example.com") site3 = new_site(domain: "three.example.com") site4 = new_site(domain: "four.example.com") - site5 = new_site(owner: user3, domain: "five.example.com") - invite_guest(site2, user1, role: :editor, inviter: user2) add_guest(site3, user: user1, role: :viewer) add_guest(site4, user: user1, role: :editor) - invite_transfer(site5, user1, inviter: user3) - {:ok, _} = Sites.toggle_pin(user1, site3) site1_id = site1.id - site2_id = site2.id site3_id = site3.id site4_id = site4.id - site5_id = site5.id assert %{ entries: [%{id: ^site3_id}, %{id: ^site4_id}], @@ -835,32 +715,6 @@ defmodule Plausible.SitesTest do total_entries: 3, total_pages: 1 } = Sites.list(user1, %{"page_size" => 3}) - - # list_with_invitations - # - assert %{ - entries: [%{id: ^site5_id}, %{id: ^site2_id}], - page_number: 1, - page_size: 2, - total_entries: 5, - total_pages: 3 - } = Sites.list_with_invitations(user1, %{"page_size" => 2}) - - assert %{ - entries: [%{id: ^site3_id}, %{id: ^site4_id}], - page_number: 2, - page_size: 2, - total_entries: 5, - total_pages: 3 - } = Sites.list_with_invitations(user1, %{"page_size" => 2, "page" => 2}) - - assert %{ - entries: [%{id: ^site1_id}], - page_number: 3, - page_size: 2, - total_entries: 5, - total_pages: 3 - } = Sites.list_with_invitations(user1, %{"page_size" => 2, "page" => 3}) end end diff --git a/test/plausible_web/live/sites_test.exs b/test/plausible_web/live/sites_test.exs index e6d83d21550b..96deed3efe98 100644 --- a/test/plausible_web/live/sites_test.exs +++ b/test/plausible_web/live/sites_test.exs @@ -51,63 +51,6 @@ defmodule PlausibleWeb.Live.SitesTest do assert element_exists?(html, ~s|a[data-test-id="team-settings-link"]|) end - test "renders team invitations", %{user: user, conn: conn} do - owner1 = new_user(name: "G.I. Joe") - new_site(owner: owner1) - team1 = team_of(owner1) - - owner2 = new_user(name: "G.I. Jane") - new_site(owner: owner2) - team2 = team_of(owner2) - - invitation1 = invite_member(team1, user, inviter: owner1, role: :viewer) - invitation2 = invite_member(team2, user, inviter: owner2, role: :editor) - - {:ok, _lv, html} = live(conn, "/sites") - - assert text_of_element(html, "#invitation-#{invitation1.invitation_id}") =~ - "G.I. Joe has invited you to join the \"My personal sites\" as viewer member." - - assert text_of_element(html, "#invitation-#{invitation2.invitation_id}") =~ - "G.I. Jane has invited you to join the \"My personal sites\" as editor member." - - assert element_exists?( - html, - ~s|#invitation-#{invitation1.invitation_id} a[href="#{Routes.invitation_path(PlausibleWeb.Endpoint, :accept_invitation, invitation1.invitation_id)}"]| - ) - - assert element_exists?( - html, - ~s|#invitation-#{invitation1.invitation_id} a[href="#{Routes.invitation_path(PlausibleWeb.Endpoint, :reject_invitation, invitation1.invitation_id)}"]| - ) - - assert element_exists?( - html, - ~s|#invitation-#{invitation2.invitation_id} a[href="#{Routes.invitation_path(PlausibleWeb.Endpoint, :accept_invitation, invitation2.invitation_id)}"]| - ) - - assert element_exists?( - html, - ~s|#invitation-#{invitation2.invitation_id} a[href="#{Routes.invitation_path(PlausibleWeb.Endpoint, :reject_invitation, invitation2.invitation_id)}"]| - ) - end - - @tag :ee_only - test "renders ownership transfer invitation for a case with no plan", %{ - conn: conn, - user: user - } do - inviter = new_user() - site = new_site(owner: inviter) - - transfer = invite_transfer(site, user, inviter: inviter) - - {:ok, _lv, html} = live(conn, "/sites") - - assert text_of_element(html, "#invitation-modal-#{transfer.transfer_id}") =~ - "You are unable to accept the ownership of this site because your account does not have a subscription" - end - @tag :ee_only test "renders upgrade nag when current team has a site and trial expired", %{ conn: conn, @@ -124,27 +67,6 @@ defmodule PlausibleWeb.Live.SitesTest do assert html =~ "Payment required" end - @tag :ee_only - test "renders upgrade nag when there's a pending transfer", %{ - conn: conn, - user: user - } do - {:ok, personal_team} = Plausible.Teams.get_or_create(user) - - another_user = new_user() - site = new_site(owner: another_user) - - personal_team - |> Ecto.Changeset.change(trial_expiry_date: Date.add(Date.utc_today(), -1)) - |> Repo.update!() - - invite_transfer(site, user, inviter: another_user) - - {:ok, _lv, html} = live(conn, "/sites") - - assert html =~ "Payment required" - end - @tag :ee_only test "does not render upgrade nag when there's no current team", %{conn: conn, user: user} do team = new_site().team |> Plausible.Teams.complete_setup() @@ -156,20 +78,7 @@ defmodule PlausibleWeb.Live.SitesTest do end @tag :ee_only - test "does not render upgrade nag when current team has no sites and user has no pending transfers", - %{conn: conn, user: user} do - {:ok, _personal_team} = Plausible.Teams.get_or_create(user) - - team = new_site().team |> Plausible.Teams.complete_setup() - add_member(team, user: user, role: :owner) - - {:ok, _lv, html} = live(conn, "/sites") - - refute html =~ "Payment required" - end - - @tag :ee_only - test "does not render upgrade nag if current team does not have any sites yet and user has no pending transfers", + test "does not render upgrade nag if current team does not have any sites yet", %{conn: conn, user: user} do {:ok, personal_team} = Plausible.Teams.get_or_create(user) @@ -185,44 +94,6 @@ defmodule PlausibleWeb.Live.SitesTest do refute html =~ "Payment required" end - @tag :ee_only - test "renders ownership transfer invitation for a case with exceeded limits", %{ - conn: conn, - user: user - } do - inviter = new_user() - site = new_site(owner: inviter) - - transfer = invite_transfer(site, user, inviter: inviter) - - # fill site quota - subscribe_to_growth_plan(user) - for _ <- 1..10, do: new_site(owner: user) - - {:ok, _lv, html} = live(conn, "/sites") - - assert text_of_element(html, "#invitation-modal-#{transfer.transfer_id}") =~ - "Owning this site would exceed your site limit" - end - - @tag :ee_only - test "renders ownership transfer invitation for a case with missing features", %{ - conn: conn, - user: user - } do - inviter = new_user() - site = new_site(owner: inviter, allowed_event_props: ["dummy"]) - - transfer = invite_transfer(site, user, inviter: inviter) - - subscribe_to_growth_plan(user) - - {:ok, _lv, html} = live(conn, "/sites") - - assert text_of_element(html, "#invitation-modal-#{transfer.transfer_id}") =~ - "The site uses Custom Properties, which your current subscription does not support" - end - test "renders 24h visitors correctly", %{conn: conn, user: user} do site = new_site(owner: user) From 6b6806339de0af412633dec743d16c8b0f74f567 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 9 Feb 2026 13:58:56 +0100 Subject: [PATCH 02/13] !fixup --- .../controllers/site_controller_test.exs | 25 +++---------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs index 2be757b4c93b..0652fed70de2 100644 --- a/test/plausible_web/controllers/site_controller_test.exs +++ b/test/plausible_web/controllers/site_controller_test.exs @@ -146,25 +146,6 @@ defmodule PlausibleWeb.SiteControllerTest do assert site_card =~ site.domain end - test "shows invitations for user by email address", %{conn: conn, user: user} do - inviter = new_user() - site = new_site(owner: inviter) - invite_guest(site, user, inviter: inviter, role: :editor) - conn = get(conn, "/sites") - - assert html_response(conn, 200) =~ site.domain - end - - test "invitations are case insensitive", %{conn: conn, user: user} do - inviter = new_user() - site = new_site(owner: inviter) - invite_guest(site, String.upcase(user.email), inviter: inviter, role: :editor) - - conn = get(conn, "/sites") - - assert html_response(conn, 200) =~ site.domain - end - test "paginates sites", %{conn: initial_conn, user: user} do for i <- 1..25 do new_site( @@ -240,14 +221,14 @@ defmodule PlausibleWeb.SiteControllerTest do inviter = new_user() - new_site(owner: inviter, domain: "first-another.example.com") - |> invite_guest(user, inviter: inviter, role: :viewer) + site3 = new_site(owner: inviter, domain: "guest.example.com") + add_guest(site3, user: user, role: :viewer) conn = get(conn, "/sites", filter_text: "first") resp = html_response(conn, 200) assert resp =~ "first.example.com" - assert resp =~ "first-another.example.com" + assert resp =~ "guest.example.com" refute resp =~ "second.example.com" end From 999fbd017c3febd9c0089da5076e77c7896a69c9 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 9 Feb 2026 14:05:26 +0100 Subject: [PATCH 03/13] !fixup --- .../controllers/site_controller_test.exs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs index 0652fed70de2..31cabe186f91 100644 --- a/test/plausible_web/controllers/site_controller_test.exs +++ b/test/plausible_web/controllers/site_controller_test.exs @@ -215,21 +215,21 @@ defmodule PlausibleWeb.SiteControllerTest do end test "filters by domain", %{conn: conn, user: user} do - _site1 = new_site(domain: "first.example.com", owner: user) - _site2 = new_site(domain: "second.example.com", owner: user) + _site1 = new_site(domain: "alpha.example.com", owner: user) + _site2 = new_site(domain: "beta.example.com", owner: user) _rogue_site = new_site() inviter = new_user() - site3 = new_site(owner: inviter, domain: "guest.example.com") + site3 = new_site(owner: inviter, domain: "alpha-another.example.com") add_guest(site3, user: user, role: :viewer) - conn = get(conn, "/sites", filter_text: "first") + conn = get(conn, "/sites", filter_text: "alpha") resp = html_response(conn, 200) - assert resp =~ "first.example.com" - assert resp =~ "guest.example.com" - refute resp =~ "second.example.com" + assert resp =~ "alpha.example.com" + assert resp =~ "alpha-another.example.com" + refute resp =~ "beta.example.com" end test "does not show empty state when filter returns empty but there are sites", %{ From e79935d41e71431f59940a32f43413d891cd7a50 Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Mon, 9 Feb 2026 20:45:42 +0200 Subject: [PATCH 04/13] Experiment from pairing with @adam --- lib/plausible/clickhouse_repo.ex | 2 + lib/plausible/teams/sites.ex | 58 +++++++++++++++++++ .../live/settings/cancel_flow.ex | 18 ++++++ mix.exs | 2 +- 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 lib/plausible_web/live/settings/cancel_flow.ex diff --git a/lib/plausible/clickhouse_repo.ex b/lib/plausible/clickhouse_repo.ex index cf5c0dedf764..6d1163a5f459 100644 --- a/lib/plausible/clickhouse_repo.ex +++ b/lib/plausible/clickhouse_repo.ex @@ -6,6 +6,8 @@ defmodule Plausible.ClickhouseRepo do adapter: Ecto.Adapters.ClickHouse, read_only: true + use Scrivener, page_size: 24 + defmacro __using__(_) do quote do alias Plausible.ClickhouseRepo diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex index 3ffe7136e995..7c17b4cb297e 100644 --- a/lib/plausible/teams/sites.ex +++ b/lib/plausible/teams/sites.ex @@ -1,13 +1,71 @@ defmodule Plausible.Teams.Sites do @moduledoc false + @sample_threshold 10_000_000 import Ecto.Query + use Plausible.Stats.SQL.Fragments alias Plausible.Auth alias Plausible.Repo alias Plausible.Site alias Plausible.Teams + def list_with_clickhouse(user, team) do + utc_start = ~N[2026-01-01 00:00:00] + utc_end = ~N[2026-01-31 23:59:59] + + all_query = + if Teams.setup?(team) do + from(tm in Teams.Membership, + inner_join: t in assoc(tm, :team), + inner_join: s in assoc(t, :sites), + where: tm.user_id == ^user.id and tm.role != :guest, + where: tm.team_id == ^team.id, + select: struct(s, [:id, :domain]) + ) + else + my_team_query = + from(tm in Teams.Membership, + inner_join: t in assoc(tm, :team), + inner_join: s in assoc(t, :sites), + where: tm.user_id == ^user.id and tm.role != :guest, + where: tm.is_autocreated == true, + where: t.setup_complete == false, + select: struct(s, [:id, :domain]) + ) + + guest_membership_query = + from tm in Teams.Membership, + inner_join: gm in assoc(tm, :guest_memberships), + inner_join: s in assoc(gm, :site), + where: tm.user_id == ^user.id and tm.role == :guest, + select: struct(s, [:id, :domain]) + + from s in my_team_query, + union_all: ^guest_membership_query + end + + clickhouse_query = + from e in Plausible.ClickhouseEventV2, + hints: unsafe_fragment(^"SAMPLE #{@sample_threshold}"), + right_join: sites in subquery(all_query, prefix: "postgres_remote"), + on: fragment("CAST(?, 'UInt64')", sites.id) == e.site_id, + select: %{ + site_id: sites.id, + domain: sites.domain, + visitors: + selected_as( + scale_sample(fragment("uniqIf(?, ? != 0)", e.user_id, e.site_id)), + :visitors + ) + }, + where: e.site_id == 0 or (e.timestamp >= ^utc_start and e.timestamp <= ^utc_end), + group_by: [sites.id, sites.domain], + order_by: [desc: selected_as(:visitors)] + + sites_by_traffic = Plausible.ClickhouseRepo.paginate(clickhouse_query, %{}) + end + @type list_opt() :: {:filter_by_domain, String.t()} | {:team, Teams.Team.t() | nil} @role_type Plausible.Teams.Invitation.__schema__(:type, :role) diff --git a/lib/plausible_web/live/settings/cancel_flow.ex b/lib/plausible_web/live/settings/cancel_flow.ex new file mode 100644 index 000000000000..bde1021cac2a --- /dev/null +++ b/lib/plausible_web/live/settings/cancel_flow.ex @@ -0,0 +1,18 @@ +defmodule PlausibleWeb.Live.CancelFlow do + @moduledoc """ + Live view for subscription cancel flow + """ + use PlausibleWeb, :live_view + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def render(assigns) do + ~H""" +
    + Cancel +
    + """ + end +end diff --git a/mix.exs b/mix.exs index a68bb8e0058d..76c79804724d 100644 --- a/mix.exs +++ b/mix.exs @@ -87,7 +87,7 @@ defmodule Plausible.MixProject do {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:double, "~> 0.8.0", only: [:dev, :test, :ce_test, :ce_dev, :e2e_test]}, - {:ecto, "~> 3.13.5"}, + {:ecto, [path: "/Users/ukutaht/plausible/ecto", override: true]}, {:ecto_sql, "~> 3.13.2"}, {:envy, "~> 1.1.1"}, {:eqrcode, "~> 0.2.1"}, From 0eae9908b0eb1422b92753bd12304f936ed4b11f Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 10 Feb 2026 06:38:48 +0100 Subject: [PATCH 05/13] Make the query PoC work without local ecto patches --- lib/plausible/teams/sites.ex | 23 +++++++++++++++-------- mix.exs | 2 +- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex index 7c17b4cb297e..7fc293c1130b 100644 --- a/lib/plausible/teams/sites.ex +++ b/lib/plausible/teams/sites.ex @@ -29,24 +29,26 @@ defmodule Plausible.Teams.Sites do inner_join: t in assoc(tm, :team), inner_join: s in assoc(t, :sites), where: tm.user_id == ^user.id and tm.role != :guest, - where: tm.is_autocreated == true, - where: t.setup_complete == false, + where: tm.is_autocreated, + where: not t.setup_complete, select: struct(s, [:id, :domain]) ) guest_membership_query = - from tm in Teams.Membership, + from(tm in Teams.Membership, inner_join: gm in assoc(tm, :guest_memberships), inner_join: s in assoc(gm, :site), where: tm.user_id == ^user.id and tm.role == :guest, select: struct(s, [:id, :domain]) + ) - from s in my_team_query, + from(s in my_team_query, union_all: ^guest_membership_query + ) end clickhouse_query = - from e in Plausible.ClickhouseEventV2, + from(e in Plausible.ClickhouseEventV2, hints: unsafe_fragment(^"SAMPLE #{@sample_threshold}"), right_join: sites in subquery(all_query, prefix: "postgres_remote"), on: fragment("CAST(?, 'UInt64')", sites.id) == e.site_id, @@ -62,6 +64,9 @@ defmodule Plausible.Teams.Sites do where: e.site_id == 0 or (e.timestamp >= ^utc_start and e.timestamp <= ^utc_end), group_by: [sites.id, sites.domain], order_by: [desc: selected_as(:visitors)] + ) + + clickhouse_query |> dbg() sites_by_traffic = Plausible.ClickhouseRepo.paginate(clickhouse_query, %{}) end @@ -91,12 +96,12 @@ defmodule Plausible.Teams.Sites do inner_join: s in assoc(t, :sites), where: tm.user_id == ^user.id and tm.role != :guest, where: tm.is_autocreated == true, - where: t.setup_complete == false, + where: not t.setup_complete, select: %{site_id: s.id, entry_type: "site", role: tm.role} ) guest_membership_query = - from tm in Teams.Membership, + from(tm in Teams.Membership, inner_join: gm in assoc(tm, :guest_memberships), inner_join: s in assoc(gm, :site), where: tm.user_id == ^user.id and tm.role == :guest, @@ -115,9 +120,11 @@ defmodule Plausible.Teams.Sites do gm.role ) } + ) - from s in my_team_query, + from(s in my_team_query, union_all: ^guest_membership_query + ) end from(u in subquery(all_query), diff --git a/mix.exs b/mix.exs index 76c79804724d..a68bb8e0058d 100644 --- a/mix.exs +++ b/mix.exs @@ -87,7 +87,7 @@ defmodule Plausible.MixProject do {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:double, "~> 0.8.0", only: [:dev, :test, :ce_test, :ce_dev, :e2e_test]}, - {:ecto, [path: "/Users/ukutaht/plausible/ecto", override: true]}, + {:ecto, "~> 3.13.5"}, {:ecto_sql, "~> 3.13.2"}, {:envy, "~> 1.1.1"}, {:eqrcode, "~> 0.2.1"}, From 8617db6f16e4674ba4dffa4a52ad072a8fe4bec2 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 10 Feb 2026 07:06:38 +0100 Subject: [PATCH 06/13] Query for pinned sites and sort by pinned first --- lib/plausible/clickhouse_repo.ex | 2 -- lib/plausible/teams/sites.ex | 47 ++++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/lib/plausible/clickhouse_repo.ex b/lib/plausible/clickhouse_repo.ex index 6d1163a5f459..cf5c0dedf764 100644 --- a/lib/plausible/clickhouse_repo.ex +++ b/lib/plausible/clickhouse_repo.ex @@ -6,8 +6,6 @@ defmodule Plausible.ClickhouseRepo do adapter: Ecto.Adapters.ClickHouse, read_only: true - use Scrivener, page_size: 24 - defmacro __using__(_) do quote do alias Plausible.ClickhouseRepo diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex index 7fc293c1130b..66251d9ee79c 100644 --- a/lib/plausible/teams/sites.ex +++ b/lib/plausible/teams/sites.ex @@ -11,6 +11,8 @@ defmodule Plausible.Teams.Sites do alias Plausible.Teams def list_with_clickhouse(user, team) do + # TODO maybe filter by domain + # TODO export time ranges utc_start = ~N[2026-01-01 00:00:00] utc_end = ~N[2026-01-31 23:59:59] @@ -21,6 +23,7 @@ defmodule Plausible.Teams.Sites do inner_join: s in assoc(t, :sites), where: tm.user_id == ^user.id and tm.role != :guest, where: tm.team_id == ^team.id, + where: not s.consolidated, select: struct(s, [:id, :domain]) ) else @@ -28,6 +31,7 @@ defmodule Plausible.Teams.Sites do from(tm in Teams.Membership, inner_join: t in assoc(tm, :team), inner_join: s in assoc(t, :sites), + where: not s.consolidated, where: tm.user_id == ^user.id and tm.role != :guest, where: tm.is_autocreated, where: not t.setup_complete, @@ -38,6 +42,7 @@ defmodule Plausible.Teams.Sites do from(tm in Teams.Membership, inner_join: gm in assoc(tm, :guest_memberships), inner_join: s in assoc(gm, :site), + where: not s.consolidated, where: tm.user_id == ^user.id and tm.role == :guest, select: struct(s, [:id, :domain]) ) @@ -47,12 +52,40 @@ defmodule Plausible.Teams.Sites do ) end + all_query = + from(u in subquery(all_query), + inner_join: s in ^Plausible.Site.regular(), + on: u.id == s.id, + as: :site, + left_join: up in Site.UserPreference, + on: up.site_id == s.id and up.user_id == ^user.id, + select: %{ + s + | entry_type: + selected_as( + fragment( + """ + CASE + WHEN ? IS NOT NULL THEN 'pinned_site' + ELSE ? + END + """, + up.pinned_at, + "site" + ), + :entry_type + ), + pinned_at: selected_as(up.pinned_at, :pinned_at) + } + ) + clickhouse_query = from(e in Plausible.ClickhouseEventV2, hints: unsafe_fragment(^"SAMPLE #{@sample_threshold}"), right_join: sites in subquery(all_query, prefix: "postgres_remote"), on: fragment("CAST(?, 'UInt64')", sites.id) == e.site_id, select: %{ + entry_type: selected_as(sites.entry_type, :entry_type), site_id: sites.id, domain: sites.domain, visitors: @@ -62,13 +95,21 @@ defmodule Plausible.Teams.Sites do ) }, where: e.site_id == 0 or (e.timestamp >= ^utc_start and e.timestamp <= ^utc_end), - group_by: [sites.id, sites.domain], - order_by: [desc: selected_as(:visitors)] + group_by: [sites.id, sites.domain, sites.entry_type], + order_by: [ + asc: selected_as(:entry_type), + desc: selected_as(:visitors) + ] ) clickhouse_query |> dbg() - sites_by_traffic = Plausible.ClickhouseRepo.paginate(clickhouse_query, %{}) + Paginator.paginate( + clickhouse_query, + [limit: 24, cursor_fields: [:visitors, :id], sort_direction: :desc], + Plausible.ClickhouseRepo, + [] + ) end @type list_opt() :: {:filter_by_domain, String.t()} | {:team, Teams.Team.t() | nil} From 64ffa8f3585152055995f02d0e74471a44bbff15 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 10 Feb 2026 07:08:54 +0100 Subject: [PATCH 07/13] Sort by pinned at too --- lib/plausible/teams/sites.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex index 66251d9ee79c..4db8ccad1a43 100644 --- a/lib/plausible/teams/sites.ex +++ b/lib/plausible/teams/sites.ex @@ -86,6 +86,7 @@ defmodule Plausible.Teams.Sites do on: fragment("CAST(?, 'UInt64')", sites.id) == e.site_id, select: %{ entry_type: selected_as(sites.entry_type, :entry_type), + pinned_at: selected_as(sites.pinned_at, :pinned_at), site_id: sites.id, domain: sites.domain, visitors: @@ -95,9 +96,10 @@ defmodule Plausible.Teams.Sites do ) }, where: e.site_id == 0 or (e.timestamp >= ^utc_start and e.timestamp <= ^utc_end), - group_by: [sites.id, sites.domain, sites.entry_type], + group_by: [sites.id, sites.domain, sites.entry_type, sites.pinned_at], order_by: [ asc: selected_as(:entry_type), + desc: selected_as(:pinned_at), desc: selected_as(:visitors) ] ) From e150abd0c4b79ae56347dcf9edec053e3bec3469 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 10 Feb 2026 07:34:09 +0100 Subject: [PATCH 08/13] Calculate majority timezone --- lib/plausible/teams/sites.ex | 56 ++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex index 4db8ccad1a43..dea76af3ae98 100644 --- a/lib/plausible/teams/sites.ex +++ b/lib/plausible/teams/sites.ex @@ -24,7 +24,7 @@ defmodule Plausible.Teams.Sites do where: tm.user_id == ^user.id and tm.role != :guest, where: tm.team_id == ^team.id, where: not s.consolidated, - select: struct(s, [:id, :domain]) + select: struct(s, [:id, :domain, :timezone, :team_id]) ) else my_team_query = @@ -35,7 +35,7 @@ defmodule Plausible.Teams.Sites do where: tm.user_id == ^user.id and tm.role != :guest, where: tm.is_autocreated, where: not t.setup_complete, - select: struct(s, [:id, :domain]) + select: struct(s, [:id, :domain, :timezone, :team_id]) ) guest_membership_query = @@ -44,7 +44,7 @@ defmodule Plausible.Teams.Sites do inner_join: s in assoc(gm, :site), where: not s.consolidated, where: tm.user_id == ^user.id and tm.role == :guest, - select: struct(s, [:id, :domain]) + select: struct(s, [:id, :domain, :timezone, :team_id]) ) from(s in my_team_query, @@ -52,15 +52,45 @@ defmodule Plausible.Teams.Sites do ) end + timezone_counts_query = + from(s in subquery(all_query), + group_by: s.timezone, + select: %{ + timezone: + fragment( + "CASE WHEN ? = 'UTC' THEN 'Etc/UTC' ELSE ? END", + s.timezone, + s.timezone + ), + cnt: count(s.id) + } + ) + + majority_timezone_query = + from(tz in subquery(timezone_counts_query), + select: %{ + majority_timezone: + fragment( + "FIRST_VALUE(?) OVER (ORDER BY ? DESC, ? ASC)", + tz.timezone, + tz.cnt, + tz.timezone + ) + }, + limit: 1 + ) + all_query = - from(u in subquery(all_query), - inner_join: s in ^Plausible.Site.regular(), - on: u.id == s.id, + from(s in subquery(all_query), + as: :site_with_team, + inner_join: site in ^Plausible.Site.regular(), + on: s.id == site.id, as: :site, left_join: up in Site.UserPreference, on: up.site_id == s.id and up.user_id == ^user.id, + cross_join: tz in subquery(majority_timezone_query), select: %{ - s + site | entry_type: selected_as( fragment( @@ -75,7 +105,8 @@ defmodule Plausible.Teams.Sites do ), :entry_type ), - pinned_at: selected_as(up.pinned_at, :pinned_at) + pinned_at: selected_as(up.pinned_at, :pinned_at), + majority_timezone: fragment("COALESCE(?, 'Etc/UTC')", tz.majority_timezone) } ) @@ -89,6 +120,7 @@ defmodule Plausible.Teams.Sites do pinned_at: selected_as(sites.pinned_at, :pinned_at), site_id: sites.id, domain: sites.domain, + majority_timezone: sites.majority_timezone, visitors: selected_as( scale_sample(fragment("uniqIf(?, ? != 0)", e.user_id, e.site_id)), @@ -96,7 +128,13 @@ defmodule Plausible.Teams.Sites do ) }, where: e.site_id == 0 or (e.timestamp >= ^utc_start and e.timestamp <= ^utc_end), - group_by: [sites.id, sites.domain, sites.entry_type, sites.pinned_at], + group_by: [ + sites.id, + sites.domain, + sites.entry_type, + sites.pinned_at, + sites.majority_timezone + ], order_by: [ asc: selected_as(:entry_type), desc: selected_as(:pinned_at), From 76a001dc508858aea7009009d4d1b55433538bff Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 10 Feb 2026 11:57:56 +0100 Subject: [PATCH 09/13] Revert "Calculate majority timezone" This reverts commit e150abd0c4b79ae56347dcf9edec053e3bec3469. --- lib/plausible/teams/sites.ex | 56 ++++++------------------------------ 1 file changed, 9 insertions(+), 47 deletions(-) diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex index dea76af3ae98..4db8ccad1a43 100644 --- a/lib/plausible/teams/sites.ex +++ b/lib/plausible/teams/sites.ex @@ -24,7 +24,7 @@ defmodule Plausible.Teams.Sites do where: tm.user_id == ^user.id and tm.role != :guest, where: tm.team_id == ^team.id, where: not s.consolidated, - select: struct(s, [:id, :domain, :timezone, :team_id]) + select: struct(s, [:id, :domain]) ) else my_team_query = @@ -35,7 +35,7 @@ defmodule Plausible.Teams.Sites do where: tm.user_id == ^user.id and tm.role != :guest, where: tm.is_autocreated, where: not t.setup_complete, - select: struct(s, [:id, :domain, :timezone, :team_id]) + select: struct(s, [:id, :domain]) ) guest_membership_query = @@ -44,7 +44,7 @@ defmodule Plausible.Teams.Sites do inner_join: s in assoc(gm, :site), where: not s.consolidated, where: tm.user_id == ^user.id and tm.role == :guest, - select: struct(s, [:id, :domain, :timezone, :team_id]) + select: struct(s, [:id, :domain]) ) from(s in my_team_query, @@ -52,45 +52,15 @@ defmodule Plausible.Teams.Sites do ) end - timezone_counts_query = - from(s in subquery(all_query), - group_by: s.timezone, - select: %{ - timezone: - fragment( - "CASE WHEN ? = 'UTC' THEN 'Etc/UTC' ELSE ? END", - s.timezone, - s.timezone - ), - cnt: count(s.id) - } - ) - - majority_timezone_query = - from(tz in subquery(timezone_counts_query), - select: %{ - majority_timezone: - fragment( - "FIRST_VALUE(?) OVER (ORDER BY ? DESC, ? ASC)", - tz.timezone, - tz.cnt, - tz.timezone - ) - }, - limit: 1 - ) - all_query = - from(s in subquery(all_query), - as: :site_with_team, - inner_join: site in ^Plausible.Site.regular(), - on: s.id == site.id, + from(u in subquery(all_query), + inner_join: s in ^Plausible.Site.regular(), + on: u.id == s.id, as: :site, left_join: up in Site.UserPreference, on: up.site_id == s.id and up.user_id == ^user.id, - cross_join: tz in subquery(majority_timezone_query), select: %{ - site + s | entry_type: selected_as( fragment( @@ -105,8 +75,7 @@ defmodule Plausible.Teams.Sites do ), :entry_type ), - pinned_at: selected_as(up.pinned_at, :pinned_at), - majority_timezone: fragment("COALESCE(?, 'Etc/UTC')", tz.majority_timezone) + pinned_at: selected_as(up.pinned_at, :pinned_at) } ) @@ -120,7 +89,6 @@ defmodule Plausible.Teams.Sites do pinned_at: selected_as(sites.pinned_at, :pinned_at), site_id: sites.id, domain: sites.domain, - majority_timezone: sites.majority_timezone, visitors: selected_as( scale_sample(fragment("uniqIf(?, ? != 0)", e.user_id, e.site_id)), @@ -128,13 +96,7 @@ defmodule Plausible.Teams.Sites do ) }, where: e.site_id == 0 or (e.timestamp >= ^utc_start and e.timestamp <= ^utc_end), - group_by: [ - sites.id, - sites.domain, - sites.entry_type, - sites.pinned_at, - sites.majority_timezone - ], + group_by: [sites.id, sites.domain, sites.entry_type, sites.pinned_at], order_by: [ asc: selected_as(:entry_type), desc: selected_as(:pinned_at), From 17bbaaddd193e397c1e6734b2ae35beb621775f2 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 10 Feb 2026 12:05:51 +0100 Subject: [PATCH 10/13] hum --- lib/plausible/teams/sites.ex | 64 +++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex index 4db8ccad1a43..3d9ec1b68166 100644 --- a/lib/plausible/teams/sites.ex +++ b/lib/plausible/teams/sites.ex @@ -10,11 +10,12 @@ defmodule Plausible.Teams.Sites do alias Plausible.Site alias Plausible.Teams - def list_with_clickhouse(user, team) do + def list_with_clickhouse(user, team, opts \\ []) do # TODO maybe filter by domain - # TODO export time ranges - utc_start = ~N[2026-01-01 00:00:00] - utc_end = ~N[2026-01-31 23:59:59] + date_range = Keyword.get(opts, :date_range, {:last_n_days, 30}) + now = Keyword.get(opts, :now, DateTime.utc_now()) + + {relative_start_date, relative_end_date} = calculate_relative_dates(date_range, now) all_query = if Teams.setup?(team) do @@ -24,7 +25,7 @@ defmodule Plausible.Teams.Sites do where: tm.user_id == ^user.id and tm.role != :guest, where: tm.team_id == ^team.id, where: not s.consolidated, - select: struct(s, [:id, :domain]) + select: struct(s, [:id, :domain, :timezone]) ) else my_team_query = @@ -35,7 +36,7 @@ defmodule Plausible.Teams.Sites do where: tm.user_id == ^user.id and tm.role != :guest, where: tm.is_autocreated, where: not t.setup_complete, - select: struct(s, [:id, :domain]) + select: struct(s, [:id, :domain, :timezone]) ) guest_membership_query = @@ -44,7 +45,7 @@ defmodule Plausible.Teams.Sites do inner_join: s in assoc(gm, :site), where: not s.consolidated, where: tm.user_id == ^user.id and tm.role == :guest, - select: struct(s, [:id, :domain]) + select: struct(s, [:id, :domain, :timezone]) ) from(s in my_team_query, @@ -89,14 +90,28 @@ defmodule Plausible.Teams.Sites do pinned_at: selected_as(sites.pinned_at, :pinned_at), site_id: sites.id, domain: sites.domain, + timezone: sites.timezone, visitors: selected_as( scale_sample(fragment("uniqIf(?, ? != 0)", e.user_id, e.site_id)), :visitors ) }, - where: e.site_id == 0 or (e.timestamp >= ^utc_start and e.timestamp <= ^utc_end), - group_by: [sites.id, sites.domain, sites.entry_type, sites.pinned_at], + where: + e.site_id == 0 or + fragment( + """ + ? >= toDateTime(concat(?, ' 00:00:00'), ?) + AND ? <= toDateTime(concat(?, ' 23:59:59'), ?) + """, + e.timestamp, + ^relative_start_date, + sites.timezone, + e.timestamp, + ^relative_end_date, + sites.timezone + ), + group_by: [sites.id, sites.domain, sites.entry_type, sites.pinned_at, sites.timezone], order_by: [ asc: selected_as(:entry_type), desc: selected_as(:pinned_at), @@ -114,6 +129,37 @@ defmodule Plausible.Teams.Sites do ) end + # Helper function to calculate date range for ClickHouse queries + # Returns {start_date, end_date} as Date structs or date strings + defp calculate_relative_dates({:last_n_days, n}, now) do + end_date = now |> DateTime.to_date() |> Date.add(-1) + start_date = end_date |> Date.add(-(n - 1)) + {Date.to_string(start_date), Date.to_string(end_date)} + end + + defp calculate_relative_dates(:day, now) do + date = DateTime.to_date(now) + {Date.to_string(date), Date.to_string(date)} + end + + defp calculate_relative_dates(:month, now) do + date = DateTime.to_date(now) + start_date = Date.beginning_of_month(date) + end_date = Date.end_of_month(date) + {Date.to_string(start_date), Date.to_string(end_date)} + end + + defp calculate_relative_dates(:year, now) do + date = DateTime.to_date(now) + start_date = %{date | month: 1, day: 1} + end_date = %{date | month: 12, day: 31} + {Date.to_string(start_date), Date.to_string(end_date)} + end + + defp calculate_relative_dates({:date_range, from, to}, _now) do + {Date.to_string(from), Date.to_string(to)} + end + @type list_opt() :: {:filter_by_domain, String.t()} | {:team, Teams.Team.t() | nil} @role_type Plausible.Teams.Invitation.__schema__(:type, :role) From b56d78250af73c0736ac71ceb0bbf948da2a0a72 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 10 Feb 2026 12:14:38 +0100 Subject: [PATCH 11/13] Revert "hum" This reverts commit 17bbaaddd193e397c1e6734b2ae35beb621775f2. --- lib/plausible/teams/sites.ex | 64 +++++------------------------------- 1 file changed, 9 insertions(+), 55 deletions(-) diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex index 3d9ec1b68166..4db8ccad1a43 100644 --- a/lib/plausible/teams/sites.ex +++ b/lib/plausible/teams/sites.ex @@ -10,12 +10,11 @@ defmodule Plausible.Teams.Sites do alias Plausible.Site alias Plausible.Teams - def list_with_clickhouse(user, team, opts \\ []) do + def list_with_clickhouse(user, team) do # TODO maybe filter by domain - date_range = Keyword.get(opts, :date_range, {:last_n_days, 30}) - now = Keyword.get(opts, :now, DateTime.utc_now()) - - {relative_start_date, relative_end_date} = calculate_relative_dates(date_range, now) + # TODO export time ranges + utc_start = ~N[2026-01-01 00:00:00] + utc_end = ~N[2026-01-31 23:59:59] all_query = if Teams.setup?(team) do @@ -25,7 +24,7 @@ defmodule Plausible.Teams.Sites do where: tm.user_id == ^user.id and tm.role != :guest, where: tm.team_id == ^team.id, where: not s.consolidated, - select: struct(s, [:id, :domain, :timezone]) + select: struct(s, [:id, :domain]) ) else my_team_query = @@ -36,7 +35,7 @@ defmodule Plausible.Teams.Sites do where: tm.user_id == ^user.id and tm.role != :guest, where: tm.is_autocreated, where: not t.setup_complete, - select: struct(s, [:id, :domain, :timezone]) + select: struct(s, [:id, :domain]) ) guest_membership_query = @@ -45,7 +44,7 @@ defmodule Plausible.Teams.Sites do inner_join: s in assoc(gm, :site), where: not s.consolidated, where: tm.user_id == ^user.id and tm.role == :guest, - select: struct(s, [:id, :domain, :timezone]) + select: struct(s, [:id, :domain]) ) from(s in my_team_query, @@ -90,28 +89,14 @@ defmodule Plausible.Teams.Sites do pinned_at: selected_as(sites.pinned_at, :pinned_at), site_id: sites.id, domain: sites.domain, - timezone: sites.timezone, visitors: selected_as( scale_sample(fragment("uniqIf(?, ? != 0)", e.user_id, e.site_id)), :visitors ) }, - where: - e.site_id == 0 or - fragment( - """ - ? >= toDateTime(concat(?, ' 00:00:00'), ?) - AND ? <= toDateTime(concat(?, ' 23:59:59'), ?) - """, - e.timestamp, - ^relative_start_date, - sites.timezone, - e.timestamp, - ^relative_end_date, - sites.timezone - ), - group_by: [sites.id, sites.domain, sites.entry_type, sites.pinned_at, sites.timezone], + where: e.site_id == 0 or (e.timestamp >= ^utc_start and e.timestamp <= ^utc_end), + group_by: [sites.id, sites.domain, sites.entry_type, sites.pinned_at], order_by: [ asc: selected_as(:entry_type), desc: selected_as(:pinned_at), @@ -129,37 +114,6 @@ defmodule Plausible.Teams.Sites do ) end - # Helper function to calculate date range for ClickHouse queries - # Returns {start_date, end_date} as Date structs or date strings - defp calculate_relative_dates({:last_n_days, n}, now) do - end_date = now |> DateTime.to_date() |> Date.add(-1) - start_date = end_date |> Date.add(-(n - 1)) - {Date.to_string(start_date), Date.to_string(end_date)} - end - - defp calculate_relative_dates(:day, now) do - date = DateTime.to_date(now) - {Date.to_string(date), Date.to_string(date)} - end - - defp calculate_relative_dates(:month, now) do - date = DateTime.to_date(now) - start_date = Date.beginning_of_month(date) - end_date = Date.end_of_month(date) - {Date.to_string(start_date), Date.to_string(end_date)} - end - - defp calculate_relative_dates(:year, now) do - date = DateTime.to_date(now) - start_date = %{date | month: 1, day: 1} - end_date = %{date | month: 12, day: 31} - {Date.to_string(start_date), Date.to_string(end_date)} - end - - defp calculate_relative_dates({:date_range, from, to}, _now) do - {Date.to_string(from), Date.to_string(to)} - end - @type list_opt() :: {:filter_by_domain, String.t()} | {:team, Teams.Team.t() | nil} @role_type Plausible.Teams.Invitation.__schema__(:type, :role) From 5a5a904e3fed9cb17451cdf62bc23611e85143df Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 10 Feb 2026 14:14:07 +0100 Subject: [PATCH 12/13] Reapply "hum" This reverts commit b56d78250af73c0736ac71ceb0bbf948da2a0a72. --- lib/plausible/teams/sites.ex | 64 +++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex index 4db8ccad1a43..3d9ec1b68166 100644 --- a/lib/plausible/teams/sites.ex +++ b/lib/plausible/teams/sites.ex @@ -10,11 +10,12 @@ defmodule Plausible.Teams.Sites do alias Plausible.Site alias Plausible.Teams - def list_with_clickhouse(user, team) do + def list_with_clickhouse(user, team, opts \\ []) do # TODO maybe filter by domain - # TODO export time ranges - utc_start = ~N[2026-01-01 00:00:00] - utc_end = ~N[2026-01-31 23:59:59] + date_range = Keyword.get(opts, :date_range, {:last_n_days, 30}) + now = Keyword.get(opts, :now, DateTime.utc_now()) + + {relative_start_date, relative_end_date} = calculate_relative_dates(date_range, now) all_query = if Teams.setup?(team) do @@ -24,7 +25,7 @@ defmodule Plausible.Teams.Sites do where: tm.user_id == ^user.id and tm.role != :guest, where: tm.team_id == ^team.id, where: not s.consolidated, - select: struct(s, [:id, :domain]) + select: struct(s, [:id, :domain, :timezone]) ) else my_team_query = @@ -35,7 +36,7 @@ defmodule Plausible.Teams.Sites do where: tm.user_id == ^user.id and tm.role != :guest, where: tm.is_autocreated, where: not t.setup_complete, - select: struct(s, [:id, :domain]) + select: struct(s, [:id, :domain, :timezone]) ) guest_membership_query = @@ -44,7 +45,7 @@ defmodule Plausible.Teams.Sites do inner_join: s in assoc(gm, :site), where: not s.consolidated, where: tm.user_id == ^user.id and tm.role == :guest, - select: struct(s, [:id, :domain]) + select: struct(s, [:id, :domain, :timezone]) ) from(s in my_team_query, @@ -89,14 +90,28 @@ defmodule Plausible.Teams.Sites do pinned_at: selected_as(sites.pinned_at, :pinned_at), site_id: sites.id, domain: sites.domain, + timezone: sites.timezone, visitors: selected_as( scale_sample(fragment("uniqIf(?, ? != 0)", e.user_id, e.site_id)), :visitors ) }, - where: e.site_id == 0 or (e.timestamp >= ^utc_start and e.timestamp <= ^utc_end), - group_by: [sites.id, sites.domain, sites.entry_type, sites.pinned_at], + where: + e.site_id == 0 or + fragment( + """ + ? >= toDateTime(concat(?, ' 00:00:00'), ?) + AND ? <= toDateTime(concat(?, ' 23:59:59'), ?) + """, + e.timestamp, + ^relative_start_date, + sites.timezone, + e.timestamp, + ^relative_end_date, + sites.timezone + ), + group_by: [sites.id, sites.domain, sites.entry_type, sites.pinned_at, sites.timezone], order_by: [ asc: selected_as(:entry_type), desc: selected_as(:pinned_at), @@ -114,6 +129,37 @@ defmodule Plausible.Teams.Sites do ) end + # Helper function to calculate date range for ClickHouse queries + # Returns {start_date, end_date} as Date structs or date strings + defp calculate_relative_dates({:last_n_days, n}, now) do + end_date = now |> DateTime.to_date() |> Date.add(-1) + start_date = end_date |> Date.add(-(n - 1)) + {Date.to_string(start_date), Date.to_string(end_date)} + end + + defp calculate_relative_dates(:day, now) do + date = DateTime.to_date(now) + {Date.to_string(date), Date.to_string(date)} + end + + defp calculate_relative_dates(:month, now) do + date = DateTime.to_date(now) + start_date = Date.beginning_of_month(date) + end_date = Date.end_of_month(date) + {Date.to_string(start_date), Date.to_string(end_date)} + end + + defp calculate_relative_dates(:year, now) do + date = DateTime.to_date(now) + start_date = %{date | month: 1, day: 1} + end_date = %{date | month: 12, day: 31} + {Date.to_string(start_date), Date.to_string(end_date)} + end + + defp calculate_relative_dates({:date_range, from, to}, _now) do + {Date.to_string(from), Date.to_string(to)} + end + @type list_opt() :: {:filter_by_domain, String.t()} | {:team, Teams.Team.t() | nil} @role_type Plausible.Teams.Invitation.__schema__(:type, :role) From aafc0558fbf0ba2d38beefa97e6dc42aaec22ac8 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 10 Feb 2026 15:01:28 +0100 Subject: [PATCH 13/13] This works lol --- lib/plausible/teams/sites.ex | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex index 3d9ec1b68166..1937a3f06c09 100644 --- a/lib/plausible/teams/sites.ex +++ b/lib/plausible/teams/sites.ex @@ -101,15 +101,15 @@ defmodule Plausible.Teams.Sites do e.site_id == 0 or fragment( """ - ? >= toDateTime(concat(?, ' 00:00:00'), ?) - AND ? <= toDateTime(concat(?, ' 23:59:59'), ?) + toString(?, ?) >= concat(?, ' 00:00:00') + AND toString(?, ?) <= concat(?, ' 23:59:59') """, e.timestamp, - ^relative_start_date, sites.timezone, + ^relative_start_date, e.timestamp, - ^relative_end_date, - sites.timezone + sites.timezone, + ^relative_end_date ), group_by: [sites.id, sites.domain, sites.entry_type, sites.pinned_at, sites.timezone], order_by: [