diff --git a/.credo.exs b/.credo.exs index 882e3a7c2..f0f0f3980 100644 --- a/.credo.exs +++ b/.credo.exs @@ -83,7 +83,7 @@ # If you don't want TODO comments to cause `mix credo` to fail, just # set this value to 0 (zero). # - {Credo.Check.Design.TagTODO, [exit_status: 2]}, + {Credo.Check.Design.TagTODO, [exit_status: 0]}, {Credo.Check.Design.TagFIXME, []}, # diff --git a/lib/mix/tasks/create_free_subscription.ex b/lib/mix/tasks/create_free_subscription.ex index be0c7a993..846e7ce0c 100644 --- a/lib/mix/tasks/create_free_subscription.ex +++ b/lib/mix/tasks/create_free_subscription.ex @@ -15,10 +15,13 @@ defmodule Mix.Tasks.CreateFreeSubscription do def execute(user_id) do user = Repo.get(Plausible.Auth.User, user_id) + {:ok, team} = Plausible.Teams.get_or_create(user) - Subscription.free(%{user_id: user_id}) + Subscription.free(%{user_id: user_id, 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 438b36951..7aad71254 100644 --- a/lib/mix/tasks/pull_sandbox_subscription.ex +++ b/lib/mix/tasks/pull_sandbox_subscription.ex @@ -40,6 +40,8 @@ defmodule Mix.Tasks.PullSandboxSubscription do if body["success"] 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(), @@ -47,6 +49,7 @@ defmodule Mix.Tasks.PullSandboxSubscription do 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"], next_bill_date: res["next_payment"]["date"], diff --git a/lib/plausible.ex b/lib/plausible.ex index b4c922618..671892a20 100644 --- a/lib/plausible.ex +++ b/lib/plausible.ex @@ -4,6 +4,7 @@ defmodule Plausible do """ @ce_builds [:ce, :ce_test, :ce_dev] + @public_builds [:ce, :prod] defmacro __using__(_) do quote do @@ -58,4 +59,12 @@ defmodule Plausible do "Plausible Analytics" end end + + defmacro with_teams(do: do_block) do + if Mix.env() in @public_builds do + quote do + unquote(do_block) + end + end + end end diff --git a/lib/plausible/auth/user.ex b/lib/plausible/auth/user.ex index 116c1437f..00aaed639 100644 --- a/lib/plausible/auth/user.ex +++ b/lib/plausible/auth/user.ex @@ -53,6 +53,7 @@ defmodule Plausible.Auth.User do 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 diff --git a/lib/plausible/auth/user_admin.ex b/lib/plausible/auth/user_admin.ex index 4ebafa13b..5be0fa09e 100644 --- a/lib/plausible/auth/user_admin.ex +++ b/lib/plausible/auth/user_admin.ex @@ -62,10 +62,18 @@ defmodule Plausible.Auth.UserAdmin do ] end + with_teams do + def after_update(_conn, user) do + Plausible.Teams.sync_team(user) + + {:ok, user} + end + end + defp lock(user) do if user.grace_period do Plausible.Billing.SiteLocker.set_lock_status_for(user, true) - user |> Plausible.Auth.GracePeriod.end_changeset() |> Repo.update() + {:ok, Plausible.Users.end_grace_period(user)} else {:error, user, "No active grace period on this user"} end @@ -73,7 +81,7 @@ defmodule Plausible.Auth.UserAdmin do defp unlock(user) do if user.grace_period do - Plausible.Auth.GracePeriod.remove_changeset(user) |> Repo.update() + Plausible.Users.remove_grace_period(user) Plausible.Billing.SiteLocker.set_lock_status_for(user, false) {:ok, user} else diff --git a/lib/plausible/billing/billing.ex b/lib/plausible/billing/billing.ex index 1278a07cf..c8a3b4c5f 100644 --- a/lib/plausible/billing/billing.ex +++ b/lib/plausible/billing/billing.ex @@ -120,10 +120,32 @@ defmodule Plausible.Billing do defp handle_subscription_created(params) do params = if present?(params["passthrough"]) do - params + case String.split(to_string(params["passthrough"]), ";") do + [user_id] -> + user = Repo.get!(User, user_id) + {:ok, team} = Plausible.Teams.get_or_create(user) + Map.put(params, "team_id", team.id) + + [user_id, ""] -> + user = Repo.get!(User, user_id) + {:ok, team} = Plausible.Teams.get_or_create(user) + + params + |> Map.put("passthrough", user_id) + |> Map.put("team_id", team.id) + + [user_id, team_id] -> + params + |> Map.put("passthrough", user_id) + |> Map.put("team_id", team_id) + end else - user = Repo.get_by(User, email: params["email"]) - Map.put(params, "passthrough", user && user.id) + user = Repo.get_by!(User, email: params["email"]) + {:ok, team} = Plausible.Teams.get_or_create(user) + + params + |> Map.put("passthrough", user.id) + |> Map.put("team_id", team.id) end subscription_params = format_subscription(params) |> add_last_bill_date(params) @@ -209,7 +231,7 @@ defmodule Plausible.Billing do end defp format_subscription(params) do - %{ + params = %{ paddle_subscription_id: params["subscription_id"], paddle_plan_id: params["subscription_plan_id"], cancel_url: params["cancel_url"], @@ -220,6 +242,12 @@ defmodule Plausible.Billing do next_bill_amount: params["unit_price"] || params["new_unit_price"], currency_code: params["currency"] } + + if team_id = params["team_id"] do + Map.put(params, :team_id, team_id) + else + params + end end defp add_last_bill_date(subscription_params, paddle_params) do @@ -236,12 +264,6 @@ defmodule Plausible.Billing do defp present?(nil), do: false defp present?(_), do: true - defp remove_grace_period(%User{} = user) do - user - |> Plausible.Auth.GracePeriod.remove_changeset() - |> Repo.update!() - end - @spec format_price(Money.t()) :: String.t() def format_price(money) do Money.to_string!(money, fractional_digits: 2, no_fraction_if_integer: true) @@ -275,7 +297,7 @@ defmodule Plausible.Billing do user |> Plausible.Users.update_accept_traffic_until() - |> remove_grace_period() + |> Plausible.Users.remove_grace_period() |> Plausible.Users.maybe_reset_next_upgrade_override() |> tap(&Plausible.Billing.SiteLocker.update_sites_for/1) |> maybe_adjust_api_key_limits() diff --git a/lib/plausible/billing/enterprise_plan.ex b/lib/plausible/billing/enterprise_plan.ex index 54fa7be42..2a224cce2 100644 --- a/lib/plausible/billing/enterprise_plan.ex +++ b/lib/plausible/billing/enterprise_plan.ex @@ -13,6 +13,10 @@ 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] @@ -23,13 +27,14 @@ defmodule Plausible.Billing.EnterprisePlan do field :hourly_api_request_limit, :integer belongs_to :user, Plausible.Auth.User + belongs_to :team, Plausible.Teams.Team timestamps() end def changeset(model, attrs \\ %{}) do model - |> cast(attrs, @required_fields) + |> cast(attrs, @required_fields ++ @optional_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 b88b1944b..027fdbe70 100644 --- a/lib/plausible/billing/enterprise_plan_admin.ex +++ b/lib/plausible/billing/enterprise_plan_admin.ex @@ -51,6 +51,16 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do def create_changeset(schema, attrs) do attrs = sanitize_attrs(attrs) + + team_id = + if user_id = attrs["user_id"] do + user = Repo.get!(Plausible.Auth.User, user_id) + {:ok, team} = Plausible.Teams.get_or_create(user) + team.id + end + + attrs = Map.put(attrs, "team_id", team_id) + Plausible.Billing.EnterprisePlan.changeset(schema, attrs) end diff --git a/lib/plausible/billing/site_locker.ex b/lib/plausible/billing/site_locker.ex index 11b2c4228..de47bdefa 100644 --- a/lib/plausible/billing/site_locker.ex +++ b/lib/plausible/billing/site_locker.ex @@ -21,9 +21,7 @@ defmodule Plausible.Billing.SiteLocker do set_lock_status_for(user, true) if user.grace_period.is_over != true do - user - |> Plausible.Auth.GracePeriod.end_changeset() - |> Repo.update!() + Plausible.Users.end_grace_period(user) if send_email? do send_grace_period_end_email(user) diff --git a/lib/plausible/billing/subscription.ex b/lib/plausible/billing/subscription.ex index e5c72af0f..f328a30dc 100644 --- a/lib/plausible/billing/subscription.ex +++ b/lib/plausible/billing/subscription.ex @@ -20,7 +20,7 @@ defmodule Plausible.Billing.Subscription do :currency_code ] - @optional_fields [:last_bill_date] + @optional_fields [:last_bill_date, :team_id] schema "subscriptions" do field :paddle_subscription_id, :string @@ -34,6 +34,7 @@ defmodule Plausible.Billing.Subscription do field :currency_code, :string belongs_to :user, Plausible.Auth.User + belongs_to :team, Plausible.Teams.Team timestamps() end @@ -52,7 +53,7 @@ defmodule Plausible.Billing.Subscription do next_bill_amount: "0", currency_code: "EUR" } - |> cast(attrs, @required_fields) + |> cast(attrs, @required_fields ++ @optional_fields) |> validate_required([:user_id]) |> unique_constraint(:paddle_subscription_id) end diff --git a/lib/plausible/data_migration.ex b/lib/plausible/data_migration.ex index 838b101ad..e33d55370 100644 --- a/lib/plausible/data_migration.ex +++ b/lib/plausible/data_migration.ex @@ -6,7 +6,7 @@ defmodule Plausible.DataMigration do defmacro __using__(opts) do dir = Keyword.fetch!(opts, :dir) - repo = Keyword.get(opts, :repo, Plausible.DataMigration.Repo) + repo = Keyword.get(opts, :repo, Plausible.DataMigration.ClickhouseRepo) quote bind_quoted: [dir: dir, repo: repo] do @dir dir diff --git a/lib/plausible/data_migration/backfill_teams.ex b/lib/plausible/data_migration/backfill_teams.ex new file mode 100644 index 000000000..9e544f101 --- /dev/null +++ b/lib/plausible/data_migration/backfill_teams.ex @@ -0,0 +1,724 @@ +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 + + defmacrop is_distinct(f1, f2) do + quote do + fragment("? IS DISTINCT FROM ?", unquote(f1), unquote(f2)) + end + end + + def run() do + # Teams backfill + db_url = + System.get_env( + "TEAMS_MIGRATION_DB_URL", + Application.get_env(:plausible, Plausible.Repo)[:url] + ) + + @repo.start(db_url, pool_size: System.schedulers_online() * 2) + + backfill() + end + + defp backfill() do + 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...") + + teams_count = backfill_teams(sites_without_teams) + + log("Backfilled #{teams_count} teams.") + + 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 = + 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..." + ) + + teams_count = backfill_teams_for_users(users_with_subscriptions_without_sites) + + log("Backfilled #{teams_count} teams from users with subscriptions without sites.") + + # 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["id"], t.grace_period["id"]) or + is_distinct(o.grace_period["is_over"], t.grace_period["is_over"]) or + is_distinct(o.grace_period["end_date"], t.grace_period["end_date"]) or + is_distinct(o.grace_period["manual_lock"], t.grace_period["manual_lock"]), + preload: [team_memberships: {tm, user: o}] + ) + |> @repo.all(timeout: :infinity) + + log("Found #{length(stale_teams)} teams which have fields out of sync...") + + sync_teams(stale_teams) + + # Subsciprtions backfill + + log("Brought out of sync teams up to date.") + + 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...") + + backfill_subscriptions(subscriptions_without_teams) + + log("All subscriptions are linked to a team now.") + + # 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...") + + backfill_enterprise_plans(enterprise_plans_without_teams) + + log("All enterprise plans are linked to a team now.") + + # 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...") + + 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.") + + # 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..." + ) + + backfill_guest_memberships(site_memberships_to_backfill) + + log("Backfilled missing guest memberships.") + + # 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...") + + sync_guest_memberships(stale_guest_memberships) + + log("All guest memberships are up to date now.") + + # 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...") + + 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.") + + # 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..." + ) + + backfill_guest_invitations(site_invitations_to_backfill) + + log("Backfilled missing guest invitations.") + + # 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), + select: {gi, si.role} + ) + |> @repo.all(timeout: :infinity) + + log("Found #{length(stale_guest_invitations)} guest invitations with role out of sync...") + + sync_guest_invitations(stale_guest_invitations) + + log("All guest invitations are up to date now.") + + # 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...") + + remove_site_transfers(site_transfers_to_remove) + + log("Site transfers cleared.") + + # 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..." + ) + + backfill_site_transfers(site_invitations_to_backfill) + + log("Backfilled missing site transfers.") + + log("All data are up to date now!") + 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 -> + team = + "My Team" + |> Teams.Team.changeset() + |> 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.put_change(:inserted_at, owner.inserted_at) + |> Ecto.Changeset.put_change(:updated_at, owner.updated_at) + |> @repo.insert!() + + team + |> Teams.Membership.changeset(owner, :owner) + |> Ecto.Changeset.put_change(:inserted_at, owner.inserted_at) + |> Ecto.Changeset.put_change(:updated_at, owner.updated_at) + |> @repo.insert!() + + @repo.update_all(from(s in Plausible.Site, where: s.id in ^site_ids), + set: [team_id: team.id] + ) + end, + timeout: :infinity + ) + + 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 -> + team = + "My Team" + |> Teams.Team.changeset() + |> 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.put_change(:inserted_at, owner.inserted_at) + |> Ecto.Changeset.put_change(:updated_at, owner.updated_at) + |> @repo.insert!() + + team + |> Teams.Membership.changeset(owner, :owner) + |> Ecto.Changeset.put_change(:inserted_at, owner.inserted_at) + |> Ecto.Changeset.put_change(:updated_at, owner.updated_at) + |> @repo.insert!() + end, + timeout: :infinity + ) + + 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, owner.grace_period) + |> @repo.update!() + end) + 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 + ) + |> 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 + ) + |> 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 + ) + |> 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(:invitation_id, first_site_invitation.invitation_id) + |> 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(: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_invitations_and_roles) do + guest_invitations_and_roles + |> Enum.with_index() + |> Enum.each(fn {{guest_invitation, role}, idx} -> + guest_invitation + |> Ecto.Changeset.change(role: translate_role(role)) + |> 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/repo.ex b/lib/plausible/data_migration/clickhouse_repo.ex similarity index 92% rename from lib/plausible/data_migration/repo.ex rename to lib/plausible/data_migration/clickhouse_repo.ex index 4215d45b8..522fa34f8 100644 --- a/lib/plausible/data_migration/repo.ex +++ b/lib/plausible/data_migration/clickhouse_repo.ex @@ -1,4 +1,4 @@ -defmodule Plausible.DataMigration.Repo do +defmodule Plausible.DataMigration.ClickhouseRepo do @moduledoc """ Ecto.Repo for Clickhouse data migrations, to be started manually, outside of the main application supervision tree. diff --git a/lib/plausible/data_migration/postgres_repo.ex b/lib/plausible/data_migration/postgres_repo.ex new file mode 100644 index 000000000..351850079 --- /dev/null +++ b/lib/plausible/data_migration/postgres_repo.ex @@ -0,0 +1,22 @@ +defmodule Plausible.DataMigration.PostgresRepo do + @moduledoc """ + Ecto.Repo for Posrtgres data migrations, to be started manually, + outside of the main application supervision tree. + """ + use Ecto.Repo, + otp_app: :plausible, + adapter: Ecto.Adapters.Postgres + + def start(url, opts \\ []) when is_binary(url) do + default_config = Plausible.Repo.config() + + start_link( + url: url, + queue_target: 500, + queue_interval: 2000, + pool_size: opts[:pool_size] || 1, + ssl: opts[:ssl] || default_config[:ssl], + ssl_opts: opts[:ssl_opts] || default_config[:ssl_opts] + ) + end +end diff --git a/lib/plausible/site.ex b/lib/plausible/site.ex index 5d0ab1391..63cdefa48 100644 --- a/lib/plausible/site.ex +++ b/lib/plausible/site.ex @@ -32,6 +32,11 @@ defmodule Plausible.Site do # NOTE: needed by `SiteImports` data migration script embeds_one :imported_data, Plausible.Site.ImportedData, on_replace: :update + # NOTE: new teams relations + belongs_to :team, Plausible.Teams.Team + has_many :guest_memberships, Plausible.Teams.GuestMembership + has_many :guest_invitations, Plausible.Teams.GuestInvitation + embeds_one :installation_meta, Plausible.Site.InstallationMeta, on_replace: :update, defaults_to_struct: true @@ -69,6 +74,12 @@ defmodule Plausible.Site do timestamps() end + def new_for_team(team, params) do + params + |> new() + |> put_assoc(:team, team) + end + def new(params), do: changeset(%__MODULE__{}, params) @domain_unique_error """ diff --git a/lib/plausible/site/memberships/accept_invitation.ex b/lib/plausible/site/memberships/accept_invitation.ex index 98f82d3dd..b1cc23031 100644 --- a/lib/plausible/site/memberships/accept_invitation.ex +++ b/lib/plausible/site/memberships/accept_invitation.ex @@ -36,10 +36,20 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do with :ok <- Invitations.ensure_transfer_valid(site, user, :owner), :ok <- Invitations.ensure_can_take_ownership(site, user) do membership = get_or_create_owner_membership(site, user) + multi = add_and_transfer_ownership(site, membership, user) case Repo.transaction(multi) do {:ok, changes} -> + with_teams do + sync_job = + Task.async(fn -> + Plausible.Teams.Invitations.transfer_site_sync(site, user) + end) + + Task.await(sync_job) + end + membership = Repo.preload(changes.membership, [:site, :user]) {:ok, membership} @@ -72,6 +82,10 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do site = Repo.preload(invitation.site, :owner) with :ok <- Invitations.ensure_can_take_ownership(site, user) do + with_teams do + Plausible.Teams.Invitations.accept_transfer_sync(invitation, user) + end + site |> add_and_transfer_ownership(membership, user) |> Multi.delete(:invitation, invitation) @@ -80,6 +94,10 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do end defp do_accept_invitation(invitation, user) do + with_teams do + Plausible.Teams.Invitations.accept_invitation_sync(invitation, user) + end + membership = get_or_create_membership(invitation, user) invitation @@ -182,12 +200,17 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do end defp notify_invitation_accepted(%Auth.Invitation{role: :owner} = invitation) do - PlausibleWeb.Email.ownership_transfer_accepted(invitation) + PlausibleWeb.Email.ownership_transfer_accepted( + invitation.email, + invitation.inviter.email, + invitation.site + ) |> Plausible.Mailer.send() end defp notify_invitation_accepted(invitation) do - PlausibleWeb.Email.invitation_accepted(invitation) + invitation.inviter.email + |> PlausibleWeb.Email.invitation_accepted(invitation.email, invitation.site) |> Plausible.Mailer.send() end end diff --git a/lib/plausible/site/memberships/create_invitation.ex b/lib/plausible/site/memberships/create_invitation.ex index 615fd3b8f..ab52322c5 100644 --- a/lib/plausible/site/memberships/create_invitation.ex +++ b/lib/plausible/site/memberships/create_invitation.ex @@ -9,6 +9,7 @@ defmodule Plausible.Site.Memberships.CreateInvitation do alias Plausible.Site.Memberships.Invitations alias Plausible.Billing.Quota import Ecto.Query + use Plausible @type invite_error() :: Ecto.Changeset.t() @@ -80,6 +81,11 @@ defmodule Plausible.Site.Memberships.CreateInvitation do %Ecto.Changeset{} = changeset <- Invitation.new(attrs), {:ok, invitation} <- Plausible.Repo.insert(changeset) do send_invitation_email(invitation, invitee) + + with_teams do + Plausible.Teams.Invitations.invite_sync(site, invitation) + end + invitation else {:error, cause} -> Plausible.Repo.rollback(cause) @@ -108,9 +114,29 @@ defmodule Plausible.Site.Memberships.CreateInvitation do email = case {invitee, invitation.role} do - {invitee, :owner} -> PlausibleWeb.Email.ownership_transfer_request(invitation, invitee) - {nil, _role} -> PlausibleWeb.Email.new_user_invitation(invitation) - {%User{}, _role} -> PlausibleWeb.Email.existing_user_invitation(invitation) + {invitee, :owner} -> + PlausibleWeb.Email.ownership_transfer_request( + invitation.email, + invitation.invitation_id, + invitation.site, + invitation.inviter, + invitee + ) + + {nil, _role} -> + PlausibleWeb.Email.new_user_invitation( + invitation.email, + invitation.invitation_id, + invitation.site, + invitation.inviter + ) + + {%User{}, _role} -> + PlausibleWeb.Email.existing_user_invitation( + invitation.email, + invitation.site, + invitation.inviter + ) end Plausible.Mailer.send(email) diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index 97d6feaff..a492ec093 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -208,8 +208,9 @@ defmodule Plausible.Sites do defp maybe_start_trial(multi, user) do case user.trial_expiry_date do nil -> - changeset = Auth.User.start_trial(user) - Ecto.Multi.update(multi, :user, changeset) + Ecto.Multi.run(multi, :user, fn _, _ -> + {:ok, Plausible.Users.start_trial(user)} + end) _ -> multi diff --git a/lib/plausible/teams.ex b/lib/plausible/teams.ex new file mode 100644 index 000000000..b953de26e --- /dev/null +++ b/lib/plausible/teams.ex @@ -0,0 +1,102 @@ +defmodule Plausible.Teams do + @moduledoc """ + Core context of teams. + """ + + import Ecto.Query + + alias __MODULE__ + alias Plausible.Repo + + def with_subscription(team) do + Repo.preload(team, subscription: last_subscription_query()) + end + + def owned_sites(team) do + Repo.preload(team, :sites).sites + end + + @doc """ + Get or create user's team. + + If the user has no non-guest membership yet, an implicit "My Team" team is + created with them as an owner. + + If the user already has an owner membership in an existing team, + that team is returned. + + If the user has a non-guest membership other than owner, `:no_team` error + is returned. + """ + def get_or_create(user) do + with {:error, :no_team} <- get_owned_by_user(user) do + case create_my_team(user) do + {:ok, team} -> {:ok, team} + {:error, :exists_already} -> get_owned_by_user(user) + end + end + end + + def sync_team(user) do + case get_owned_by_user(user) do + {:ok, team} -> + team + |> Teams.Team.sync_changeset(user) + |> Repo.update!() + + _ -> + :skip + end + + :ok + end + + defp create_my_team(user) do + team = + "My Team" + |> Teams.Team.changeset() + |> Ecto.Changeset.put_change(:inserted_at, user.inserted_at) + |> Ecto.Changeset.put_change(:updated_at, user.updated_at) + |> Repo.insert!() + + team_membership = + team + |> Teams.Membership.changeset(user, :owner) + |> Ecto.Changeset.put_change(:inserted_at, user.inserted_at) + |> Ecto.Changeset.put_change(:updated_at, user.updated_at) + |> Repo.insert!( + on_conflict: :nothing, + conflict_target: {:unsafe_fragment, "(user_id) WHERE role != 'guest'"} + ) + + if team_membership.id do + {:ok, team} + else + Repo.delete!(team) + {:error, :exists_already} + end + end + + defp get_owned_by_user(user) do + result = + from(tm in Teams.Membership, + inner_join: t in assoc(tm, :team), + where: tm.user_id == ^user.id and tm.role == :owner, + select: t, + order_by: t.id + ) + |> Repo.one() + + case result do + nil -> {:error, :no_team} + team -> {:ok, team} + end + end + + defp last_subscription_query() do + from(subscription in Plausible.Billing.Subscription, + order_by: [desc: subscription.inserted_at], + limit: 1 + ) + end +end diff --git a/lib/plausible/teams/billing.ex b/lib/plausible/teams/billing.ex new file mode 100644 index 000000000..2cd5aec8a --- /dev/null +++ b/lib/plausible/teams/billing.ex @@ -0,0 +1,235 @@ +defmodule Plausible.Teams.Billing do + @moduledoc false + + import Ecto.Query + + alias Plausible.Billing.EnterprisePlan + alias Plausible.Billing.Plans + alias Plausible.Billing.Subscriptions + alias Plausible.Repo + alias Plausible.Teams + + @team_member_limit_for_trials 3 + @limit_sites_since ~D[2021-05-05] + @site_limit_for_trials 10 + + def ensure_can_add_new_site(team) do + team = Teams.with_subscription(team) + + case Plans.get_subscription_plan(team.subscription) do + %EnterprisePlan{} -> + :ok + + _ -> + usage = site_usage(team) + limit = site_limit(team) + + if Plausible.Billing.Quota.below_limit?(usage, limit) do + :ok + else + {:error, {:over_limit, limit}} + end + end + end + + def site_limit(team) do + if Timex.before?(team.inserted_at, @limit_sites_since) do + :unlimited + else + get_site_limit_from_plan(team) + end + end + + def site_usage(team) do + team + |> Teams.owned_sites() + |> length() + end + + defp get_site_limit_from_plan(team) do + team = Teams.with_subscription(team) + + case Plans.get_subscription_plan(team.subscription) do + %{site_limit: site_limit} -> site_limit + :free_10k -> 50 + nil -> @site_limit_for_trials + end + end + + def team_member_limit(team) do + team = Teams.with_subscription(team) + + case Plans.get_subscription_plan(team.subscription) do + %{team_member_limit: limit} -> limit + :free_10k -> :unlimited + nil -> @team_member_limit_for_trials + end + end + + def quota_usage(team, opts) do + team = Teams.with_subscription(team) + with_features? = Keyword.get(opts, :with_features, false) + pending_site_ids = Keyword.get(opts, :pending_ownership_site_ids, []) + team_site_ids = team |> Teams.owned_sites() |> Enum.map(& &1.id) + all_site_ids = pending_site_ids ++ team_site_ids + + monthly_pageviews = monthly_pageview_usage(team, all_site_ids) + team_member_usage = team_member_usage(team, pending_ownership_site_ids: pending_site_ids) + + basic_usage = %{ + monthly_pageviews: monthly_pageviews, + team_members: team_member_usage, + sites: length(all_site_ids) + } + + if with_features? do + Map.put(basic_usage, :features, features_usage(team, all_site_ids)) + else + basic_usage + end + end + + def monthly_pageview_usage(team, site_ids) do + team = Teams.with_subscription(team) + active_subscription? = Subscriptions.active?(team.subscription) + + if active_subscription? and team.subscription.last_bill_date != nil do + [:current_cycle, :last_cycle, :penultimate_cycle] + |> Task.async_stream(fn cycle -> + {cycle, usage_cycle(team, cycle, site_ids)} + end) + |> Enum.into(%{}, fn {:ok, cycle_usage} -> cycle_usage end) + else + %{last_30_days: usage_cycle(team, :last_30_days, site_ids)} + end + end + + def team_member_usage(team, opts) do + exclude_emails = Keyword.get(opts, :exclude_emails, []) + pending_site_ids = Keyword.get(opts, :pending_ownership_site_ids, []) + + team + |> query_team_member_emails(pending_site_ids, exclude_emails) + |> Repo.aggregate(:count) + end + + def usage_cycle(team, cycle, owned_site_ids \\ nil, today \\ Date.utc_today()) + + def usage_cycle(team, cycle, nil, today) do + owned_site_ids = team |> Teams.owned_sites() |> Enum.map(& &1.id) + usage_cycle(team, cycle, owned_site_ids, today) + end + + def usage_cycle(_team, :last_30_days, owned_site_ids, today) do + date_range = Date.range(Date.shift(today, day: -30), today) + + {pageviews, custom_events} = + Plausible.Stats.Clickhouse.usage_breakdown(owned_site_ids, date_range) + + %{ + date_range: date_range, + pageviews: pageviews, + custom_events: custom_events, + total: pageviews + custom_events + } + end + + def usage_cycle(team, cycle, owned_site_ids, today) do + team = Teams.with_subscription(team) + last_bill_date = team.subscription.last_bill_date + + normalized_last_bill_date = + Date.shift(last_bill_date, month: Timex.diff(today, last_bill_date, :months)) + + date_range = + case cycle do + :current_cycle -> + Date.range( + normalized_last_bill_date, + Date.shift(normalized_last_bill_date, month: 1, day: -1) + ) + + :last_cycle -> + Date.range( + Date.shift(normalized_last_bill_date, month: -1), + Date.shift(normalized_last_bill_date, day: -1) + ) + + :penultimate_cycle -> + Date.range( + Date.shift(normalized_last_bill_date, month: -2), + Date.shift(normalized_last_bill_date, day: -1, month: -1) + ) + end + + {pageviews, custom_events} = + Plausible.Stats.Clickhouse.usage_breakdown(owned_site_ids, date_range) + + %{ + date_range: date_range, + pageviews: pageviews, + custom_events: custom_events, + total: pageviews + custom_events + } + end + + def features_usage(user, site_ids \\ nil) + + def features_usage(%Teams.Team{} = team, nil) do + owned_site_ids = team |> Teams.owned_sites() |> Enum.map(& &1.id) + features_usage(team, owned_site_ids) + end + + def features_usage(%Teams.Team{} = team, owned_site_ids) when is_list(owned_site_ids) do + site_scoped_feature_usage = features_usage(nil, owned_site_ids) + + stats_api_used? = + from(a in Plausible.Auth.ApiKey, where: a.team_id == ^team.id) + |> Plausible.Repo.exists?() + + if stats_api_used? do + site_scoped_feature_usage ++ [Feature.StatsAPI] + else + site_scoped_feature_usage + end + end + + def features_usage(nil, owned_site_ids) when is_list(owned_site_ids) do + Plausible.Billing.Quota.Usage.features_usage(nil, owned_site_ids) + end + + defp query_team_member_emails(team, site_ids, exclude_emails) do + pending_memberships_q = + from tm in Teams.Membership, + inner_join: u in assoc(tm, :user), + inner_join: gm in assoc(tm, :guest_memberships), + where: gm.site_id in ^site_ids and tm.role != :owner, + where: u.email not in ^exclude_emails, + select: %{email: u.email} + + pending_invitations_q = + from ti in Teams.Invitation, + inner_join: gi in assoc(ti, :guest_invitations), + where: gi.site_id in ^site_ids and ti.role != :owner, + where: ti.email not in ^exclude_emails, + select: %{email: ti.email} + + team_memberships_q = + from tm in Teams.Membership, + inner_join: u in assoc(tm, :user), + where: tm.team_id == ^team.id and tm.role != :owner, + where: u.email not in ^exclude_emails, + select: %{email: u.email} + + team_invitations_q = + from ti in Teams.Invitation, + where: ti.team_id == ^team.id and ti.role != :owner, + where: ti.email not in ^exclude_emails, + select: %{email: ti.email} + + pending_memberships_q + |> union(^pending_invitations_q) + |> union(^team_memberships_q) + |> union(^team_invitations_q) + end +end diff --git a/lib/plausible/teams/guest_invitation.ex b/lib/plausible/teams/guest_invitation.ex new file mode 100644 index 000000000..9f0e71002 --- /dev/null +++ b/lib/plausible/teams/guest_invitation.ex @@ -0,0 +1,26 @@ +defmodule Plausible.Teams.GuestInvitation do + @moduledoc """ + Guest invitation schema + """ + + use Ecto.Schema + + import Ecto.Changeset + + schema "guest_invitations" do + field :role, Ecto.Enum, values: [:viewer, :editor] + + belongs_to :site, Plausible.Site + belongs_to :team_invitation, Plausible.Teams.Invitation + + timestamps() + end + + def changeset(team_invitation, site, role) do + %__MODULE__{} + |> cast(%{role: role}, [:role]) + |> validate_required(:role) + |> put_assoc(:team_invitation, team_invitation) + |> put_assoc(:site, site) + end +end diff --git a/lib/plausible/teams/guest_membership.ex b/lib/plausible/teams/guest_membership.ex new file mode 100644 index 000000000..3932685e1 --- /dev/null +++ b/lib/plausible/teams/guest_membership.ex @@ -0,0 +1,26 @@ +defmodule Plausible.Teams.GuestMembership do + @moduledoc """ + Guest membership schema + """ + + use Ecto.Schema + + import Ecto.Changeset + + schema "guest_memberships" do + field :role, Ecto.Enum, values: [:viewer, :editor] + + belongs_to :team_membership, Plausible.Teams.Membership + belongs_to :site, Plausible.Site + + timestamps() + end + + def changeset(team_membership, site, role) do + %__MODULE__{} + |> change() + |> put_change(:role, role) + |> put_assoc(:team_membership, team_membership) + |> put_assoc(:site, site) + end +end diff --git a/lib/plausible/teams/invitation.ex b/lib/plausible/teams/invitation.ex new file mode 100644 index 000000000..473dc7ea6 --- /dev/null +++ b/lib/plausible/teams/invitation.ex @@ -0,0 +1,34 @@ +defmodule Plausible.Teams.Invitation do + @moduledoc """ + Team invitation schema + """ + + use Ecto.Schema + + import Ecto.Changeset + + schema "team_invitations" do + field :invitation_id, :string + field :email, :string + field :role, Ecto.Enum, values: [:guest, :viewer, :editor, :admin, :owner] + + belongs_to :inviter, Plausible.Auth.User + belongs_to :team, Plausible.Teams.Team + + has_many :guest_invitations, Plausible.Teams.GuestInvitation, foreign_key: :team_invitation_id + + timestamps() + end + + def changeset(team, opts) do + email = Keyword.fetch!(opts, :email) + role = Keyword.fetch!(opts, :role) + inviter = Keyword.fetch!(opts, :inviter) + + %__MODULE__{invitation_id: Nanoid.generate()} + |> cast(%{email: email, role: role}, [:email, :role]) + |> validate_required([:email, :role]) + |> put_assoc(:team, team) + |> put_assoc(:inviter, inviter) + end +end diff --git a/lib/plausible/teams/invitations.ex b/lib/plausible/teams/invitations.ex new file mode 100644 index 000000000..e443901b2 --- /dev/null +++ b/lib/plausible/teams/invitations.ex @@ -0,0 +1,560 @@ +defmodule Plausible.Teams.Invitations do + @moduledoc false + + import Ecto.Query + + alias Plausible.Auth + alias Plausible.Billing + alias Plausible.Repo + alias Plausible.Teams + + def invite(site, inviter, invitee_email, role, opts \\ []) + + def invite(site, initiator, invitee_email, :owner, opts) do + check_permissions? = opts[:check_permissions] + site = Repo.preload(site, :team) + + with :ok <- check_transfer_permissions(site.team, initiator, check_permissions?), + new_owner = Plausible.Auth.find_user_by(email: invitee_email), + :ok <- ensure_transfer_valid(site.team, new_owner), + {:ok, site_transfer} <- create_site_transfer(site, initiator, invitee_email) do + send_transfer_init_email(site_transfer, new_owner) + {:ok, site_transfer} + end + end + + def invite(site, inviter, invitee_email, role, opts) do + check_permissions? = opts[:check_permissions] + site = Repo.preload(site, :team) + role = translate_role(role) + + with :ok <- check_invitation_permissions(site.team, inviter, check_permissions?), + :ok <- check_team_member_limit(site.team, role, invitee_email), + invitee = Auth.find_user_by(email: invitee_email), + :ok <- ensure_new_membership(site, invitee, role), + {:ok, guest_invitation} <- create_invitation(site, invitee_email, role, inviter) do + send_invitation_email(guest_invitation, invitee) + {:ok, guest_invitation} + end + end + + def invite_sync(site, site_invitation) do + site = Repo.preload(site, :team) + site_invitation = Repo.preload(site_invitation, :inviter) + role = translate_role(site_invitation.role) + + if site_invitation.role == :owner do + create_site_transfer( + site, + site_invitation.inviter, + site_invitation.email + ) + else + create_invitation( + site, + site_invitation.email, + role, + site_invitation.inviter + ) + end + catch + _, thrown -> + Sentry.capture_message( + "Failed to sync invitation for site ##{site.id} and email ##{site_invitation.email}", + extra: %{ + error: inspect(thrown) + } + ) + end + + def transfer_site(site, new_owner, now \\ NaiveDateTime.utc_now(:second)) do + site = Repo.preload(site, :team) + + with :ok <- ensure_transfer_valid(site.team, new_owner), + {:ok, team} <- Teams.get_or_create(new_owner), + :ok <- ensure_can_take_ownership(site, team) do + site = + Repo.preload(site, [ + :owner, + guest_memberships: [team_membership: :user], + guest_invitations: [team_invitation: :user] + ]) + + {:ok, _} = + Repo.transaction(fn -> + :ok = transfer_site_ownership(site, team, now) + end) + + {:ok, team_membership} = Teams.Memberships.get(team, new_owner) + + {:ok, team_membership} + end + end + + def transfer_site_sync(site, user) do + {:ok, team} = Plausible.Teams.get_or_create(user) + + site = + Repo.preload(site, [ + :owner, + guest_memberships: [team_membership: :user], + guest_invitations: [team_invitation: :user] + ]) + + {:ok, _} = + Repo.transaction(fn -> + :ok = transfer_site_ownership(site, team, NaiveDateTime.utc_now(:second)) + end) + catch + _, thrown -> + Sentry.capture_message( + "Failed to sync transfer site for site ##{site.id} and user ##{user.id}", + extra: %{ + error: inspect(thrown) + } + ) + end + + def accept(invitation_id, user, now \\ NaiveDateTime.utc_now(:second)) do + case find_for_user(invitation_id, user) do + {:ok, %Teams.Invitation{} = team_invitation} -> + do_accept(team_invitation, user, now) + + {:ok, %Teams.SiteTransfer{} = site_transfer} -> + do_transfer(site_transfer, user, now) + + {:error, _} = error -> + error + end + end + + def accept_invitation_sync(site_invitation, user) do + site_invitation = + Repo.preload( + site_invitation, + site: :team + ) + + role = + case site_invitation.role do + :viewer -> :viewer + :admin -> :editor + end + + guest_invitation = + create_invitation( + site_invitation.site, + site_invitation.invitee_email, + role, + site_invitation.inviter + ) + + {:ok, _} = + do_accept(guest_invitation.team_invitation, user, NaiveDateTime.utc_now(:second), + send_email?: false + ) + catch + _, thrown -> + Sentry.capture_message( + "Failed to sync accept invitation for site ##{site_invitation.site_id} and user ##{user.id}", + extra: %{ + error: inspect(thrown) + } + ) + end + + def accept_transfer_sync(site_invitation, user) do + {:ok, team} = Teams.get_or_create(user) + + site = + Repo.preload(site_invitation.site, [:owner, guest_memberships: [team_membership: :user]]) + + 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) + catch + _, thrown -> + Sentry.capture_message( + "Failed to sync accept transfer for site ##{site_invitation.site_id} and user ##{user.id}", + extra: %{ + error: inspect(thrown) + } + ) + end + + defp check_transfer_permissions(_team, _initiator, false = _check_permissions?) do + :ok + end + + defp check_transfer_permissions(team, initiator, _) do + case Teams.Memberships.team_role(team, initiator) do + {:ok, :owner} -> :ok + _ -> {:error, :forbidden} + end + end + + defp ensure_transfer_valid(_team, nil), do: :ok + + defp ensure_transfer_valid(team, new_owner) do + case Teams.Memberships.team_role(team, new_owner) do + {:ok, :owner} -> {:error, :transfer_to_self} + _ -> :ok + end + end + + 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] + ) + end + + defp 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 + ) + + Plausible.Mailer.send(email) + end + + defp do_accept(team_invitation, user, now, opts \\ []) do + send_email? = Keyword.get(opts, :send_email?, true) + guest_invitations = team_invitation.guest_invitations + + Repo.transaction(fn -> + with {:ok, team_membership} <- + create_team_membership(team_invitation.team, team_invitation.role, user, now), + {:ok, _guest_memberships} <- + create_guest_memberships(team_membership, guest_invitations, now) do + Repo.delete!(team_invitation) + + if send_email? do + send_invitation_accepted_email(team_invitation, guest_invitations) + end + + team_membership + else + {:error, changeset} -> Repo.rollback(changeset) + end + end) + end + + defp do_transfer(site_transfer, new_owner, now) do + # That's probably the most involved flow of all so far + # - if new owner does not have a team yet, create one + # - ensure the new team can take ownership of the site + # - move site to and create guest memberships in the new team + # - create editor guest membership for the old owner + # - remove old guest memberships + site_transfer = Repo.preload(site_transfer, [:initiator, site: :team]) + + with :ok <- ensure_transfer_valid(site_transfer.site.team, new_owner), + {:ok, team} <- Teams.get_or_create(new_owner), + :ok <- ensure_can_take_ownership(site_transfer.site, team) do + site = Repo.preload(site_transfer.site, guest_memberships: [team_membership: :user]) + + {:ok, _} = + Repo.transaction(fn -> + :ok = transfer_site_ownership(site, team, now) + Repo.delete!(site_transfer) + end) + + send_transfer_accepted_email(site_transfer) + + {:ok, team_membership} = Teams.Memberships.get(team, new_owner) + + {:ok, team_membership} + end + end + + defp transfer_site_ownership(site, team, now) do + prior_team = site.team + + site + |> Ecto.Changeset.change(team_id: team.id) + |> Repo.update!() + + {_old_team_invitations, old_guest_invitations} = + site.guest_invitations + |> Enum.map(fn old_guest_invitation -> + old_team_invitation = old_guest_invitation.team_invitation + + {:ok, new_team_invitation} = + create_team_invitation(team, old_team_invitation.email, now) + + {:ok, _new_guest_invitation} = + create_guest_invitation(new_team_invitation, site, old_guest_invitation.role) + + {old_team_invitation, old_guest_invitation} + end) + |> Enum.unzip() + + old_guest_ids = Enum.map(old_guest_invitations, & &1.id) + :ok = prune_guest_invitations(prior_team, ignore_guest_ids: old_guest_ids) + + {_old_team_memberships, old_guest_memberships} = + site.guest_memberships + |> Enum.map(fn old_guest_membership -> + old_team_membership = old_guest_membership.team_membership + + {:ok, new_team_membership} = + create_team_membership(team, :guest, old_team_membership.user, now) + + if new_team_membership.role == :guest do + {:ok, _} = + new_team_membership + |> Teams.GuestMembership.changeset(site, old_guest_membership.role) + |> Repo.insert( + on_conflict: [set: [updated_at: now, role: old_guest_membership.role]], + conflict_target: [:team_membership_id, :site_id] + ) + end + + {old_team_membership, old_guest_membership} + end) + |> Enum.unzip() + + old_guest_ids = Enum.map(old_guest_memberships, & &1.id) + :ok = Teams.Memberships.prune_guests(prior_team, ignore_guest_ids: old_guest_ids) + + {:ok, prior_owner} = Teams.Sites.get_owner(prior_team) + + {:ok, prior_owner_team_membership} = create_team_membership(team, :guest, prior_owner, now) + + if prior_owner_team_membership.role == :guest do + {:ok, _} = + prior_owner_team_membership + |> Teams.GuestMembership.changeset(site, :editor) + |> Repo.insert( + on_conflict: [set: [updated_at: now, role: :editor]], + conflict_target: [:team_membership_id, :site_id] + ) + end + + # TODO: Update site lock status with SiteLocker + + :ok + end + + def prune_guest_invitations(team, opts \\ []) do + ignore_guest_ids = Keyword.get(opts, :ignore_guest_ids, []) + + guest_query = + from( + gi in Teams.GuestInvitation, + where: gi.team_invitation_id == parent_as(:team_invitation).id, + where: gi.id not in ^ignore_guest_ids, + select: true + ) + + Repo.delete_all( + from( + ti in Teams.Invitation, + as: :team_invitation, + where: ti.team_id == ^team.id and ti.role == :guest, + where: not exists(guest_query) + ) + ) + + :ok + end + + defp send_transfer_accepted_email(site_transfer) do + PlausibleWeb.Email.ownership_transfer_accepted( + site_transfer.email, + site_transfer.initiator.email, + site_transfer.site + ) + |> Plausible.Mailer.send() + end + + defp ensure_can_take_ownership(site, team) do + team = Teams.with_subscription(team) + plan = Billing.Plans.get_subscription_plan(team.subscription) + active_subscription? = Billing.Subscriptions.active?(team.subscription) + + if active_subscription? and plan != :free_10k do + team + |> Teams.Billing.quota_usage(pending_ownership_site_ids: [site.id]) + |> Billing.Quota.ensure_within_plan_limits(plan) + else + {:error, :no_plan} + end + end + + defp find_for_user(invitation_id, user) do + with {:error, :invitation_not_found} <- find_invitation(invitation_id, user) do + find_site_transfer(invitation_id, user) + end + end + + defp find_invitation(invitation_id, user) do + invitation = + Teams.Invitation + |> Repo.get_by(invitation_id: invitation_id, email: user.email) + |> Repo.preload([:team, :inviter, guest_invitations: :site]) + + if invitation do + {:ok, invitation} + else + {:error, :invitation_not_found} + end + end + + defp find_site_transfer(transfer_id, user) do + site_transfer = + Teams.SiteTransfer + |> Repo.get_by(transfer_id: transfer_id, email: user.email) + |> Repo.preload([:initiator, site: :team]) + + if site_transfer do + {:ok, site_transfer} + else + {:error, :invitation_not_found} + end + end + + defp check_invitation_permissions(_team, _inviter, false = _check_permission?) do + :ok + end + + defp check_invitation_permissions(team, inviter, _) do + case Teams.Memberships.team_role(team, inviter) do + {:ok, role} when role in [:owner, :admin] -> :ok + _ -> {:error, :forbidden} + end + end + + defp translate_role(:admin), do: :editor + defp translate_role(role), do: role + + defp check_team_member_limit(team, _role, invitee_email) do + limit = Teams.Billing.team_member_limit(team) + usage = Teams.Billing.team_member_usage(team, exclude_emails: [invitee_email]) + + if Billing.Quota.below_limit?(usage, limit) do + :ok + else + {:error, {:over_limit, limit}} + end + end + + defp ensure_new_membership(_site, nil, _role), do: :ok + + defp ensure_new_membership(site, invitee, _role) do + if Teams.Memberships.site_role(site, invitee) == {:error, :not_a_member} do + :ok + else + {:error, :already_a_member} + end + end + + defp create_invitation(site, invitee_email, role, inviter) do + Repo.transaction(fn -> + with {:ok, team_invitation} <- create_team_invitation(site.team, invitee_email, inviter), + {:ok, guest_invitation} <- create_guest_invitation(team_invitation, site, role) do + guest_invitation + else + {:error, changeset} -> Repo.rollback(changeset) + end + end) + end + + defp create_team_invitation(team, invitee_email, inviter) 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]) + end + + defp create_guest_invitation(team_invitation, site, role) do + now = NaiveDateTime.utc_now(:second) + + team_invitation + |> Teams.GuestInvitation.changeset(site, role) + |> Repo.insert( + on_conflict: [set: [updated_at: now]], + conflict_target: [:team_invitation_id, :site_id] + ) + end + + defp send_invitation_email(guest_invitation, invitee) do + team_invitation = guest_invitation.team_invitation + + email = + if invitee do + PlausibleWeb.Email.existing_user_invitation( + team_invitation.email, + guest_invitation.site, + team_invitation.inviter + ) + else + PlausibleWeb.Email.new_user_invitation( + team_invitation.email, + team_invitation.invitation_id, + guest_invitation.site, + team_invitation.inviter + ) + end + + Plausible.Mailer.send(email) + end + + defp create_team_membership(team, role, user, now) do + team + |> Teams.Membership.changeset(user, role) + |> Repo.insert( + on_conflict: [set: [updated_at: now]], + conflict_target: [:team_id, :user_id] + ) + end + + defp create_guest_memberships(_team_membership, [], _now) do + {:ok, []} + end + + defp create_guest_memberships(%{role: role} = _team_membership, _, _) when role != :guest do + {:ok, []} + end + + defp create_guest_memberships(team_membership, guest_invitations, now) do + Enum.reduce_while(guest_invitations, {:ok, []}, fn guest_invitation, + {:ok, guest_memberships} -> + result = + team_membership + |> Teams.GuestMembership.changeset(guest_invitation.site, guest_invitation.role) + |> Repo.insert( + on_conflict: [set: [updated_at: now, role: guest_invitation.role]], + conflict_target: [:team_membership_id, :site_id] + ) + + case result do + {:ok, guest_membership} -> {:cont, {:ok, [guest_membership | guest_memberships]}} + {:error, changeset} -> {:halt, {:error, changeset}} + end + end) + end + + defp send_invitation_accepted_email(_team_invitation, []) do + # NOOP for now + :ok + end + + defp send_invitation_accepted_email(team_invitation, [guest_invitation | _]) do + team_invitation.inviter.email + |> PlausibleWeb.Email.invitation_accepted(team_invitation.email, guest_invitation.site) + |> Plausible.Mailer.send() + end +end diff --git a/lib/plausible/teams/membership.ex b/lib/plausible/teams/membership.ex new file mode 100644 index 000000000..b446d995a --- /dev/null +++ b/lib/plausible/teams/membership.ex @@ -0,0 +1,29 @@ +defmodule Plausible.Teams.Membership do + @moduledoc """ + Team membership schema + """ + + use Ecto.Schema + + import Ecto.Changeset + + schema "team_memberships" do + field :role, Ecto.Enum, values: [:guest, :viewer, :editor, :admin, :owner] + + belongs_to :user, Plausible.Auth.User + belongs_to :team, Plausible.Teams.Team + + has_many :guest_memberships, Plausible.Teams.GuestMembership, foreign_key: :team_membership_id + + timestamps() + end + + def changeset(team, user, role) do + %__MODULE__{} + |> change() + |> put_change(:role, role) + |> put_assoc(:team, team) + |> put_assoc(:user, user) + |> unique_constraint(:user_id, name: :one_team_per_user) + end +end diff --git a/lib/plausible/teams/memberships.ex b/lib/plausible/teams/memberships.ex new file mode 100644 index 000000000..15f9c9cf0 --- /dev/null +++ b/lib/plausible/teams/memberships.ex @@ -0,0 +1,133 @@ +defmodule Plausible.Teams.Memberships do + @moduledoc false + + import Ecto.Query + + alias Plausible.Auth + alias Plausible.Repo + alias Plausible.Teams + + 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 + result = + from(u in Auth.User, + inner_join: tm in assoc(u, :team_memberships), + where: tm.team_id == ^team.id and tm.user_id == ^user.id, + select: tm.role + ) + |> Repo.one() + + case result do + nil -> {:error, :not_a_member} + role -> {:ok, role} + end + end + + def site_role(site, user) do + result = + from(u in Auth.User, + inner_join: tm in assoc(u, :team_memberships), + left_join: gm in assoc(tm, :guest_memberships), + where: tm.team_id == ^site.team_id and tm.user_id == ^user.id, + where: tm.role != :guest or gm.site_id == ^site.id, + select: {tm.role, gm.role} + ) + |> Repo.one() + + case result do + {:guest, role} -> {:ok, role} + {role, _} -> {:ok, role} + _ -> {:error, :not_a_member} + 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 + + 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 + end + + :ok + end + + def remove_sync(site_membership) do + site_id = site_membership.site_id + user_id = site_membership.user_id + + case get_guest_membership(site_id, user_id) do + {:ok, guest_membership} -> + guest_membership = Repo.preload(guest_membership, team_membership: :team) + Repo.delete!(guest_membership) + prune_guests(guest_membership.team_membership.team) + + {:error, _} -> + :pass + end + end + + def prune_guests(team, opts \\ []) do + ignore_guest_ids = Keyword.get(opts, :ignore_guest_ids, []) + + guest_query = + from( + gm in Teams.GuestMembership, + where: gm.team_membership_id == parent_as(:team_membership).id, + where: gm.id not in ^ignore_guest_ids, + select: true + ) + + Repo.delete_all( + from( + tm in Teams.Membership, + as: :team_membership, + where: tm.team_id == ^team.id and tm.role == :guest, + where: not exists(guest_query) + ) + ) + + :ok + end + + defp get_guest_membership(site_id, user_id) do + query = + 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 + ) + + case Repo.one(query) do + nil -> {:error, :no_guest} + membership -> {:ok, membership} + end + end +end diff --git a/lib/plausible/teams/site_transfer.ex b/lib/plausible/teams/site_transfer.ex new file mode 100644 index 000000000..d46dfaab0 --- /dev/null +++ b/lib/plausible/teams/site_transfer.ex @@ -0,0 +1,35 @@ +defmodule Plausible.Teams.SiteTransfer do + @moduledoc """ + Site transfer schema + """ + + use Ecto.Schema + + import Ecto.Changeset + + schema "team_site_transfers" do + field :transfer_id, :string + field :email, :string + field :transfer_guests, :boolean, default: true + + belongs_to :site, Plausible.Site + belongs_to :initiator, Plausible.Auth.User + belongs_to :destination_team, Plausible.Teams.Team + + timestamps() + end + + def changeset(site, opts) do + initiator = Keyword.fetch!(opts, :initiator) + transfer_guests = Keyword.get(opts, :transfer_guests, true) + destination_team = Keyword.get(opts, :destination_team) + email = Keyword.get(opts, :email) + + %__MODULE__{transfer_id: Nanoid.generate()} + |> cast(%{email: email}, [:email]) + |> put_change(:transfer_guests, transfer_guests) + |> put_assoc(:site, site) + |> put_assoc(:destination_team, destination_team) + |> put_assoc(:initiator, initiator) + end +end diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex new file mode 100644 index 000000000..173066ce0 --- /dev/null +++ b/lib/plausible/teams/sites.ex @@ -0,0 +1,206 @@ +defmodule Plausible.Teams.Sites do + @moduledoc false + + import Ecto.Query + + alias Plausible.Auth + alias Plausible.Repo + alias Plausible.Site + alias Plausible.Teams + + @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 get_owner(Teams.Team.t()) :: {:ok, Auth.User.t()} | {:error, :no_owner | :multiple_owners} + def get_owner(team) do + owner_query = + from( + tm in Teams.Membership, + inner_join: u in assoc(tm, :user), + where: tm.team_id == ^team.id and tm.role == :owner, + select: u + ) + + case Repo.all(owner_query) do + [owner_user] -> {:ok, owner_user} + [] -> {:error, :no_owner} + _ -> {:error, :multiple_owners} + 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) + + team_membership_query = + from tm in Teams.Membership, + inner_join: t in assoc(tm, :team), + inner_join: s in assoc(t, :sites), + where: tm.user_id == ^user.id and tm.role != :guest, + select: %{site_id: s.id, entry_type: "site"} + + guest_membership_query = + from tm in Teams.Membership, + inner_join: gm in assoc(tm, :guest_memberships), + inner_join: s in assoc(gm, :site), + where: tm.user_id == ^user.id and tm.role == :guest, + select: %{site_id: s.id, entry_type: "site"} + + union_query = + from s in team_membership_query, + union_all: ^guest_membership_query + + from(u in subquery(union_query), + inner_join: s in Plausible.Site, + on: u.site_id == s.id, + left_join: up in Site.UserPreference, + on: up.site_id == s.id, + select: %{ + s + | entry_type: + selected_as( + fragment( + """ + CASE + WHEN ? IS NOT NULL THEN 'pinned_site' + ELSE ? + END + """, + up.pinned_at, + u.entry_type + ), + :entry_type + ), + pinned_at: selected_as(up.pinned_at, :pinned_at) + }, + order_by: [ + asc: selected_as(:entry_type), + desc: selected_as(:pinned_at), + asc: s.domain + ] + ) + |> maybe_filter_by_domain(domain_filter) + |> Repo.paginate(pagination_params) + end + + @spec list_with_invitations(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t() + def list_with_invitations(user, pagination_params, opts \\ []) do + domain_filter = Keyword.get(opts, :filter_by_domain) + + team_membership_query = + from tm in Teams.Membership, + inner_join: t in assoc(tm, :team), + inner_join: s in assoc(t, :sites), + where: tm.user_id == ^user.id and tm.role != :guest, + select: %{site_id: s.id, entry_type: "site", invitation_id: 0, invitation_role: ""} + + guest_membership_query = + from(tm in Teams.Membership, + inner_join: gm in assoc(tm, :guest_memberships), + inner_join: s in assoc(gm, :site), + where: tm.user_id == ^user.id and tm.role == :guest, + select: %{site_id: s.id, entry_type: "site", invitation_id: 0, invitation_role: ""} + ) + + guest_invitation_query = + from ti in Teams.Invitation, + inner_join: gi in assoc(ti, :guest_invitations), + inner_join: s in assoc(gi, :site), + where: ti.email == ^user.email and ti.role == :guest, + select: %{ + site_id: s.id, + entry_type: "invitation", + invitation_id: ti.id, + invitation_role: gi.role + } + + union_query = + from s in team_membership_query, + union_all: ^guest_membership_query, + union_all: ^guest_invitation_query + + from(u in subquery(union_query), + inner_join: s in Plausible.Site, + on: u.site_id == s.id, + left_join: up in Site.UserPreference, + on: up.site_id == s.id, + left_join: ti in Teams.Invitation, + on: ti.id == u.invitation_id, + select: %{ + s + | entry_type: + selected_as( + fragment( + """ + CASE + WHEN ? IS NOT NULL THEN 'pinned_site' + ELSE ? + END + """, + up.pinned_at, + u.entry_type + ), + :entry_type + ), + pinned_at: selected_as(up.pinned_at, :pinned_at), + invitations: [ + %Plausible.Auth.Invitation{ + invitation_id: ti.invitation_id, + email: ti.email, + role: u.invitation_role + } + ] + }, + order_by: [ + asc: selected_as(:entry_type), + desc: selected_as(:pinned_at), + asc: s.domain + ] + ) + |> maybe_filter_by_domain(domain_filter) + |> Repo.paginate(pagination_params) + end + + defp maybe_filter_by_domain(query, domain) + when byte_size(domain) >= 1 and byte_size(domain) <= 64 do + where(query, [s], ilike(s.domain, ^"%#{domain}%")) + end + + defp maybe_filter_by_domain(query, _), do: query +end diff --git a/lib/plausible/teams/team.ex b/lib/plausible/teams/team.ex new file mode 100644 index 000000000..e1fe3a056 --- /dev/null +++ b/lib/plausible/teams/team.ex @@ -0,0 +1,73 @@ +defmodule Plausible.Teams.Team do + @moduledoc """ + Team schema + """ + + use Ecto.Schema + use Plausible + + import Ecto.Changeset + + @type t() :: %__MODULE__{} + + @trial_accept_traffic_until_offset_days 14 + + schema "teams" do + field :name, :string + field :trial_expiry_date, :date + field :accept_traffic_until, :date + field :allow_next_upgrade_override, :boolean + + embeds_one :grace_period, Plausible.Auth.GracePeriod, on_replace: :update + + has_many :sites, Plausible.Site + has_many :team_memberships, Plausible.Teams.Membership + has_many :team_invitations, Plausible.Teams.Invitation + has_one :subscription, Plausible.Billing.Subscription + has_one :enterprise_plan, Plausible.Billing.EnterprisePlan + + timestamps() + end + + def sync_changeset(team, user) 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, user.grace_period) + |> put_change(:inserted_at, user.inserted_at) + |> put_change(:updated_at, user.updated_at) + end + + def changeset(name, today \\ Date.utc_today()) do + trial_expiry_date = + if ee?() do + Date.shift(today, day: 30) + else + Date.shift(today, year: 100) + end + + %__MODULE__{} + |> cast(%{name: name}, [:name]) + |> validate_required(:name) + |> put_change(:trial_expiry_date, trial_expiry_date) + end + + def start_trial(team) do + trial_expiry = trial_expiry() + + change(team, + trial_expiry_date: trial_expiry, + accept_traffic_until: Date.add(trial_expiry, @trial_accept_traffic_until_offset_days) + ) + 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 +end diff --git a/lib/plausible/users.ex b/lib/plausible/users.ex index 39a32d78d..960438005 100644 --- a/lib/plausible/users.ex +++ b/lib/plausible/users.ex @@ -8,6 +8,7 @@ defmodule Plausible.Users do import Ecto.Query alias Plausible.Auth + alias Plausible.Auth.GracePeriod alias Plausible.Billing.Subscription alias Plausible.Repo @@ -30,9 +31,16 @@ defmodule Plausible.Users do @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!() + + with_teams do + Plausible.Teams.sync_team(user) + end + user - |> Auth.User.changeset(%{accept_traffic_until: accept_traffic_until(user)}) - |> Repo.update!() end @spec bump_last_seen(Auth.User.t() | pos_integer(), NaiveDateTime.t()) :: :ok @@ -98,17 +106,44 @@ defmodule Plausible.Users do Auth.EmailVerification.any?(user) end - def allow_next_upgrade_override(%Auth.User{} = user) do + def start_trial(%Auth.User{} = user) do + user = + user + |> Auth.User.start_trial() + |> Repo.update!() + + with_teams do + Plausible.Teams.sync_team(user) + end + + user + end + + def allow_next_upgrade_override(%Auth.User{} = user) do + user = + user + |> Auth.User.changeset(%{allow_next_upgrade_override: true}) + |> Repo.update!() + + with_teams do + Plausible.Teams.sync_team(user) + end + user - |> Auth.User.changeset(%{allow_next_upgrade_override: true}) - |> Repo.update!() 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!() + + with_teams do + Plausible.Teams.sync_team(user) + end + user - |> Auth.User.changeset(%{allow_next_upgrade_override: false}) - |> Repo.update!() else user end @@ -120,6 +155,58 @@ defmodule Plausible.Users do ) end + def start_grace_period(user) do + user = + user + |> GracePeriod.start_changeset() + |> Repo.update!() + + with_teams do + Plausible.Teams.sync_team(user) + end + + user + end + + def start_manual_lock_grace_period(user) do + user = + user + |> GracePeriod.start_manual_lock_changeset() + |> Repo.update!() + + with_teams do + Plausible.Teams.sync_team(user) + end + + user + end + + def end_grace_period(user) do + user = + user + |> GracePeriod.end_changeset() + |> Repo.update!() + + with_teams do + Plausible.Teams.sync_team(user) + end + + user + end + + def remove_grace_period(user) do + user = + user + |> GracePeriod.remove_changeset() + |> Repo.update!() + + with_teams do + Plausible.Teams.sync_team(user) + end + + user + end + defp last_subscription_query() do from(subscription in Subscription, order_by: [desc: subscription.inserted_at], diff --git a/lib/plausible_web/components/billing/billing.ex b/lib/plausible_web/components/billing/billing.ex index 74ce1544a..6f51713bd 100644 --- a/lib/plausible_web/components/billing/billing.ex +++ b/lib/plausible_web/components/billing/billing.ex @@ -227,6 +227,7 @@ defmodule PlausibleWeb.Components.Billing do attr :paddle_product_id, :string, required: true attr :checkout_disabled, :boolean, default: false attr :user, :map, required: true + attr :team, :map, default: nil attr :confirm_message, :any, default: nil slot :inner_block, required: true @@ -234,12 +235,22 @@ defmodule PlausibleWeb.Components.Billing do confirmed = if assigns.confirm_message, do: "confirm(\"#{assigns.confirm_message}\")", else: "true" - assigns = assign(assigns, :confirmed, confirmed) + passthrough = + if assigns.team do + "user:#{assigns.user.id};team:#{assigns.team.id}" + else + assigns.user.id + end + + assigns = + assigns + |> assign(:confirmed, confirmed) + |> assign(:passthrough, passthrough) ~H"""