398 lines
9.6 KiB
Elixir
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
|