Implement limits for team members (#3305)

* Refactor MembershipController.invite_member/2

This commit refactors the controller action used for creating new
invitations. It moves the code to Plausible.Sites.invite/4 and replaces
`ifs` and `cases` with `with`.

* Add team_member_limit to plan definition

* Create usage and limits functions for team members

* Apply team member limit when inviting new users

* Add team members to Usage & Limits section

* Change invite function to receive email address instead of %User{}

* Wrap invite function in a DB transaction

* Remove unnecessary joins from team member usage query

* Replace UNION ALL with UNION to remove duplicates
This commit is contained in:
Vini Brasil 2023-09-04 09:55:07 -03:00 committed by GitHub
parent b3ff695797
commit d22c011aa3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 518 additions and 113 deletions

View File

@ -2,6 +2,8 @@ defmodule Plausible.Auth.Invitation do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@type t() :: %__MODULE__{}
@derive {Jason.Encoder, only: [:invitation_id, :role, :site]} @derive {Jason.Encoder, only: [:invitation_id, :role, :site]}
@required [:email, :role, :site_id, :inviter_id] @required [:email, :role, :site_id, :inviter_id]
schema "invitations" do schema "invitations" do

View File

@ -2,7 +2,7 @@ defmodule Plausible.Billing.Plan do
@moduledoc false @moduledoc false
@derive Jason.Encoder @derive Jason.Encoder
@enforce_keys ~w(kind site_limit monthly_pageview_limit volume monthly_cost yearly_cost monthly_product_id yearly_product_id)a @enforce_keys ~w(kind site_limit monthly_pageview_limit team_member_limit volume monthly_cost yearly_cost monthly_product_id yearly_product_id)a
defstruct @enforce_keys defstruct @enforce_keys
@type t() :: @type t() ::
@ -10,6 +10,7 @@ defmodule Plausible.Billing.Plan do
kind: atom(), kind: atom(),
monthly_pageview_limit: non_neg_integer(), monthly_pageview_limit: non_neg_integer(),
site_limit: non_neg_integer(), site_limit: non_neg_integer(),
team_member_limit: non_neg_integer() | :unlimited,
volume: String.t(), volume: String.t(),
monthly_cost: String.t() | nil, monthly_cost: String.t() | nil,
yearly_cost: String.t() | nil, yearly_cost: String.t() | nil,
@ -36,15 +37,22 @@ defmodule Plausible.Billing.Plans do
path path
|> File.read!() |> File.read!()
|> Jason.decode!(keys: :atoms!) |> Jason.decode!(keys: :atoms!)
|> Enum.map( |> Enum.map(fn raw ->
&Map.put( team_member_limit =
&1, case raw.team_member_limit do
:volume, number when is_integer(number) -> number
PlausibleWeb.StatsView.large_number_format(&1.monthly_pageview_limit) "unlimited" -> :unlimited
) _any -> raise ArgumentError, "Failed to parse team member limit from plan JSON files"
) end
|> Enum.map(&Map.put(&1, :kind, String.to_atom(&1.kind)))
|> Enum.map(&struct!(Plausible.Billing.Plan, &1)) volume = PlausibleWeb.StatsView.large_number_format(raw.monthly_pageview_limit)
raw
|> Map.put(:volume, volume)
|> Map.put(:kind, String.to_atom(raw.kind))
|> Map.put(:team_member_limit, team_member_limit)
|> then(&struct!(Plausible.Billing.Plan, &1))
end)
Module.put_attribute(__MODULE__, f, contents) Module.put_attribute(__MODULE__, f, contents)
Module.put_attribute(__MODULE__, :external_resource, path) Module.put_attribute(__MODULE__, :external_resource, path)

View File

@ -3,6 +3,7 @@ defmodule Plausible.Billing.Quota do
This module provides functions to work with plans usage and limits. This module provides functions to work with plans usage and limits.
""" """
import Ecto.Query
alias Plausible.Billing.Plans alias Plausible.Billing.Plans
@limit_sites_since ~D[2021-05-05] @limit_sites_since ~D[2021-05-05]
@ -79,6 +80,55 @@ defmodule Plausible.Billing.Quota do
|> Tuple.sum() |> Tuple.sum()
end end
@team_member_limit_for_trials 5
@spec team_member_limit(Plausible.Auth.User.t()) :: non_neg_integer()
@doc """
Returns the limit of team members a user can have in their sites.
"""
def team_member_limit(user) do
user = Plausible.Users.with_subscription(user)
case Plans.get_subscription_plan(user.subscription) do
%Plausible.Billing.EnterprisePlan{} -> :unlimited
%Plausible.Billing.Plan{team_member_limit: limit} -> limit
:free_10k -> :unlimited
nil -> @team_member_limit_for_trials
end
end
@spec team_member_usage(Plausible.Auth.User.t()) :: integer()
@doc """
Returns the total count of team members and pending invitations associated
with the user's sites.
"""
def team_member_usage(user) do
owned_sites_query =
from sm in Plausible.Site.Membership,
where: sm.role == :owner and sm.user_id == ^user.id,
select: %{site_id: sm.site_id}
team_members_query =
from os in subquery(owned_sites_query),
inner_join: sm in Plausible.Site.Membership,
on: sm.site_id == os.site_id,
inner_join: u in assoc(sm, :user),
select: %{email: u.email}
invitations_and_team_members_query =
from i in Plausible.Auth.Invitation,
inner_join: os in subquery(owned_sites_query),
on: i.site_id == os.site_id,
select: %{email: i.email},
union: ^team_members_query
query =
from itm in subquery(invitations_and_team_members_query),
where: itm.email != ^user.email,
select: count(itm.email, :distinct)
Plausible.Repo.one(query)
end
@spec within_limit?(non_neg_integer(), non_neg_integer() | :unlimited) :: boolean() @spec within_limit?(non_neg_integer(), non_neg_integer() | :unlimited) :: boolean()
@doc """ @doc """
Returns whether the limit has been exceeded or not. Returns whether the limit has been exceeded or not.

View File

@ -49,6 +49,57 @@ defmodule Plausible.Sites do
end end
end end
@spec invite(Site.t(), Plausible.Auth.User.t(), String.t(), atom()) ::
{:ok, Plausible.Auth.Invitation.t()}
| {:error, Ecto.Changeset.t()}
| {:error, :already_a_member}
| {:error, {:over_limit, non_neg_integer()}}
def invite(site, inviter, invitee_email, role) do
Repo.transaction(fn ->
do_invite(site, inviter, invitee_email, role)
end)
end
defp do_invite(site, inviter, invitee_email, role) do
send_invitation_email = fn invitation, invitee ->
invitation = Repo.preload(invitation, [:site, :inviter])
email =
if invitee,
do: PlausibleWeb.Email.existing_user_invitation(invitation),
else: PlausibleWeb.Email.new_user_invitation(invitation)
Plausible.Mailer.send(email)
end
ensure_new_membership = fn site, invitee ->
if invitee && is_member?(invitee.id, site), do: {:error, :already_a_member}, else: :ok
end
check_limit = fn site ->
owner = owner_for(site)
usage = Plausible.Billing.Quota.team_member_usage(owner)
limit = Plausible.Billing.Quota.team_member_limit(owner)
if Plausible.Billing.Quota.within_limit?(usage, limit),
do: :ok,
else: {:error, {:over_limit, limit}}
end
attrs = %{email: invitee_email, role: role, site_id: site.id, inviter_id: inviter.id}
with :ok <- check_limit.(site),
invitee <- Plausible.Auth.find_user_by(email: invitee_email),
:ok <- ensure_new_membership.(site, invitee),
%Ecto.Changeset{} = changeset <- Plausible.Auth.Invitation.new(attrs),
{:ok, invitation} <- Repo.insert(changeset) do
send_invitation_email.(invitation, invitee)
invitation
else
{:error, cause} -> Repo.rollback(cause)
end
end
@spec stats_start_date(Plausible.Site.t()) :: Date.t() | nil @spec stats_start_date(Plausible.Site.t()) :: Date.t() | nil
@doc """ @doc """
Returns the date of the first event of the given site, or `nil` if the site Returns the date of the first event of the given site, or `nil` if the site

View File

@ -27,10 +27,18 @@ defmodule PlausibleWeb.Components.Billing do
<tr {@rest}> <tr {@rest}>
<td class={["py-4 text-sm whitespace-nowrap text-left", @pad && "pl-6"]}><%= @title %></td> <td class={["py-4 text-sm whitespace-nowrap text-left", @pad && "pl-6"]}><%= @title %></td>
<td class="py-4 text-sm whitespace-nowrap text-right"> <td class="py-4 text-sm whitespace-nowrap text-right">
<%= Cldr.Number.to_string!(@usage) %> <%= render_quota(@usage) %>
<%= if is_number(@limit), do: "/ #{Cldr.Number.to_string!(@limit)}" %> <%= if @limit, do: "/ #{render_quota(@limit)}" %>
</td> </td>
</tr> </tr>
""" """
end end
defp render_quota(quota) do
case quota do
quota when is_number(quota) -> Cldr.Number.to_string!(quota)
:unlimited -> ""
nil -> ""
end
end
end end

View File

@ -498,6 +498,8 @@ defmodule PlausibleWeb.AuthController do
subscription: user.subscription, subscription: user.subscription,
invoices: Plausible.Billing.paddle_api().get_invoices(user.subscription), invoices: Plausible.Billing.paddle_api().get_invoices(user.subscription),
theme: user.theme || "system", theme: user.theme || "system",
team_member_limit: Plausible.Billing.Quota.team_member_limit(user),
team_member_usage: Plausible.Billing.Quota.team_member_usage(user),
site_limit: Plausible.Billing.Quota.site_limit(user), site_limit: Plausible.Billing.Quota.site_limit(user),
site_usage: Plausible.Billing.Quota.site_usage(user), site_usage: Plausible.Billing.Quota.site_usage(user),
total_pageview_limit: Plausible.Billing.Quota.monthly_pageview_limit(user.subscription), total_pageview_limit: Plausible.Billing.Quota.monthly_pageview_limit(user.subscription),

View File

@ -39,62 +39,46 @@ defmodule PlausibleWeb.Site.MembershipController do
def invite_member(conn, %{"email" => email, "role" => role}) do def invite_member(conn, %{"email" => email, "role" => role}) do
site_domain = conn.assigns[:site].domain site_domain = conn.assigns[:site].domain
site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain) site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain)
user = Plausible.Auth.find_user_by(email: email)
if user && Sites.is_member?(user.id, site) do case Sites.invite(site, conn.assigns.current_user, email, role) do
msg = "Cannot send invite because #{user.email} is already a member of #{site.domain}" {:ok, invitation} ->
conn
|> put_flash(
:success,
"#{email} has been invited to #{site_domain} as #{PlausibleWeb.SiteView.with_indefinite_article("#{invitation.role}")}"
)
|> redirect(to: Routes.site_path(conn, :settings_people, site.domain))
render(conn, "invite_member_form.html", {:error, :already_a_member} ->
error: msg, render(conn, "invite_member_form.html",
site: site, error: "Cannot send invite because #{email} is already a member of #{site.domain}",
layout: {PlausibleWeb.LayoutView, "focus.html"}, site: site,
skip_plausible_tracking: true layout: {PlausibleWeb.LayoutView, "focus.html"},
) skip_plausible_tracking: true
else )
case Repo.insert(
Invitation.new(%{
email: email,
role: role,
site_id: site.id,
inviter_id: conn.assigns[:current_user].id
})
) do
{:ok, invitation} ->
invitation = Repo.preload(invitation, [:site, :inviter])
email_template = {:error, {:over_limit, limit}} ->
if user do render(conn, "invite_member_form.html",
PlausibleWeb.Email.existing_user_invitation(invitation) error:
else "Your account is limited to #{limit} team members. You can upgrade your plan to increase this limit.",
PlausibleWeb.Email.new_user_invitation(invitation) site: site,
end layout: {PlausibleWeb.LayoutView, "focus.html"},
skip_plausible_tracking: true
)
Plausible.Mailer.send(email_template) {:error, %Ecto.Changeset{} = changeset} ->
error_msg =
case changeset.errors[:invitation] do
{"already sent", _} ->
"This invitation has been already sent. To send again, remove it from pending invitations first."
conn _ ->
|> put_flash( "Something went wrong."
:success, end
"#{email} has been invited to #{site_domain} as #{PlausibleWeb.SiteView.with_indefinite_article(role)}"
)
|> redirect(to: Routes.site_path(conn, :settings_people, site.domain))
{:error, changeset} -> conn
error_msg = |> put_flash(:error, error_msg)
case changeset.errors[:invitation] do |> redirect(to: Routes.site_path(conn, :settings_people, site.domain))
{"already sent", _} ->
"This invitation has been already sent. To send again, remove it from pending invitations first."
_ ->
"Something went wrong."
end
conn
|> put_flash(
:error,
error_msg
)
|> redirect(to: Routes.site_path(conn, :settings_people, site.domain))
end
end end
end end

