diff --git a/extra/lib/plausible/funnels.ex b/extra/lib/plausible/funnels.ex index 84f30295d61a..24ac019cbfea 100644 --- a/extra/lib/plausible/funnels.ex +++ b/extra/lib/plausible/funnels.ex @@ -18,9 +18,9 @@ defmodule Plausible.Funnels do | {:error, Ecto.Changeset.t() | :invalid_funnel_size | :upgrade_required} def create(site, name, steps) when is_list(steps) and length(steps) in Funnel.min_steps()..Funnel.max_steps() do - site = Plausible.Repo.preload(site, :owner) + site = Plausible.Repo.preload(site, :team) - case Plausible.Billing.Feature.Funnels.check_availability(site.owner) do + case Plausible.Billing.Feature.Funnels.check_availability(site.team) do {:error, _} = error -> error @@ -39,9 +39,9 @@ defmodule Plausible.Funnels do {:ok, Funnel.t()} | {:error, Ecto.Changeset.t() | :invalid_funnel_size | :upgrade_required} def update(funnel, name, steps) do - site = Plausible.Repo.preload(funnel, site: :owner).site + site = Plausible.Repo.preload(funnel, site: :team).site - case Plausible.Billing.Feature.Funnels.check_availability(site.owner) do + case Plausible.Billing.Feature.Funnels.check_availability(site.team) do {:error, _} = error -> error diff --git a/extra/lib/plausible/stats/goal/revenue.ex b/extra/lib/plausible/stats/goal/revenue.ex index 08da84d8aad6..6c2d96ede137 100644 --- a/extra/lib/plausible/stats/goal/revenue.ex +++ b/extra/lib/plausible/stats/goal/revenue.ex @@ -60,8 +60,8 @@ defmodule Plausible.Stats.Goal.Revenue do end def available?(site) do - site = Plausible.Repo.preload(site, :owner) - Plausible.Billing.Feature.RevenueGoals.check_availability(site.owner) == :ok + site = Plausible.Repo.preload(site, :team) + Plausible.Billing.Feature.RevenueGoals.check_availability(site.team) == :ok end # :NOTE: Legacy queries don't have metrics associated with them so work around the issue by assuming diff --git a/lib/mix/tasks/create_free_subscription.ex b/lib/mix/tasks/create_free_subscription.ex index 846e7ce0c8ad..be89017458c7 100644 --- a/lib/mix/tasks/create_free_subscription.ex +++ b/lib/mix/tasks/create_free_subscription.ex @@ -17,11 +17,9 @@ defmodule Mix.Tasks.CreateFreeSubscription do user = Repo.get(Plausible.Auth.User, user_id) {:ok, team} = Plausible.Teams.get_or_create(user) - Subscription.free(%{user_id: user_id, team_id: team.id}) + Subscription.free(%{team_id: team.id}) |> Repo.insert!() - Plausible.Teams.sync_team(user) - IO.puts("Created a free subscription for user: #{user.name}") end end diff --git a/lib/mix/tasks/pull_sandbox_subscription.ex b/lib/mix/tasks/pull_sandbox_subscription.ex index 7aad71254740..56ba45b65473 100644 --- a/lib/mix/tasks/pull_sandbox_subscription.ex +++ b/lib/mix/tasks/pull_sandbox_subscription.ex @@ -41,14 +41,12 @@ defmodule Mix.Tasks.PullSandboxSubscription do res = body["response"] |> List.first() user = Repo.get_by!(User, email: res["user_email"]) {:ok, team} = Plausible.Teams.get_or_create(user) - Plausible.Teams.sync_team(user) subscription = %{ paddle_subscription_id: res["subscription_id"] |> to_string(), paddle_plan_id: res["plan_id"] |> to_string(), cancel_url: res["cancel_url"], update_url: res["update_url"], - user_id: user.id, team_id: team.id, status: res["state"], last_bill_date: res["last_payment"]["date"], diff --git a/lib/plausible/auth/api_key_admin.ex b/lib/plausible/auth/api_key_admin.ex index aa9dc7be3f25..ff7f7094aab8 100644 --- a/lib/plausible/auth/api_key_admin.ex +++ b/lib/plausible/auth/api_key_admin.ex @@ -14,7 +14,7 @@ defmodule Plausible.Auth.ApiKeyAdmin do def create_changeset(schema, attrs) do scopes = [attrs["scope"]] - Plausible.Auth.ApiKey.changeset(schema, Map.merge(%{"scopes" => scopes}, attrs)) + Plausible.Auth.ApiKey.changeset(struct(schema, %{}), Map.merge(%{"scopes" => scopes}, attrs)) end def update_changeset(schema, attrs) do diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex index 69d767d59529..2ad4ebb295a7 100644 --- a/lib/plausible/auth/auth.ex +++ b/lib/plausible/auth/auth.ex @@ -35,9 +35,6 @@ defmodule Plausible.Auth do @type rate_limit_type() :: unquote(Enum.reduce(@rate_limit_types, &{:|, [], [&1, &2]})) - @spec rate_limits() :: map() - def rate_limits(), do: @rate_limits - @spec rate_limit(rate_limit_type(), Auth.User.t() | Plug.Conn.t()) :: :ok | {:error, {:rate_limit, rate_limit_type()}} def rate_limit(limit_type, key) when limit_type in @rate_limit_types do @@ -50,11 +47,6 @@ defmodule Plausible.Auth do end end - def create_user(name, email, pwd) do - Auth.User.new(%{name: name, email: email, password: pwd, password_confirmation: pwd}) - |> Repo.insert() - end - @spec find_user_by(Keyword.t()) :: Auth.User.t() | nil def find_user_by(opts) do Repo.get_by(Auth.User, opts) @@ -77,22 +69,6 @@ defmodule Plausible.Auth do end end - def has_active_sites?(user, roles \\ [:owner, :admin, :viewer]) do - sites = - Repo.all( - from u in Plausible.Auth.User, - where: u.id == ^user.id, - join: sm in Plausible.Site.Membership, - on: sm.user_id == u.id, - where: sm.role in ^roles, - join: s in Plausible.Site, - on: s.id == sm.site_id, - select: s - ) - - Enum.any?(sites, &Plausible.Sites.has_stats?/1) - end - def delete_user(user) do Repo.transaction(fn -> case Plausible.Teams.get_by_owner(user) do diff --git a/lib/plausible/auth/grace_period.ex b/lib/plausible/auth/grace_period.ex index 40c5df052fde..d709039cc0d4 100644 --- a/lib/plausible/auth/grace_period.ex +++ b/lib/plausible/auth/grace_period.ex @@ -13,7 +13,7 @@ defmodule Plausible.Auth.GracePeriod do """ use Ecto.Schema - alias Plausible.Auth.User + alias Plausible.Teams @type t() :: %__MODULE__{ end_date: Date.t() | nil, @@ -27,59 +27,59 @@ defmodule Plausible.Auth.GracePeriod do field :manual_lock, :boolean end - @spec start_changeset(User.t()) :: Ecto.Changeset.t() + @spec start_changeset(Teams.Team.t()) :: Ecto.Changeset.t() @doc """ Starts a account locking grace period of 7 days by changing the User struct. """ - def start_changeset(%User{} = user) do + def start_changeset(%Teams.Team{} = team) do grace_period = %__MODULE__{ end_date: Date.shift(Date.utc_today(), day: 7), is_over: false, manual_lock: false } - Ecto.Changeset.change(user, grace_period: grace_period) + Ecto.Changeset.change(team, grace_period: grace_period) end - @spec start_manual_lock_changeset(User.t()) :: Ecto.Changeset.t() + @spec start_manual_lock_changeset(Teams.Team.t()) :: Ecto.Changeset.t() @doc """ Starts a manual account locking grace period by changing the User struct. Manual locking means the grace period can only be removed manually from the CRM. """ - def start_manual_lock_changeset(%User{} = user) do + def start_manual_lock_changeset(%Teams.Team{} = team) do grace_period = %__MODULE__{ end_date: nil, is_over: false, manual_lock: true } - Ecto.Changeset.change(user, grace_period: grace_period) + Ecto.Changeset.change(team, grace_period: grace_period) end - @spec end_changeset(User.t()) :: Ecto.Changeset.t() + @spec end_changeset(Teams.Team.t()) :: Ecto.Changeset.t() @doc """ Ends an existing grace period by `setting users.grace_period.is_over` to true. This means the grace period has expired. """ - def end_changeset(%User{} = user) do - Ecto.Changeset.change(user, grace_period: %{is_over: true}) + def end_changeset(%Teams.Team{} = team) do + Ecto.Changeset.change(team, grace_period: %{is_over: true}) end - @spec remove_changeset(User.t()) :: Ecto.Changeset.t() + @spec remove_changeset(Teams.Team.t()) :: Ecto.Changeset.t() @doc """ Removes the grace period from the User completely. """ - def remove_changeset(%User{} = user) do - Ecto.Changeset.change(user, grace_period: nil) + def remove_changeset(%Teams.Team{} = team) do + Ecto.Changeset.change(team, grace_period: nil) end - @spec active?(User.t() | Plausible.Teams.Team.t()) :: boolean() + @spec active?(Teams.Team.t() | nil) :: boolean() @doc """ Returns whether the grace period is still active for a User. Defaults to false if the user is nil or there is no grace period. """ - def active?(user_or_team) + def active?(team) def active?(%{grace_period: %__MODULE__{end_date: %Date{} = end_date}}) do Date.diff(end_date, Date.utc_today()) >= 0 @@ -89,14 +89,14 @@ defmodule Plausible.Auth.GracePeriod do true end - def active?(_user), do: false + def active?(_team), do: false - @spec expired?(User.t() | Plausible.Teams.Team.t() | nil) :: boolean() + @spec expired?(Teams.Team.t() | nil) :: boolean() @doc """ Returns whether the grace period has already expired for a User. Defaults to false if the user is nil or there is no grace period. """ - def expired?(user_or_team) do - if user_or_team && user_or_team.grace_period, do: !active?(user_or_team), else: false + def expired?(team) do + if team && team.grace_period, do: !active?(team), else: false end end diff --git a/lib/plausible/auth/invitation.ex b/lib/plausible/auth/invitation.ex deleted file mode 100644 index d0a2eb70f989..000000000000 --- a/lib/plausible/auth/invitation.ex +++ /dev/null @@ -1,30 +0,0 @@ -defmodule Plausible.Auth.Invitation do - use Ecto.Schema - import Ecto.Changeset - - @type t() :: %__MODULE__{} - - @derive {Jason.Encoder, only: [:invitation_id, :role, :site]} - @required [:email, :role, :site_id, :inviter_id] - schema "invitations" do - field :invitation_id, :string - field :email, :string - field :role, Ecto.Enum, values: [:owner, :admin, :viewer] - - belongs_to :inviter, Plausible.Auth.User - belongs_to :site, Plausible.Site - - timestamps() - end - - def new(attrs \\ %{}) do - %__MODULE__{invitation_id: Nanoid.generate()} - |> cast(attrs, @required) - |> validate_required(@required) - |> unique_constraint([:email, :site_id], - name: :invitations_site_id_email_index, - error_key: :invitation, - message: "already sent" - ) - end -end diff --git a/lib/plausible/auth/user.ex b/lib/plausible/auth/user.ex index a0682ca91f46..e210798f6fe9 100644 --- a/lib/plausible/auth/user.ex +++ b/lib/plausible/auth/user.ex @@ -19,9 +19,6 @@ defmodule Plausible.Auth.User do @required [:email, :name, :password] - @trial_accept_traffic_until_offset_days 14 - @susbscription_accept_traffic_until_offset_days 30 - schema "users" do field :email, :string field :password_hash @@ -30,18 +27,17 @@ defmodule Plausible.Auth.User do field :password_confirmation, :string, virtual: true field :name, :string field :last_seen, :naive_datetime - field :trial_expiry_date, :date field :theme, Ecto.Enum, values: [:system, :light, :dark] field :email_verified, :boolean field :previous_email, :string - field :accept_traffic_until, :date # Field for purely informational purposes in CRM context field :notes, :string - # A field only used as a manual override - allow subscribing - # to any plan, even when exceeding its pageview limit - field :allow_next_upgrade_override, :boolean, default: false + # Fields used only by CRM for mapping to the ones in the owned team + field :trial_expiry_date, :date, virtual: true + field :allow_next_upgrade_override, :boolean, virtual: true + field :accept_traffic_until, :date, virtual: true # Fields for TOTP authentication. See `Plausible.Auth.TOTP`. field :totp_enabled, :boolean, default: false @@ -49,16 +45,12 @@ defmodule Plausible.Auth.User do field :totp_token, :string field :totp_last_used_at, :naive_datetime - embeds_one :grace_period, Plausible.Auth.GracePeriod, on_replace: :update - has_many :sessions, Plausible.Auth.UserSession - has_many :site_memberships, Plausible.Site.Membership has_many :team_memberships, Plausible.Teams.Membership - has_many :sites, through: [:site_memberships, :site] has_many :api_keys, Plausible.Auth.ApiKey has_one :google_auth, Plausible.Site.GoogleAuth - has_one :subscription, Plausible.Billing.Subscription - has_one :enterprise_plan, Plausible.Billing.EnterprisePlan + has_one :owner_membership, Plausible.Teams.Membership, where: [role: :owner] + has_one :my_team, through: [:owner_membership, :team] timestamps() end @@ -71,7 +63,6 @@ defmodule Plausible.Auth.User do |> validate_confirmation(:password, required: true) |> validate_password_strength() |> hash_password() - |> start_trial() |> set_email_verification_status() |> unique_constraint(:email) end @@ -127,30 +118,15 @@ defmodule Plausible.Auth.User do :name, :email_verified, :theme, + :notes, :trial_expiry_date, :allow_next_upgrade_override, - :accept_traffic_until, - :notes + :accept_traffic_until ]) |> validate_required([:email, :name, :email_verified]) - |> maybe_bump_accept_traffic_until() |> unique_constraint(:email) end - defp maybe_bump_accept_traffic_until(changeset) do - expiry_change = get_change(changeset, :trial_expiry_date) - - if expiry_change do - put_change( - changeset, - :accept_traffic_until, - Date.add(expiry_change, @trial_accept_traffic_until_offset_days) - ) - else - changeset - end - end - def set_password(user, password) do user |> cast(%{password: password}, [:password]) @@ -179,23 +155,6 @@ defmodule Plausible.Auth.User do def hash_password(changeset), do: changeset - def remove_trial_expiry(user) do - change(user, trial_expiry_date: nil) - end - - def start_trial(user) do - trial_expiry = trial_expiry() - - change(user, - trial_expiry_date: trial_expiry, - accept_traffic_until: Date.add(trial_expiry, @trial_accept_traffic_until_offset_days) - ) - end - - def end_trial(user) do - change(user, trial_expiry_date: Date.utc_today() |> Date.shift(day: -1)) - end - def password_strength(changeset) do case get_field(changeset, :password) do nil -> @@ -237,11 +196,6 @@ defmodule Plausible.Auth.User do Path.join(PlausibleWeb.Endpoint.url(), ["avatar/", hash]) end - def trial_accept_traffic_until_offset_days(), do: @trial_accept_traffic_until_offset_days - - def subscription_accept_traffic_until_offset_days(), - do: @susbscription_accept_traffic_until_offset_days - defp validate_email_changed(changeset) do if !get_change(changeset, :email) && !changeset.errors[:email] do add_error(changeset, :email, "can't be the same", validation: :different_email) @@ -297,14 +251,6 @@ defmodule Plausible.Auth.User do |> Enum.uniq() end - defp trial_expiry() do - on_ee do - Date.utc_today() |> Date.shift(day: 30) - else - Date.utc_today() |> Date.shift(year: 100) - end - end - defp set_email_verification_status(user) do on_ee do change(user, email_verified: false) diff --git a/lib/plausible/auth/user_admin.ex b/lib/plausible/auth/user_admin.ex index 79057ac17e21..4e24f343ebf2 100644 --- a/lib/plausible/auth/user_admin.ex +++ b/lib/plausible/auth/user_admin.ex @@ -6,7 +6,19 @@ defmodule Plausible.Auth.UserAdmin do def custom_index_query(_conn, _schema, query) do subscripton_q = from(s in Plausible.Billing.Subscription, order_by: [desc: s.inserted_at]) - from(r in query, preload: [subscription: ^subscripton_q]) + from(r in query, preload: [my_team: [subscription: ^subscripton_q]]) + end + + def custom_show_query(_conn, _schema, query) do + from(u in query, + left_join: t in assoc(u, :my_team), + select: %{ + u + | trial_expiry_date: t.trial_expiry_date, + allow_next_upgrade_override: t.allow_next_upgrade_override, + accept_traffic_until: t.accept_traffic_until + } + ) end def form_fields(_) do @@ -25,6 +37,37 @@ defmodule Plausible.Auth.UserAdmin do ] end + def update(_conn, changeset) do + my_team = Repo.preload(changeset.data, :my_team).my_team + + team_changed_params = + [:trial_expiry_date, :allow_next_upgrade_override, :accept_traffic_until] + |> Enum.map(&{&1, Ecto.Changeset.get_change(changeset, &1, :no_change)}) + |> Enum.reject(fn {_, val} -> val == :no_change end) + |> Map.new() + + with {:ok, user} <- Repo.update(changeset) do + cond do + my_team && map_size(team_changed_params) > 0 -> + my_team + |> Plausible.Teams.Team.crm_sync_changeset(team_changed_params) + |> Repo.update!() + + team_changed_params[:trial_expiry_date] -> + {:ok, team} = Plausible.Teams.get_or_create(user) + + team + |> Plausible.Teams.Team.crm_sync_changeset(team_changed_params) + |> Repo.update!() + + true -> + :ignore + end + + {:ok, user} + end + end + def delete(_conn, %{data: user}) do Plausible.Auth.delete_user(user) end @@ -62,25 +105,24 @@ defmodule Plausible.Auth.UserAdmin do ] end - def after_update(_conn, user) do - Plausible.Teams.sync_team(user) - - {:ok, user} - end - defp lock(user) do - if user.grace_period do - Plausible.Billing.SiteLocker.set_lock_status_for(user, true) - {:ok, Plausible.Users.end_grace_period(user)} + user = Repo.preload(user, :my_team) + + if user.my_team && user.my_team.grace_period do + Plausible.Billing.SiteLocker.set_lock_status_for(user.my_team, true) + Plausible.Teams.end_grace_period(user.my_team) + {:ok, user} else {:error, user, "No active grace period on this user"} end end defp unlock(user) do - if user.grace_period do - Plausible.Users.remove_grace_period(user) - Plausible.Billing.SiteLocker.set_lock_status_for(user, false) + user = Repo.preload(user, :my_team) + + if user.my_team && user.my_team.grace_period do + Plausible.Teams.remove_grace_period(user.my_team) + Plausible.Billing.SiteLocker.set_lock_status_for(user.my_team, false) {:ok, user} else {:error, user, "No active grace period on this user"} @@ -91,7 +133,9 @@ defmodule Plausible.Auth.UserAdmin do Plausible.Auth.TOTP.force_disable(user) end - defp grace_period_status(%{grace_period: grace_period}) do + defp grace_period_status(user) do + grace_period = user.my_team && user.my_team.grace_period + case grace_period do nil -> "--" @@ -112,25 +156,20 @@ defmodule Plausible.Auth.UserAdmin do end defp subscription_plan(user) do - if Subscription.Status.active?(user.subscription) && user.subscription.paddle_subscription_id do - quota = PlausibleWeb.AuthView.subscription_quota(user.subscription) - interval = PlausibleWeb.AuthView.subscription_interval(user.subscription) + subscription = user.my_team && user.my_team.subscription - {:safe, ~s(#{quota} \(#{interval}\))} + if Subscription.Status.active?(subscription) && subscription.paddle_subscription_id do + quota = PlausibleWeb.AuthView.subscription_quota(subscription) + interval = PlausibleWeb.AuthView.subscription_interval(subscription) + + {:safe, ~s(#{quota} \(#{interval}\))} else "--" end end defp subscription_status(user) do - team = - case Plausible.Teams.get_by_owner(user) do - {:ok, team} -> - Plausible.Teams.with_subscription(team) - - {:error, :no_team} -> - nil - end + team = user.my_team cond do team && team.subscription -> diff --git a/lib/plausible/billing/billing.ex b/lib/plausible/billing/billing.ex index a4fa1cf32f4c..459c50fe5527 100644 --- a/lib/plausible/billing/billing.ex +++ b/lib/plausible/billing/billing.ex @@ -4,6 +4,7 @@ defmodule Plausible.Billing do require Plausible.Billing.Subscription.Status alias Plausible.Billing.Subscription alias Plausible.Auth.User + alias Plausible.Teams def subscription_created(params) do Repo.transaction(fn -> @@ -101,7 +102,7 @@ defmodule Plausible.Billing do subscription = Subscription |> Repo.get_by(paddle_subscription_id: params["subscription_id"]) - |> Repo.preload(:user) + |> Repo.preload(team: :owner) if subscription do changeset = @@ -111,8 +112,7 @@ defmodule Plausible.Billing do updated = Repo.update!(changeset) - subscription - |> Map.fetch!(:user) + subscription.team.owner |> PlausibleWeb.Email.cancellation_email() |> Plausible.Mailer.send() @@ -137,9 +137,9 @@ defmodule Plausible.Billing do last_bill_date: api_subscription["last_payment"]["date"] }) |> Repo.update!() - |> Repo.preload(:user) + |> Repo.preload(:team) - Plausible.Users.update_accept_traffic_until(subscription.user) + Plausible.Teams.update_accept_traffic_until(subscription.team) subscription end @@ -209,38 +209,38 @@ defmodule Plausible.Billing do end defp after_subscription_update(subscription) do - user = - User - |> Repo.get!(subscription.user_id) - |> Plausible.Users.with_subscription() + team = + Teams.Team + |> Repo.get!(subscription.team_id) + |> Teams.with_subscription() + |> Repo.preload(:owner) - if subscription.id != user.subscription.id do + if subscription.id != team.subscription.id do Sentry.capture_message("Susbscription ID mismatch", - extra: %{subscription: inspect(subscription), user_id: user.id} + extra: %{subscription: inspect(subscription), team_id: team.id} ) end - user - |> Plausible.Users.update_accept_traffic_until() - |> Plausible.Users.remove_grace_period() - |> Plausible.Users.maybe_reset_next_upgrade_override() + team + |> Plausible.Teams.update_accept_traffic_until() + |> Plausible.Teams.remove_grace_period() + |> Plausible.Teams.maybe_reset_next_upgrade_override() |> tap(&Plausible.Billing.SiteLocker.update_sites_for/1) |> maybe_adjust_api_key_limits() end - defp maybe_adjust_api_key_limits(user) do + defp maybe_adjust_api_key_limits(team) do plan = Repo.get_by(Plausible.Billing.EnterprisePlan, - user_id: user.id, - paddle_plan_id: user.subscription.paddle_plan_id + team_id: team.id, + paddle_plan_id: team.subscription.paddle_plan_id ) if plan do - user_id = user.id - api_keys = from(key in Plausible.Auth.ApiKey, where: key.user_id == ^user_id) + api_keys = from(key in Plausible.Auth.ApiKey, where: key.user_id == ^team.owner.id) Repo.update_all(api_keys, set: [hourly_request_limit: plan.hourly_api_request_limit]) end - user + team end end diff --git a/lib/plausible/billing/enterprise_plan.ex b/lib/plausible/billing/enterprise_plan.ex index 9589c1e0d82d..afc166cc8805 100644 --- a/lib/plausible/billing/enterprise_plan.ex +++ b/lib/plausible/billing/enterprise_plan.ex @@ -5,7 +5,7 @@ defmodule Plausible.Billing.EnterprisePlan do @type t() :: %__MODULE__{} @required_fields [ - :user_id, + :team_id, :paddle_plan_id, :billing_interval, :monthly_pageview_limit, @@ -15,10 +15,6 @@ defmodule Plausible.Billing.EnterprisePlan do :team_member_limit ] - @optional_fields [ - :team_id - ] - schema "enterprise_plans" do field :paddle_plan_id, :string field :billing_interval, Ecto.Enum, values: [:monthly, :yearly] @@ -28,7 +24,9 @@ defmodule Plausible.Billing.EnterprisePlan do field :features, Plausible.Billing.Ecto.FeatureList, default: [] field :hourly_api_request_limit, :integer - belongs_to :user, Plausible.Auth.User + # Field used only by CRM for mapping to the ones in the owned team + field :user_id, :integer, virtual: true + belongs_to :team, Plausible.Teams.Team timestamps() @@ -36,7 +34,7 @@ defmodule Plausible.Billing.EnterprisePlan do def changeset(model, attrs \\ %{}) do model - |> cast(attrs, @required_fields ++ @optional_fields) + |> cast(attrs, @required_fields) |> validate_required(@required_fields) |> unique_constraint(:user_id) end diff --git a/lib/plausible/billing/enterprise_plan_admin.ex b/lib/plausible/billing/enterprise_plan_admin.ex index 027fdbe706cc..5ca92b0113a1 100644 --- a/lib/plausible/billing/enterprise_plan_admin.ex +++ b/lib/plausible/billing/enterprise_plan_admin.ex @@ -13,7 +13,7 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do def search_fields(_schema) do [ :paddle_plan_id, - user: [:name, :email] + team: [owner: [:name, :email]] ] end @@ -31,7 +31,15 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do end def custom_index_query(_conn, _schema, query) do - from(r in query, preload: :user) + from(r in query, preload: [team: :owner]) + end + + def custom_show_query(_conn, _schema, query) do + from(ep in query, + inner_join: t in assoc(ep, :team), + inner_join: o in assoc(t, :owner), + select: %{ep | user_id: o.id} + ) end def index(_) do @@ -47,7 +55,7 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do ] end - defp get_user_email(plan), do: plan.user.email + defp get_user_email(plan), do: plan.team.owner.email def create_changeset(schema, attrs) do attrs = sanitize_attrs(attrs) @@ -61,7 +69,7 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do attrs = Map.put(attrs, "team_id", team_id) - Plausible.Billing.EnterprisePlan.changeset(schema, attrs) + Plausible.Billing.EnterprisePlan.changeset(struct(schema, %{}), attrs) end def update_changeset(enterprise_plan, attrs) do diff --git a/lib/plausible/billing/site_locker.ex b/lib/plausible/billing/site_locker.ex index f3f82e5c8e21..666bb0e04849 100644 --- a/lib/plausible/billing/site_locker.ex +++ b/lib/plausible/billing/site_locker.ex @@ -1,6 +1,8 @@ defmodule Plausible.Billing.SiteLocker do use Plausible.Repo + alias Plausible.Teams + @type update_opt() :: {:send_email?, boolean()} @type lock_reason() :: @@ -9,28 +11,23 @@ defmodule Plausible.Billing.SiteLocker do | :no_trial | :no_active_subscription - @spec update_sites_for(Plausible.Auth.User.t(), [update_opt()]) :: + @spec update_sites_for(Teams.Team.t(), [update_opt()]) :: {:locked, lock_reason()} | :unlocked - def update_sites_for(user, opts \\ []) do + def update_sites_for(team, opts \\ []) do send_email? = Keyword.get(opts, :send_email?, true) - user = Plausible.Users.with_subscription(user) - - team = - case Plausible.Teams.get_by_owner(user) do - {:ok, team} -> team - _ -> nil - end + team = Teams.with_subscription(team) case Plausible.Teams.Billing.check_needs_to_upgrade(team) do {:needs_to_upgrade, :grace_period_ended} -> - set_lock_status_for(user, true) + set_lock_status_for(team, true) - if user.grace_period.is_over != true do - Plausible.Users.end_grace_period(user) + if team.grace_period.is_over != true do + Plausible.Teams.end_grace_period(team) if send_email? do - send_grace_period_end_email(user) + team = Repo.preload(team, :owner) + send_grace_period_end_email(team) end {:locked, :grace_period_ended_now} @@ -39,25 +36,18 @@ defmodule Plausible.Billing.SiteLocker do end {:needs_to_upgrade, reason} -> - set_lock_status_for(user, true) + set_lock_status_for(team, true) {:locked, reason} :no_upgrade_needed -> - set_lock_status_for(user, false) + set_lock_status_for(team, false) :unlocked end end - @spec set_lock_status_for(Plausible.Auth.User.t(), boolean()) :: {:ok, non_neg_integer()} - def set_lock_status_for(user, status) do - site_ids = - Repo.all( - from(s in Plausible.Site.Membership, - where: s.user_id == ^user.id, - where: s.role == :owner, - select: s.site_id - ) - ) + @spec set_lock_status_for(Teams.Team.t(), boolean()) :: {:ok, non_neg_integer()} + def set_lock_status_for(team, status) do + site_ids = Teams.owned_sites_ids(team) site_q = from( @@ -70,18 +60,11 @@ defmodule Plausible.Billing.SiteLocker do {:ok, num_updated} end - @spec send_grace_period_end_email(Plausible.Auth.User.t()) :: Plausible.Mailer.result() - def send_grace_period_end_email(user) do - team = - case Plausible.Teams.get_by_owner(user) do - {:ok, team} -> team - _ -> nil - end - - usage = Plausible.Teams.Billing.monthly_pageview_usage(team) + defp send_grace_period_end_email(team) do + usage = Teams.Billing.monthly_pageview_usage(team) suggested_plan = Plausible.Billing.Plans.suggest(team, usage.last_cycle.total) - user + team.owner |> PlausibleWeb.Email.dashboard_locked(usage, suggested_plan) |> Plausible.Mailer.send() end diff --git a/lib/plausible/billing/subscription.ex b/lib/plausible/billing/subscription.ex index c4a645dd5077..def530a09a84 100644 --- a/lib/plausible/billing/subscription.ex +++ b/lib/plausible/billing/subscription.ex @@ -16,11 +16,10 @@ defmodule Plausible.Billing.Subscription do :status, :next_bill_amount, :next_bill_date, - # :team_id, :currency_code ] - @optional_fields [:last_bill_date, :team_id, :user_id] + @optional_fields [:last_bill_date, :team_id] schema "subscriptions" do field :paddle_subscription_id, :string @@ -33,7 +32,6 @@ defmodule Plausible.Billing.Subscription do field :last_bill_date, :date field :currency_code, :string - belongs_to :user, Plausible.Auth.User belongs_to :team, Plausible.Teams.Team timestamps() @@ -54,7 +52,7 @@ defmodule Plausible.Billing.Subscription do currency_code: "EUR" } |> cast(attrs, @required_fields ++ @optional_fields) - |> validate_required([:user_id]) + |> validate_required([:team_id]) |> unique_constraint(:paddle_subscription_id) end end diff --git a/lib/plausible/data_migration/backfill_teams.ex b/lib/plausible/data_migration/backfill_teams.ex deleted file mode 100644 index eb1aee2c697e..000000000000 --- a/lib/plausible/data_migration/backfill_teams.ex +++ /dev/null @@ -1,848 +0,0 @@ -defmodule Plausible.DataMigration.BackfillTeams do - @moduledoc """ - Backfill and sync all teams related entities. - """ - - import Ecto.Query - - alias Plausible.Auth - alias Plausible.Teams - - @repo Plausible.DataMigration.PostgresRepo - @max_concurrency 12 - - defmacrop is_distinct(f1, f2) do - quote do - fragment("? IS DISTINCT FROM ?", unquote(f1), unquote(f2)) - end - end - - def run(opts \\ []) do - dry_run? = Keyword.get(opts, :dry_run?, true) - - # Teams backfill - db_url = - System.get_env( - "TEAMS_MIGRATION_DB_URL", - Application.get_env(:plausible, Plausible.Repo)[:url] - ) - - @repo.start(db_url, pool_size: 2 * @max_concurrency) - - backfill(dry_run?) - end - - defp backfill(dry_run?) do - # Orphaned teams - - orphaned_teams = - from( - t in Plausible.Teams.Team, - left_join: tm in assoc(t, :team_memberships), - where: is_nil(tm.id), - left_join: sub in assoc(t, :subscription), - where: is_nil(sub.id), - left_join: s in assoc(t, :sites), - where: is_nil(s.id) - ) - |> @repo.all(timeout: :infinity) - - log("Found #{length(orphaned_teams)} orphaned teams...") - - if not dry_run? do - delete_orphaned_teams(orphaned_teams) - - log("Deleted orphaned teams") - end - - # Sites without teams - - sites_without_teams = - from( - s in Plausible.Site, - inner_join: m in assoc(s, :memberships), - inner_join: o in assoc(m, :user), - where: m.role == :owner, - where: is_nil(s.team_id), - preload: [memberships: {m, user: o}] - ) - |> @repo.all(timeout: :infinity) - - log("Found #{length(sites_without_teams)} sites without teams...") - - if not dry_run? do - teams_count = backfill_teams(sites_without_teams) - - log("Backfilled #{teams_count} teams.") - end - - owner_site_memberships_query = - from( - tm in Plausible.Site.Membership, - where: tm.user_id == parent_as(:user).id, - where: tm.role == :owner, - select: 1 - ) - - # Users with subscriptions without sites - - users_with_subscriptions_without_sites = - from( - s in Plausible.Billing.Subscription, - inner_join: u in assoc(s, :user), - as: :user, - where: not exists(owner_site_memberships_query), - where: is_nil(s.team_id), - select: u, - distinct: true - ) - |> @repo.all(timeout: :infinity) - - log( - "Found #{length(users_with_subscriptions_without_sites)} users with subscriptions without sites..." - ) - - if not dry_run? do - teams_count = backfill_teams_for_users(users_with_subscriptions_without_sites) - - log("Backfilled #{teams_count} teams from users with subscriptions without sites.") - end - - # Users on trial without team - - users_on_trial_without_team = - from( - u in Plausible.Auth.User, - as: :user, - where: not is_nil(u.trial_expiry_date), - where: - not exists( - from tm in Teams.Membership, - where: tm.role == :owner, - where: tm.user_id == parent_as(:user).id - ) - ) - |> @repo.all(timeout: :infinity) - - log("Found #{length(users_on_trial_without_team)} users on trial without team...") - - if not dry_run? do - Enum.each(users_on_trial_without_team, fn user -> - {:ok, _} = Teams.get_or_create(user) - end) - - log("Created teams for all users on trial without a team.") - end - - # Stale teams sync - - stale_teams = - from( - t in Teams.Team, - inner_join: tm in assoc(t, :team_memberships), - inner_join: o in assoc(tm, :user), - where: tm.role == :owner, - where: - is_distinct(o.trial_expiry_date, t.trial_expiry_date) or - is_distinct(o.accept_traffic_until, t.accept_traffic_until) or - is_distinct(o.allow_next_upgrade_override, t.allow_next_upgrade_override) or - (is_distinct(o.grace_period, t.grace_period) and - (is_distinct(o.grace_period["id"], t.grace_period["id"]) or - (is_nil(o.grace_period["is_over"]) and t.grace_period["is_over"] == true) or - (o.grace_period["is_over"] == true and t.grace_period["is_over"] == false) or - (o.grace_period["is_over"] == false and t.grace_period["is_over"] == true) or - is_distinct(o.grace_period["end_date"], t.grace_period["end_date"]) or - (is_nil(o.grace_period["manual_lock"]) and t.grace_period["manual_lock"] == true) or - (o.grace_period["manual_lock"] == true and - t.grace_period["manual_lock"] == false) or - (o.grace_period["manual_lock"] == false and - t.grace_period["manual_lock"] == true))), - preload: [team_memberships: {tm, user: o}] - ) - |> @repo.all(timeout: :infinity) - - log("Found #{length(stale_teams)} teams which have fields out of sync...") - - if not dry_run? do - sync_teams(stale_teams) - - log("Brought out of sync teams up to date.") - end - - # Subsciprtions backfill - - subscriptions_without_teams = - from( - s in Plausible.Billing.Subscription, - inner_join: u in assoc(s, :user), - inner_join: tm in assoc(u, :team_memberships), - inner_join: t in assoc(tm, :team), - where: tm.role == :owner, - where: is_nil(s.team_id), - preload: [user: {u, team_memberships: {tm, team: t}}] - ) - |> @repo.all(timeout: :infinity) - - log("Found #{length(subscriptions_without_teams)} subscriptions without team...") - - if not dry_run? do - backfill_subscriptions(subscriptions_without_teams) - - log("All subscriptions are linked to a team now.") - end - - # Enterprise plans backfill - - enterprise_plans_without_teams = - from( - ep in Plausible.Billing.EnterprisePlan, - inner_join: u in assoc(ep, :user), - inner_join: tm in assoc(u, :team_memberships), - inner_join: t in assoc(tm, :team), - where: tm.role == :owner, - where: is_nil(ep.team_id), - preload: [user: {u, team_memberships: {tm, team: t}}] - ) - |> @repo.all(timeout: :infinity) - - log("Found #{length(enterprise_plans_without_teams)} enterprise plans without team...") - - if not dry_run? do - backfill_enterprise_plans(enterprise_plans_without_teams) - - log("All enterprise plans are linked to a team now.") - end - - # Guest memberships with mismatched team site - - mismatched_guest_memberships_to_remove = - from( - gm in Teams.GuestMembership, - inner_join: tm in assoc(gm, :team_membership), - inner_join: s in assoc(gm, :site), - where: tm.team_id != s.team_id - ) - |> @repo.all() - - log( - "Found #{length(mismatched_guest_memberships_to_remove)} guest memberships with mismatched team to remove..." - ) - - if not dry_run? do - team_ids_to_prune = remove_guest_memberships(mismatched_guest_memberships_to_remove) - - log("Pruning guest team memberships for #{length(team_ids_to_prune)} teams...") - - from(t in Teams.Team, where: t.id in ^team_ids_to_prune) - |> @repo.all(timeout: :infinity) - |> Enum.each(fn team -> - Plausible.Teams.Memberships.prune_guests(team) - end) - - log("Guest memberships with mismatched team cleared.") - end - - # Guest Memberships cleanup - - site_memberships_query = - from( - sm in Plausible.Site.Membership, - where: sm.site_id == parent_as(:guest_membership).site_id, - where: sm.user_id == parent_as(:team_membership).user_id, - where: sm.role != :owner, - select: 1 - ) - - guest_memberships_to_remove = - from( - gm in Teams.GuestMembership, - as: :guest_membership, - inner_join: tm in assoc(gm, :team_membership), - as: :team_membership, - where: not exists(site_memberships_query) - ) - |> @repo.all(timeout: :infinity) - - log("Found #{length(guest_memberships_to_remove)} guest memberships to remove...") - - if not dry_run? do - team_ids_to_prune = remove_guest_memberships(guest_memberships_to_remove) - - log("Pruning guest team memberships for #{length(team_ids_to_prune)} teams...") - - from(t in Teams.Team, where: t.id in ^team_ids_to_prune) - |> @repo.all(timeout: :infinity) - |> Enum.each(fn team -> - Plausible.Teams.Memberships.prune_guests(team) - end) - - log("Guest memberships cleared.") - end - - # Guest Memberships backfill - - guest_memberships_query = - from( - gm in Teams.GuestMembership, - inner_join: tm in assoc(gm, :team_membership), - where: gm.site_id == parent_as(:site_membership).site_id, - where: tm.user_id == parent_as(:site_membership).user_id, - select: 1 - ) - - site_memberships_to_backfill = - from( - sm in Plausible.Site.Membership, - as: :site_membership, - inner_join: s in assoc(sm, :site), - inner_join: t in assoc(s, :team), - inner_join: u in assoc(sm, :user), - where: sm.role != :owner, - where: not exists(guest_memberships_query), - preload: [user: u, site: {s, team: t}] - ) - |> @repo.all(timeout: :infinity) - - log( - "Found #{length(site_memberships_to_backfill)} site memberships without guest membership..." - ) - - if not dry_run? do - backfill_guest_memberships(site_memberships_to_backfill) - - log("Backfilled missing guest memberships.") - end - - # Stale guest memberships sync - - stale_guest_memberships = - from( - sm in Plausible.Site.Membership, - inner_join: tm in Teams.Membership, - on: tm.user_id == sm.user_id, - inner_join: gm in assoc(tm, :guest_memberships), - on: gm.site_id == sm.site_id, - where: tm.role == :guest, - where: - (gm.role == :viewer and sm.role == :admin) or - (gm.role == :editor and sm.role == :viewer), - select: {gm, sm.role} - ) - |> @repo.all(timeout: :infinity) - - log("Found #{length(stale_guest_memberships)} guest memberships with role out of sync...") - - if not dry_run? do - sync_guest_memberships(stale_guest_memberships) - - log("All guest memberships are up to date now.") - end - - # Guest invitations cleanup - - site_invitations_query = - from( - i in Auth.Invitation, - where: i.site_id == parent_as(:guest_invitation).site_id, - where: i.email == parent_as(:team_invitation).email, - where: - (i.role == :viewer and parent_as(:guest_invitation).role == :viewer) or - (i.role == :admin and parent_as(:guest_invitation).role == :editor) - ) - - guest_invitations_to_remove = - from( - gi in Teams.GuestInvitation, - as: :guest_invitation, - inner_join: ti in assoc(gi, :team_invitation), - as: :team_invitation, - where: not exists(site_invitations_query) - ) - |> @repo.all(timeout: :infinity) - - log("Found #{length(guest_invitations_to_remove)} guest invitations to remove...") - - if not dry_run? do - team_ids_to_prune = remove_guest_invitations(guest_invitations_to_remove) - - log("Pruning guest team invitations for #{length(team_ids_to_prune)} teams...") - - from(t in Teams.Team, where: t.id in ^team_ids_to_prune) - |> @repo.all(timeout: :infinity) - |> Enum.each(fn team -> - Plausible.Teams.Invitations.prune_guest_invitations(team) - end) - - log("Guest invitations cleared.") - end - - # Guest invitations backfill - - guest_invitations_query = - from( - gi in Teams.GuestInvitation, - inner_join: ti in assoc(gi, :team_invitation), - where: gi.site_id == parent_as(:site_invitation).site_id, - where: ti.email == parent_as(:site_invitation).email, - select: 1 - ) - - site_invitations_to_backfill = - from( - si in Auth.Invitation, - as: :site_invitation, - inner_join: s in assoc(si, :site), - inner_join: t in assoc(s, :team), - inner_join: inv in assoc(si, :inviter), - where: si.role != :owner, - where: not exists(guest_invitations_query), - preload: [site: {s, team: t}, inviter: inv] - ) - |> @repo.all(timeout: :infinity) - - log( - "Found #{length(site_invitations_to_backfill)} site invitations without guest invitation..." - ) - - if not dry_run? do - backfill_guest_invitations(site_invitations_to_backfill) - - log("Backfilled missing guest invitations.") - end - - # Stale guest invitations sync - - stale_guest_invitations = - from( - si in Auth.Invitation, - inner_join: ti in Teams.Invitation, - on: ti.email == si.email, - inner_join: gi in assoc(ti, :guest_invitations), - on: gi.site_id == si.site_id, - where: ti.role == :guest, - where: - (gi.role == :viewer and si.role == :admin) or - (gi.role == :editor and si.role == :viewer) or - is_distinct(gi.invitation_id, si.invitation_id), - select: {gi, si} - ) - |> @repo.all(timeout: :infinity) - - log("Found #{length(stale_guest_invitations)} guest invitations with role out of sync...") - - if not dry_run? do - sync_guest_invitations(stale_guest_invitations) - - log("All guest invitations are up to date now.") - end - - # Site transfers cleanup - - site_invitations_query = - from( - i in Auth.Invitation, - where: i.site_id == parent_as(:site_transfer).site_id, - where: i.email == parent_as(:site_transfer).email, - where: i.role == :owner - ) - - site_transfers_to_remove = - from( - st in Teams.SiteTransfer, - as: :site_transfer, - where: not exists(site_invitations_query) - ) - |> @repo.all(timeout: :infinity) - - log("Found #{length(site_transfers_to_remove)} site transfers to remove...") - - if not dry_run? do - remove_site_transfers(site_transfers_to_remove) - - log("Site transfers cleared.") - end - - # Site transfers backfill - - site_transfers_query = - from( - st in Teams.SiteTransfer, - where: st.site_id == parent_as(:site_invitation).site_id, - where: st.email == parent_as(:site_invitation).email, - select: 1 - ) - - site_invitations_to_backfill = - from( - si in Auth.Invitation, - as: :site_invitation, - inner_join: s in assoc(si, :site), - inner_join: inv in assoc(si, :inviter), - where: si.role == :owner, - where: not exists(site_transfers_query), - preload: [inviter: inv, site: s] - ) - |> @repo.all(timeout: :infinity) - - log( - "Found #{length(site_invitations_to_backfill)} ownership transfers without site transfer..." - ) - - if not dry_run? do - backfill_site_transfers(site_invitations_to_backfill) - - log("Backfilled missing site transfers.") - - log("All data are up to date now!") - end - end - - def delete_orphaned_teams(teams) do - Enum.each(teams, &@repo.delete!(&1)) - end - - defp backfill_teams(sites) do - sites - |> Enum.map(fn %{id: site_id, memberships: [%{user: owner, role: :owner}]} -> - {owner, site_id} - end) - |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) - |> tap(fn - grouped when grouped != %{} -> - log("Teams about to be created: #{map_size(grouped)}") - - log( - "Max sites: #{Enum.max_by(grouped, fn {_, sites} -> length(sites) end) |> elem(1) |> length()}" - ) - - _ -> - :pass - end) - |> Enum.with_index() - |> Task.async_stream( - fn {{owner, site_ids}, idx} -> - @repo.transaction( - fn -> - {:ok, team} = Teams.get_or_create(owner) - - team = - team - |> Ecto.Changeset.change() - |> Ecto.Changeset.put_change(:trial_expiry_date, owner.trial_expiry_date) - |> Ecto.Changeset.put_change(:accept_traffic_until, owner.accept_traffic_until) - |> Ecto.Changeset.put_change( - :allow_next_upgrade_override, - owner.allow_next_upgrade_override - ) - |> Ecto.Changeset.put_embed(:grace_period, owner.grace_period) - |> Ecto.Changeset.force_change(:updated_at, owner.updated_at) - |> @repo.update!() - - @repo.update_all(from(s in Plausible.Site, where: s.id in ^site_ids), - set: [team_id: team.id] - ) - end, - timeout: :infinity, - max_concurrency: @max_concurrency - ) - - if rem(idx, 10) == 0 do - IO.write(".") - end - end, - timeout: :infinity - ) - |> Enum.to_list() - |> length() - end - - defp backfill_teams_for_users(users) do - users - |> Enum.with_index() - |> Task.async_stream( - fn {owner, idx} -> - @repo.transaction( - fn -> - {:ok, team} = Teams.get_or_create(owner) - - team - |> Ecto.Changeset.change() - |> Ecto.Changeset.put_change(:trial_expiry_date, owner.trial_expiry_date) - |> Ecto.Changeset.put_change(:accept_traffic_until, owner.accept_traffic_until) - |> Ecto.Changeset.put_change( - :allow_next_upgrade_override, - owner.allow_next_upgrade_override - ) - |> Ecto.Changeset.put_embed(:grace_period, owner.grace_period) - |> Ecto.Changeset.force_change(:updated_at, owner.updated_at) - |> @repo.update!() - end, - timeout: :infinity, - max_concurrency: @max_concurrency - ) - - if rem(idx, 10) == 0 do - IO.write(".") - end - end, - timeout: :infinity - ) - |> Enum.to_list() - |> length() - end - - defp sync_teams(stale_teams) do - Enum.each(stale_teams, fn team -> - [%{user: owner}] = team.team_memberships - - team - |> Ecto.Changeset.change() - |> Ecto.Changeset.put_change(:trial_expiry_date, owner.trial_expiry_date) - |> Ecto.Changeset.put_change(:accept_traffic_until, owner.accept_traffic_until) - |> Ecto.Changeset.put_change( - :allow_next_upgrade_override, - owner.allow_next_upgrade_override - ) - |> Ecto.Changeset.put_embed(:grace_period, embed_params(owner.grace_period)) - |> @repo.update!() - end) - end - - defp embed_params(nil), do: nil - - defp embed_params(grace_period) do - Map.from_struct(grace_period) - end - - defp backfill_subscriptions(subscriptions) do - subscriptions - |> Enum.with_index() - |> Task.async_stream( - fn {subscription, idx} -> - [%{team: team, role: :owner}] = subscription.user.team_memberships - - subscription - |> Ecto.Changeset.change(team_id: team.id) - |> Ecto.Changeset.put_change(:updated_at, subscription.updated_at) - |> @repo.update!() - - if rem(idx, 1000) == 0 do - IO.write(".") - end - end, - timeout: :infinity, - max_concurrency: @max_concurrency - ) - |> Stream.run() - end - - defp backfill_enterprise_plans(enterprise_plans) do - enterprise_plans - |> Enum.with_index() - |> Task.async_stream( - fn {enterprise_plan, idx} -> - [%{team: team, role: :owner}] = enterprise_plan.user.team_memberships - - enterprise_plan - |> Ecto.Changeset.change(team_id: team.id) - |> Ecto.Changeset.put_change(:updated_at, enterprise_plan.updated_at) - |> @repo.update!() - - if rem(idx, 1000) == 0 do - IO.write(".") - end - end, - timeout: :infinity, - max_concurrency: @max_concurrency - ) - |> Stream.run() - end - - defp remove_guest_memberships(guest_memberships) do - ids = Enum.map(guest_memberships, & &1.id) - - {_, team_ids} = - @repo.delete_all( - from( - gm in Teams.GuestMembership, - inner_join: tm in assoc(gm, :team_membership), - where: gm.id in ^ids, - select: tm.team_id - ) - ) - - Enum.uniq(team_ids) - end - - defp backfill_guest_memberships(site_memberships) do - site_memberships - |> Enum.group_by(&{&1.site.team, &1.user}, & &1) - |> tap(fn - grouped when grouped != %{} -> - log("Team memberships to be created: #{map_size(grouped)}") - - log( - "Max guest memberships: #{Enum.max_by(grouped, fn {_, gms} -> length(gms) end) |> elem(1) |> length()}" - ) - - _ -> - :pass - end) - |> Enum.with_index() - |> Task.async_stream( - fn {{{team, user}, site_memberships}, idx} -> - first_site_membership = - Enum.min_by(site_memberships, & &1.inserted_at) - - team_membership = - team - |> Teams.Membership.changeset(user, :guest) - |> Ecto.Changeset.put_change(:inserted_at, first_site_membership.inserted_at) - |> Ecto.Changeset.put_change(:updated_at, first_site_membership.updated_at) - |> @repo.insert!( - on_conflict: [set: [updated_at: first_site_membership.updated_at]], - conflict_target: [:team_id, :user_id] - ) - - Enum.each(site_memberships, fn site_membership -> - team_membership - |> Teams.GuestMembership.changeset( - site_membership.site, - translate_role(site_membership.role) - ) - |> Ecto.Changeset.put_change(:inserted_at, site_membership.inserted_at) - |> Ecto.Changeset.put_change(:updated_at, site_membership.updated_at) - |> @repo.insert!() - end) - - if rem(idx, 1000) == 0 do - IO.write(".") - end - end, - timeout: :infinity, - max_concurrency: @max_concurrency - ) - |> Stream.run() - end - - defp sync_guest_memberships(guest_memberships_and_roles) do - guest_memberships_and_roles - |> Enum.with_index() - |> Enum.each(fn {{guest_membership, role}, idx} -> - guest_membership - |> Ecto.Changeset.change(role: translate_role(role)) - |> Ecto.Changeset.put_change(:updated_at, guest_membership.updated_at) - |> @repo.update!() - - if rem(idx, 1000) == 0 do - IO.write(".") - end - end) - end - - defp remove_guest_invitations(guest_invitations) do - ids = Enum.map(guest_invitations, & &1.id) - - {_, team_ids} = - @repo.delete_all( - from( - gi in Teams.GuestInvitation, - inner_join: ti in assoc(gi, :team_invitation), - where: gi.id in ^ids, - select: ti.team_id - ) - ) - - Enum.uniq(team_ids) - end - - defp backfill_guest_invitations(site_invitations) do - site_invitations - |> Enum.group_by(&{&1.site.team, &1.email}, & &1) - |> Enum.with_index() - |> Enum.each(fn {{{team, email}, site_invitations}, idx} -> - first_site_invitation = List.first(site_invitations) - - team_invitation = - team - # NOTE: we put first inviter and invitation ID matching team/email combination - |> Teams.Invitation.changeset( - email: email, - role: :guest, - inviter: first_site_invitation.inviter - ) - |> Ecto.Changeset.put_change(:inserted_at, first_site_invitation.inserted_at) - |> Ecto.Changeset.put_change(:updated_at, first_site_invitation.updated_at) - |> @repo.insert!( - on_conflict: [set: [updated_at: first_site_invitation.updated_at]], - conflict_target: [:team_id, :email] - ) - - Enum.each(site_invitations, fn site_invitation -> - team_invitation - |> Teams.GuestInvitation.changeset( - site_invitation.site, - translate_role(site_invitation.role) - ) - |> Ecto.Changeset.put_change(:invitation_id, site_invitation.invitation_id) - |> Ecto.Changeset.put_change(:inserted_at, site_invitation.inserted_at) - |> Ecto.Changeset.put_change(:updated_at, site_invitation.updated_at) - |> @repo.insert!() - end) - - if rem(idx, 1000) == 0 do - IO.write(".") - end - end) - end - - defp sync_guest_invitations(guest_and_site_invitations) do - guest_and_site_invitations - |> Enum.with_index() - |> Enum.each(fn {{guest_invitation, site_invitation}, idx} -> - guest_invitation - |> Ecto.Changeset.change() - |> Ecto.Changeset.put_change(:role, translate_role(site_invitation.role)) - |> Ecto.Changeset.put_change(:invitation_id, site_invitation.invitation_id) - |> Ecto.Changeset.put_change(:updated_at, guest_invitation.updated_at) - |> @repo.update!() - - if rem(idx, 1000) == 0 do - IO.write(".") - end - end) - end - - defp remove_site_transfers(site_transfers) do - ids = Enum.map(site_transfers, & &1.id) - - @repo.delete_all(from(st in Teams.SiteTransfer, where: st.id in ^ids)) - end - - defp backfill_site_transfers(site_invitations) do - site_invitations - |> Enum.with_index() - |> Enum.each(fn {site_invitation, idx} -> - site_invitation.site - |> Teams.SiteTransfer.changeset( - initiator: site_invitation.inviter, - email: site_invitation.email - ) - |> Ecto.Changeset.put_change(:transfer_id, site_invitation.invitation_id) - |> Ecto.Changeset.put_change(:inserted_at, site_invitation.inserted_at) - |> Ecto.Changeset.put_change(:updated_at, site_invitation.updated_at) - |> @repo.insert!() - - if rem(idx, 1000) == 0 do - IO.write(".") - end - end) - end - - defp translate_role(:admin), do: :editor - defp translate_role(:viewer), do: :viewer - - defp log(msg) do - IO.puts("[#{NaiveDateTime.utc_now(:second)}] #{msg}") - end -end diff --git a/lib/plausible/data_migration/teams_consistency_check.ex b/lib/plausible/data_migration/teams_consistency_check.ex deleted file mode 100644 index e192b0405771..000000000000 --- a/lib/plausible/data_migration/teams_consistency_check.ex +++ /dev/null @@ -1,335 +0,0 @@ -defmodule Plausible.DataMigration.TeamsConsitencyCheck do - @moduledoc """ - Verify consistency of teams. - """ - - import Ecto.Query - - alias Plausible.Teams - - @repo Plausible.DataMigration.PostgresRepo - - defmacrop is_distinct(f1, f2) do - quote do - fragment("? IS DISTINCT FROM ?", unquote(f1), unquote(f2)) - end - end - - def run() do - # Teams consistency check - db_url = - System.get_env( - "TEAMS_MIGRATION_DB_URL", - Application.get_env(:plausible, Plausible.Repo)[:url] - ) - - @repo.start(db_url, pool_size: 1) - - check() - end - - defp check() do - # Sites without teams - - sites_without_teams_count = - from( - s in Plausible.Site, - where: is_nil(s.team_id) - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{sites_without_teams_count} sites without teams") - - # Teams without owner - - owner_membership_query = - from( - tm in Teams.Membership, - where: tm.team_id == parent_as(:team).id, - where: tm.role == :owner, - select: 1 - ) - - teams_without_owner_count = - from( - t in Plausible.Teams.Team, - as: :team, - where: not exists(owner_membership_query) - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{teams_without_owner_count} teams without owner") - - # Subscriptions without teams - - subscriptions_without_teams_count = - from( - s in Plausible.Billing.Subscription, - where: is_nil(s.team_id) - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{subscriptions_without_teams_count} subscriptions without teams") - - # Subscriptions out of sync - - subscriptions_out_of_sync_count = - from( - s in Plausible.Billing.Subscription, - inner_join: u in assoc(s, :user), - left_join: tm in assoc(u, :team_memberships), - on: tm.role == :owner, - where: s.team_id != tm.team_id - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{subscriptions_out_of_sync_count} subscriptions out of sync") - - # Enterprise plans without teams - - enterprise_plans_without_teams_count = - from( - ep in Plausible.Billing.EnterprisePlan, - where: is_nil(ep.team_id) - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{enterprise_plans_without_teams_count} enterprise_plans without teams") - - # Enterprise plans out of sync - - enterprise_plans_out_of_sync_count = - from( - ep in Plausible.Billing.EnterprisePlan, - inner_join: u in assoc(ep, :user), - left_join: tm in assoc(u, :team_memberships), - on: tm.role == :owner, - where: ep.team_id != tm.team_id - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{enterprise_plans_out_of_sync_count} enterprise_plans out of sync") - - # Teams out of sync - - teams_out_of_sync_count = - from( - t in Teams.Team, - inner_join: tm in assoc(t, :team_memberships), - inner_join: o in assoc(tm, :user), - where: tm.role == :owner, - where: - is_distinct(o.trial_expiry_date, t.trial_expiry_date) or - is_distinct(o.accept_traffic_until, t.accept_traffic_until) or - is_distinct(o.allow_next_upgrade_override, t.allow_next_upgrade_override) or - (is_distinct(o.grace_period, t.grace_period) and - (is_distinct(o.grace_period["id"], t.grace_period["id"]) or - (is_nil(o.grace_period["is_over"]) and t.grace_period["is_over"] == true) or - (o.grace_period["is_over"] == true and t.grace_period["is_over"] == false) or - (o.grace_period["is_over"] == false and t.grace_period["is_over"] == true) or - is_distinct(o.grace_period["end_date"], t.grace_period["end_date"]) or - (is_nil(o.grace_period["manual_lock"]) and t.grace_period["manual_lock"] == true) or - (o.grace_period["manual_lock"] == true and - t.grace_period["manual_lock"] == false) or - (o.grace_period["manual_lock"] == false and - t.grace_period["manual_lock"] == true))), - preload: [team_memberships: {tm, user: o}] - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{teams_out_of_sync_count} teams out of sync") - - # Non-owner site memberships out of sync - - respective_guest_memberships_query = - from( - tm in Teams.Membership, - inner_join: gm in assoc(tm, :guest_memberships), - on: - gm.site_id == parent_as(:site_membership).site_id and - ((gm.role == :viewer and parent_as(:site_membership).role == :viewer) or - (gm.role == :editor and parent_as(:site_membership).role == :admin)), - where: tm.user_id == parent_as(:site_membership).user_id, - select: 1 - ) - - out_of_sync_nonowner_memberships_count = - from( - m in Plausible.Site.Membership, - as: :site_membership, - where: m.role != :owner, - where: not exists(respective_guest_memberships_query) - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{out_of_sync_nonowner_memberships_count} out of sync non-owner site memberships") - - # Owner site memberships out of sync - - respective_owner_memberships_query = - from( - tm in Teams.Membership, - where: tm.team_id == parent_as(:site).team_id and tm.role == :owner, - select: 1 - ) - - out_of_sync_owner_memberships_count = - from( - m in Plausible.Site.Membership, - as: :site_membership, - inner_join: s in assoc(m, :site), - as: :site, - where: m.role == :owner, - where: not exists(respective_owner_memberships_query) - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{out_of_sync_owner_memberships_count} out of sync owner site memberships") - - # Site invitations out of sync - - respective_guest_invitations_query = - from( - gi in Teams.GuestInvitation, - inner_join: ti in assoc(gi, :team_invitation), - on: ti.email == parent_as(:site_invitation).email, - where: gi.site_id == parent_as(:site_invitation).site_id, - select: 1 - ) - - out_of_sync_site_invitations_count = - from( - i in Plausible.Auth.Invitation, - as: :site_invitation, - where: i.role != :owner, - where: not exists(respective_guest_invitations_query) - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{out_of_sync_site_invitations_count} out of sync site invitations") - - # Site invitations out of sync - - respective_site_transfers_query = - from( - st in Teams.SiteTransfer, - where: st.email == parent_as(:site_invitation).email, - where: st.site_id == parent_as(:site_invitation).site_id, - select: 1 - ) - - out_of_sync_site_transfers_count = - from( - i in Plausible.Auth.Invitation, - as: :site_invitation, - where: i.role == :owner, - where: not exists(respective_site_transfers_query) - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{out_of_sync_site_transfers_count} out of sync site transfers") - - # Guest memberships out of sync - - respective_site_memberships_query = - from( - sm in Plausible.Site.Membership, - where: sm.site_id == parent_as(:guest_membership).site_id, - where: sm.user_id == parent_as(:team_membership).user_id, - where: - (sm.role == :viewer and parent_as(:guest_membership).role == :viewer) or - (sm.role == :admin and parent_as(:guest_membership).role == :editor), - select: 1 - ) - - out_of_sync_guest_memberships_count = - from( - gm in Plausible.Teams.GuestMembership, - as: :guest_membership, - inner_join: tm in assoc(gm, :team_membership), - as: :team_membership, - where: tm.role != :owner, - where: not exists(respective_site_memberships_query) - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{out_of_sync_guest_memberships_count} out of sync guest memberships") - - # Owner memberships out of sync - - respective_site_memberships_query = - from( - sm in Plausible.Site.Membership, - where: sm.site_id == parent_as(:site).id, - where: sm.user_id == parent_as(:team_membership).user_id, - where: sm.role == :owner, - select: 1 - ) - - out_of_sync_owner_memberships_count = - from( - tm in Plausible.Teams.Membership, - as: :team_membership, - inner_join: t in assoc(tm, :team), - inner_join: s in assoc(t, :sites), - as: :site, - where: tm.role == :owner, - where: not exists(respective_site_memberships_query) - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{out_of_sync_owner_memberships_count} out of sync owner team memberships") - - # Guest invitations out of sync - - respective_site_invitations_query = - from( - i in Plausible.Auth.Invitation, - where: i.site_id == parent_as(:guest_invitation).site_id, - where: i.email == parent_as(:team_invitation).email, - where: - (i.role == :viewer and parent_as(:guest_invitation).role == :viewer) or - (i.role == :admin and parent_as(:guest_invitation).role == :editor), - where: i.invitation_id == parent_as(:guest_invitation).invitation_id, - select: 1 - ) - - out_of_sync_guest_invitations_count = - from( - gi in Plausible.Teams.GuestInvitation, - as: :guest_invitation, - inner_join: ti in assoc(gi, :team_invitation), - as: :team_invitation, - where: ti.role != :owner, - where: not exists(respective_site_invitations_query) - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{out_of_sync_guest_invitations_count} out of sync guest invitations") - - # Team site transfers out of sync - - respective_site_transfers_query = - from( - i in Plausible.Auth.Invitation, - where: i.site_id == parent_as(:site_transfer).site_id, - where: i.email == parent_as(:site_transfer).email, - where: i.role == :owner, - select: 1 - ) - - out_of_sync_site_transfers_count = - from( - st in Plausible.Teams.SiteTransfer, - as: :site_transfer, - where: not exists(respective_site_transfers_query) - ) - |> @repo.aggregate(:count, timeout: :infinity) - - log("#{out_of_sync_site_transfers_count} out of sync team site transfers") - end - - defp log(msg) do - IO.puts("[#{NaiveDateTime.utc_now(:second)}] #{msg}") - end -end diff --git a/lib/plausible/goals/goals.ex b/lib/plausible/goals/goals.ex index e9a608377182..af17a254f4eb 100644 --- a/lib/plausible/goals/goals.ex +++ b/lib/plausible/goals/goals.ex @@ -319,8 +319,8 @@ defmodule Plausible.Goals do defp maybe_check_feature_access(site, changeset) do if Ecto.Changeset.get_field(changeset, :currency) do - site = Plausible.Repo.preload(site, :owner) - Plausible.Billing.Feature.RevenueGoals.check_availability(site.owner) + site = Plausible.Repo.preload(site, :team) + Plausible.Billing.Feature.RevenueGoals.check_availability(site.team) else :ok end diff --git a/lib/plausible/ingestion/counters/buffer.ex b/lib/plausible/ingestion/counters/buffer.ex index 40b3b1b671a0..2519149a0643 100644 --- a/lib/plausible/ingestion/counters/buffer.ex +++ b/lib/plausible/ingestion/counters/buffer.ex @@ -52,7 +52,8 @@ defmodule Plausible.Ingestion.Counters.Buffer do domain, timestamp ) do - bucket = bucket_fn.(timestamp) + bucket = + bucket_fn.(timestamp) :ets.update_counter( buffer_name, diff --git a/lib/plausible/ingestion/event.ex b/lib/plausible/ingestion/event.ex index ad04a639ad24..c235e567e529 100644 --- a/lib/plausible/ingestion/event.ex +++ b/lib/plausible/ingestion/event.ex @@ -100,11 +100,15 @@ defmodule Plausible.Ingestion.Event do @spec emit_telemetry_dropped(t(), drop_reason()) :: :ok def emit_telemetry_dropped(event, reason) do - :telemetry.execute(telemetry_event_dropped(), %{}, %{ - domain: event.domain, - reason: reason, - request_timestamp: event.request.timestamp - }) + :telemetry.execute( + telemetry_event_dropped(), + %{}, + %{ + domain: event.domain, + reason: reason, + request_timestamp: event.request.timestamp + } + ) end defp pipeline() do diff --git a/lib/plausible/props.ex b/lib/plausible/props.ex index 25c9ca46b85b..da1d9054b544 100644 --- a/lib/plausible/props.ex +++ b/lib/plausible/props.ex @@ -57,9 +57,9 @@ defmodule Plausible.Props do """ @spec allowed_for(Plausible.Site.t()) :: [prop()] | :all def allowed_for(site, opts \\ []) do - site = Plausible.Repo.preload(site, :owner) + site = Plausible.Repo.preload(site, :team) internal_keys = Plausible.Props.internal_keys() - props_enabled? = Plausible.Billing.Feature.Props.check_availability(site.owner) == :ok + props_enabled? = Plausible.Billing.Feature.Props.check_availability(site.team) == :ok bypass_setup? = Keyword.get(opts, :bypass_setup?) cond do @@ -78,8 +78,8 @@ defmodule Plausible.Props do data to be dropped or lost. """ def allow(site, prop_or_props) do - with site <- Plausible.Repo.preload(site, :owner), - :ok <- Plausible.Billing.Feature.Props.check_availability(site.owner) do + with site <- Plausible.Repo.preload(site, :team), + :ok <- Plausible.Billing.Feature.Props.check_availability(site.team) do site |> allow_changeset(prop_or_props) |> Plausible.Repo.update() @@ -139,11 +139,11 @@ defmodule Plausible.Props do allow(site, props_to_allow) end - def ensure_prop_key_accessible(prop_key, user) do + def ensure_prop_key_accessible(prop_key, team) do if prop_key in @internal_keys do :ok else - Plausible.Billing.Feature.Props.check_availability(user) + Plausible.Billing.Feature.Props.check_availability(team) end end diff --git a/lib/plausible/site.ex b/lib/plausible/site.ex index b0ffe7aa1fde..5ea17a189f8a 100644 --- a/lib/plausible/site.ex +++ b/lib/plausible/site.ex @@ -5,7 +5,6 @@ defmodule Plausible.Site do use Ecto.Schema use Plausible import Ecto.Changeset - alias Plausible.Auth.User alias Plausible.Site.GoogleAuth @type t() :: %__MODULE__{} @@ -42,16 +41,12 @@ defmodule Plausible.Site do on_replace: :update, defaults_to_struct: true - many_to_many :members, User, join_through: Plausible.Site.Membership - has_many :memberships, Plausible.Site.Membership - has_many :invitations, Plausible.Auth.Invitation has_many :goals, Plausible.Goal, preload_order: [desc: :id] has_many :revenue_goals, Plausible.Goal, where: [currency: {:not, nil}] has_one :google_auth, GoogleAuth has_one :weekly_report, Plausible.Site.WeeklyReport has_one :monthly_report, Plausible.Site.MonthlyReport - has_one :ownership, Plausible.Site.Membership, where: [role: :owner] - has_one :legacy_owner, through: [:ownership, :user] + has_one :ownership, through: [:team, :ownership] has_one :owner, through: [:team, :owner] # If `from_cache?` is set, the struct might be incomplete - see `Plausible.Site.Cache`. @@ -63,6 +58,8 @@ defmodule Plausible.Site do # user's membership state. Currently it can be either "invitation", # "pinned_site" or "site", where invitations are first. field :entry_type, :string, virtual: true + field :memberships, {:array, :map}, virtual: true + field :invitations, {:array, :map}, virtual: true field :pinned_at, :naive_datetime, virtual: true # Used for caching imports data for the duration of the whole request diff --git a/lib/plausible/site/admin.ex b/lib/plausible/site/admin.ex index 1422a62e7f1a..2607778a31ad 100644 --- a/lib/plausible/site/admin.ex +++ b/lib/plausible/site/admin.ex @@ -18,7 +18,7 @@ defmodule Plausible.SiteAdmin do from(r in query, inner_join: o in assoc(r, :owner), as: :owner, - preload: [owner: o, memberships: :user] + preload: [owner: o, team: [team_memberships: :user]] ) end @@ -68,15 +68,13 @@ defmodule Plausible.SiteAdmin do n -> "⏱ #{n}/#{site.ingest_rate_limit_scale_seconds}s (per server)" end - owner = site.owner - - owner_limits = - if owner.accept_traffic_until && - Date.after?(Date.utc_today(), owner.accept_traffic_until) do + team_limits = + if site.team.accept_traffic_until && + Date.after?(Date.utc_today(), site.team.accept_traffic_until) do "💸 Rejecting traffic" end - {:safe, Enum.join([rate_limiting_status, owner_limits], "

