Implement basics of Teams (#4658)

* Extend schemas with new fields and relationships for teams

* Implement listing sites and sites with invitations with teams

* Implement creating invitations with teams

* Implement accepting invites with teams

* Add `Teams.SiteTransfer` schema

* Implement creating ownership transfers

* Implement accepting site transfer between teams

* Make results shapes from `Teams.Memberships` role functions more consistent

* Remove :team relation from ApiKey schema

* Pass and provision team on subscription creation

* Pass and provision team on enterprise plan creation

* Implement creating site for a team

* Keep team in sync during legacy ownership transfer and invitations

* Resolve conflict in `Teams.get_or_create` without transaction

* Abstract `GracePeriod` manipulation behind `Plausible.Users`

* Put `User.start_trial` behind `Plausible.Users` API

* Sync team fields on user update, if team exists

* Sync cleaning invitations, updating and removing members

* Transfer invitations too

* Implement backfill script

* Allow separate pg repo for backfill script

* Rollback purposefully at the end

* Update backfill script with parallel processing

* Use `IS DISTINCT FROM` when comparing nullable fields

* Handle no teams to backfill case gracefully when reporting

* Parallelize guest memberships backfill

* Remove transaction wrapping and query timeouts

* Make team sync check more granular and fix formatting

* Wrap single team backfill in a transatction for consistent restarts

* Make invitation and site transfer backfills preserve invitation ID

* Update migration repo config for easier dev access

* Backfill teams for users with subscriptions without sites

* Log timestamps

* Put teams sync behind a compile-time flag

* Keep timestamps in sync and fix subscriptions backfill

* Fix formatting

* Make credo happy

* Don't `use Plausible.Migration` to avoid dialyzer complaining

None of the tooling from there is used anywhere and `@repo` can
be defined directly in the migration script.

* Drop SSL workarounds in the backfill script

---------

Co-authored-by: Adam Rutkowski <hq@mtod.org>
This commit is contained in:
Adrian Gruntkowski 2024-10-21 09:35:23 +02:00 committed by GitHub
parent 1e38bd8771
commit 17b12ddaeb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 2910 additions and 113 deletions

View File

@ -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, []},
#

View File

@ -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

View File