View File

@ -151,6 +151,11 @@
usage={@site_usage} usage={@site_usage}
limit={@site_limit} limit={@site_limit}
/> />
<PlausibleWeb.Components.Billing.usage_and_limits_row
title="Team members"
usage={@team_member_usage}
limit={@team_member_limit}
/>
</PlausibleWeb.Components.Billing.usage_and_limits_table> </PlausibleWeb.Components.Billing.usage_and_limits_table>
</article> </article>

View File

@ -6,7 +6,8 @@
"monthly_product_id":"558018", "monthly_product_id":"558018",
"yearly_cost":"$48", "yearly_cost":"$48",
"yearly_product_id":"572810", "yearly_product_id":"572810",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -15,7 +16,8 @@
"monthly_product_id":"558745", "monthly_product_id":"558745",
"yearly_cost":"$96", "yearly_cost":"$96",
"yearly_product_id":"590752", "yearly_product_id":"590752",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -24,7 +26,8 @@
"monthly_product_id":"597485", "monthly_product_id":"597485",
"yearly_cost":"$144", "yearly_cost":"$144",
"yearly_product_id":"597486", "yearly_product_id":"597486",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -33,7 +36,8 @@
"monthly_product_id":"597487", "monthly_product_id":"597487",
"yearly_cost":"$216", "yearly_cost":"$216",
"yearly_product_id":"597488", "yearly_product_id":"597488",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -42,7 +46,8 @@
"monthly_product_id":"597642", "monthly_product_id":"597642",
"yearly_cost":"$384", "yearly_cost":"$384",
"yearly_product_id":"597643", "yearly_product_id":"597643",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -51,7 +56,8 @@
"monthly_product_id":"597309", "monthly_product_id":"597309",
"yearly_cost":"$552", "yearly_cost":"$552",
"yearly_product_id":"597310", "yearly_product_id":"597310",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -60,7 +66,8 @@
"monthly_product_id":"597311", "monthly_product_id":"597311",
"yearly_cost":"$792", "yearly_cost":"$792",
"yearly_product_id":"597312", "yearly_product_id":"597312",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -69,7 +76,8 @@
"monthly_product_id":"642352", "monthly_product_id":"642352",
"yearly_cost":"$1200", "yearly_cost":"$1200",
"yearly_product_id":"642354", "yearly_product_id":"642354",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -78,7 +86,8 @@
"monthly_product_id":"642355", "monthly_product_id":"642355",
"yearly_cost":"$1800", "yearly_cost":"$1800",
"yearly_product_id":"642356", "yearly_product_id":"642356",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -87,6 +96,7 @@
"monthly_product_id":"650652", "monthly_product_id":"650652",
"yearly_cost":"$2640", "yearly_cost":"$2640",
"yearly_product_id":"650653", "yearly_product_id":"650653",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
} }
] ]

