defmodule Plausible.Teams do @moduledoc """ Core context of teams. """ import Ecto.Query alias __MODULE__ alias Plausible.Auth alias Plausible.Billing alias Plausible.Repo use Plausible @accept_traffic_until_free ~D[2135-01-01] @spec default_name() :: String.t() def default_name(), do: "My personal sites" @spec name(nil | Teams.Team.t()) :: String.t() def name(nil), do: default_name() def name(%{setup_complete: false}), do: default_name() def name(team), do: team.name @spec setup?(nil | Teams.Team.t()) :: boolean() def setup?(nil), do: false def setup?(%{setup_complete: setup_complete}), do: setup_complete @spec get(pos_integer() | binary() | nil) :: Teams.Team.t() | nil def get(nil), do: nil def get(team_id) when is_integer(team_id) do Repo.get(Teams.Team, team_id) end def get(team_identifier) when is_binary(team_identifier) do case Ecto.UUID.cast(team_identifier) do {:ok, uuid} -> Repo.get_by(Teams.Team, identifier: uuid) :error -> nil end end @spec get!(pos_integer() | binary()) :: Teams.Team.t() def get!(team_id) when is_integer(team_id) do Repo.get!(Teams.Team, team_id) end def get!(team_identifier) when is_binary(team_identifier) do Repo.get_by!(Teams.Team, identifier: team_identifier) end @spec on_trial?(Teams.Team.t() | nil) :: boolean() on_ee do def on_trial?(nil), do: false def on_trial?(%Teams.Team{trial_expiry_date: nil}), do: false def on_trial?(team) do team = with_subscription(team) is_nil(team.subscription) and trial_days_left(team) >= 0 end else def on_trial?(_), do: always(true) end @spec locked?(Teams.Team.t() | nil) :: boolean() def locked?(nil), do: false def locked?(%Teams.Team{locked: locked}), do: locked @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 def with_subscription(team) do Repo.preload(team, subscription: last_subscription_query()) end @spec owned_sites(Teams.Team.t() | nil, pos_integer() | nil) :: [Plausible.Site.t()] def owned_sites(team, limit \\ nil) def owned_sites(nil, _), do: [] def owned_sites(team, limit) do query = from(s in Plausible.Site.regular(), where: s.team_id == ^team.id, order_by: [asc: s.domain] ) if limit do query |> limit(^limit) |> Repo.all() else Repo.all(query) end end @spec owned_sites_ids(Teams.Team.t() | nil) :: [pos_integer()] def owned_sites_ids(nil) do [] end def owned_sites_ids(team) do Repo.all( from(s in Plausible.Site.regular(), where: s.team_id == ^team.id, select: s.id, order_by: [desc: s.id] ) ) end @spec owned_sites_count(Teams.Team.t() | nil) :: non_neg_integer() def owned_sites_count(nil), do: 0 def owned_sites_count(team) do Repo.aggregate( from(s in Plausible.Site.regular(), where: s.team_id == ^team.id ), :count ) end def has_active_sites?(team) do team |> owned_sites() |> Enum.any?(&Plausible.Sites.has_stats?/1) end @doc """ Get or create user's team. If the user has no non-guest membership yet, an implicit "My Personal Sites" team is created with them as an owner. If the user already has an owner membership in an existing team, that team is returned. """ @spec get_or_create(Auth.User.t()) :: {:ok, Teams.Team.t()} | {:error, :multiple_teams | :permission_denied} def get_or_create(user) do with :ok <- check_user_type(user), {:error, :no_team} <- get_owned_team(user, only_not_setup?: true) do case create_my_team(user) do {:ok, team} -> {:ok, team} {:error, :exists_already} -> get_owned_team(user, only_not_setup?: true) end end end @spec force_create_my_team(Auth.User.t()) :: Teams.Team.t() def force_create_my_team(user) do # This is going to crash hard for SSO user. This shouldn't happen # under normal circumstances except in case of a _very_ unlucky timing. # Manual resolution is necessary anyway. case check_user_type(user) do :ok -> :pass _ -> raise "SSO user tried to force create a personal team" end {:ok, team} = Repo.transaction(fn -> clear_autocreated(user) {:ok, team} = create_my_team(user) team end) team end @spec complete_setup(Teams.Team.t()) :: Teams.Team.t() def complete_setup(team) do if not team.setup_complete do team = team |> Teams.Team.setup_changeset() |> Repo.update!() |> Repo.preload(:owners) [owner] = team.owners clear_autocreated(owner) team else team end end @spec delete(Teams.Team.t()) :: {:ok, :deleted} | {:error, :active_subscription} def delete(team) do team = Teams.with_subscription(team) if Billing.Subscription.Status.active?(team.subscription) do {:error, :active_subscription} else Repo.transaction(fn -> for site <- Teams.owned_sites(team) do Plausible.Site.Removal.run(site) end Repo.delete_all(from s in Billing.Subscription, where: s.team_id == ^team.id) Repo.delete_all(from ep in Billing.EnterprisePlan, where: ep.team_id == ^team.id) Repo.delete!(team) :deleted end) end end @spec get_by_owner(Auth.User.t(), Keyword.t()) :: {:ok, Teams.Team.t()} | {:error, :no_team | :multiple_teams} def get_by_owner(user, opts \\ []) do get_owned_team(user, opts) 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 |> Teams.GracePeriod.start_changeset() |> Repo.update!() end def start_manual_lock_grace_period(team) do team |> Teams.GracePeriod.start_manual_lock_changeset() |> Repo.update!() end def end_grace_period(team) do team |> Teams.GracePeriod.end_changeset() |> Repo.update!() end def remove_grace_period(team) do team |> Teams.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 @spec last_subscription_join_query() :: Ecto.Query.t() def last_subscription_join_query() do from(subscription in last_subscription_query(), where: subscription.team_id == parent_as(:team).id ) end @spec last_subscription_query() :: Ecto.Query.t() def last_subscription_query() do from(subscription in Plausible.Billing.Subscription, order_by: [desc: subscription.inserted_at, desc: subscription.id], limit: 1 ) end @spec force_2fa_enabled?(Teams.Team.t() | nil) :: boolean() def force_2fa_enabled?(nil), do: false def force_2fa_enabled?(team) do team.policy.force_2fa end @spec enable_force_2fa(Teams.Team.t(), Auth.User.t()) :: {:ok, Teams.Team.t()} | {:error, Ecto.Changeset.t()} def enable_force_2fa(team, user) do with {:ok, team} <- set_force_2fa(team, true) do team |> Teams.Memberships.all(exclude_guests?: true) |> Enum.each(fn membership -> if membership.user.id != user.id and not Auth.TOTP.enabled?(membership.user) do team |> PlausibleWeb.Email.force_2fa_enabled(membership.user, user) |> Plausible.Mailer.deliver_later() end end) {:ok, team} end end @spec disable_force_2fa(Teams.Team.t(), Auth.User.t(), String.t()) :: {:ok, Teams.Team.t()} | {:error, :invalid_password | Ecto.Changeset.t()} def disable_force_2fa(team, user, password) do if Auth.Password.match?(password, user.password_hash) do set_force_2fa(team, false) else {:error, :invalid_password} end end defp set_force_2fa(team, enabled?) do params = %{policy: %{force_2fa: enabled?}} audit_entry_name = if(enabled?, do: "force_2fa_enabled", else: "force_2fa_disabled") team |> Ecto.Changeset.cast(params, []) |> Ecto.Changeset.cast_embed(:policy, with: &Teams.Policy.force_2fa_changeset(&1, &2.force_2fa) ) |> Repo.update_with_audit(audit_entry_name, %{team_id: team.id}) end # Exposed for use in tests @doc false def get_owned_team(user, opts \\ []) do only_not_setup? = Keyword.get(opts, :only_not_setup?, false) query = from(tm in Teams.Membership, inner_join: t in assoc(tm, :team), as: :team, where: tm.user_id == ^user.id and tm.role == :owner, select: t, order_by: t.id ) query = if only_not_setup? do where(query, [team: t], t.setup_complete == false) else query end result = Repo.all(query) case result do [] -> {:error, :no_team} [team] -> {:ok, team} _teams -> {:error, :multiple_teams} end end defp check_user_type(user) do if Plausible.Users.type(user) == :sso do {:error, :permission_denied} else :ok end end defp clear_autocreated(user) do Repo.update_all( from(tm in Teams.Membership, where: tm.user_id == ^user.id, where: tm.is_autocreated == true ), set: [is_autocreated: false] ) :ok end defp create_my_team(user) do team = %Teams.Team{} |> Teams.Team.changeset(%{name: default_name()}) |> Repo.insert!() team_membership = team |> Teams.Membership.changeset(user, :owner) |> Ecto.Changeset.put_change(:is_autocreated, true) |> Repo.insert!( on_conflict: :nothing, conflict_target: {:unsafe_fragment, "(user_id) WHERE role = 'owner' and is_autocreated = true"} ) if team_membership.id do {:ok, team} else Repo.delete!(team) {:error, :exists_already} end end end