defmodule Plausible.Teams.Invitations do @moduledoc false use Plausible import Ecto.Query alias Plausible.Auth alias Plausible.Billing alias Plausible.Repo alias Plausible.Teams def get_team_invitation(team, invitation_id) do invitation = Repo.get_by(Teams.Invitation, team_id: team.id, invitation_id: invitation_id) if invitation do {:ok, invitation} else {:error, :invitation_not_found} end end @spec find_by_team_identifier(String.t() | nil, Plausible.Auth.User.t()) :: {:ok, Teams.Invitation.t()} | {:error, :invitation_not_found} def find_by_team_identifier(nil, _user), do: {:error, :invitation_not_found} def find_by_team_identifier(team_identifier, 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.email == ^user.email, where: team.identifier == ^team_identifier, 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 def find_for_user(invitation_or_transfer_id, user) do with {:error, :invitation_not_found} <- 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 def find_for_site(invitation_or_transfer_id, site) do with {:error, :invitation_not_found} <- find_invitation_for_site(invitation_or_transfer_id, site) do find_transfer_for_site(invitation_or_transfer_id, site) end end @spec pending_team_invitations_for(Auth.User.t() | Teams.Team.t()) :: [Teams.Invitation.t()] def pending_team_invitations_for(%Teams.Team{} = team) do Repo.all( from ti in Teams.Invitation, inner_join: inviter in assoc(ti, :inviter), where: ti.team_id == ^team.id, preload: [inviter: inviter] ) end def pending_team_invitations_for(%Plausible.Auth.User{} = user) do Repo.all( from ti in Teams.Invitation, inner_join: inviter in assoc(ti, :inviter), inner_join: team in assoc(ti, :team), where: ti.email == ^user.email, where: ti.role != :guest, preload: [inviter: inviter, team: team] ) end @spec pending_guest_invitations_for(Auth.User.t()) :: [Teams.GuestInvitation.t()] def pending_guest_invitations_for(%Plausible.Auth.User{} = user) do Repo.all( from gi in Teams.GuestInvitation, inner_join: ti in assoc(gi, :team_invitation), as: :team_invitation, inner_join: inviter in assoc(ti, :inviter), inner_join: s in assoc(gi, :site), as: :site, where: ti.email == ^user.email, where: not exists( from tm in Teams.Membership, inner_join: u in assoc(tm, :user), left_join: gm in assoc(tm, :guest_memberships), on: gm.site_id == parent_as(:site).id, where: tm.team_id == parent_as(:team_invitation).team_id, where: u.email == ^user.email, where: not is_nil(gm.id) or tm.role != :guest, select: 1 ), preload: [site: s, team_invitation: {ti, inviter: inviter}] ) end @spec pending_site_transfers_for(Auth.User.t()) :: [Teams.SiteTransfer.t()] def pending_site_transfers_for(%Plausible.Auth.User{} = user) do Repo.all( from st in Teams.SiteTransfer, inner_join: s in assoc(st, :site), inner_join: initiator in assoc(st, :initiator), where: st.email == ^user.email, preload: [site: s, initiator: initiator] ) end 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), inner_join: ti in assoc(gi, :team_invitation), inner_join: t in assoc(ti, :team), inner_join: inviter in assoc(ti, :inviter), where: gi.invitation_id == ^guest_invitation_id, where: ti.email == ^user.email, preload: [site: s, team_invitation: {ti, team: t, inviter: inviter}] case Repo.one(invitation_query) do nil -> {:error, :invitation_not_found} invitation -> {:ok, invitation} end end defp find_transfer_for_user(transfer_id, user) do transfer = Teams.SiteTransfer |> Repo.get_by(transfer_id: transfer_id, email: user.email) |> Repo.preload([:initiator, site: :team]) case transfer do nil -> {:error, :invitation_not_found} transfer -> {:ok, transfer} end end defp find_invitation_for_site(guest_invitation_id, site) do invitation = Teams.GuestInvitation |> Repo.get_by(invitation_id: guest_invitation_id, site_id: site.id) |> Repo.preload([:site, team_invitation: :inviter]) case invitation do nil -> {:error, :invitation_not_found} invitation -> {:ok, invitation} end end defp find_transfer_for_site(transfer_id, site) do transfer = Teams.SiteTransfer |> Repo.get_by(transfer_id: transfer_id, site_id: site.id) |> Repo.preload([:site, :initiator]) case transfer do nil -> {:error, :invitation_not_found} transfer -> {:ok, transfer} end end def invite(%Teams.Team{} = team, invitee_email, role, inviter) do create_team_invitation(team, invitee_email, inviter, role: role) end def invite(%Plausible.Site{} = site, invitee_email, role, inviter) do site = Repo.preload(site, :team) if role == :owner do create_site_transfer( site, inviter, invitee_email ) else create_invitation( site, invitee_email, role, inviter ) 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 Repo.delete_all( from gi in Teams.GuestInvitation, where: gi.id == ^guest_invitation.id ) prune_guest_invitations(site.team) end def remove_site_transfer(site_transfer) do Repo.delete_all( from st in Teams.SiteTransfer, where: st.id == ^site_transfer.id ) end def accept_site_transfer(site_transfer, team, opts \\ []) do {:ok, _} = Repo.transaction(fn -> :ok = transfer_site_ownership(site_transfer.site, team, NaiveDateTime.utc_now(:second), opts) Repo.delete_all(from st in Teams.SiteTransfer, where: st.id == ^site_transfer.id) end) :ok end def transfer_site(site, team) do {:ok, _} = Repo.transaction(fn -> :ok = transfer_site_ownership(site, team, NaiveDateTime.utc_now(:second)) end) :ok end def accept_guest_invitation(guest_invitation, user) do guest_invitation = Repo.preload(guest_invitation, :site) team_invitation = guest_invitation.team_invitation |> Repo.preload([ :team, :inviter, guest_invitations: :site ]) now = NaiveDateTime.utc_now(:second) 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) if Plausible.Users.type(user) == :sso do {:error, :permission_denied} else do_accept(team_invitation, user, now, guest_invitations: []) end end @doc false def ensure_transfer_valid(_team, nil, :owner), do: :ok def ensure_transfer_valid(team, new_team, :owner) do if team.id == new_team.id do {:error, :transfer_to_self} else :ok end end def ensure_transfer_valid(_team, _new_owner, _role), do: :ok defp create_site_transfer(site, initiator, invitee_email, now \\ NaiveDateTime.utc_now(:second)) do result = Ecto.Multi.new() |> Ecto.Multi.put( :site_transfer_changeset, Teams.SiteTransfer.changeset(site, initiator: initiator, email: invitee_email) ) |> Ecto.Multi.run(:ensure_no_invitations, fn _repo, %{site_transfer_changeset: changeset} -> q = from ti in Teams.Invitation, inner_join: gi in assoc(ti, :guest_invitations), where: ti.email == ^invitee_email, where: ti.team_id == ^site.team_id, where: gi.site_id == ^site.id if Repo.exists?(q) do {:error, Ecto.Changeset.add_error(changeset, :invitation, "already sent")} else {:ok, :pass} end end) |> Ecto.Multi.insert( :site_transfer, fn %{site_transfer_changeset: changeset} -> changeset end, on_conflict: [set: [updated_at: now]], conflict_target: [:email, :site_id], returning: true ) |> Repo.transaction() case result do {:ok, success} -> {:ok, success.site_transfer} {:error, _, changeset, _} -> {:error, changeset} end end defp do_accept(team_invitation, user, now, opts) do send_email? = Keyword.get(opts, :send_email?, true) guest_invitations = Keyword.get(opts, :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 # 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 %{ team: team_invitation.team, team_membership: team_membership, guest_memberships: guest_memberships } else {:error, changeset} -> Repo.rollback(changeset) end 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, opts \\ []) do skip_site_members_transfer? = Keyword.get(opts, :skip_site_members_transfer?, false) site = Repo.preload(site, [ :team, :owners, guest_memberships: [team_membership: :user], guest_invitations: [team_invitation: :inviter] ]) prior_team = site.team site |> Ecto.Changeset.change(team_id: team.id) |> Repo.update!() old_guest_invitations = site.guest_invitations if not skip_site_members_transfer? do Enum.each(old_guest_invitations, fn old_guest_invitation -> recreate_guest_invitation(site, team, old_guest_invitation) end) end old_guest_ids = Enum.map(old_guest_invitations, & &1.id) Repo.delete_all(from gi in Teams.GuestInvitation, where: gi.id in ^old_guest_ids) :ok = prune_guest_invitations(prior_team) old_guest_memberships = site.guest_memberships if not skip_site_members_transfer? do Enum.each(old_guest_memberships, fn old_guest_membership -> recreate_guest_membership(site, team, old_guest_membership, now) end) end old_guest_ids = Enum.map(old_guest_memberships, & &1.id) Repo.delete_all(from gm in Teams.GuestMembership, where: gm.id in ^old_guest_ids) :ok = Teams.Memberships.prune_guests(prior_team) if not skip_site_members_transfer? do add_prior_owners_as_site_editors(prior_team, team, site, now) end on_ee do Billing.SiteLocker.update_for(prior_team, send_email?: false) :unlocked = Billing.SiteLocker.update_for(team, send_email?: false) Plausible.ConsolidatedView.reset_if_enabled(prior_team) end :ok end defp recreate_guest_invitation(site, new_team, old_guest_invitation) do old_team_invitation = old_guest_invitation.team_invitation {:ok, new_team_invitation} = create_team_invitation(new_team, old_team_invitation.email, old_team_invitation.inviter) {:ok, _new_guest_invitation} = create_guest_invitation(new_team_invitation, site, old_guest_invitation.role) end defp recreate_guest_membership(site, new_team, old_guest_membership, now) do old_team_membership = old_guest_membership.team_membership {:ok, new_team_membership} = create_team_membership(new_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], returning: true ) end end defp add_prior_owners_as_site_editors(prior_team, new_team, site, now) do prior_owners = Repo.preload(prior_team, :owners).owners for prior_owner <- prior_owners do {:ok, prior_owner_team_membership} = create_team_membership(new_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], returning: true ) end end end def prune_guest_invitations(team) do guest_query = from( gi in Teams.GuestInvitation, where: gi.team_invitation_id == parent_as(:team_invitation).id, 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 def ensure_can_take_ownership(site, team, opts \\ []) on_ee do def ensure_can_take_ownership(_site, nil, _opts), do: {:error, :no_plan} def ensure_can_take_ownership(site, team, opts) 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, skip_site_limit_check?: Teams.Billing.grandfathered_team?(team), skip_team_member_limit_check?: opts[:skip_site_members_transfer?] == true ) else {:error, :no_plan} end end else def ensure_can_take_ownership(_site, _team, _opts) do if always(true) do :ok else {:error, :no_plan} end end end def send_transfer_accepted_email(site_transfer, team) do initiator_as_guest_editor? = Teams.Memberships.site_role(site_transfer.site, site_transfer.initiator) == {:ok, {:guest_member, :editor}} PlausibleWeb.Email.ownership_transfer_accepted( site_transfer.email, site_transfer.initiator.email, team, site_transfer.site, initiator_as_guest_editor? ) |> Plausible.Mailer.send() end def send_team_changed_email(site, user, team) do owners = Repo.preload(team, :owners).owners for owner <- owners do PlausibleWeb.Email.team_changed(owner.email, user, team, site) |> Plausible.Mailer.send() end end @spec check_can_transfer_site(Teams.Team.t(), Auth.User.t()) :: :ok | {:error, :permission_denied} def check_can_transfer_site(team, user) do case Teams.Memberships.team_role(team, user) do {:ok, role} when role in [:owner, :admin] -> :ok _ -> {:error, :permission_denied} end end @doc false def check_invitation_permissions(%Teams.Team{} = team, inviter, invitation_role, opts) do check_permissions? = Keyword.get(opts, :check_permissions, true) if check_permissions? do case Teams.Memberships.team_role(team, inviter) do {:ok, :owner} when invitation_role == :owner -> :ok {:ok, inviter_role} when inviter_role in [:owner, :admin] and invitation_role != :owner -> :ok _ -> {:error, :permission_denied} end else :ok end end def check_invitation_permissions(%Plausible.Site{} = site, inviter, _invitation_role, opts) do check_permissions? = Keyword.get(opts, :check_permissions, true) if check_permissions? do case Teams.Memberships.site_role(site, inviter) do {:ok, {:team_member, inviter_role}} when inviter_role in [:owner, :admin] -> :ok _ -> {:error, :permission_denied} end else :ok end end @doc false def check_team_member_limit(_team, :owner, _invitee_email), do: :ok def 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 @doc false def ensure_new_membership(_site_or_team, nil, _role), do: :ok def ensure_new_membership(_site_or_team, _invitee, :owner), do: :ok def ensure_new_membership(%Teams.Team{} = team, invitee, _role) do case Teams.Memberships.team_role(team, invitee) do {:ok, :guest} -> :ok {:error, :not_a_member} -> :ok {:ok, _} -> {:error, :already_a_member} end end def ensure_new_membership(%Plausible.Site{} = 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, ensure_no_site_transfers_for: site.id ), {: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, opts \\ []) do now = NaiveDateTime.utc_now(:second) role = Keyword.get(opts, :role, :guest) result = Ecto.Multi.new() |> Ecto.Multi.put( :changeset, Teams.Invitation.changeset(team, email: invitee_email, role: role, inviter: inviter) ) |> Ecto.Multi.run(:ensure_no_site_transfers, fn _repo, %{changeset: changeset} -> ensure_no_site_transfers(changeset, opts[:ensure_no_site_transfers_for], invitee_email) end) |> Ecto.Multi.insert( :team_invitation, & &1.changeset, on_conflict: [set: [updated_at: now, role: role]], conflict_target: [:team_id, :email], returning: true ) |> Ecto.Multi.run(:prune_guest_entries, fn _repo, %{team_invitation: team_invitation} -> if team_invitation.role != :guest do team_invitation |> Ecto.assoc(:guest_invitations) |> Repo.delete_all() end {:ok, nil} end) |> Repo.transaction() case result do {:ok, success} -> {:ok, success.team_invitation} {:error, _, changeset, _} -> {:error, changeset} end 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], returning: true ) end @doc false def send_invitation_email(%Teams.SiteTransfer{} = transfer, invitee) do email = PlausibleWeb.Email.ownership_transfer_request( transfer.email, transfer.transfer_id, transfer.site, transfer.initiator, invitee ) Plausible.Mailer.send(email) end def send_invitation_email(%Teams.Invitation{} = team_invitation, invitee) do email = if invitee do PlausibleWeb.Email.existing_user_team_invitation( team_invitation.email, team_invitation.team, team_invitation.inviter ) else PlausibleWeb.Email.new_user_team_invitation( team_invitation.email, team_invitation.invitation_id, team_invitation.team, team_invitation.inviter ) end Plausible.Mailer.send(email) end def send_invitation_email(%Teams.GuestInvitation{} = 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, guest_invitation.invitation_id, guest_invitation.site, team_invitation.inviter ) end Plausible.Mailer.send(email) end @team_role_type Plausible.Teams.Membership.__schema__(:type, :role) def 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: conflict_query, conflict_target: [:team_id, :user_id], returning: true ) 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]], 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 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.guest_invitation_accepted( team_invitation.email, team_invitation.team, guest_invitation.site ) |> Plausible.Mailer.send() end defp ensure_no_site_transfers(_, nil, _) do {:ok, :skip} end defp ensure_no_site_transfers(changeset, site_id, invitee_email) when is_integer(site_id) and is_binary(invitee_email) do q = from st in Teams.SiteTransfer, where: st.email == ^invitee_email, where: st.site_id == ^site_id if Repo.exists?(q) do {:error, Ecto.Changeset.add_error(changeset, :invitation, "already sent")} else {:ok, :pass} end end end