View File

@ -6,7 +6,8 @@
"monthly_product_id":"654177", "monthly_product_id":"654177",
"yearly_cost":"$60", "yearly_cost":"$60",
"yearly_product_id":"653232", "yearly_product_id":"653232",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -15,7 +16,8 @@
"monthly_product_id":"654178", "monthly_product_id":"654178",
"yearly_cost":"$120", "yearly_cost":"$120",
"yearly_product_id":"653234", "yearly_product_id":"653234",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -24,7 +26,8 @@
"monthly_product_id":"653237", "monthly_product_id":"653237",
"yearly_cost":"$200", "yearly_cost":"$200",
"yearly_product_id":"653236", "yearly_product_id":"653236",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -33,7 +36,8 @@
"monthly_product_id":"653238", "monthly_product_id":"653238",
"yearly_cost":"$300", "yearly_cost":"$300",
"yearly_product_id":"653239", "yearly_product_id":"653239",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -42,7 +46,8 @@
"monthly_product_id":"653240", "monthly_product_id":"653240",
"yearly_cost":"$500", "yearly_cost":"$500",
"yearly_product_id":"653242", "yearly_product_id":"653242",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -51,7 +56,8 @@
"monthly_product_id":"653253", "monthly_product_id":"653253",
"yearly_cost":"$700", "yearly_cost":"$700",
"yearly_product_id":"653254", "yearly_product_id":"653254",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -60,7 +66,8 @@
"monthly_product_id":"653255", "monthly_product_id":"653255",
"yearly_cost":"$1000", "yearly_cost":"$1000",
"yearly_product_id":"653256", "yearly_product_id":"653256",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -69,7 +76,8 @@
"monthly_product_id":"654181", "monthly_product_id":"654181",
"yearly_cost":"$1500", "yearly_cost":"$1500",
"yearly_product_id":"653257", "yearly_product_id":"653257",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -78,7 +86,8 @@
"monthly_product_id":"654182", "monthly_product_id":"654182",
"yearly_cost":"$2250", "yearly_cost":"$2250",
"yearly_product_id":"653258", "yearly_product_id":"653258",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -87,6 +96,7 @@
"monthly_product_id":"654183", "monthly_product_id":"654183",
"yearly_cost":"$3300", "yearly_cost":"$3300",
"yearly_product_id":"653259", "yearly_product_id":"653259",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
} }
] ]

