-
Notifications
You must be signed in to change notification settings - Fork 86
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
User invites #1351
User invites #1351
Changes from 22 commits
df090d7
6c6d22e
c9ce8fc
bf72b97
3aa1a0f
9e137ca
21fe4d4
00d2646
64246b9
c56eee7
2969154
93a6d67
36eacd2
cba0b7a
88b103b
d7a7b1d
461c8a4
776a9dd
bb1f4c2
561dfe2
d639acb
1145d37
965ea83
b60e8a5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
defmodule CodeCorps.Accounts.UserInvites do | ||
@moduledoc ~S""" | ||
Subcontext for managing of `UserInvite` records | ||
""" | ||
|
||
alias CodeCorps.{Project, ProjectUser, Repo, User, UserInvite} | ||
alias Ecto.{Changeset, Multi} | ||
|
||
@spec create_invite(map) :: {:ok, UserInvite.t()} | {:error, Changeset.t()} | ||
def create_invite(%{} = params) do | ||
%UserInvite{} | ||
|> Changeset.cast(params, [:email, :name, :role, :inviter_id, :project_id]) | ||
|> Changeset.validate_required([:email, :inviter_id]) | ||
|> Changeset.validate_inclusion(:role, ProjectUser.roles()) | ||
|> Changeset.assoc_constraint(:inviter) | ||
|> Changeset.assoc_constraint(:project) | ||
|> ensure_email_not_owned_by_user() | ||
|> ensure_role_and_project() | ||
|> Repo.insert() | ||
end | ||
|
||
@spec ensure_email_not_owned_by_user(Changeset.t()) :: Changeset.t() | ||
defp ensure_email_not_owned_by_user(%Changeset{changes: %{email: email}} = changeset) | ||
when not is_nil(email) do | ||
if User |> Repo.get_by(email: email) do | ||
changeset |> Changeset.add_error(:email, "Already associated with a user") | ||
else | ||
changeset | ||
end | ||
end | ||
|
||
defp ensure_email_not_owned_by_user(%Changeset{} = changeset), do: changeset | ||
|
||
@spec ensure_role_and_project(Changeset.t()) :: Changeset.t() | ||
defp ensure_role_and_project(%Changeset{} = changeset) do | ||
changes = [ | ||
changeset |> Changeset.get_field(:role), | ||
changeset |> Changeset.get_field(:project_id) | ||
] | ||
|
||
case changes do | ||
[nil, nil] -> | ||
changeset | ||
|
||
[nil, _project_id] -> | ||
changeset |> Changeset.add_error(:role, "Needs to be specified for a project invite") | ||
|
||
[_role, nil] -> | ||
changeset | ||
|> Changeset.add_error(:project_id, "Needs to be specified for a project invite") | ||
|
||
[_role, _project_id] -> | ||
changeset | ||
end | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm hoping there's a shorter way to write this, but couldn't think of anything. |
||
|
||
@spec claim_invite(map) :: {:ok, User.t()} | ||
def claim_invite(%{} = params) do | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We'll need to accommodate invites for projects and general invites to Code Corps. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right now, the code sort of does that. It will
I'm guessing an email will also be involved, which should be pluggable into this process |
||
Multi.new() | ||
|> Multi.run(:load_invite, fn %{} -> params |> load_invite() end) | ||
|> Multi.run(:user, fn %{} -> params |> claim_new_user() end) | ||
|> Multi.run(:project_user, fn %{user: user, load_invite: user_invite} -> | ||
user |> join_project(user_invite) | ||
end) | ||
|> Multi.run(:user_invite, fn %{user: user, load_invite: user_invite} -> | ||
user_invite |> associate_invitee(user) | ||
end) | ||
|> Repo.transaction() | ||
|> marshall_response() | ||
end | ||
|
||
@spec load_invite(map) :: {:ok, UserInvite.t()} | {:error, :not_found} | ||
defp load_invite(%{"invite_id" => invite_id}) do | ||
case UserInvite |> Repo.get(invite_id) |> Repo.preload([:invitee, :project]) do | ||
nil -> {:error, :not_found} | ||
%UserInvite{} = invite -> {:ok, invite} | ||
end | ||
end | ||
|
||
defp load_invite(%{}), do: {:error, :not_found} | ||
|
||
@spec claim_new_user(map) :: {:ok, User.t()} | ||
defp claim_new_user(%{} = params) do | ||
%User{} |> User.registration_changeset(params) |> Repo.insert() | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As we discussed, we assume new user. Existing users will eventually be invitable into the project by creating a special type of Also, as a user registers for an account directly, not via invite, there should be a step where any existing invites are claimed. |
||
|
||
@spec join_project(User.t(), UserInvite.t()) :: {:ok, ProjectUser.t()} | {:error, Changeset.t()} | ||
defp join_project(%User{} = user, %UserInvite{role: role, project: %Project{} = project}) do | ||
%ProjectUser{} | ||
|> Changeset.change(%{role: role}) | ||
|> Changeset.put_assoc(:project, project) | ||
|> Changeset.put_assoc(:user, user) | ||
|> Changeset.unique_constraint(:project, name: :project_users_user_id_project_id_index) | ||
|> Repo.insert() | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Technically, validation can fail due to the |
||
|
||
defp join_project(%User{}, %UserInvite{}), do: {:ok, nil} | ||
|
||
@spec associate_invitee(UserInvite.t(), User.t()) :: {:ok, UserInvite.t()} | ||
defp associate_invitee(%UserInvite{invitee: nil} = invite, %User{} = user) do | ||
invite | ||
|> Changeset.change(%{}) | ||
|> Changeset.put_assoc(:invitee, user) | ||
|> Repo.update() | ||
end | ||
|
||
@spec marshall_response(tuple) :: tuple | ||
defp marshall_response({:ok, %{user: user, user_invite: user_invite}}) do | ||
{:ok, user |> Map.put(:claimed_invite, user_invite)} | ||
end | ||
defp marshall_response({:error, :load_invite, :not_found, _}), do: {:error, :invite_not_found} | ||
defp marshall_response({:error, :user, %Changeset{} = changeset, _}), do: {:error, changeset} | ||
defp marshall_response(other_tuple), do: other_tuple | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,15 +8,14 @@ defmodule CodeCorps.ProjectUser do | |
@type t :: %__MODULE__{} | ||
|
||
schema "project_users" do | ||
field :role, :string | ||
field(:role, :string) | ||
|
||
belongs_to :project, CodeCorps.Project | ||
belongs_to :user, CodeCorps.User | ||
belongs_to(:project, CodeCorps.Project) | ||
belongs_to(:user, CodeCorps.User) | ||
|
||
timestamps() | ||
end | ||
|
||
|
||
@doc """ | ||
Builds a changeset to create a pending membership | ||
""" | ||
|
@@ -55,7 +54,11 @@ defmodule CodeCorps.ProjectUser do | |
|> validate_inclusion(:role, roles()) | ||
end | ||
|
||
defp roles do | ||
~w{ pending contributor admin owner } | ||
@doc ~S""" | ||
Returns supported project user role types | ||
""" | ||
@spec roles :: list(String.t()) | ||
def roles do | ||
~w{pending contributor admin owner} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did an auto-formatting of the file as I was making the |
||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
defmodule CodeCorps.UserInvite do | ||
use CodeCorps.Model | ||
|
||
@type t :: %__MODULE__{} | ||
|
||
schema "user_invites" do | ||
field(:email, :string, null: false) | ||
field(:role, :string) | ||
field(:name, :string) | ||
|
||
belongs_to(:project, CodeCorps.Project) | ||
belongs_to(:inviter, CodeCorps.User) | ||
belongs_to(:invitee, CodeCorps.User) | ||
|
||
timestamps() | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
defmodule CodeCorps.Policy.UserInvite do | ||
@moduledoc ~S""" | ||
Handles `CodeCorps.User` authorization of actions on `CodeCorps.UserInvite` | ||
records. | ||
""" | ||
|
||
alias CodeCorps.{Policy.Helpers, User} | ||
|
||
@doc ~S""" | ||
Returns true if the specified `CodeCorps.User` is allowed to create a | ||
`CodeCorps.UserInvite` using the specified attributes. | ||
""" | ||
@spec create?(User.t(), map) :: boolean | ||
def create?( | ||
%User{id: user_id} = user, | ||
%{"project_id" => _, "inviter_id" => inviter_id} = params | ||
) | ||
when user_id == inviter_id do | ||
user_role = | ||
params | ||
|> Helpers.get_project() | ||
|> Helpers.get_membership(user) | ||
|> Helpers.get_role() | ||
|
||
new_role = Map.get(params, "role") | ||
do_create?(user_role, new_role) | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two conditions for the case of a project invite
|
||
|
||
def create?(%User{id: user_id}, %{"inviter_id" => inviter_id}) | ||
when user_id == inviter_id, | ||
do: true | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One condition for a plain invite
|
||
|
||
def create?(%User{}, %{}), do: false | ||
|
||
@spec do_create?(String.t() | nil, String.t() | nil) :: boolean | ||
defp do_create?("admin", "pending"), do: true | ||
defp do_create?("admin", "contributor"), do: true | ||
defp do_create?("owner", "pending"), do: true | ||
defp do_create?("owner", "contributor"), do: true | ||
defp do_create?("owner", "admin"), do: true | ||
defp do_create?(_, _), do: false | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could a contributor also invite a "pending" user, or even another contributor? |
||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We needed to extract creation into a context. I didn't have a better idea other than this multiple clause approach to differentiate between plain account creation and an invite claim.