@ -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"],

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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 """

View File

@ -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

View File

@ -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)

View File

@ -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

102
lib/plausible/teams.ex Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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],

View File

@ -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"""
<button
id={@id}
onclick={"if (#{@confirmed}) {Paddle.Checkout.open(#{Jason.encode!(%{product: @paddle_product_id, email: @user.email, disableLogout: true, passthrough: @user.id, success: Routes.billing_path(PlausibleWeb.Endpoint, :upgrade_success), theme: "none"})})}"}
onclick={"if (#{@confirmed}) {Paddle.Checkout.open(#{Jason.encode!(%{product: @paddle_product_id, email: @user.email, disableLogout: true, passthrough: @passthrough, success: Routes.billing_path(PlausibleWeb.Endpoint, :upgrade_success), theme: "none"})})}"}
class={[
"text-sm w-full mt-6 block rounded-md py-2 px-3 text-center font-semibold leading-6 text-white",
!@checkout_disabled && "bg-indigo-600 hover:bg-indigo-500",

View File

@ -227,7 +227,11 @@ defmodule PlausibleWeb.Components.Billing.PlanBox do
<%= if @owned_plan && Plausible.Billing.Subscriptions.resumable?(@current_user.subscription) do %>
<.change_plan_link {assigns} />
<% else %>
<PlausibleWeb.Components.Billing.paddle_button user={@current_user} {assigns}>
<PlausibleWeb.Components.Billing.paddle_button
user={@current_user}
team={@current_team}
{assigns}
>
Upgrade
</PlausibleWeb.Components.Billing.paddle_button>
<% end %>

View File

@ -12,6 +12,7 @@ defmodule PlausibleWeb.Site.MembershipController do
use PlausibleWeb, :controller
use Plausible.Repo
use Plausible
alias Plausible.Sites
alias Plausible.Site.{Membership, Memberships}
@ -170,6 +171,10 @@ defmodule PlausibleWeb.Site.MembershipController do
|> Membership.set_role(new_role)
|> Repo.update!()
with_teams do
Plausible.Teams.Memberships.update_role_sync(membership)
end
redirect_target =
if membership.user.id == current_user.id and new_role == :viewer do
"/#{URI.encode_www_form(site.domain)}"
@ -216,6 +221,10 @@ defmodule PlausibleWeb.Site.MembershipController do
if membership do
Repo.delete!(membership)
with_teams do
Plausible.Teams.Memberships.remove_sync(membership)
end
membership
|> PlausibleWeb.Email.site_member_removed()
|> Plausible.Mailer.send()

View File

@ -233,49 +233,52 @@ defmodule PlausibleWeb.Email do
|> render("cancellation_email.html", user: user)
end
def new_user_invitation(invitation) do
def new_user_invitation(email, invitation_id, site, inviter) do
priority_email()
|> to(invitation.email)
|> to(email)
|> tag("new-user-invitation")
|> subject("[#{Plausible.product_name()}] You've been invited to #{invitation.site.domain}")
|> subject("[#{Plausible.product_name()}] You've been invited to #{site.domain}")
|> render("new_user_invitation.html",
invitation: invitation
invitation_id: invitation_id,
site: site,
inviter: inviter
)
end
def existing_user_invitation(invitation) do
def existing_user_invitation(email, site, inviter) do
priority_email()
|> to(invitation.email)
|> to(email)
|> tag("existing-user-invitation")
|> subject("[#{Plausible.product_name()}] You've been invited to #{invitation.site.domain}")
|> subject("[#{Plausible.product_name()}] You've been invited to #{site.domain}")
|> render("existing_user_invitation.html",
invitation: invitation
site: site,
inviter: inviter
)
end
def ownership_transfer_request(invitation, new_owner_account) do
def ownership_transfer_request(email, invitation_id, site, inviter, new_owner_account) do
priority_email()
|> to(invitation.email)
|> to(email)
|> tag("ownership-transfer-request")
|> subject(
"[#{Plausible.product_name()}] Request to transfer ownership of #{invitation.site.domain}"
)
|> subject("[#{Plausible.product_name()}] Request to transfer ownership of #{site.domain}")
|> render("ownership_transfer_request.html",
invitation: invitation,
invitation_id: invitation_id,
inviter: inviter,
site: site,
new_owner_account: new_owner_account
)
end
def invitation_accepted(invitation) do
def invitation_accepted(inviter_email, invitee_email, site) do
priority_email()
|> to(invitation.inviter.email)
|> to(inviter_email)
|> tag("invitation-accepted")
|> subject(
"[#{Plausible.product_name()}] #{invitation.email} accepted your invitation to #{invitation.site.domain}"
"[#{Plausible.product_name()}] #{invitee_email} accepted your invitation to #{site.domain}"
)
|> render("invitation_accepted.html",
user: invitation.inviter,
invitation: invitation
invitee_email: invitee_email,
site: site
)
end
@ -292,16 +295,16 @@ defmodule PlausibleWeb.Email do
)
end
def ownership_transfer_accepted(invitation) do
def ownership_transfer_accepted(new_owner_email, inviter_email, site) do
priority_email()
|> to(invitation.inviter.email)
|> to(inviter_email)
|> tag("ownership-transfer-accepted")
|> subject(
"[#{Plausible.product_name()}] #{invitation.email} accepted the ownership transfer of #{invitation.site.domain}"
"[#{Plausible.product_name()}] #{new_owner_email} accepted the ownership transfer of #{site.domain}"
)
|> render("ownership_transfer_accepted.html",
user: invitation.inviter,
invitation: invitation
new_owner_email: new_owner_email,
site: site
)
end

View File

@ -31,6 +31,13 @@ defmodule PlausibleWeb.Live.AuthContext do
_ -> nil
end
end)
|> assign_new(:current_team, fn context ->
case context.current_user do
nil -> nil
%{team_memberships: [%{team: team}]} -> team
%{team_memberships: []} -> nil
end
end)
{:cont, socket}
end

View File

@ -20,12 +20,22 @@ defmodule PlausibleWeb.AuthPlug do
{:ok, user_session} ->
user = user_session.user
team =
case user.team_memberships do
[%{team: team}] ->
team
[] ->
nil
end
Plausible.OpenTelemetry.add_user_attributes(user)
Sentry.Context.set_user_context(%{id: user.id, name: user.name, email: user.email})
conn
|> assign(:current_user, user)
|> assign(:current_user_session, user_session)
|> assign(:current_team, team)
_ ->
conn

