Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extend existing invite accept and reject flows to support team invitations #4953

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions lib/plausible/site/memberships/accept_invitation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do

@type accept_error() ::
:invitation_not_found
| :already_other_team_member
| Billing.Quota.Limits.over_limits_error()
| Ecto.Changeset.t()
| :no_plan
Expand Down Expand Up @@ -59,8 +60,11 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do
%Teams.SiteTransfer{} = site_transfer ->
do_accept_ownership_transfer(site_transfer, user)

%Teams.Invitation{} = team_invitation ->
do_accept_team_invitation(team_invitation, user)

%Teams.GuestInvitation{} = guest_invitation ->
do_accept_invitation(guest_invitation, user)
do_accept_guest_invitation(guest_invitation, user)
end
end
end
Expand Down Expand Up @@ -103,7 +107,21 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do
end
end

defp do_accept_invitation(guest_invitation, user) do
defp do_accept_guest_invitation(guest_invitation, user) do
Teams.Invitations.accept_guest_invitation(guest_invitation, user)
end

defp do_accept_team_invitation(team_invitation, user) do
with :ok <- ensure_no_other_team_membership(team_invitation.team, user) do
Teams.Invitations.accept_team_invitation(team_invitation, user)
end
end

defp ensure_no_other_team_membership(team, user) do
if Teams.Users.team_member?(user, except: [team.id]) do
{:error, :already_other_team_member}
else
:ok
end
end
end
13 changes: 12 additions & 1 deletion lib/plausible/site/memberships/reject_invitation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ defmodule Plausible.Site.Memberships.RejectInvitation do
end
end

defp do_reject(%Teams.Invitation{} = team_invitation) do
Teams.Invitations.remove_team_invitation(team_invitation)

notify_team_invitation_rejected(team_invitation)
end

defp do_reject(%Teams.GuestInvitation{} = guest_invitation) do
Teams.Invitations.remove_guest_invitation(guest_invitation)

Expand All @@ -35,7 +41,12 @@ defmodule Plausible.Site.Memberships.RejectInvitation do
end

defp notify_guest_invitation_rejected(guest_invitation) do
PlausibleWeb.Email.invitation_rejected(guest_invitation)
PlausibleWeb.Email.guest_invitation_rejected(guest_invitation)
|> Plausible.Mailer.send()
end

defp notify_team_invitation_rejected(team_invitation) do
PlausibleWeb.Email.team_invitation_rejected(team_invitation)
|> Plausible.Mailer.send()
end
end
95 changes: 83 additions & 12 deletions lib/plausible/teams/invitations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ defmodule Plausible.Teams.Invitations do

def find_for_user(invitation_or_transfer_id, user) do
with {:error, :invitation_not_found} <-
find_invitation_for_user(invitation_or_transfer_id, user) do
find_team_invitation_for_user(invitation_or_transfer_id, user),
{:error, :invitation_not_found} <-
find_guest_invitation_for_user(invitation_or_transfer_id, user) do
find_transfer_for_user(invitation_or_transfer_id, user)
end
end
Expand All @@ -23,7 +25,26 @@ defmodule Plausible.Teams.Invitations do
end
end

defp find_invitation_for_user(guest_invitation_id, user) do
defp find_team_invitation_for_user(team_invitation_id, user) do
invitation_query =
from ti in Teams.Invitation,
inner_join: inviter in assoc(ti, :inviter),
inner_join: team in assoc(ti, :team),
where: ti.invitation_id == ^team_invitation_id,
where: ti.email == ^user.email,
where: ti.role != :guest,
preload: [inviter: inviter, team: team]

case Repo.one(invitation_query) do
nil ->
{:error, :invitation_not_found}

invitation ->
{:ok, invitation}
end
end

defp find_guest_invitation_for_user(guest_invitation_id, user) do
invitation_query =
from gi in Teams.GuestInvitation,
inner_join: s in assoc(gi, :site),
Expand Down Expand Up @@ -110,6 +131,15 @@ defmodule Plausible.Teams.Invitations do
end
end

def remove_team_invitation(team_invitation) do
Repo.delete_all(
from ti in Teams.Invitation,
where: ti.id == ^team_invitation.id
)

:ok
end

def remove_guest_invitation(guest_invitation) do
site = Repo.preload(guest_invitation, site: :team).site

Expand Down Expand Up @@ -162,11 +192,14 @@ defmodule Plausible.Teams.Invitations do

now = NaiveDateTime.utc_now(:second)

with {:ok, team_membership} <-
do_accept(team_invitation, user, now, guest_invitations: [guest_invitation]) do
prune_guest_invitations(team_invitation.team)
{:ok, team_membership}
end
do_accept(team_invitation, user, now, guest_invitations: [guest_invitation])
end

def accept_team_invitation(team_invitation, user) do
team_invitation = Repo.preload(team_invitation, [:team, :inviter])
now = NaiveDateTime.utc_now(:second)

do_accept(team_invitation, user, now, guest_invitations: [])
end

@doc false
Expand Down Expand Up @@ -232,8 +265,17 @@ defmodule Plausible.Teams.Invitations do
# Clean up guest invitations after accepting
guest_invitation_ids = Enum.map(guest_invitations, & &1.id)
Repo.delete_all(from gi in Teams.GuestInvitation, where: gi.id in ^guest_invitation_ids)

if team_membership.role != :guest do
Repo.delete_all(from ti in Teams.Invitation, where: ti.id == ^team_invitation.id)
end

prune_guest_invitations(team_invitation.team)

# Prune guest memberships if any exist when team membership role
# is other than guest
maybe_prune_guest_memberships(team_membership)