")} + {:safe, Enum.join([rate_limiting_status, team_limits], "

")} end } ] @@ -175,7 +173,7 @@ defmodule Plausible.SiteAdmin do end defp get_other_members(site) do - Enum.filter(site.memberships, &(&1.role != :owner)) + site.team.team_memberships |> Enum.map(fn m -> m.user.email <> "(#{to_string(m.role)})" end) |> Enum.join(", ") end diff --git a/lib/plausible/site/membership.ex b/lib/plausible/site/membership.ex deleted file mode 100644 index 26e791fdb26b..000000000000 --- a/lib/plausible/site/membership.ex +++ /dev/null @@ -1,31 +0,0 @@ -defmodule Plausible.Site.Membership do - use Ecto.Schema - import Ecto.Changeset - - @roles [:owner, :admin, :editor, :viewer] - - @type t() :: %__MODULE__{} - - # Generate a union type for roles - @type role() :: unquote(Enum.reduce(@roles, &{:|, [], [&1, &2]})) - - schema "site_memberships" do - field :role, Ecto.Enum, values: @roles - belongs_to :site, Plausible.Site - belongs_to :user, Plausible.Auth.User - - timestamps() - end - - def new(site, user) do - %__MODULE__{} - |> change() - |> put_assoc(:site, site) - |> put_assoc(:user, user) - end - - def set_role(changeset, role) do - changeset - |> cast(%{role: role}, [:role]) - end -end diff --git a/lib/plausible/site/memberships.ex b/lib/plausible/site/memberships.ex index 9f381ed6efa0..ea9797ee7f52 100644 --- a/lib/plausible/site/memberships.ex +++ b/lib/plausible/site/memberships.ex @@ -3,10 +3,6 @@ defmodule Plausible.Site.Memberships do API for site memberships and invitations """ - import Ecto.Query, only: [from: 2] - - alias Plausible.Auth - alias Plausible.Repo alias Plausible.Site.Memberships defdelegate accept_invitation(invitation_id, user), to: Memberships.AcceptInvitation @@ -21,20 +17,4 @@ defmodule Plausible.Site.Memberships do defdelegate bulk_transfer_ownership_direct(sites, new_owner), to: Memberships.AcceptInvitation - - @spec any?(Auth.User.t()) :: boolean() - def any?(user) do - user - |> Ecto.assoc(:site_memberships) - |> Repo.exists?() - end - - @spec pending?(String.t()) :: boolean() - def pending?(email) do - Repo.exists?( - from(i in Plausible.Auth.Invitation, - where: i.email == ^email - ) - ) - end end diff --git a/lib/plausible/site/memberships/accept_invitation.ex b/lib/plausible/site/memberships/accept_invitation.ex index 4677cbb374d7..b7839939bfde 100644 --- a/lib/plausible/site/memberships/accept_invitation.ex +++ b/lib/plausible/site/memberships/accept_invitation.ex @@ -12,14 +12,11 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do the invitation and accepting it. """ - import Ecto.Query, only: [from: 2] - - alias Ecto.Multi alias Plausible.Auth alias Plausible.Billing alias Plausible.Repo alias Plausible.Site - alias Plausible.Site.Memberships.Invitations + alias Plausible.Teams require Logger @@ -52,13 +49,16 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do end @spec accept_invitation(String.t(), Auth.User.t()) :: - {:ok, Site.Membership.t()} | {:error, accept_error()} - def accept_invitation(invitation_id, user) do - with {:ok, invitation} <- Invitations.find_for_user(invitation_id, user) do - if invitation.role == :owner do - do_accept_ownership_transfer(invitation, user) - else - do_accept_invitation(invitation, user) + {:ok, map()} | {:error, accept_error()} + def accept_invitation(invitation_or_transfer_id, user) do + with {:ok, invitation_or_transfer} <- + Teams.Invitations.find_for_user(invitation_or_transfer_id, user) do + case invitation_or_transfer do + %Teams.SiteTransfer{} = site_transfer -> + do_accept_ownership_transfer(site_transfer, user) + + %Teams.GuestInvitation{} = guest_invitation -> + do_accept_invitation(guest_invitation, user) end end end @@ -73,28 +73,16 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do :owner ), {:ok, new_team} = Plausible.Teams.get_or_create(new_owner), - :ok <- Plausible.Teams.Invitations.ensure_can_take_ownership(site, new_team) do - membership = get_or_create_owner_membership(site, new_owner) - - multi = add_and_transfer_ownership(site, membership, new_owner) - - case Repo.transaction(multi) do - {:ok, changes} -> - Plausible.Teams.Invitations.transfer_site_sync(site, new_owner) - - membership = Repo.preload(changes.membership, [:site, :user]) - - {:ok, membership} + :ok <- Plausible.Teams.Invitations.ensure_can_take_ownership(site, new_team), + :ok <- Plausible.Teams.Invitations.transfer_site(site, new_owner) do + site = site |> Repo.reload!() |> Repo.preload(ownership: :user) - {:error, _operation, error, _changes} -> - {:error, error} - end + {:ok, site.ownership} end end - defp do_accept_ownership_transfer(invitation, user) do - membership = get_or_create_membership(invitation, user) - site = Repo.preload(invitation.site, :team) + defp do_accept_ownership_transfer(site_transfer, user) do + site = Repo.preload(site_transfer.site, :team) with :ok <- Plausible.Teams.Invitations.ensure_transfer_valid( @@ -103,136 +91,17 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do :owner ), {:ok, team} = Plausible.Teams.get_or_create(user), - :ok <- Plausible.Teams.Invitations.ensure_can_take_ownership(site, team) do - site - |> add_and_transfer_ownership(membership, user) - |> Multi.delete(:invitation, invitation) - |> Multi.run(:sync_transfer, fn _repo, _context -> - Plausible.Teams.Invitations.accept_transfer_sync(invitation, user) - {:ok, nil} - end) - |> finalize_invitation(invitation) - end - end + :ok <- Plausible.Teams.Invitations.ensure_can_take_ownership(site, team), + :ok <- Teams.Invitations.accept_site_transfer(site_transfer, user) do + Teams.Invitations.send_transfer_accepted_email(site_transfer) - defp do_accept_invitation(invitation, user) do - membership = get_or_create_membership(invitation, user) + site = site |> Repo.reload!() |> Repo.preload(ownership: :user) - invitation - |> add(membership, user) - |> Multi.run(:sync_invitation, fn _repo, _context -> - Plausible.Teams.Invitations.accept_invitation_sync(invitation, user) - {:ok, nil} - end) - |> finalize_invitation(invitation) - end - - defp finalize_invitation(multi, invitation) do - case Repo.transaction(multi) do - {:ok, changes} -> - notify_invitation_accepted(invitation) - - membership = Repo.preload(changes.membership, [:site, :user]) - - {:ok, membership} - - {:error, _operation, error, _changes} -> - {:error, error} + {:ok, %{team_membership: site.ownership, site: site}} end end - defp add_and_transfer_ownership(site, membership, user) do - Multi.new() - |> downgrade_previous_owner(site, user) - |> Multi.insert_or_update(:membership, membership) - |> Multi.run(:update_locked_sites, fn _, _ -> - on_ee do - # At this point this function should be guaranteed to unlock - # the site, via `Invitations.ensure_can_take_ownership/2`. - :unlocked = Billing.SiteLocker.update_sites_for(user, send_email?: false) - end - - {:ok, :unlocked} - end) - end - - # If there's an existing membership, we DO NOT change the role - # to avoid accidental role downgrade. - defp add(invitation, membership, _user) do - if membership.data.id do - Multi.new() - |> Multi.put(:membership, membership.data) - |> Multi.delete(:invitation, invitation) - else - Multi.new() - |> Multi.insert(:membership, membership) - |> Multi.delete(:invitation, invitation) - end - end - - defp get_or_create_membership(invitation, user) do - case Repo.get_by(Site.Membership, user_id: user.id, site_id: invitation.site.id) do - nil -> Site.Membership.new(invitation.site, user) - membership -> membership - end - |> Site.Membership.set_role(invitation.role) - end - - defp get_or_create_owner_membership(site, user) do - case Repo.get_by(Site.Membership, user_id: user.id, site_id: site.id) do - nil -> Site.Membership.new(site, user) - membership -> membership - end - |> Site.Membership.set_role(:owner) - end - - # If the new owner is the same as old owner, we do not downgrade them - # to avoid leaving site without an owner! - defp downgrade_previous_owner(multi, site, new_owner) do - new_owner_id = new_owner.id - - previous_owner = - Repo.one( - from( - sm in Site.Membership, - where: sm.site_id == ^site.id, - where: sm.role == :owner - ) - ) - - case previous_owner do - %{user_id: ^new_owner_id} -> - Multi.put(multi, :previous_owner_membership, previous_owner) - - nil -> - Logger.warning( - "Transferring ownership from a site with no owner: #{site.domain} " <> - ", new owner ID: #{new_owner_id}" - ) - - Multi.put(multi, :previous_owner_membership, nil) - - previous_owner -> - Multi.update( - multi, - :previous_owner_membership, - Site.Membership.set_role(previous_owner, :admin) - ) - end - end - - defp notify_invitation_accepted(%Auth.Invitation{role: :owner} = invitation) do - PlausibleWeb.Email.ownership_transfer_accepted( - invitation.email, - invitation.inviter.email, - invitation.site - ) - |> Plausible.Mailer.send() - end - - defp notify_invitation_accepted(invitation) do - invitation.inviter.email - |> PlausibleWeb.Email.invitation_accepted(invitation.email, invitation.site) - |> Plausible.Mailer.send() + defp do_accept_invitation(guest_invitation, user) do + Teams.Invitations.accept_guest_invitation(guest_invitation, user) end end diff --git a/lib/plausible/site/memberships/create_invitation.ex b/lib/plausible/site/memberships/create_invitation.ex index 45e903e01a6a..cb405624c626 100644 --- a/lib/plausible/site/memberships/create_invitation.ex +++ b/lib/plausible/site/memberships/create_invitation.ex @@ -48,8 +48,6 @@ defmodule Plausible.Site.Memberships.CreateInvitation do end defp do_invite(site, inviter, invitee_email, role, opts \\ []) do - attrs = %{email: invitee_email, role: role, site_id: site.id, inviter_id: inviter.id} - with site <- Repo.preload(site, [:owner, :team]), :ok <- Teams.Invitations.check_invitation_permissions( @@ -77,29 +75,25 @@ defmodule Plausible.Site.Memberships.CreateInvitation do invitee, role ), - %Ecto.Changeset{} = changeset <- Invitation.new(attrs), - {:ok, invitation} <- Repo.insert(changeset) do - Teams.Invitations.invite_sync(site, invitation) - - send_invitation_email(inviter, invitation, invitee) + {:ok, invitation_or_transfer} <- + Teams.Invitations.invite(site, invitee_email, role, inviter) do + send_invitation_email(invitation_or_transfer, invitee) - invitation + invitation_or_transfer else {:error, cause} -> Repo.rollback(cause) end end - defp send_invitation_email(inviter, invitation, invitee) do - if invitation.role == :owner do - Teams.SiteTransfer - |> Repo.get_by!(transfer_id: invitation.invitation_id, initiator_id: inviter.id) - |> Repo.preload([:site, :initiator]) - |> Teams.Invitations.send_invitation_email(invitee) - else - Teams.GuestInvitation - |> Repo.get_by!(invitation_id: invitation.invitation_id) - |> Repo.preload([:site, team_invitation: :inviter]) - |> Teams.Invitations.send_invitation_email(invitee) - end + defp send_invitation_email(%Teams.GuestInvitation{} = guest_invitation, invitee) do + guest_invitation + |> Repo.preload([:site, team_invitation: :inviter]) + |> Teams.Invitations.send_invitation_email(invitee) + end + + defp send_invitation_email(%Teams.SiteTransfer{} = site_transfer, invitee) do + site_transfer + |> Repo.preload([:site, :initiator]) + |> Teams.Invitations.send_invitation_email(invitee) end end diff --git a/lib/plausible/site/memberships/invitations.ex b/lib/plausible/site/memberships/invitations.ex deleted file mode 100644 index dd2b55af3348..000000000000 --- a/lib/plausible/site/memberships/invitations.ex +++ /dev/null @@ -1,50 +0,0 @@ -defmodule Plausible.Site.Memberships.Invitations do - @moduledoc false - - use Plausible - - import Ecto.Query, only: [from: 2] - - alias Plausible.Auth - alias Plausible.Repo - alias Plausible.Billing.Feature - - @type missing_features_error() :: {:missing_features, [Feature.t()]} - - @spec find_for_user(String.t(), Auth.User.t()) :: - {:ok, Auth.Invitation.t()} | {:error, :invitation_not_found} - def find_for_user(invitation_id, user) do - invitation = - Auth.Invitation - |> Repo.get_by(invitation_id: invitation_id, email: user.email) - |> Repo.preload([:site, :inviter]) - - if invitation do - {:ok, invitation} - else - {:error, :invitation_not_found} - end - end - - @spec find_for_site(String.t(), Plausible.Site.t()) :: - {:ok, Auth.Invitation.t()} | {:error, :invitation_not_found} - def find_for_site(invitation_id, site) do - invitation = - Auth.Invitation - |> Repo.get_by(invitation_id: invitation_id, site_id: site.id) - |> Repo.preload([:site, :inviter]) - - if invitation do - {:ok, invitation} - else - {:error, :invitation_not_found} - end - end - - @spec delete_invitation(Auth.Invitation.t()) :: :ok - def delete_invitation(invitation) do - Repo.delete_all(from(i in Auth.Invitation, where: i.id == ^invitation.id)) - - :ok - end -end diff --git a/lib/plausible/site/memberships/reject_invitation.ex b/lib/plausible/site/memberships/reject_invitation.ex index 98e9d4c820a0..d1cca277f8da 100644 --- a/lib/plausible/site/memberships/reject_invitation.ex +++ b/lib/plausible/site/memberships/reject_invitation.ex @@ -4,32 +4,38 @@ defmodule Plausible.Site.Memberships.RejectInvitation do """ alias Plausible.Auth - alias Plausible.Repo - alias Plausible.Site.Memberships.Invitations alias Plausible.Teams @spec reject_invitation(String.t(), Auth.User.t()) :: - {:ok, Auth.Invitation.t()} | {:error, :invitation_not_found} - def reject_invitation(invitation_id, user) do - with {:ok, invitation} <- Invitations.find_for_user(invitation_id, user) do - Repo.transaction(fn -> - Invitations.delete_invitation(invitation) - Teams.Invitations.remove_invitation_sync(invitation) - end) + {:ok, Teams.GuestInvitation.t() | Teams.SiteTransfer.t()} + | {:error, :invitation_not_found} + def reject_invitation(invitation_or_transfer_id, user) do + with {:ok, invitation_or_transfer} <- + Teams.Invitations.find_for_user(invitation_or_transfer_id, user) do + do_reject(invitation_or_transfer) + {:ok, invitation_or_transfer} + end + end - notify_invitation_rejected(invitation) + defp do_reject(%Teams.GuestInvitation{} = guest_invitation) do + Teams.Invitations.remove_guest_invitation(guest_invitation) - {:ok, invitation} - end + notify_guest_invitation_rejected(guest_invitation) + end + + defp do_reject(%Teams.SiteTransfer{} = site_transfer) do + Teams.Invitations.remove_site_transfer(site_transfer) + + notify_site_transfer_rejected(site_transfer) end - defp notify_invitation_rejected(%Auth.Invitation{role: :owner} = invitation) do - PlausibleWeb.Email.ownership_transfer_rejected(invitation) + defp notify_site_transfer_rejected(site_transfer) do + PlausibleWeb.Email.ownership_transfer_rejected(site_transfer) |> Plausible.Mailer.send() end - defp notify_invitation_rejected(invitation) do - PlausibleWeb.Email.invitation_rejected(invitation) + defp notify_guest_invitation_rejected(guest_invitation) do + PlausibleWeb.Email.invitation_rejected(guest_invitation) |> Plausible.Mailer.send() end end diff --git a/lib/plausible/site/memberships/remove_invitation.ex b/lib/plausible/site/memberships/remove_invitation.ex index e4f1baa5b5ef..97f23ffd73da 100644 --- a/lib/plausible/site/memberships/remove_invitation.ex +++ b/lib/plausible/site/memberships/remove_invitation.ex @@ -3,21 +3,25 @@ defmodule Plausible.Site.Memberships.RemoveInvitation do Service for removing invitations. """ - alias Plausible.Auth - alias Plausible.Repo - alias Plausible.Site.Memberships.Invitations alias Plausible.Teams @spec remove_invitation(String.t(), Plausible.Site.t()) :: - {:ok, Auth.Invitation.t()} | {:error, :invitation_not_found} - def remove_invitation(invitation_id, site) do - with {:ok, invitation} <- Invitations.find_for_site(invitation_id, site) do - Repo.transaction(fn -> - Invitations.delete_invitation(invitation) - Teams.Invitations.remove_invitation_sync(invitation) - end) + {:ok, Teams.GuestInvitation.t() | Teams.SiteTransfer.t()} + | {:error, :invitation_not_found} + def remove_invitation(invitation_or_transfer_id, site) do + with {:ok, invitation_or_transfer} <- + Teams.Invitations.find_for_site(invitation_or_transfer_id, site) do + do_delete(invitation_or_transfer) - {:ok, invitation} + {:ok, invitation_or_transfer} end end + + defp do_delete(%Teams.GuestInvitation{} = guest_invitation) do + Teams.Invitations.remove_guest_invitation(guest_invitation) + end + + defp do_delete(%Teams.SiteTransfer{} = site_transfer) do + Teams.Invitations.remove_site_transfer(site_transfer) + end end diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index cd99d77f71bf..a56f5db7819b 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -78,10 +78,11 @@ defmodule Plausible.Sites do owner_membership = from( tm in Teams.Membership, + inner_join: u in assoc(tm, :user), where: tm.team_id == ^site.team_id, where: tm.role == :owner, - select: %Plausible.Site.Membership{ - user_id: tm.user_id, + select: %{ + user: u, role: tm.role } ) @@ -91,9 +92,10 @@ defmodule Plausible.Sites do from( gm in Teams.GuestMembership, inner_join: tm in assoc(gm, :team_membership), + inner_join: u in assoc(tm, :user), where: gm.site_id == ^site.id, - select: %Plausible.Site.Membership{ - user_id: tm.user_id, + select: %{ + user: u, role: fragment( """ @@ -109,14 +111,14 @@ defmodule Plausible.Sites do ) |> Repo.all() - memberships = Repo.preload([owner_membership | memberships], :user) + memberships = [owner_membership | memberships] invitations = from( gi in Teams.GuestInvitation, inner_join: ti in assoc(gi, :team_invitation), where: gi.site_id == ^site.id, - select: %Plausible.Auth.Invitation{ + select: %{ invitation_id: gi.invitation_id, email: ti.email, role: @@ -138,7 +140,7 @@ defmodule Plausible.Sites do from( st in Teams.SiteTransfer, where: st.site_id == ^site.id, - select: %Plausible.Auth.Invitation{ + select: %{ invitation_id: st.transfer_id, email: st.email, role: :owner @@ -152,8 +154,11 @@ defmodule Plausible.Sites do @spec for_user_query(Auth.User.t()) :: Ecto.Query.t() def for_user_query(user) do from(s in Site, - inner_join: sm in assoc(s, :memberships), - on: sm.user_id == ^user.id, + inner_join: t in assoc(s, :team), + inner_join: tm in assoc(t, :team_memberships), + left_join: gm in assoc(tm, :guest_memberships), + where: tm.user_id == ^user.id, + where: tm.role != :guest or gm.site_id == s.id, order_by: [desc: s.id] ) end @@ -162,7 +167,9 @@ defmodule Plausible.Sites do Ecto.Multi.new() |> Ecto.Multi.put(:site_changeset, Site.new(params)) |> Ecto.Multi.run(:create_team, fn _repo, _context -> - Plausible.Teams.get_or_create(user) + {:ok, team} = Plausible.Teams.get_or_create(user) + + {:ok, Plausible.Teams.with_subscription(team)} end) |> Ecto.Multi.run(:ensure_can_add_new_site, fn _repo, %{create_team: team} -> case Plausible.Teams.Billing.ensure_can_add_new_site(team) do @@ -190,29 +197,17 @@ defmodule Plausible.Sites do |> Ecto.Multi.insert(:site, fn %{site_changeset: site, create_team: team} -> Ecto.Changeset.put_assoc(site, :team, team) end) - |> Ecto.Multi.insert(:site_membership, fn %{site: site} -> - Site.Membership.new(site, user) - end) - |> maybe_start_trial(user) - |> Ecto.Multi.run(:sync_team, fn _repo, %{user: user} -> - Plausible.Teams.sync_team(user) - {:ok, nil} + |> Ecto.Multi.run(:trial, fn _repo, %{create_team: team} -> + if is_nil(team.trial_expiry_date) and is_nil(team.subscription) do + Teams.start_trial(team) + {:ok, :trial_started} + else + {:ok, :trial_already_started} + end end) |> Repo.transaction() end - defp maybe_start_trial(multi, user) do - case user.trial_expiry_date do - nil -> - Ecto.Multi.run(multi, :user, fn _, _ -> - {:ok, Plausible.Users.start_trial(user)} - end) - - _ -> - Ecto.Multi.put(multi, :user, user) - end - end - @spec clear_stats_start_date!(Site.t()) :: Site.t() def clear_stats_start_date!(site) do site @@ -299,14 +294,6 @@ defmodule Plausible.Sites do ) end - def is_member?(user_id, site) do - role(user_id, site) !== nil - end - - def has_admin_access?(user_id, site) do - role(user_id, site) in [:admin, :owner] - end - def locked?(%Site{locked: locked}) do locked end @@ -360,15 +347,6 @@ defmodule Plausible.Sites do ) end - def role(user_id, site) do - Repo.one( - from(sm in Site.Membership, - where: sm.user_id == ^user_id and sm.site_id == ^site.id, - select: sm.role - ) - ) - end - def owned_sites_locked?(user) do user |> owned_sites_query() diff --git a/lib/plausible/teams.ex b/lib/plausible/teams.ex index 32c9154aa164..ecd430b7e1f9 100644 --- a/lib/plausible/teams.ex +++ b/lib/plausible/teams.ex @@ -6,9 +6,12 @@ defmodule Plausible.Teams do import Ecto.Query alias __MODULE__ + alias Plausible.Auth.GracePeriod alias Plausible.Repo use Plausible + @accept_traffic_until_free ~D[2135-01-01] + @spec get_owner(Teams.Team.t()) :: {:ok, Plausible.Auth.User.t()} | {:error, :no_owner | :multiple_owners} def get_owner(team) do @@ -34,6 +37,10 @@ defmodule Plausible.Teams do end @spec trial_days_left(Teams.Team.t()) :: integer() + def trial_days_left(nil) do + nil + end + def trial_days_left(team) do Date.diff(team.trial_expiry_date, Date.utc_today()) end @@ -133,14 +140,6 @@ defmodule Plausible.Teams do end end - def sync_team(user) do - {:ok, team} = get_or_create(user) - - team - |> Teams.Team.sync_changeset(user) - |> Repo.update!() - end - def get_by_owner(user_id) when is_integer(user_id) do result = from(tm in Teams.Membership, @@ -164,6 +163,82 @@ defmodule Plausible.Teams do get_by_owner(user.id) end + @spec update_accept_traffic_until(Teams.Team.t()) :: Teams.Team.t() + def update_accept_traffic_until(team) do + team + |> Ecto.Changeset.change(accept_traffic_until: accept_traffic_until(team)) + |> Repo.update!() + end + + def start_trial(%Teams.Team{} = team) do + team + |> Teams.Team.start_trial() + |> Repo.update!() + end + + def start_grace_period(team) do + team + |> GracePeriod.start_changeset() + |> Repo.update!() + end + + def start_manual_lock_grace_period(team) do + team + |> GracePeriod.start_manual_lock_changeset() + |> Repo.update!() + end + + def end_grace_period(team) do + team + |> GracePeriod.end_changeset() + |> Repo.update!() + end + + def remove_grace_period(team) do + team + |> GracePeriod.remove_changeset() + |> Repo.update!() + end + + def maybe_reset_next_upgrade_override(%Teams.Team{} = team) do + if team.allow_next_upgrade_override do + team + |> Ecto.Changeset.change(allow_next_upgrade_override: false) + |> Repo.update!() + else + team + end + end + + @spec accept_traffic_until(Teams.Team.t()) :: Date.t() + on_ee do + def accept_traffic_until(team) do + team = with_subscription(team) + + cond do + on_trial?(team) -> + Date.shift(team.trial_expiry_date, + day: Teams.Team.trial_accept_traffic_until_offset_days() + ) + + team.subscription && team.subscription.paddle_plan_id == "free_10k" -> + @accept_traffic_until_free + + team.subscription && team.subscription.next_bill_date -> + Date.shift(team.subscription.next_bill_date, + day: Teams.Team.subscription_accept_traffic_until_offset_days() + ) + + true -> + raise "This user is neither on trial or has a valid subscription. Manual intervention required." + end + end + else + def accept_traffic_until(_user) do + @accept_traffic_until_free + end + end + def last_subscription_join_query() do from(subscription in last_subscription_query(), where: subscription.team_id == parent_as(:team).id diff --git a/lib/plausible/teams/billing.ex b/lib/plausible/teams/billing.ex index d1e219878c8c..e168fc1f5d4c 100644 --- a/lib/plausible/teams/billing.ex +++ b/lib/plausible/teams/billing.ex @@ -183,7 +183,9 @@ defmodule Plausible.Teams.Billing do end def site_limit(team) do - if Timex.before?(team.inserted_at, @limit_sites_since) do + {:ok, user} = Teams.get_owner(team) + + if Timex.before?(user.inserted_at, @limit_sites_since) do :unlimited else get_site_limit_from_plan(team) @@ -202,10 +204,6 @@ defmodule Plausible.Teams.Billing do |> length() end - defp get_site_limit_from_plan(nil) do - @site_limit_for_trials - end - defp get_site_limit_from_plan(team) do team = Teams.with_subscription(team) @@ -239,7 +237,7 @@ defmodule Plausible.Teams.Billing do * `pending_ownership_site_ids` - a list of site IDs from which to count additional usage. This allows us to look at the total usage from pending ownerships and owned sites at the same time, which is useful, for example, - when deciding whether to let the team owner upgrade to a plan, or accept a + when deciding whether to let the team owner upgrade to a plan, or accept a site ownership. * `with_features` - when `true`, the returned map will contain features diff --git a/lib/plausible/teams/guest_invitation.ex b/lib/plausible/teams/guest_invitation.ex index 815a608fabac..a8ecc57e2946 100644 --- a/lib/plausible/teams/guest_invitation.ex +++ b/lib/plausible/teams/guest_invitation.ex @@ -7,6 +7,8 @@ defmodule Plausible.Teams.GuestInvitation do import Ecto.Changeset + @type t() :: %__MODULE__{} + schema "guest_invitations" do field :invitation_id, :string field :role, Ecto.Enum, values: [:viewer, :editor] diff --git a/lib/plausible/teams/guest_membership.ex b/lib/plausible/teams/guest_membership.ex index 3932685e10e3..d045583de298 100644 --- a/lib/plausible/teams/guest_membership.ex +++ b/lib/plausible/teams/guest_membership.ex @@ -7,6 +7,8 @@ defmodule Plausible.Teams.GuestMembership do import Ecto.Changeset + @type t() :: %__MODULE__{} + schema "guest_memberships" do field :role, Ecto.Enum, values: [:viewer, :editor] diff --git a/lib/plausible/teams/invitation.ex b/lib/plausible/teams/invitation.ex index 473dc7ea68f3..5a5722a09fd9 100644 --- a/lib/plausible/teams/invitation.ex +++ b/lib/plausible/teams/invitation.ex @@ -7,6 +7,8 @@ defmodule Plausible.Teams.Invitation do import Ecto.Changeset + @type t() :: %__MODULE__{} + schema "team_invitations" do field :invitation_id, :string field :email, :string diff --git a/lib/plausible/teams/invitations.ex b/lib/plausible/teams/invitations.ex index 161d9f8cf1a7..ff064e001092 100644 --- a/lib/plausible/teams/invitations.ex +++ b/lib/plausible/teams/invitations.ex @@ -9,157 +9,159 @@ defmodule Plausible.Teams.Invitations do alias Plausible.Repo alias Plausible.Teams - def invite_sync(site, site_invitation) do - site = Teams.load_for_site(site) - site_invitation = Repo.preload(site_invitation, :inviter) - role = translate_role(site_invitation.role) - - if site_invitation.role == :owner do - {:ok, site_transfer} = - create_site_transfer( - site, - site_invitation.inviter, - site_invitation.email - ) + def find_for_user(invitation_or_transfer_id, user) do + with {:error, :invitation_not_found} <- + find_invitation_for_user(invitation_or_transfer_id, user) do + find_transfer_for_user(invitation_or_transfer_id, user) + end + end - site_transfer - |> Ecto.Changeset.change(transfer_id: site_invitation.invitation_id) - |> Repo.update!() - else - {:ok, guest_invitation} = - create_invitation( - site, - site_invitation.email, - role, - site_invitation.inviter - ) + def find_for_site(invitation_or_transfer_id, site) do + with {:error, :invitation_not_found} <- + find_invitation_for_site(invitation_or_transfer_id, site) do + find_transfer_for_site(invitation_or_transfer_id, site) + end + end - guest_invitation - |> Ecto.Changeset.change(invitation_id: site_invitation.invitation_id) - |> Repo.update!() + defp find_invitation_for_user(guest_invitation_id, user) do + invitation_query = + from gi in Teams.GuestInvitation, + inner_join: s in assoc(gi, :site), + inner_join: ti in assoc(gi, :team_invitation), + inner_join: inviter in assoc(ti, :inviter), + where: gi.invitation_id == ^guest_invitation_id, + where: ti.email == ^user.email, + preload: [site: s, team_invitation: {ti, inviter: inviter}] + + case Repo.one(invitation_query) do + nil -> + {:error, :invitation_not_found} + + invitation -> + {:ok, invitation} end end - def remove_invitation_sync(site_invitation) do - site = Repo.preload(site_invitation, :site).site - site = Teams.load_for_site(site) + defp find_transfer_for_user(transfer_id, user) do + transfer = + Teams.SiteTransfer + |> Repo.get_by(transfer_id: transfer_id, email: user.email) + |> Repo.preload([:site, :initiator]) - if site_invitation.role == :owner do - Repo.delete_all( - from( - st in Teams.SiteTransfer, - where: st.email == ^site_invitation.email, - where: st.site_id == ^site.id - ) - ) - else - Repo.delete_all( - from( - gi in Teams.GuestInvitation, - inner_join: ti in assoc(gi, :team_invitation), - where: ti.email == ^site_invitation.email, - where: gi.site_id == ^site.id - ) - ) + case transfer do + nil -> + {:error, :invitation_not_found} - prune_guest_invitations(site.team) + transfer -> + {:ok, transfer} end - - :ok end - def transfer_site_sync(site, user) do - {:ok, team} = Teams.get_or_create(user) - site = Teams.load_for_site(site) + defp find_invitation_for_site(guest_invitation_id, site) do + invitation = + Teams.GuestInvitation + |> Repo.get_by(invitation_id: guest_invitation_id, site_id: site.id) + |> Repo.preload([:site, team_invitation: :inviter]) - site = - Repo.preload(site, [ - :team, - :owner, - guest_memberships: [team_membership: :user], - guest_invitations: [team_invitation: :inviter] - ]) + case invitation do + nil -> + {:error, :invitation_not_found} - {:ok, _} = - Repo.transaction(fn -> - :ok = transfer_site_ownership(site, team, NaiveDateTime.utc_now(:second)) - end) + invitation -> + {:ok, invitation} + end end - def accept_invitation_sync(site_invitation, user) do - site_invitation = - Repo.preload( - site_invitation, - site: :team - ) + defp find_transfer_for_site(transfer_id, site) do + transfer = + Teams.SiteTransfer + |> Repo.get_by(transfer_id: transfer_id, site_id: site.id) + |> Repo.preload([:site, :initiator]) - site = Teams.load_for_site(site_invitation.site) - site_invitation = %{site_invitation | site: site} + case transfer do + nil -> + {:error, :invitation_not_found} - role = - case site_invitation.role do - :viewer -> :viewer - :admin -> :editor - end + transfer -> + {:ok, transfer} + end + end + + def invite(site, invitee_email, role, inviter) do + site = Teams.load_for_site(site) - {:ok, guest_invitation} = + if role == :owner do + create_site_transfer( + site, + inviter, + invitee_email + ) + else create_invitation( - site_invitation.site, - site_invitation.email, + site, + invitee_email, role, - site_invitation.inviter + inviter ) + end + end - team_invitation = - guest_invitation.team_invitation - |> Repo.preload([ - :team, - :inviter, - guest_invitations: :site - ]) + def remove_guest_invitation(guest_invitation) do + site = Repo.preload(guest_invitation, site: :team).site - {:ok, _} = - result = - do_accept(team_invitation, user, NaiveDateTime.utc_now(:second), - send_email?: false, - guest_invitations: [guest_invitation] - ) + Repo.delete_all( + from gi in Teams.GuestInvitation, + where: gi.id == ^guest_invitation.id + ) - prune_guest_invitations(team_invitation.team) - result + prune_guest_invitations(site.team) end - def accept_transfer_sync(site_invitation, user) do - {:ok, team} = Teams.get_or_create(user) + def remove_site_transfer(site_transfer) do + Repo.delete_all( + from st in Teams.SiteTransfer, + where: st.id == ^site_transfer.id + ) + end - site = - site_invitation.site - |> Teams.load_for_site() - |> Repo.preload([ - :team, - :owner, - guest_memberships: [team_membership: :user], - guest_invitations: [team_invitation: :inviter] - ]) + def accept_site_transfer(site_transfer, user) do + {:ok, _} = + Repo.transaction(fn -> + {:ok, team} = Teams.get_or_create(user) + :ok = transfer_site_ownership(site_transfer.site, team, NaiveDateTime.utc_now(:second)) + Repo.delete_all(from st in Teams.SiteTransfer, where: st.id == ^site_transfer.id) + end) - {:ok, site_transfer} = - create_site_transfer(site, site_invitation.inviter, site_invitation.email) + :ok + end + def transfer_site(site, user) do {:ok, _} = Repo.transaction(fn -> + {:ok, team} = Teams.get_or_create(user) :ok = transfer_site_ownership(site, team, NaiveDateTime.utc_now(:second)) - Repo.delete!(site_transfer) end) - end - def check_transfer_permissions(_team, _initiator, false = _check_permissions?) do :ok end - def check_transfer_permissions(team, initiator, _) do - case Teams.Memberships.team_role(team, initiator) do - {:ok, :owner} -> :ok - _ -> {:error, :forbidden} + def accept_guest_invitation(guest_invitation, user) do + guest_invitation = Repo.preload(guest_invitation, :site) + + team_invitation = + guest_invitation.team_invitation + |> Repo.preload([ + :team, + :inviter, + guest_invitations: :site + ]) + + now = NaiveDateTime.utc_now(:second) + + with {:ok, team_membership} <- + do_accept(team_invitation, user, now, guest_invitations: [guest_invitation]) do + prune_guest_invitations(team_invitation.team) + {:ok, team_membership} end end @@ -176,26 +178,42 @@ defmodule Plausible.Teams.Invitations do def ensure_transfer_valid(_team, _new_owner, _role), do: :ok defp create_site_transfer(site, initiator, invitee_email, now \\ NaiveDateTime.utc_now(:second)) do - site - |> Teams.SiteTransfer.changeset(initiator: initiator, email: invitee_email) - |> Repo.insert( - on_conflict: [set: [updated_at: now]], - conflict_target: [:email, :site_id], - returning: true - ) - end - - def send_transfer_init_email(site_transfer, new_owner) do - email = - PlausibleWeb.Email.ownership_transfer_request( - site_transfer.email, - site_transfer.transfer_id, - site_transfer.site, - site_transfer.initiator, - new_owner + result = + Ecto.Multi.new() + |> Ecto.Multi.put( + :site_transfer_changeset, + Teams.SiteTransfer.changeset(site, initiator: initiator, email: invitee_email) + ) + |> Ecto.Multi.run(:ensure_no_invitations, fn _repo, %{site_transfer_changeset: changeset} -> + q = + from ti in Teams.Invitation, + inner_join: gi in assoc(ti, :guest_invitations), + where: ti.email == ^invitee_email, + where: ti.team_id == ^site.team_id, + where: gi.site_id == ^site.id + + if Repo.exists?(q) do + {:error, Ecto.Changeset.add_error(changeset, :invitation, "already sent")} + else + {:ok, :pass} + end + end) + |> Ecto.Multi.insert( + :site_transfer, + fn %{site_transfer_changeset: changeset} -> changeset end, + on_conflict: [set: [updated_at: now]], + conflict_target: [:email, :site_id], + returning: true ) + |> Repo.transaction() - Plausible.Mailer.send(email) + case result do + {:ok, success} -> + {:ok, success.site_transfer} + + {:error, _, changeset, _} -> + {:error, changeset} + end end defp do_accept(team_invitation, user, now, opts) do @@ -205,7 +223,7 @@ defmodule Plausible.Teams.Invitations do Repo.transaction(fn -> with {:ok, team_membership} <- create_team_membership(team_invitation.team, team_invitation.role, user, now), - {:ok, _guest_memberships} <- + {:ok, guest_memberships} <- create_guest_memberships(team_membership, guest_invitations, now) do # Clean up guest invitations after accepting guest_invitation_ids = Enum.map(guest_invitations, & &1.id) @@ -216,7 +234,7 @@ defmodule Plausible.Teams.Invitations do send_invitation_accepted_email(team_invitation, guest_invitations) end - team_membership + %{team_membership: team_membership, guest_memberships: guest_memberships} else {:error, changeset} -> Repo.rollback(changeset) end @@ -224,6 +242,14 @@ defmodule Plausible.Teams.Invitations do end defp transfer_site_ownership(site, team, now) do + site = + Repo.preload(site, [ + :team, + :owner, + guest_memberships: [team_membership: :user], + guest_invitations: [team_invitation: :inviter] + ]) + prior_team = site.team site @@ -291,6 +317,10 @@ defmodule Plausible.Teams.Invitations do ) end + on_ee do + :unlocked = Billing.SiteLocker.update_sites_for(team, send_email?: false) + end + :ok end @@ -366,9 +396,6 @@ defmodule Plausible.Teams.Invitations do end end - defp translate_role(:admin), do: :editor - defp translate_role(role), do: role - @doc false def check_team_member_limit(_team, :owner, _invitee_email), do: :ok @@ -398,7 +425,10 @@ defmodule Plausible.Teams.Invitations do defp create_invitation(site, invitee_email, role, inviter) do Repo.transaction(fn -> - with {:ok, team_invitation} <- create_team_invitation(site.team, invitee_email, inviter), + with {:ok, team_invitation} <- + create_team_invitation(site.team, invitee_email, inviter, + ensure_no_site_transfers_for: site.id + ), {:ok, guest_invitation} <- create_guest_invitation(team_invitation, site, role) do guest_invitation else @@ -407,16 +437,34 @@ defmodule Plausible.Teams.Invitations do end) end - defp create_team_invitation(team, invitee_email, inviter) do + defp create_team_invitation(team, invitee_email, inviter, opts \\ []) do now = NaiveDateTime.utc_now(:second) - team - |> Teams.Invitation.changeset(email: invitee_email, role: :guest, inviter: inviter) - |> Repo.insert( - on_conflict: [set: [updated_at: now]], - conflict_target: [:team_id, :email], - returning: true - ) + result = + Ecto.Multi.new() + |> Ecto.Multi.put( + :changeset, + Teams.Invitation.changeset(team, email: invitee_email, role: :guest, inviter: inviter) + ) + |> Ecto.Multi.run(:ensure_no_site_transfers, fn _repo, %{changeset: changeset} -> + ensure_no_site_transfers(changeset, opts[:ensure_no_site_transfers_for], invitee_email) + end) + |> Ecto.Multi.insert( + :team_invitation, + & &1.changeset, + on_conflict: [set: [updated_at: now]], + conflict_target: [:team_id, :email], + returning: true + ) + |> Repo.transaction() + + case result do + {:ok, success} -> + {:ok, success.team_invitation} + + {:error, _, changeset, _} -> + {:error, changeset} + end end defp create_guest_invitation(team_invitation, site, role) do @@ -513,4 +561,22 @@ defmodule Plausible.Teams.Invitations do |> PlausibleWeb.Email.invitation_accepted(team_invitation.email, guest_invitation.site) |> Plausible.Mailer.send() end + + defp ensure_no_site_transfers(_, nil, _) do + {:ok, :skip} + end + + defp ensure_no_site_transfers(changeset, site_id, invitee_email) + when is_integer(site_id) and is_binary(invitee_email) do + q = + from st in Teams.SiteTransfer, + where: st.email == ^invitee_email, + where: st.site_id == ^site_id + + if Repo.exists?(q) do + {:error, Ecto.Changeset.add_error(changeset, :invitation, "already sent")} + else + {:ok, :pass} + end + end end diff --git a/lib/plausible/teams/membership.ex b/lib/plausible/teams/membership.ex index b446d995a3a1..afc740565def 100644 --- a/lib/plausible/teams/membership.ex +++ b/lib/plausible/teams/membership.ex @@ -7,6 +7,8 @@ defmodule Plausible.Teams.Membership do import Ecto.Changeset + @type t() :: %__MODULE__{} + schema "team_memberships" do field :role, Ecto.Enum, values: [:guest, :viewer, :editor, :admin, :owner] diff --git a/lib/plausible/teams/memberships.ex b/lib/plausible/teams/memberships.ex index de2c8aac2b2f..ee1492cc3973 100644 --- a/lib/plausible/teams/memberships.ex +++ b/lib/plausible/teams/memberships.ex @@ -11,34 +11,6 @@ defmodule Plausible.Teams.Memberships do email |> pending_site_transfers_query() |> Repo.all() - |> Enum.map(fn transfer -> - %Plausible.Auth.Invitation{ - site_id: transfer.site_id, - email: transfer.email, - invitation_id: transfer.transfer_id, - role: :owner - } - end) - end - - def any_pending_site_transfers?(email) do - email - |> pending_site_transfers_query() - |> Repo.exists?() - end - - def get(team, user) do - result = - from(tm in Teams.Membership, - left_join: gm in assoc(tm, :guest_memberships), - where: tm.team_id == ^team.id and tm.user_id == ^user.id - ) - |> Repo.one() - - case result do - nil -> {:error, :not_a_member} - team_membership -> {:ok, team_membership} - end end def team_role(team, user) do @@ -83,46 +55,76 @@ defmodule Plausible.Teams.Memberships do end end - def update_role_sync(site_membership) do - site_id = site_membership.site_id - user_id = site_membership.user_id - role = site_membership.role + def has_admin_access?(site, user) do + case site_role(site, user) do + {:ok, role} when role in [:editor, :admin, :owner] -> + true + + _ -> + false + end + end - new_role = - case role do - :viewer -> :viewer - _ -> :editor - end + def update_role(site, user_id, new_role_str, current_user, current_user_role) do + new_role = String.to_existing_atom(new_role_str) - case get_guest_membership(site_id, user_id) do + case get_guest_membership(site.id, user_id) do {:ok, guest_membership} -> - guest_membership - |> Ecto.Changeset.change(role: new_role) - |> Ecto.Changeset.put_change(:updated_at, site_membership.updated_at) - |> Repo.update!() + can_grant_role? = + if guest_membership.team_membership.user_id == current_user.id do + can_grant_role_to_self?(current_user_role, new_role) + else + can_grant_role_to_other?(current_user_role, new_role) + end + + if can_grant_role? do + guest_membership = + guest_membership + |> Ecto.Changeset.change(role: new_role) + |> Repo.update!() + |> Repo.preload(team_membership: :user) + + {:ok, guest_membership} + else + {:error, :not_allowed} + end {:error, _} -> - :pass + {:error, :no_guest} end - - :ok end - def remove_sync(site_membership) do - site_id = site_membership.site_id - user_id = site_membership.user_id - - case get_guest_membership(site_id, user_id) do + def remove(site, user) do + case get_guest_membership(site.id, user.id) do {:ok, guest_membership} -> - guest_membership = Repo.preload(guest_membership, team_membership: :team) + guest_membership = + Repo.preload(guest_membership, [:site, team_membership: [:team, :user]]) + Repo.delete!(guest_membership) prune_guests(guest_membership.team_membership.team) + send_site_member_removed_email(guest_membership) {:error, _} -> :pass end end + defp can_grant_role_to_self?(:editor, :viewer), do: true + defp can_grant_role_to_self?(_, _), do: false + + defp can_grant_role_to_other?(:owner, :editor), do: true + defp can_grant_role_to_other?(:owner, :admin), do: true + defp can_grant_role_to_other?(:owner, :viewer), do: true + defp can_grant_role_to_other?(:editor, :editor), do: true + defp can_grant_role_to_other?(:editor, :viewer), do: true + defp can_grant_role_to_other?(_, _), do: false + + defp send_site_member_removed_email(guest_membership) do + guest_membership + |> PlausibleWeb.Email.site_member_removed() + |> Plausible.Mailer.send() + end + def prune_guests(team) do guest_query = from( @@ -148,7 +150,8 @@ defmodule Plausible.Teams.Memberships do from( gm in Teams.GuestMembership, inner_join: tm in assoc(gm, :team_membership), - where: gm.site_id == ^site_id and tm.user_id == ^user_id + where: gm.site_id == ^site_id and tm.user_id == ^user_id, + preload: [team_membership: tm] ) case Repo.one(query) do @@ -158,6 +161,6 @@ defmodule Plausible.Teams.Memberships do end defp pending_site_transfers_query(email) do - from st in Teams.SiteTransfer, where: st.email == ^email + from st in Teams.SiteTransfer, where: st.email == ^email, select: st.site_id end end diff --git a/lib/plausible/teams/site_transfer.ex b/lib/plausible/teams/site_transfer.ex index d46dfaab0b6f..54b9f91eff32 100644 --- a/lib/plausible/teams/site_transfer.ex +++ b/lib/plausible/teams/site_transfer.ex @@ -7,6 +7,8 @@ defmodule Plausible.Teams.SiteTransfer do import Ecto.Changeset + @type t() :: %__MODULE__{} + schema "team_site_transfers" do field :transfer_id, :string field :email, :string diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex index 62e01d9ada5d..c09ee1a8b6b9 100644 --- a/lib/plausible/teams/sites.ex +++ b/lib/plausible/teams/sites.ex @@ -10,43 +10,6 @@ defmodule Plausible.Teams.Sites do @type list_opt() :: {:filter_by_domain, String.t()} - @spec create(Teams.Team.t(), map()) :: {:ok, map()} - def create(team, params) do - with :ok <- Teams.Billing.ensure_can_add_new_site(team) do - Ecto.Multi.new() - |> Ecto.Multi.put(:site_changeset, Site.new_for_team(team, params)) - |> Ecto.Multi.run(:clear_changed_from, fn - _repo, %{site_changeset: %{changes: %{domain: domain}}} -> - if site_to_clear = Repo.get_by(Site, team_id: team.id, domain_changed_from: domain) do - site_to_clear - |> Ecto.Changeset.change() - |> Ecto.Changeset.put_change(:domain_changed_from, nil) - |> Ecto.Changeset.put_change(:domain_changed_at, nil) - |> Repo.update() - else - {:ok, :ignore} - end - - _repo, _context -> - {:ok, :ignore} - end) - |> Ecto.Multi.insert(:site, fn %{site_changeset: site} -> site end) - |> maybe_start_trial(team) - |> Repo.transaction() - end - end - - defp maybe_start_trial(multi, team) do - case team.trial_expiry_date do - nil -> - changeset = Teams.Team.start_trial(team) - Ecto.Multi.update(multi, :team, changeset) - - _ -> - multi - end - end - @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) @@ -103,7 +66,7 @@ defmodule Plausible.Teams.Sites do |> Repo.paginate(pagination_params) end - @role_type Plausible.Auth.Invitation.__schema__(:type, :role) + @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 @@ -267,14 +230,14 @@ defmodule Plausible.Teams.Sites do ), pinned_at: selected_as(up.pinned_at, :pinned_at), memberships: [ - %Plausible.Site.Membership{ + %{ role: type(u.role, ^@role_type), site_id: s.id, site: s } ], invitations: [ - %Plausible.Auth.Invitation{ + %{ invitation_id: coalesce(gi.invitation_id, st.transfer_id), email: coalesce(ti.email, st.email), role: type(u.role, ^@role_type), diff --git a/lib/plausible/teams/team.ex b/lib/plausible/teams/team.ex index 3db626150b80..f9f732bd9232 100644 --- a/lib/plausible/teams/team.ex +++ b/lib/plausible/teams/team.ex @@ -11,6 +11,7 @@ defmodule Plausible.Teams.Team do @type t() :: %__MODULE__{} @trial_accept_traffic_until_offset_days 14 + @subscription_accept_traffic_until_offset_days 30 schema "teams" do field :name, :string @@ -32,15 +33,9 @@ defmodule Plausible.Teams.Team do timestamps() end - def sync_changeset(team, user) do + def crm_sync_changeset(team, params) do team - |> change() - |> put_change(:trial_expiry_date, user.trial_expiry_date) - |> put_change(:accept_traffic_until, user.accept_traffic_until) - |> put_change(:allow_next_upgrade_override, user.allow_next_upgrade_override) - |> put_embed(:grace_period, embed_params(user.grace_period)) - |> put_change(:inserted_at, user.inserted_at) - |> put_change(:updated_at, user.updated_at) + |> cast(params, [:trial_expiry_date, :allow_next_upgrade_override, :accept_traffic_until]) end def changeset(name, today \\ Date.utc_today()) do @@ -48,6 +43,7 @@ defmodule Plausible.Teams.Team do |> cast(%{name: name}, [:name]) |> validate_required(:name) |> start_trial(today) + |> maybe_bump_accept_traffic_until() end def start_trial(team, today \\ Date.utc_today()) do @@ -59,13 +55,31 @@ defmodule Plausible.Teams.Team do ) end - defp embed_params(nil), do: nil + def end_trial(team) do + change(team, trial_expiry_date: Date.utc_today() |> Date.shift(day: -1)) + end + + defp maybe_bump_accept_traffic_until(changeset) do + expiry_change = get_change(changeset, :trial_expiry_date) - defp embed_params(grace_period) do - Map.from_struct(grace_period) + if expiry_change do + put_change( + changeset, + :accept_traffic_until, + Date.add(expiry_change, @trial_accept_traffic_until_offset_days) + ) + else + changeset + end end - defp trial_expiry(today) do + def trial_accept_traffic_until_offset_days(), do: @trial_accept_traffic_until_offset_days + + def subscription_accept_traffic_until_offset_days(), + do: @subscription_accept_traffic_until_offset_days + + @doc false + def trial_expiry(today \\ Date.utc_today()) do on_ee do Date.shift(today, day: 30) else diff --git a/lib/plausible/users.ex b/lib/plausible/users.ex index a53121b7bae5..e8a2bdb2eab5 100644 --- a/lib/plausible/users.ex +++ b/lib/plausible/users.ex @@ -2,45 +2,11 @@ defmodule Plausible.Users do @moduledoc """ User context """ - use Plausible - @accept_traffic_until_free ~D[2135-01-01] - import Ecto.Query alias Plausible.Auth - alias Plausible.Auth.GracePeriod - alias Plausible.Billing.Subscription alias Plausible.Repo - @spec on_trial?(Auth.User.t()) :: boolean() - on_ee do - def on_trial?(%Auth.User{trial_expiry_date: nil}), do: false - - def on_trial?(user) do - user = with_subscription(user) - not Plausible.Billing.Subscriptions.active?(user.subscription) && trial_days_left(user) >= 0 - end - else - def on_trial?(_), do: true - end - - @spec trial_days_left(Auth.User.t()) :: integer() - def trial_days_left(user) do - Date.diff(user.trial_expiry_date, Date.utc_today()) - end - - @spec update_accept_traffic_until(Auth.User.t()) :: Auth.User.t() - def update_accept_traffic_until(user) do - user = - user - |> Auth.User.changeset(%{accept_traffic_until: accept_traffic_until(user)}) - |> Repo.update!() - - Plausible.Teams.sync_team(user) - - user - end - @spec bump_last_seen(Auth.User.t() | pos_integer(), NaiveDateTime.t()) :: :ok def bump_last_seen(%Auth.User{id: user_id}, now) do bump_last_seen(user_id, now) @@ -54,147 +20,8 @@ defmodule Plausible.Users do :ok end - @spec accept_traffic_until(Auth.User.t()) :: Date.t() - on_ee do - def accept_traffic_until(user) do - user = with_subscription(user) - - cond do - Plausible.Users.on_trial?(user) -> - Date.shift(user.trial_expiry_date, - day: Auth.User.trial_accept_traffic_until_offset_days() - ) - - user.subscription && user.subscription.paddle_plan_id == "free_10k" -> - @accept_traffic_until_free - - user.subscription && user.subscription.next_bill_date -> - Date.shift(user.subscription.next_bill_date, - day: Auth.User.subscription_accept_traffic_until_offset_days() - ) - - true -> - raise "This user is neither on trial or has a valid subscription. Manual intervention required." - end - end - else - def accept_traffic_until(_user) do - @accept_traffic_until_free - end - end - - def with_subscription(%Auth.User{} = user) do - Repo.preload(user, subscription: last_subscription_query()) - end - - def with_subscription(user_id) when is_integer(user_id) do - Repo.one( - from(user in Auth.User, - as: :user, - left_lateral_join: s in subquery(last_subscription_join_query()), - on: true, - where: user.id == ^user_id, - preload: [subscription: s] - ) - ) - end - @spec has_email_code?(Auth.User.t()) :: boolean() def has_email_code?(user) do Auth.EmailVerification.any?(user) end - - def start_trial(%Auth.User{} = user) do - user = - user - |> Auth.User.start_trial() - |> Repo.update!() - - Plausible.Teams.sync_team(user) - - user - end - - def allow_next_upgrade_override(%Auth.User{} = user) do - user = - user - |> Auth.User.changeset(%{allow_next_upgrade_override: true}) - |> Repo.update!() - - Plausible.Teams.sync_team(user) - - user - end - - def maybe_reset_next_upgrade_override(%Auth.User{} = user) do - if user.allow_next_upgrade_override do - user = - user - |> Auth.User.changeset(%{allow_next_upgrade_override: false}) - |> Repo.update!() - - Plausible.Teams.sync_team(user) - - user - else - user - end - end - - def last_subscription_join_query() do - from(subscription in last_subscription_query(), - where: subscription.user_id == parent_as(:user).id - ) - end - - def start_grace_period(user) do - user = - user - |> GracePeriod.start_changeset() - |> Repo.update!() - - Plausible.Teams.sync_team(user) - - user - end - - def start_manual_lock_grace_period(user) do - user = - user - |> GracePeriod.start_manual_lock_changeset() - |> Repo.update!() - - Plausible.Teams.sync_team(user) - - user - end - - def end_grace_period(user) do - user = - user - |> GracePeriod.end_changeset() - |> Repo.update!() - - Plausible.Teams.sync_team(user) - - user - end - - def remove_grace_period(user) do - user = - user - |> GracePeriod.remove_changeset() - |> Repo.update!() - - Plausible.Teams.sync_team(user) - - user - end - - def last_subscription_query() do - from(subscription in Subscription, - order_by: [desc: subscription.inserted_at], - limit: 1 - ) - end end diff --git a/lib/plausible_web/components/site/feature.ex b/lib/plausible_web/components/site/feature.ex index 79f0fb49131b..92b69971ea5d 100644 --- a/lib/plausible_web/components/site/feature.ex +++ b/lib/plausible_web/components/site/feature.ex @@ -15,7 +15,7 @@ defmodule PlausibleWeb.Components.Site.Feature do assigns = assigns |> assign(:current_setting, assigns.feature_mod.enabled?(assigns.site)) - |> assign(:disabled?, assigns.feature_mod.check_availability(assigns.site.owner) !== :ok) + |> assign(:disabled?, assigns.feature_mod.check_availability(assigns.site.team) !== :ok) ~H"""
diff --git a/lib/plausible_web/controllers/admin_controller.ex b/lib/plausible_web/controllers/admin_controller.ex index ba5331be7dfb..f122c20afa3b 100644 --- a/lib/plausible_web/controllers/admin_controller.ex +++ b/lib/plausible_web/controllers/admin_controller.ex @@ -89,7 +89,7 @@ defmodule PlausibleWeb.AdminController do - Usage - team:#{team.id} + Usage - team:#{team && team.id}