View File

@ -6,7 +6,8 @@
"monthly_product_id":"749342", "monthly_product_id":"749342",
"yearly_cost":"$90", "yearly_cost":"$90",
"yearly_product_id":"749343", "yearly_product_id":"749343",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -15,7 +16,8 @@
"monthly_product_id":"749344", "monthly_product_id":"749344",
"yearly_cost":"$190", "yearly_cost":"$190",
"yearly_product_id":"749345", "yearly_product_id":"749345",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -24,7 +26,8 @@
"monthly_product_id":"749346", "monthly_product_id":"749346",
"yearly_cost":"$290", "yearly_cost":"$290",
"yearly_product_id":"749347", "yearly_product_id":"749347",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -33,7 +36,8 @@
"monthly_product_id":"749348", "monthly_product_id":"749348",
"yearly_cost":"$490", "yearly_cost":"$490",
"yearly_product_id":"749349", "yearly_product_id":"749349",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -42,7 +46,8 @@
"monthly_product_id":"749350", "monthly_product_id":"749350",
"yearly_cost":"$690", "yearly_cost":"$690",
"yearly_product_id":"749352", "yearly_product_id":"749352",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -51,7 +56,8 @@
"monthly_product_id":"749353", "monthly_product_id":"749353",
"yearly_cost":"$890", "yearly_cost":"$890",
"yearly_product_id":"749355", "yearly_product_id":"749355",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -60,7 +66,8 @@
"monthly_product_id":"749356", "monthly_product_id":"749356",
"yearly_cost":"$1290", "yearly_cost":"$1290",
"yearly_product_id":"749357", "yearly_product_id":"749357",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
}, },
{ {
"kind":"growth", "kind":"growth",
@ -69,6 +76,7 @@
"monthly_product_id":"749358", "monthly_product_id":"749358",
"yearly_cost":"$1690", "yearly_cost":"$1690",
"yearly_product_id":"749359", "yearly_product_id":"749359",
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
} }
] ]