View File

@ -67,6 +67,7 @@
id="paddle-button"
paddle_product_id={@latest_enterprise_plan.paddle_plan_id}
user={@current_user}
team={@current_team}
>
<svg fill="currentColor" viewBox="0 0 20 20" class="inline w-4 h-4 mr-2">
<path

View File

@ -1,3 +1,3 @@
<%= @invitation.inviter.email %> has invited you to the <%= @invitation.site.domain %> site on <%= Plausible.product_name() %>.
<%= @inviter.email %> has invited you to the <%= @site.domain %> site on <%= Plausible.product_name() %>.
<a href={Routes.site_url(PlausibleWeb.Endpoint, :index)}>Click here</a> to view and respond to the invitation. The invitation
will expire 48 hours after this email is sent.

View File

@ -1,2 +1,2 @@
<%= @invitation.email %> has accepted your invitation to <%= @invitation.site.domain %>.
<a href={Routes.site_url(PlausibleWeb.Endpoint, :settings_general, @invitation.site.domain)}>Click here</a> to view site settings.
<%= @invitee_email %> has accepted your invitation to <%= @site.domain %>.
<a href={Routes.site_url(PlausibleWeb.Endpoint, :settings_general, @site.domain)}>Click here</a> to view site settings.

View File

@ -1,9 +1,9 @@
<%= @invitation.inviter.email %> has invited you to join the <%= @invitation.site.domain %> site on <%= Plausible.product_name() %>.
<%= @inviter.email %> has invited you to join the <%= @site.domain %> site on <%= Plausible.product_name() %>.
<a href={
Routes.auth_url(
PlausibleWeb.Endpoint,
:register_from_invitation_form,
@invitation.invitation_id
@invitation_id
)
}>Click here</a> to create your account. The link is valid for 48 hours after this email is sent.
<br /><br />

View File

@ -1,3 +1,3 @@
<%= @invitation.email %> has accepted the ownership transfer of <%= @invitation.site.domain %>. They will be responsible for billing of it going
<%= @new_owner_email %> has accepted the ownership transfer of <%= @site.domain %>. They will be responsible for billing of it going
forward and your role has been changed to <b>admin</b>.
<a href={Routes.site_url(PlausibleWeb.Endpoint, :settings_general, @invitation.site.domain)}>Click here</a> to view site settings.
<a href={Routes.site_url(PlausibleWeb.Endpoint, :settings_general, @site.domain)}>Click here</a> to view site settings.

View File

@ -1,15 +1,11 @@
<%= @invitation.inviter.email %> has requested to transfer the ownership of <%= @invitation.site.domain %> site on <%= Plausible.product_name() %> to you.
<%= @inviter.email %> has requested to transfer the ownership of <%= @site.domain %> site on <%= Plausible.product_name() %> to you.
<%= if @new_owner_account do %>
<a href={Routes.site_url(PlausibleWeb.Endpoint, :index)}>Click here</a>
to view and respond to the invitation.
<% else %>
<a
phx-no-format
href={
Routes.auth_url(PlausibleWeb.Endpoint, :register_form,
invitation: @invitation.invitation_id
)
}
href={Routes.auth_url(PlausibleWeb.Endpoint, :register_form, invitation: @invitation_id)}
>Click here</a> to create your account. <br /><br />
Plausible is a lightweight and open-source website analytics tool. We hope you like our simple and ethical approach to tracking website visitors.
<% end %>

View File

@ -129,10 +129,13 @@ defmodule PlausibleWeb.UserAuth do
from(us in Auth.UserSession,
inner_join: u in assoc(us, :user),
as: :user,
left_join: tm in assoc(u, :team_memberships),
on: tm.role != :guest,
left_join: t in assoc(tm, :team),
left_lateral_join: s in subquery(last_subscription_query),
on: true,
where: us.token == ^token and us.timeout_at > ^now,
preload: [user: {u, subscription: s}]
preload: [user: {u, subscription: s, team_memberships: {tm, team: t}}]
)
case Repo.one(token_query) do

View File

