analytics/lib/plausible/teams.ex

398 lines
9.6 KiB
Elixir

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, 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,
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,
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
# 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