View File

@ -6,7 +6,8 @@
"monthly_product_id":"63842", "monthly_product_id":"63842",
"yearly_cost":"$90", "yearly_cost":"$90",
"yearly_product_id":"63859", "yearly_product_id":"63859",
"site_limit":10 "site_limit":10,
"team_member_limit":5
}, },
{ {
"kind":"growth", "kind":"growth",
@ -15,7 +16,8 @@
"monthly_product_id":"63843", "monthly_product_id":"63843",
"yearly_cost":"$190", "yearly_cost":"$190",
"yearly_product_id":"63860", "yearly_product_id":"63860",
"site_limit":10 "site_limit":10,
"team_member_limit":5
}, },
{ {
"kind":"growth", "kind":"growth",
@ -24,7 +26,8 @@
"monthly_product_id":"63844", "monthly_product_id":"63844",
"yearly_cost":"$290", "yearly_cost":"$290",
"yearly_product_id":"63861", "yearly_product_id":"63861",
"site_limit":10 "site_limit":10,
"team_member_limit":5
}, },
{ {
"kind":"growth", "kind":"growth",
@ -33,7 +36,8 @@
"monthly_product_id":"63845", "monthly_product_id":"63845",
"yearly_cost":"$490", "yearly_cost":"$490",
"yearly_product_id":"63862", "yearly_product_id":"63862",
"site_limit":10 "site_limit":10,
"team_member_limit":5
}, },
{ {
"kind":"growth", "kind":"growth",
@ -42,7 +46,8 @@
"monthly_product_id":"63846", "monthly_product_id":"63846",
"yearly_cost":"$690", "yearly_cost":"$690",
"yearly_product_id":"63863", "yearly_product_id":"63863",
"site_limit":10 "site_limit":10,
"team_member_limit":5
}, },
{ {
"kind":"growth", "kind":"growth",
@ -51,7 +56,8 @@
"monthly_product_id":"63847", "monthly_product_id":"63847",
"yearly_cost":"$890", "yearly_cost":"$890",
"yearly_product_id":"63864", "yearly_product_id":"63864",
"site_limit":10 "site_limit":10,
"team_member_limit":5
}, },
{ {
"kind":"growth", "kind":"growth",
@ -60,7 +66,8 @@
"monthly_product_id":"63848", "monthly_product_id":"63848",
"yearly_cost":"$1290", "yearly_cost":"$1290",
"yearly_product_id":"63865", "yearly_product_id":"63865",
"site_limit":10 "site_limit":10,
"team_member_limit":5
}, },
{ {
"kind":"growth", "kind":"growth",
@ -69,7 +76,8 @@
"monthly_product_id":"63849", "monthly_product_id":"63849",
"yearly_cost":"$1690", "yearly_cost":"$1690",
"yearly_product_id":"63866", "yearly_product_id":"63866",
"site_limit":10 "site_limit":10,
"team_member_limit":5
}, },
{ {
"kind":"business", "kind":"business",
@ -78,7 +86,8 @@
"monthly_product_id":"63850", "monthly_product_id":"63850",
"yearly_cost":"$100", "yearly_cost":"$100",
"yearly_product_id":"63867", "yearly_product_id":"63867",
"site_limit":50 "site_limit":50,
"team_member_limit":50
}, },
{ {
"kind":"business", "kind":"business",
@ -87,7 +96,8 @@
"monthly_product_id":"63851", "monthly_product_id":"63851",
"yearly_cost":"$200", "yearly_cost":"$200",
"yearly_product_id":"63868", "yearly_product_id":"63868",
"site_limit":50 "site_limit":50,
"team_member_limit":50
}, },
{ {
"kind":"business", "kind":"business",
@ -96,7 +106,8 @@
"monthly_product_id":"63852", "monthly_product_id":"63852",
"yearly_cost":"$300", "yearly_cost":"$300",
"yearly_product_id":"63869", "yearly_product_id":"63869",
"site_limit":50 "site_limit":50,
"team_member_limit":50
}, },
{ {
"kind":"business", "kind":"business",
@ -105,7 +116,8 @@
"monthly_product_id":"63853", "monthly_product_id":"63853",
"yearly_cost":"$500", "yearly_cost":"$500",
"yearly_product_id":"63870", "yearly_product_id":"63870",
"site_limit":50 "site_limit":50,
"team_member_limit":50
}, },
{ {
"kind":"business", "kind":"business",
@ -114,7 +126,8 @@
"monthly_product_id":"63854", "monthly_product_id":"63854",
"yearly_cost":"$700", "yearly_cost":"$700",
"yearly_product_id":"63871", "yearly_product_id":"63871",
"site_limit":50 "site_limit":50,
"team_member_limit":50
}, },
{ {
"kind":"business", "kind":"business",
@ -123,7 +136,8 @@
"monthly_product_id":"63855", "monthly_product_id":"63855",
"yearly_cost":"$900", "yearly_cost":"$900",
"yearly_product_id":"63872", "yearly_product_id":"63872",
"site_limit":50 "site_limit":50,
"team_member_limit":50
}, },
{ {
"kind":"business", "kind":"business",
@ -132,7 +146,8 @@
"monthly_product_id":"63856", "monthly_product_id":"63856",
"yearly_cost":"$1300", "yearly_cost":"$1300",
"yearly_product_id":"63873", "yearly_product_id":"63873",
"site_limit":50 "site_limit":50,
"team_member_limit":50
}, },
{ {
"kind":"business", "kind":"business",
@ -141,6 +156,7 @@
"monthly_product_id":"63857", "monthly_product_id":"63857",
"yearly_cost":"$1700", "yearly_cost":"$1700",
"yearly_product_id":"63874", "yearly_product_id":"63874",
"site_limit":50 "site_limit":50,
"team_member_limit":50
} }
] ]