@ -103,9 +103,8 @@ defmodule Plausible.Workers.CheckUsage do
def maybe_remove_grace_period(subscriber, usage_mod) do
case check_pageview_usage_last_cycle(subscriber, usage_mod) do
{:below_limit, _} ->
subscriber
|> Plausible.Auth.GracePeriod.remove_changeset()
|> Repo.update()
Plausible.Users.remove_grace_period(subscriber)
:ok
_ ->
:skip
@ -121,9 +120,7 @@ defmodule Plausible.Workers.CheckUsage do
PlausibleWeb.Email.over_limit_email(subscriber, pageview_usage, suggested_plan)
|> Plausible.Mailer.send()
subscriber
|> Plausible.Auth.GracePeriod.start_changeset()
|> Repo.update()
Plausible.Users.start_grace_period(subscriber)
_ ->
nil
@ -147,9 +144,7 @@ defmodule Plausible.Workers.CheckUsage do
)
|> Plausible.Mailer.send()
subscriber
|> Plausible.Auth.GracePeriod.start_manual_lock_changeset()
|> Repo.update()
Plausible.Users.start_manual_lock_grace_period(subscriber)
end
end

View File

@ -15,6 +15,11 @@ defmodule Plausible.Workers.CleanInvitations do
where: i.inserted_at < ^cutoff_time
)
Repo.delete_all(
from ti in Plausible.Teams.Invitation,
where: ti.inserted_at < ^cutoff_time
)
:ok
end
end

View File

@ -77,7 +77,7 @@ defmodule Plausible.BillingTest do
user = insert(:user, trial_expiry_date: Timex.shift(Timex.today(), days: -1))
insert(:subscription, user: user, next_bill_date: Timex.today())
user = user |> Plausible.Auth.GracePeriod.end_changeset() |> Repo.update!()
user = Plausible.Users.end_grace_period(user)
assert Billing.check_needs_to_upgrade(user) == {:needs_to_upgrade, :grace_period_ended}
end

View File

@ -34,6 +34,68 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do
assert_no_emails_delivered()
end
test "transfers ownership successfully (TEAM)" do
site = insert(:site, memberships: [])
existing_owner = insert(:user)
_existing_membership =
insert(:site_membership, user: existing_owner, site: site, role: :owner)
old_team = site.team
existing_team_membership =
insert(:team_membership, user: existing_owner, team: old_team, role: :owner)
another_user = insert(:user)
another_team_membership =
insert(:team_membership, user: another_user, team: old_team, role: :guest)
another_guest_membership =
insert(:guest_membership,
team_membership: another_team_membership,
site: site,
role: :viewer
)
new_owner = insert(:user)
new_team = insert(:team)
insert(:team_membership, user: new_owner, team: new_team, role: :owner)
insert(:growth_subscription, user: new_owner, team: new_team)
assert {:ok, new_team_membership} =
Plausible.Teams.Invitations.transfer_site(site, new_owner)
assert new_team_membership.team_id == new_team.id
assert new_team_membership.user_id == new_owner.id
assert new_team_membership.role == :owner
existing_team_membership = Repo.reload!(existing_team_membership)
assert existing_team_membership.user_id == existing_owner.id
assert existing_team_membership.team_id == old_team.id
assert existing_team_membership.role == :owner
refute Repo.reload(another_team_membership)
refute Repo.reload(another_guest_membership)
assert new_another_team_membership =
Plausible.Teams.Membership
|> Repo.get_by(
team_id: new_team.id,
user_id: another_user.id
)
|> Repo.preload(:guest_memberships)
assert another_team_membership.id != new_another_team_membership.id
assert [new_another_guest_membership] = new_another_team_membership.guest_memberships
assert new_another_guest_membership.site_id == site.id
assert new_another_guest_membership.role == another_guest_membership.role
assert new_another_team_membership.role == :guest
assert_no_emails_delivered()
end
@tag :ee_only
test "unlocks the site if it was previously locked" do
site = insert(:site, locked: true, memberships: [])
@ -216,6 +278,51 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do
)
end
test "converts an invitation into a membership (TEAMS)" do
inviter = insert(:user)
invitee = insert(:user)
team = insert(:team)
site = insert(:site, team: team, members: [inviter])
insert(:team_membership, team: team, user: inviter, role: :owner)
_invitation =
insert(:invitation,
site_id: site.id,
inviter: inviter,
email: invitee.email,
role: :admin
)
team_invitation =
insert(:team_invitation,
team: team,
inviter: inviter,
email: invitee.email,
role: :guest
)
insert(:guest_invitation, team_invitation: team_invitation, site: site, role: :editor)
assert {:ok, team_membership} =
Plausible.Teams.Invitations.accept(team_invitation.invitation_id, invitee)
assert [guest_membership] =
Repo.preload(team_membership, :guest_memberships).guest_memberships
assert guest_membership.site_id == site.id
assert team_membership.user_id == invitee.id
assert guest_membership.role == :editor
assert team_membership.role == :guest
assert team_membership.team_id == team.id
refute Repo.reload(team_invitation)
assert_email_delivered_with(
to: [nil: inviter.email],
subject: @subject_prefix <> "#{invitee.email} accepted your invitation to #{site.domain}"
)
end
test "does not degrade role when trying to invite self as an owner" do
user = insert(:user)

