From 729a32e6103f8e01c3e7b9e3bdd549e6af7ed0ad Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 16 Dec 2024 12:11:14 +0100 Subject: [PATCH] Teams writes switch (#4883) * Comment out legacy fields and relationships * WIP * WIP 2 * WIP 3 * wip * Remove teams backfill and consistency check scripts * WIP 3 * Fix CheckUsage tests * Update billing/subscription tests * WIP 4 * Make site transfer fail if some invitation already exists * Fixup: do symmetric invitation/site transfer check * Update UI bugs: make listing sites/inviting admins work like before * Fix Sites test * Fix external sites controller test * Fix live sites tests * Fix props availability lookup * Fix site controller tests * Fix billing controller tests * WIP - accept invitation tests * Another round of test fixes + invitations logic bugs * users_test -> teams_test * Update registration via invitation Here, we still rely on "polymorphic" invitation structures, hence the "unified by id" helper. For now, it'll remain local unless we discover it's needed in the broader `Teams.Invitations` context. cc @zoldar * Yet another round of test and bugfixes along the way * Include team in site setup success e-mail * Fix send_site_setup_emails worker * Fixed almost all tests except CRM ones * Update enterprise plan admin test * Fix CRM + remaining tests * Address credo warnings (modulo one FIXME) * Remove last FIXME and rephrase the invitation test case description * Set Team fields via User CRM transparently * Map user reference in Enterprise Plan CRM via team owner * Fix resource actions in user CRM * Get rid of warning when opening create form in API keys CRM * Stop emitting warnings when editing Enterprise Plans via CRM * Tests: Bump await_clickhouse_count interval * Remove XXX marker * Fix register from invitation link in email sent for ownership transfer * Simplify fetching all pending site ownership site IDs * Remove commented out schema fields * Remove unused functions * Address flakiness in ingest counter tests * Remove unused `Teams.Sites.create` * Don't restart trial on team with subscription when creating site * Account for cases of legacy teams with empty trial expiry date * Revert "Address flakiness in ingest counter tests" This reverts commit 60dc1e411597a83023393945b610fe74d5ff0066. * Fix flaky ingest counters tests under load * Attempt 2 * Pre-emptively hardcode site ids in sampling cache test to avoid supplying the same IDs alongside with counters test, that inserts through another repo (async). what we're observing is, clickhouse not summing mergetree columns fast enough, even though we wait quite a bit. * Fix ingest counter tests by accounting for delayed summation --------- Co-authored-by: Adam Rutkowski --- extra/lib/plausible/funnels.ex | 8 +- extra/lib/plausible/stats/goal/revenue.ex | 4 +- lib/mix/tasks/create_free_subscription.ex | 4 +- lib/mix/tasks/pull_sandbox_subscription.ex | 2 - lib/plausible/auth/api_key_admin.ex | 2 +- lib/plausible/auth/auth.ex | 24 - lib/plausible/auth/grace_period.ex | 38 +- lib/plausible/auth/invitation.ex | 30 - lib/plausible/auth/user.ex | 70 +- lib/plausible/auth/user_admin.ex | 91 +- lib/plausible/billing/billing.ex | 42 +- lib/plausible/billing/enterprise_plan.ex | 12 +- .../billing/enterprise_plan_admin.ex | 16 +- lib/plausible/billing/site_locker.ex | 53 +- lib/plausible/billing/subscription.ex | 6 +- .../data_migration/backfill_teams.ex | 848 ------------------ .../data_migration/teams_consistency_check.ex | 335 ------- lib/plausible/goals/goals.ex | 4 +- lib/plausible/ingestion/counters/buffer.ex | 3 +- lib/plausible/ingestion/event.ex | 14 +- lib/plausible/props.ex | 12 +- lib/plausible/site.ex | 9 +- lib/plausible/site/admin.ex | 14 +- lib/plausible/site/membership.ex | 31 - lib/plausible/site/memberships.ex | 20 - .../site/memberships/accept_invitation.ex | 183 +--- .../site/memberships/create_invitation.ex | 34 +- lib/plausible/site/memberships/invitations.ex | 50 -- .../site/memberships/reject_invitation.ex | 40 +- .../site/memberships/remove_invitation.ex | 26 +- lib/plausible/sites.ex | 70 +- lib/plausible/teams.ex | 91 +- lib/plausible/teams/billing.ex | 10 +- lib/plausible/teams/guest_invitation.ex | 2 + lib/plausible/teams/guest_membership.ex | 2 + lib/plausible/teams/invitation.ex | 2 + lib/plausible/teams/invitations.ex | 374 ++++---- lib/plausible/teams/membership.ex | 2 + lib/plausible/teams/memberships.ex | 115 +-- lib/plausible/teams/site_transfer.ex | 2 + lib/plausible/teams/sites.ex | 43 +- lib/plausible/teams/team.ex | 40 +- lib/plausible/users.ex | 173 ---- lib/plausible_web/components/site/feature.ex | 2 +- .../controllers/admin_controller.ex | 2 +- .../controllers/api/internal_controller.ex | 4 +- .../controllers/api/stats_controller.ex | 10 +- .../controllers/auth_controller.ex | 6 +- .../controllers/invitation_controller.ex | 30 +- .../controllers/site/membership_controller.ex | 116 +-- lib/plausible_web/email.ex | 61 +- lib/plausible_web/live/choose_plan.ex | 4 +- lib/plausible_web/live/goal_settings/form.ex | 6 +- lib/plausible_web/live/register_form.ex | 90 +- lib/plausible_web/live/sites.ex | 14 +- .../plugs/authorize_site_access.ex | 8 +- .../email/invitation_rejected.html.heex | 4 +- .../ownership_transfer_rejected.html.heex | 4 +- .../ownership_transfer_request.html.heex | 2 +- .../email/site_member_removed.html.heex | 2 +- .../email/site_setup_help_email.html.heex | 2 +- .../email/site_setup_success_email.html.heex | 2 +- .../templates/layout/_header.html.heex | 4 +- .../membership/invite_member_form.html.heex | 2 +- .../templates/site/new.html.heex | 2 +- .../templates/site/settings_people.html.heex | 2 +- .../templates/stats/stats.html.heex | 4 +- lib/plausible_web/two_factor/session.ex | 2 +- lib/plausible_web/user_auth.ex | 5 +- lib/plausible_web/views/layout_view.ex | 4 +- .../accept_traffic_until_notification.ex | 17 +- lib/workers/check_usage.ex | 6 +- lib/workers/clean_invitations.ex | 5 - lib/workers/lock_sites.ex | 16 +- lib/workers/notify_annual_renewal.ex | 41 +- lib/workers/send_check_stats_emails.ex | 24 +- lib/workers/send_email_report.ex | 2 +- lib/workers/send_site_setup_emails.ex | 43 +- lib/workers/traffic_change_notifier.ex | 32 +- test/plausible/auth/auth_test.exs | 57 +- test/plausible/auth/grace_period_test.exs | 77 +- test/plausible/auth/users_test.exs | 91 -- test/plausible/billing/billing_test.exs | 241 ++--- .../billing/enterprise_plan_admin_test.exs | 7 +- .../billing/enterprise_plan_test.exs | 10 +- test/plausible/billing/feature_test.exs | 2 +- test/plausible/billing/plans_test.exs | 12 +- test/plausible/billing/quota_test.exs | 40 +- test/plausible/billing/site_locker_test.exs | 73 +- test/plausible/help_scout_test.exs | 4 +- test/plausible/ingestion/counters_test.exs | 58 +- test/plausible/purge_test.exs | 5 +- test/plausible/site/membership_test.exs | 13 - .../memberships/accept_invitation_test.exs | 123 ++- .../memberships/create_invitation_test.exs | 46 +- .../memberships/reject_invitation_test.exs | 19 +- .../memberships/remove_invitation_test.exs | 19 +- test/plausible/site/sites_test.exs | 92 +- test/plausible/stats/query_test.exs | 7 +- test/plausible/stats/sampling_cache_test.exs | 31 +- test/plausible/teams_test.exs | 92 ++ .../components/billing/notice_test.exs | 4 +- .../controllers/admin_controller_test.exs | 2 +- .../api/external_sites_controller_test.exs | 37 +- .../api/internal_controller/sync_test.exs | 3 +- .../api/internal_controller_test.exs | 16 +- .../api/stats_controller/conversions_test.exs | 11 +- .../controllers/auth_controller_test.exs | 40 +- .../controllers/billing_controller_test.exs | 23 +- .../invitation_controller_test.exs | 87 +- .../controllers/settings_controller_test.exs | 22 +- .../site/membership_controller_test.exs | 81 +- .../controllers/site_controller_test.exs | 17 +- .../controllers/stats_controller_test.exs | 2 +- test/plausible_web/email_test.exs | 19 +- test/plausible_web/live/choose_plan_test.exs | 13 +- .../live/funnel_settings_test.exs | 5 +- .../plausible_web/live/goal_settings_test.exs | 5 +- .../live/props_settings_test.exs | 5 +- .../plausible_web/live/register_form_test.exs | 45 +- test/plausible_web/live/sites_test.exs | 12 +- test/plausible_web/plugs/auth_plug_test.exs | 21 +- .../plugs/authorize_public_api_test.exs | 6 +- .../plugs/authorize_site_access_test.exs | 4 +- test/support/factory.ex | 26 - test/support/teams/test.ex | 146 +-- test/support/test_utils.ex | 2 +- test/workers/accept_traffic_until_test.exs | 72 +- test/workers/check_usage_test.exs | 161 +--- test/workers/clean_invitations_test.exs | 74 +- test/workers/import_analytics_test.exs | 57 +- test/workers/lock_sites_test.exs | 27 +- test/workers/notify_annual_renewal_test.exs | 81 +- .../notify_exported_analytics_test.exs | 5 +- test/workers/send_check_stats_emails_test.exs | 18 +- test/workers/send_site_setup_emails_test.exs | 4 +- .../workers/send_trial_notifications_test.exs | 5 +- test/workers/traffic_change_notifier_test.exs | 39 +- 138 files changed, 1975 insertions(+), 3872 deletions(-) delete mode 100644 lib/plausible/auth/invitation.ex delete mode 100644 lib/plausible/data_migration/backfill_teams.ex delete mode 100644 lib/plausible/data_migration/teams_consistency_check.ex delete mode 100644 lib/plausible/site/membership.ex delete mode 100644 lib/plausible/site/memberships/invitations.ex delete mode 100644 test/plausible/auth/users_test.exs delete mode 100644 test/plausible/site/membership_test.exs create mode 100644 test/plausible/teams_test.exs diff --git a/extra/lib/plausible/funnels.ex b/extra/lib/plausible/funnels.ex index 84f30295d6..24ac019cbf 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 08da84d8aa..6c2d96ede1 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 846e7ce0c8..be89017458 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 7aad712547..56ba45b654 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 aa9dc7be3f..ff7f7094aa 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 69d767d595..2ad4ebb295 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 40c5df052f..d709039cc0 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 d0a2eb70f9..0000000000 --- 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 a0682ca91f..e210798f6f 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 79057ac17e..4e24f343eb 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 a4fa1cf32f..459c50fe55 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 9589c1e0d8..afc166cc88 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 027fdbe706..5ca92b0113 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 f3f82e5c8e..666bb0e048 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 c4a645dd50..def530a09a 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 eb1aee2c69..0000000000 --- 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 e192b04057..0000000000 --- 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 e9a6083771..af17a254f4 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 40b3b1b671..2519149a06 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 ad04a639ad..c235e567e5 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 25c9ca46b8..da1d9054b5 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 b0ffe7aa1f..5ea17a189f 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 1422a62e7f..2607778a31 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 26e791fdb2..0000000000 --- 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 9f381ed6ef..ea9797ee7f 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 4677cbb374..b7839939bf 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) + :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) - 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} - - {: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) + :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) + + site = site |> Repo.reload!() |> Repo.preload(ownership: :user) + + {:ok, %{team_membership: site.ownership, site: site}} end end - defp do_accept_invitation(invitation, user) do - membership = get_or_create_membership(invitation, 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} - 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 45e903e01a..cb405624c6 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) + {:ok, invitation_or_transfer} <- + Teams.Invitations.invite(site, invitee_email, role, inviter) do + send_invitation_email(invitation_or_transfer, invitee) - send_invitation_email(inviter, invitation, 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 dd2b55af33..0000000000 --- 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 98e9d4c820..d1cca277f8 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) - - notify_invitation_rejected(invitation) - - {:ok, invitation} + {: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 - defp notify_invitation_rejected(%Auth.Invitation{role: :owner} = invitation) do - PlausibleWeb.Email.ownership_transfer_rejected(invitation) + defp do_reject(%Teams.GuestInvitation{} = guest_invitation) do + Teams.Invitations.remove_guest_invitation(guest_invitation) + + 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_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 e4f1baa5b5..97f23ffd73 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 cd99d77f71..a56f5db781 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 32c9154aa1..ecd430b7e1 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 d1e219878c..e168fc1f5d 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 815a608fab..a8ecc57e29 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 3932685e10..d045583de2 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 473dc7ea68..5a5722a09f 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 161d9f8cf1..ff064e0010 100644 --- a/lib/plausible/teams/invitations.ex +++ b/lib/plausible/teams/invitations.ex @@ -9,106 +9,144 @@ 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 - ) - - 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 - ) - - guest_invitation - |> Ecto.Changeset.change(invitation_id: site_invitation.invitation_id) - |> Repo.update!() + 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 - def remove_invitation_sync(site_invitation) do - site = Repo.preload(site_invitation, :site).site + 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 + + 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 + + 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]) + + case transfer do + nil -> + {:error, :invitation_not_found} + + transfer -> + {:ok, transfer} + end + end + + 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]) + + case invitation do + nil -> + {:error, :invitation_not_found} + + invitation -> + {:ok, invitation} + end + end + + 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]) + + case transfer do + nil -> + {:error, :invitation_not_found} + + transfer -> + {:ok, transfer} + end + end + + def invite(site, invitee_email, role, inviter) do site = Teams.load_for_site(site) - 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 - ) + if role == :owner do + create_site_transfer( + site, + inviter, + invitee_email ) 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 - ) + create_invitation( + site, + invitee_email, + role, + inviter ) - - prune_guest_invitations(site.team) end + end + + def remove_guest_invitation(guest_invitation) do + site = Repo.preload(guest_invitation, site: :team).site + + Repo.delete_all( + from gi in Teams.GuestInvitation, + where: gi.id == ^guest_invitation.id + ) + + prune_guest_invitations(site.team) + end + + def remove_site_transfer(site_transfer) do + Repo.delete_all( + from st in Teams.SiteTransfer, + where: st.id == ^site_transfer.id + ) + end + + 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 end - def transfer_site_sync(site, user) do - {:ok, team} = Teams.get_or_create(user) - site = Teams.load_for_site(site) - - site = - Repo.preload(site, [ - :team, - :owner, - guest_memberships: [team_membership: :user], - guest_invitations: [team_invitation: :inviter] - ]) - + 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)) end) + + :ok end - def accept_invitation_sync(site_invitation, user) do - site_invitation = - Repo.preload( - site_invitation, - site: :team - ) - - site = Teams.load_for_site(site_invitation.site) - site_invitation = %{site_invitation | site: site} - - role = - case site_invitation.role do - :viewer -> :viewer - :admin -> :editor - end - - {:ok, guest_invitation} = - create_invitation( - site_invitation.site, - site_invitation.email, - role, - site_invitation.inviter - ) + def accept_guest_invitation(guest_invitation, user) do + guest_invitation = Repo.preload(guest_invitation, :site) team_invitation = guest_invitation.team_invitation @@ -118,48 +156,12 @@ defmodule Plausible.Teams.Invitations do guest_invitations: :site ]) - {:ok, _} = - result = - do_accept(team_invitation, user, NaiveDateTime.utc_now(:second), - send_email?: false, - guest_invitations: [guest_invitation] - ) + now = NaiveDateTime.utc_now(:second) - prune_guest_invitations(team_invitation.team) - result - end - - def accept_transfer_sync(site_invitation, user) do - {:ok, team} = Teams.get_or_create(user) - - site = - site_invitation.site - |> Teams.load_for_site() - |> Repo.preload([ - :team, - :owner, - guest_memberships: [team_membership: :user], - guest_invitations: [team_invitation: :inviter] - ]) - - {:ok, site_transfer} = - create_site_transfer(site, site_invitation.inviter, site_invitation.email) - - {:ok, _} = - Repo.transaction(fn -> - :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} + 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 - Plausible.Mailer.send(email) + 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() + + 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 b446d995a3..afc740565d 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 de2c8aac2b..ee1492cc39 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 - new_role = - case role do - :viewer -> :viewer - _ -> :editor - end - - 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!() - - {:error, _} -> - :pass + _ -> + false end - - :ok end - def remove_sync(site_membership) do - site_id = site_membership.site_id - user_id = site_membership.user_id + 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 = Repo.preload(guest_membership, team_membership: :team) + 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, _} -> + {:error, :no_guest} + end + end + + def remove(site, user) do + case get_guest_membership(site.id, user.id) do + {:ok, guest_membership} -> + 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 d46dfaab0b..54b9f91eff 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 62e01d9ada..c09ee1a8b6 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 3db626150b..f9f732bd92 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 - - defp embed_params(grace_period) do - Map.from_struct(grace_period) + def end_trial(team) do + change(team, trial_expiry_date: Date.utc_today() |> Date.shift(day: -1)) end - defp trial_expiry(today) do + 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 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 a53121b7ba..e8a2bdb2ea 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 79f0fb4913..92b69971ea 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 ba5331be7d..f122c20afa 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}