if send_email? do
send_invitation_accepted_email(team_invitation, guest_invitations)
end
Expand All @@ -245,6 +287,16 @@ defmodule Plausible.Teams.Invitations do
end)
end

defp maybe_prune_guest_memberships(%Teams.Membership{role: :guest}), do: :ok

defp maybe_prune_guest_memberships(%Teams.Membership{} = team_membership) do
team_membership
|> Ecto.assoc(:guest_memberships)
|> Repo.delete_all()

:ok
end

defp transfer_site_ownership(site, team, now) do
site =
Repo.preload(site, [
Expand Down Expand Up @@ -577,11 +629,29 @@ defmodule Plausible.Teams.Invitations do
Plausible.Mailer.send(email)
end

@team_role_type Plausible.Teams.Membership.__schema__(:type, :role)

defp create_team_membership(team, role, user, now) do
conflict_query =
from(tm in Teams.Membership,
update: [
set: [
updated_at: ^now,
role:
fragment(
"CASE WHEN ? = 'guest' THEN ? ELSE ? END",
tm.role,
type(^role, ^@team_role_type),
tm.role
)
]
]
)

team
|> Teams.Membership.changeset(user, role)
|> Repo.insert(
on_conflict: [set: [updated_at: now]],
on_conflict: conflict_query,
conflict_target: [:team_id, :user_id],
returning: true
)
Expand Down Expand Up @@ -613,14 +683,15 @@ defmodule Plausible.Teams.Invitations do
end)
end

defp send_invitation_accepted_email(_team_invitation, []) do
# NOOP for now
:ok
defp send_invitation_accepted_email(team_invitation, []) do
team_invitation.inviter.email
|> PlausibleWeb.Email.team_invitation_accepted(team_invitation.email, team_invitation.team)
|> Plausible.Mailer.send()
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)
|> PlausibleWeb.Email.guest_invitation_accepted(team_invitation.email, guest_invitation.site)
|> Plausible.Mailer.send()
end

Expand Down
13 changes: 13 additions & 0 deletions lib/plausible/teams/users.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ defmodule Plausible.Teams.Users do
alias Plausible.Repo
alias Plausible.Teams

def team_member?(user, opts \\ []) do
excluded_team_ids = Keyword.get(opts, :except, [])

Repo.exists?(
from(
tm in Teams.Membership,
where: tm.user_id == ^user.id,
where: tm.role != :guest,
where: tm.team_id not in ^excluded_team_ids
)
)
end

def has_sites?(user, opts \\ []) do
include_pending? = Keyword.get(opts, :include_pending?, false)

Expand Down
5 changes: 5 additions & 0 deletions lib/plausible_web/controllers/invitation_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ defmodule PlausibleWeb.InvitationController do
|> put_flash(:error, "Invitation missing or already accepted")
|> redirect(to: "/sites")

{:error, :already_other_team_member} ->
conn
|> put_flash(:error, "You already are a team member in another team")
|> redirect(to: "/sites")

{:error, :no_plan} ->
conn
|> put_flash(:error, "No existing subscription")
Expand Down
38 changes: 31 additions & 7 deletions lib/plausible_web/email.ex
Original file line number Diff line number Diff line change
Expand Up @@ -299,32 +299,56 @@ defmodule PlausibleWeb.Email do
)
end

def invitation_accepted(inviter_email, invitee_email, site) do
def guest_invitation_accepted(inviter_email, invitee_email, site) do
priority_email()
|> to(inviter_email)
|> tag("invitation-accepted")
|> tag("guest-invitation-accepted")
|> subject(
"[#{Plausible.product_name()}] #{invitee_email} accepted your invitation to #{site.domain}"
)
|> render("invitation_accepted.html",
|> render("guest_invitation_accepted.html",
invitee_email: invitee_email,
site: site
)
end

def invitation_rejected(guest_invitation) do
def team_invitation_accepted(inviter_email, invitee_email, team) do
priority_email()
|> to(inviter_email)
|> tag("team-invitation-accepted")
|> subject(
"[#{Plausible.product_name()}] #{invitee_email} accepted your invitation to \"#{team.name}\" team"
)
|> render("team_invitation_accepted.html",
invitee_email: invitee_email,
team: team
)
end

def guest_invitation_rejected(guest_invitation) do
priority_email()
|> to(guest_invitation.team_invitation.inviter.email)
|> tag("invitation-rejected")
|> tag("guest-invitation-rejected")
|> subject(
"[#{Plausible.product_name()}] #{guest_invitation.team_invitation.email} rejected your invitation to #{guest_invitation.site.domain}"
)
|> render("invitation_rejected.html",
user: guest_invitation.team_invitation.inviter,
|> render("guest_invitation_rejected.html",
guest_invitation: guest_invitation
)
end

def team_invitation_rejected(team_invitation) do
priority_email()
|> to(team_invitation.inviter.email)
|> tag("team-invitation-rejected")
|> subject(
"[#{Plausible.product_name()}] #{team_invitation.email} rejected your invitation to \"#{team_invitation.team.name}\" team"
)
|> render("team_invitation_rejected.html",
team_invitation: team_invitation
)
end

def ownership_transfer_accepted(new_owner_email, inviter_email, site) do
priority_email()
|> to(inviter_email)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<%= @invitee_email %> has accepted your invitation to "<%= @team.name %>" team.
<a href={Routes.settings_url(PlausibleWeb.Endpoint, :team_general)}>Click here</a> to view team settings.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<%= @team_invitation.email %> has rejected your invitation to \"<%= @team_invitation.team.name %>\" team.
<a href={Routes.settings_url(PlausibleWeb.Endpoint, :team_general)}>Click here</a> to view team settings.
Loading
Loading