View File

@ -10,37 +10,72 @@ defmodule Plausible.Site.Memberships.CreateInvitationTest do
test "creates an invitation" do
inviter = insert(:user)
invitee = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
team = insert(:team)
site =
insert(:site,
team: team,
memberships: [build(:site_membership, user: inviter, role: :owner)]
)
insert(:team_membership, team: team, user: inviter, role: :owner)
assert {:ok, %Plausible.Auth.Invitation{}} =
CreateInvitation.create_invitation(site, inviter, invitee.email, :viewer)
assert {:ok, %Plausible.Teams.GuestInvitation{}} =
Plausible.Teams.Invitations.invite(site, inviter, invitee.email, :viewer)
end
test "returns validation errors" do
inviter = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
team = insert(:team)
site =
insert(:site,
team: team,
memberships: [build(:site_membership, user: inviter, role: :owner)]
)
insert(:team_membership, team: team, user: inviter, role: :owner)
assert {:error, changeset} = CreateInvitation.create_invitation(site, inviter, "", :viewer)
assert {"can't be blank", _} = changeset.errors[:email]
assert {:error, changeset} = Plausible.Teams.Invitations.invite(site, inviter, "", :viewer)
assert {"can't be blank", _} = changeset.errors[:email]
end
test "returns error when user is already a member" do
inviter = insert(:user)
invitee = insert(:user)
team = insert(:team)
site =
insert(:site,
team: team,
memberships: [
build(:site_membership, user: inviter, role: :owner),
build(:site_membership, user: invitee, role: :viewer)
]
)
insert(:team_membership, team: team, user: inviter, role: :owner)
team_membership = insert(:team_membership, team: team, user: invitee, role: :guest)
insert(:guest_membership, team_membership: team_membership, site: site, role: :viewer)
assert {:error, :already_a_member} =
CreateInvitation.create_invitation(site, inviter, invitee.email, :viewer)
assert {:error, :already_a_member} =
Plausible.Teams.Invitations.invite(site, inviter, invitee.email, :viewer)
assert {:error, :already_a_member} =
CreateInvitation.create_invitation(site, inviter, inviter.email, :viewer)
assert {:error, :already_a_member} =
Plausible.Teams.Invitations.invite(site, inviter, inviter.email, :viewer)
end
test "sends invitation email for existing users" do
@ -177,8 +212,14 @@ defmodule Plausible.Site.Memberships.CreateInvitationTest do
]
)
insert(:team_membership, team: site.team, user: inviter, role: :owner)
insert(:team_membership, team: site.team, user: invitee, role: :viewer)
assert {:ok, %Plausible.Auth.Invitation{}} =
CreateInvitation.create_invitation(site, inviter, invitee.email, :owner)
assert {:ok, %Plausible.Teams.SiteTransfer{}} =
Plausible.Teams.Invitations.invite(site, inviter, invitee.email, :owner)
end
test "does not allow transferring ownership to existing owner" do
@ -191,8 +232,13 @@ defmodule Plausible.Site.Memberships.CreateInvitationTest do
]
)
insert(:team_membership, team: site.team, user: inviter, role: :owner)
assert {:error, :transfer_to_self} =
CreateInvitation.create_invitation(site, inviter, "vini@plausible.test", :owner)
assert {:error, :transfer_to_self} =
Plausible.Teams.Invitations.invite(site, inviter, "vini@plausible.test", :owner)
end
test "allows creating an ownership transfer even when at team member limit" do
@ -214,17 +260,24 @@ defmodule Plausible.Site.Memberships.CreateInvitationTest do
test "does not allow viewers to invite users" do
inviter = insert(:user)
owner = insert(:user)
site =
insert(:site,
memberships: [
build(:site_membership, user: build(:user), role: :owner),
build(:site_membership, user: owner, role: :owner),
build(:site_membership, user: inviter, role: :viewer)
]
)
insert(:team_membership, team: site.team, user: owner, role: :owner)
insert(:team_membership, team: site.team, user: inviter, role: :viewer)
assert {:error, :forbidden} =
CreateInvitation.create_invitation(site, inviter, "vini@plausible.test", :viewer)
assert {:error, :forbidden} =
Plausible.Teams.Invitations.invite(site, inviter, "vini@plausible.test", :viewer)
end
test "allows admins to invite other admins" do

