mirror of
https://github.com/plausible/analytics.git
synced 2024-12-22 09:01:40 +03:00
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:
parent
1e38bd8771
commit
17b12ddaeb
@ -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, []},
|
||||
|
||||
#
|
||||
|
@ -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
|
||||
|
@ -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"],
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
724
lib/plausible/data_migration/backfill_teams.ex
Normal file
724
lib/plausible/data_migration/backfill_teams.ex
Normal 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
|
@ -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.
|
22
lib/plausible/data_migration/postgres_repo.ex
Normal file
22
lib/plausible/data_migration/postgres_repo.ex
Normal 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
|
@ -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 """
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
102
lib/plausible/teams.ex
Normal 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
|
235
lib/plausible/teams/billing.ex
Normal file
235
lib/plausible/teams/billing.ex
Normal 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
|
26
lib/plausible/teams/guest_invitation.ex
Normal file
26
lib/plausible/teams/guest_invitation.ex
Normal 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
|
26
lib/plausible/teams/guest_membership.ex
Normal file
26
lib/plausible/teams/guest_membership.ex
Normal 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
|
34
lib/plausible/teams/invitation.ex
Normal file
34
lib/plausible/teams/invitation.ex
Normal 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
|
560
lib/plausible/teams/invitations.ex
Normal file
560
lib/plausible/teams/invitations.ex
Normal 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
|
29
lib/plausible/teams/membership.ex
Normal file
29
lib/plausible/teams/membership.ex
Normal 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
|
133
lib/plausible/teams/memberships.ex
Normal file
133
lib/plausible/teams/memberships.ex
Normal 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
|
35
lib/plausible/teams/site_transfer.ex
Normal file
35
lib/plausible/teams/site_transfer.ex
Normal 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
|
206
lib/plausible/teams/sites.ex
Normal file
206
lib/plausible/teams/sites.ex
Normal 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
|
73
lib/plausible/teams/team.ex
Normal file
73
lib/plausible/teams/team.ex
Normal 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
|
@ -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],
|
||||
|
@ -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",
|
||||
|
@ -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 %>
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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 />
|
||||
|
@ -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.
|
||||
|
@ -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 %>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user