View File

@ -6,6 +6,7 @@
"yearly_cost":"$4800", "yearly_cost":"$4800",
"monthly_product_id":null, "monthly_product_id":null,
"monthly_cost":null, "monthly_cost":null,
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
} }
] ]

View File

@ -6,6 +6,7 @@
"monthly_cost":"$250", "monthly_cost":"$250",
"yearly_product_id":null, "yearly_product_id":null,
"yearly_cost":null, "yearly_cost":null,
"site_limit":50 "site_limit":50,
"team_member_limit":"unlimited"
} }
] ]

View File

@ -41,9 +41,6 @@ defmodule Plausible.Billing.QuotaTest do
test "returns 50 when user in on trial" do test "returns 50 when user in on trial" do
user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: 7)) user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: 7))
assert 50 == Quota.site_limit(user) assert 50 == Quota.site_limit(user)
user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: -7))
assert 50 == Quota.site_limit(user)
end end
test "returns the subscription limit for enterprise users who have not paid yet" do test "returns the subscription limit for enterprise users who have not paid yet" do
@ -193,4 +190,155 @@ defmodule Plausible.Billing.QuotaTest do
assert Quota.monthly_pageview_usage(user) == 0 assert Quota.monthly_pageview_usage(user) == 0
end end
end end
describe "team_member_usage/1" do
test "returns the number of members in all of the sites the user owns" do
me = insert(:user)
_site_i_own_1 =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :owner),
build(:site_membership, user: build(:user), role: :viewer)
]
)
_site_i_own_2 =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :owner),
build(:site_membership, user: build(:user), role: :admin),
build(:site_membership, user: build(:user), role: :viewer)
]
)
_site_i_own_3 =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :owner)
]
)
_site_i_have_access =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :viewer),
build(:site_membership, user: build(:user), role: :viewer),
build(:site_membership, user: build(:user), role: :viewer),
build(:site_membership, user: build(:user), role: :viewer)
]
)
assert Quota.team_member_usage(me) == 3
end
test "counts the same email address as one team member" do
me = insert(:user)
joe = insert(:user, email: "joe@plausible.test")
_site_i_own_1 =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :owner),
build(:site_membership, user: joe, role: :viewer)
]
)
_site_i_own_2 =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :owner),
build(:site_membership, user: build(:user), role: :admin),
build(:site_membership, user: joe, role: :viewer)
]
)
site_i_own_3 = insert(:site, memberships: [build(:site_membership, user: me, role: :owner)])
insert(:invitation, site: site_i_own_3, inviter: me, email: "joe@plausible.test")
assert Quota.team_member_usage(me) == 2
end
test "counts pending invitations as team members" do
me = insert(:user)
member = insert(:user)
site_i_own =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :owner),
build(:site_membership, user: member, role: :admin)
]
)
site_i_have_access =
insert(:site, memberships: [build(:site_membership, user: me, role: :admin)])
insert(:invitation, site: site_i_own, inviter: me)
insert(:invitation, site: site_i_own, inviter: member)
insert(:invitation, site: site_i_have_access, inviter: me)
assert Quota.team_member_usage(me) == 3
end
test "returns zero when user does not have any site" do
me = insert(:user)
assert Quota.team_member_usage(me) == 0
end
test "does not count email report recipients as team members" do
me = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: me, role: :owner)])
insert(:weekly_report,
site: site,
recipients: ["adam@plausible.test", "vini@plausible.test"]
)
assert Quota.team_member_usage(me) == 0
end
end
describe "team_member_limit/1" do
test "returns unlimited when user is on an old plan" do
user_on_v1 = insert(:user, subscription: build(:subscription, paddle_plan_id: @v1_plan_id))
user_on_v2 = insert(:user, subscription: build(:subscription, paddle_plan_id: @v2_plan_id))
user_on_v3 = insert(:user, subscription: build(:subscription, paddle_plan_id: @v3_plan_id))
assert :unlimited == Quota.team_member_limit(user_on_v1)
assert :unlimited == Quota.team_member_limit(user_on_v2)
assert :unlimited == Quota.team_member_limit(user_on_v3)
end
test "returns unlimited when user is on free_10k plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: "free_10k"))
assert :unlimited == Quota.team_member_limit(user)
end
test "returns unlimited when user is on an enterprise plan" do
user = insert(:user)
enterprise_plan = insert(:enterprise_plan, user_id: user.id)
_subscription =
insert(:subscription, user_id: user.id, paddle_plan_id: enterprise_plan.paddle_plan_id)
assert :unlimited == Quota.team_member_limit(user)
end
test "returns 5 when user in on trial" do
user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: 7))
assert 5 == Quota.team_member_limit(user)
end
test "is unlimited for enterprise customers" do
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "123321"),
subscription: build(:subscription, paddle_plan_id: "123321")
)
assert :unlimited == Quota.team_member_limit(user)
end
end
end end