View File

@ -13,6 +13,16 @@ defmodule Plausible.SitesTest do
Sites.create(user, params)
end
test "creates a site (TEAM)" do
user = insert(:user)
{:ok, team} = Plausible.Teams.get_or_create(user)
params = %{"domain" => "example.com", "timezone" => "Europe/London"}
assert {:ok, %{site: %{domain: "example.com", timezone: "Europe/London"}}} =
Plausible.Teams.Sites.create(team, params)
end
test "fails on invalid timezone" do
user = insert(:user)
@ -21,6 +31,16 @@ defmodule Plausible.SitesTest do
assert {:error, :site, %{errors: [timezone: {"is invalid", []}]}, %{}} =
Sites.create(user, params)
end
test "fails on invalid timezone (TEAM)" do
user = insert(:user)
{:ok, team} = Plausible.Teams.get_or_create(user)
params = %{"domain" => "example.com", "timezone" => "blah"}
assert {:error, :site, %{errors: [timezone: {"is invalid", []}]}, %{}} =
Plausible.Teams.Sites.create(team, params)
end
end
describe "is_member?" do
@ -142,6 +162,14 @@ defmodule Plausible.SitesTest do
total_pages: 1
} = Sites.list(user, %{})
assert %{
entries: [],
page_size: 24,
page_number: 1,
total_entries: 0,
total_pages: 1
} = Plausible.Teams.Sites.list(user, %{})
assert %{
entries: [],
page_size: 24,
@ -149,82 +177,186 @@ defmodule Plausible.SitesTest do
total_entries: 0,
total_pages: 1
} = Sites.list_with_invitations(user, %{})
assert %{
entries: [],
page_size: 24,
page_number: 1,
total_entries: 0,
total_pages: 1
} = Plausible.Teams.Sites.list_with_invitations(user, %{})
end
test "pinned site doesn't matter with membership revoked (no active invitations)" do
user1 = insert(:user, email: "user1@example.com")
user2 = insert(:user, email: "user2@example.com")
insert(:site, members: [user1], domain: "one.example.com")
team1 = insert(:team)
insert(:site, team: team1, members: [user1], domain: "one.example.com")
insert(:team_membership, team: team1, user: user1, role: :owner)
team2 = insert(:team)
site2 =
insert(:site,
team: team2,
members: [user2],
domain: "two.example.com"
)
insert(:team_membership, team: team2, user: user2, role: :owner)
membership = insert(:site_membership, user: user1, role: :viewer, site: site2)
team_membership = insert(:team_membership, team: team2, user: user1, role: :guest)
insert(:guest_membership, team_membership: team_membership, site: site2, role: :viewer)
{:ok, _} = Sites.toggle_pin(user1, site2)
Repo.delete!(membership)
Repo.delete!(team_membership)
assert %{entries: [%{domain: "one.example.com"}]} = Sites.list(user1, %{})
assert %{entries: [%{domain: "one.example.com"}]} = Sites.list_with_invitations(user1, %{})
assert %{entries: [%{domain: "one.example.com"}]} = Plausible.Teams.Sites.list(user1, %{})
assert %{entries: [%{domain: "one.example.com"}]} =
Plausible.Teams.Sites.list_with_invitations(user1, %{})
end
test "pinned site doesn't matter with membership revoked (with active invitation)" do
user1 = insert(:user, email: "user1@example.com")
user2 = insert(:user, email: "user2@example.com")
insert(:site, members: [user1], domain: "one.example.com")
team1 = insert(:team)
insert(:site, team: team1, members: [user1], domain: "one.example.com")
insert(:team_membership, team: team1, user: user1, role: :owner)
team2 = insert(:team)
site2 =
insert(:site,
team: team2,
members: [user2],
domain: "two.example.com"
)
insert(:team_membership, team: team2, user: user2, role: :owner)
membership = insert(:site_membership, user: user1, role: :viewer, site: site2)
team_membership = insert(:team_membership, team: team2, user: user1, role: :guest)
insert(:guest_membership, team_membership: team_membership, site: site2, role: :viewer)
insert(:invitation, email: user1.email, inviter: user2, role: :owner, site: site2)
team_invitation =
insert(:team_invitation, team: team2, email: user1.email, inviter: user2, role: :guest)
insert(:guest_invitation, team_invitation: team_invitation, site: site2, role: :editor)
{:ok, _} = Sites.toggle_pin(user1, site2)
Repo.delete!(membership)
Repo.delete!(team_membership)
assert %{entries: [%{domain: "one.example.com"}]} = Sites.list(user1, %{})
assert %{entries: [%{domain: "two.example.com"}, %{domain: "one.example.com"}]} =
Sites.list_with_invitations(user1, %{})
assert %{entries: [%{domain: "one.example.com"}]} = Plausible.Teams.Sites.list(user1, %{})
assert %{entries: [%{domain: "two.example.com"}, %{domain: "one.example.com"}]} =
Plausible.Teams.Sites.list_with_invitations(user1, %{})
end
test "puts invitations first, pinned sites second, sites last" do
user = insert(:user, email: "hello@example.com")
site1 = %{id: site_id1} = insert(:site, members: [user], domain: "one.example.com")
site2 = %{id: site_id2} = insert(:site, members: [user], domain: "two.example.com")
%{id: site_id4} = insert(:site, members: [user], domain: "four.example.com")
team1 = insert(:team)
_rogue_site = insert(:site, domain: "rogue.example.com")
site1 =
%{id: site_id1} = insert(:site, team: team1, members: [user], domain: "one.example.com")
insert(:invitation, email: user.email, inviter: build(:user), role: :owner, site: site1)
insert(:team_membership, team: team1, user: user, role: :owner)
team2 = insert(:team)
%{id: site_id3} =
insert(:site,
domain: "three.example.com",
invitations: [
build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
]
site2 =
%{id: site_id2} = insert(:site, team: team2, members: [user], domain: "two.example.com")
insert(:team_membership, team: team2, user: build(:user), role: :owner)
team_membership2 = insert(:team_membership, team: team2, user: user, role: :guest)
insert(:guest_membership, team_membership: team_membership2, site: site2, role: :editor)
team4 = insert(:team)
site4 =
%{id: site_id4} = insert(:site, team: team4, members: [user], domain: "four.example.com")
insert(:team_membership, team: team4, user: build(:user), role: :owner)
team_membership4 = insert(:team_membership, team: team4, user: user, role: :guest)
insert(:guest_membership, team_membership: team_membership4, site: site4, role: :viewer)
_rogue_site = insert(:site, team: build(:team), domain: "rogue.example.com")
## Having owner invite on owned site does not make much sense?
## Maybe that was a repro of real-life example?
# insert(:invitation, email: user.email, inviter: build(:user), role: :owner, site: site1)
# team_invitation1 =
# insert(:team_invitation,
# team: team1,
# email: user.email,
# inviter: build(:user),
# role: :guest
# )
# insert(:guest_invitation, team_invitation: team_invitation1, site: site1, role: :editor)
team3 = insert(:team)
site3 = %{id: site_id3} = insert(:site, team: team3, domain: "three.example.com")
insert(:invitation, email: user.email, inviter: build(:user), role: :viewer, site: site3)
team_invitation2 =
insert(:team_invitation,
team: team3,
email: user.email,
inviter: build(:user),
role: :guest
)
insert(:guest_invitation, team_invitation: team_invitation2, site: site3, role: :viewer)
insert(:invitation, email: "friend@example.com", inviter: user, role: :viewer, site: site1)
team_invitation3 =
insert(:team_invitation,
team: team1,
email: "friend@example.com",
inviter: user,
role: :guest
)
insert(:guest_invitation, team_invitation: team_invitation3, site: site1, role: :viewer)
insert(:invitation,
site: site1,
inviter: user,
email: "another@example.com"
)
team_invitation4 =
insert(:team_invitation,
team: team1,
email: "another@example.com",
inviter: user,
role: :guest
)
insert(:guest_invitation, team_invitation: team_invitation4, site: site1, role: :editor)
{:ok, _} = Sites.toggle_pin(user, site2)
assert %{
@ -237,12 +369,29 @@ defmodule Plausible.SitesTest do
assert %{
entries: [
%{id: ^site_id1, entry_type: "invitation"},
%{id: ^site_id3, entry_type: "invitation"},
%{id: ^site_id2, entry_type: "pinned_site"},
%{id: ^site_id4, entry_type: "site"}
%{id: ^site_id4, entry_type: "site"},
%{id: ^site_id1, entry_type: "site"}
]
} = Sites.list_with_invitations(user, %{})
assert %{
entries: [
%{id: ^site_id2, entry_type: "pinned_site"},
%{id: ^site_id4, entry_type: "site"},
%{id: ^site_id1, entry_type: "site"}
]
} = Plausible.Teams.Sites.list(user, %{})
assert %{
entries: [
%{id: ^site_id3, entry_type: "invitation"},
%{id: ^site_id2, entry_type: "pinned_site"},
%{id: ^site_id4, entry_type: "site"},
%{id: ^site_id1, entry_type: "site"}
]
} = Plausible.Teams.Sites.list_with_invitations(user, %{})
end
test "pinned sites are ordered according to the time they were pinned at" do

View File

@ -3,6 +3,41 @@ defmodule Plausible.Factory do
require Plausible.Billing.Subscription.Status
alias Plausible.Billing.Subscription
def team_factory do
%Plausible.Teams.Team{
name: "My Team",
trial_expiry_date: Timex.today() |> Timex.shift(days: 30)
}
end
def team_membership_factory do
%Plausible.Teams.Membership{
user: build(:user),
role: :viewer
}
end
def guest_membership_factory do
%Plausible.Teams.GuestMembership{
team_membership: build(:team_membership, role: :guest)
}
end
def team_invitation_factory do
%Plausible.Teams.Invitation{
invitation_id: Nanoid.generate(),
email: sequence(:email, &"email-#{&1}@example.com"),
role: :admin
}
end
def guest_invitation_factory do
%Plausible.Teams.GuestInvitation{
role: :editor,
team_invitation: build(:team_invitation, role: :guest)
}
end
def user_factory(attrs) do
pw = Map.get(attrs, :password, "password")
@ -43,6 +78,7 @@ defmodule Plausible.Factory do
attrs = if defined_memberships?, do: attrs, else: Map.put_new(attrs, :members, [build(:user)])
site = %Plausible.Site{
team: build(:team),
native_stats_start_at: ~N[2000-01-01 00:00:00],
domain: domain,
timezone: "UTC"

View File

@ -232,10 +232,7 @@ defmodule Plausible.Workers.CheckUsageTest do
end
test "skips checking users who already have a grace period", %{user: user} do
%{grace_period: existing_grace_period} =
user
|> Plausible.Auth.GracePeriod.start_changeset()
|> Repo.update!()
%{grace_period: existing_grace_period} = Plausible.Users.start_grace_period(user)
usage_stub =
Plausible.Billing.Quota.Usage
@ -326,9 +323,7 @@ defmodule Plausible.Workers.CheckUsageTest do
describe "#{status} subscription, enterprise customers" do
test "skips checking enterprise users who already have a grace period", %{user: user} do
%{grace_period: existing_grace_period} =
user
|> Plausible.Auth.GracePeriod.start_manual_lock_changeset()
|> Repo.update!()
Plausible.Users.start_manual_lock_grace_period(user)
usage_stub =
Plausible.Billing.Quota.Usage