View File

@ -1,5 +1,6 @@
defmodule Plausible.SitesTest do defmodule Plausible.SitesTest do
use Plausible.DataCase use Plausible.DataCase
use Bamboo.Test
alias Plausible.Sites alias Plausible.Sites
@ -67,4 +68,79 @@ defmodule Plausible.SitesTest do
assert Sites.has_stats?(site) assert Sites.has_stats?(site)
end end
end end
describe "invite/4" do
test "creates an invitation" do
inviter = insert(:user)
invitee = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
Sites.invite(site, inviter, invitee.email, :viewer)
end
test "returns validation errors" do
inviter = insert(:user)
invitee = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:error, changeset} = Sites.invite(site, inviter, invitee.email, :invalid_role)
assert {"is invalid", _} = changeset.errors[:role]
end
test "returns error when user is already a member" do
inviter = insert(:user)
invitee = insert(:user)
site =
insert(:site,
memberships: [
build(:site_membership, user: inviter, role: :owner),
build(:site_membership, user: invitee, role: :viewer)
]
)
assert {:error, :already_a_member} = Sites.invite(site, inviter, invitee.email, :viewer)
assert {:error, :already_a_member} = Sites.invite(site, inviter, inviter.email, :viewer)
end
test "sends invitation email for existing users" do
[inviter, invitee] = insert_list(2, :user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
Sites.invite(site, inviter, invitee.email, :viewer)
assert_email_delivered_with(
to: [nil: invitee.email],
subject: "[Plausible Analytics] You've been invited to #{site.domain}"
)
end
test "sends invitation email for new users" do
inviter = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
Sites.invite(site, inviter, "vini@plausible.test", :viewer)
assert_email_delivered_with(
to: [nil: "vini@plausible.test"],
subject: "[Plausible Analytics] You've been invited to #{site.domain}"
)
end
test "returns error when owner is over their team member limit" do
[owner, inviter, invitee] = insert_list(3, :user)
memberships =
[
build(:site_membership, user: owner, role: :owner),
build(:site_membership, user: inviter, role: :admin)
] ++ build_list(4, :site_membership)
site = insert(:site, memberships: memberships)
assert {:error, {:over_limit, 5}} = Sites.invite(site, inviter, invitee.email, :viewer)
end
end
end end

View File

@ -31,6 +31,22 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do
assert redirected_to(conn) == "/#{site.domain}/settings/people" assert redirected_to(conn) == "/#{site.domain}/settings/people"
end end
test "fails to create invitation when is over limit", %{conn: conn, user: user} do
memberships =
[build(:site_membership, user: user, role: :owner)] ++ build_list(5, :site_membership)
site = insert(:site, memberships: memberships)
conn =
post(conn, "/sites/#{site.domain}/memberships/invite", %{
email: "john.doe@example.com",
role: "admin"
})
assert html_response(conn, 200) =~
"Your account is limited to 5 team members. You can upgrade your plan to increase this limit."
end
test "fails to create invitation with insufficient permissions", %{conn: conn, user: user} do test "fails to create invitation with insufficient permissions", %{conn: conn, user: user} do
site = insert(:site, memberships: [build(:site_membership, user: user, role: :viewer)]) site = insert(:site, memberships: [build(:site_membership, user: user, role: :viewer)])
@ -95,7 +111,13 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do
test "renders form with error if the invitee is already a member", %{conn: conn, user: user} do test "renders form with error if the invitee is already a member", %{conn: conn, user: user} do
second_member = insert(:user) second_member = insert(:user)
site = insert(:site, members: [user, second_member])
memberships = [
build(:site_membership, user: user, role: :owner),
build(:site_membership, user: second_member)
]
site = insert(:site, memberships: memberships)
conn = conn =
post(conn, "/sites/#{site.domain}/memberships/invite", %{ post(conn, "/sites/#{site.domain}/memberships/invite", %{

View File

@ -32,7 +32,10 @@ defmodule Plausible.Factory do
end end
def site_membership_factory do def site_membership_factory do
%Plausible.Site.Membership{} %Plausible.Site.Membership{
user: build(:user),
role: :viewer
}
end end
def ch_session_factory do def ch_session_factory do