record invites

This commit is contained in:
shibao 2023-02-04 16:11:58 -05:00 committed by oliviasculley
parent ed8c20e967
commit 30d3f76fe1
13 changed files with 171 additions and 56 deletions

View File

@ -124,16 +124,15 @@ defmodule Lokal.Accounts do
|> Multi.one(:users_count, from(u in User, select: count(u.id), distinct: true)) |> Multi.one(:users_count, from(u in User, select: count(u.id), distinct: true))
|> Multi.run(:use_invite, fn _changes_so_far, _repo -> |> Multi.run(:use_invite, fn _changes_so_far, _repo ->
if allow_registration?() and invite_token |> is_nil() do if allow_registration?() and invite_token |> is_nil() do
{:ok, :invite_not_required} {:ok, nil}
else else
Invites.use_invite(invite_token) Invites.use_invite(invite_token)
end end
end) end)
|> Multi.insert(:add_user, fn %{users_count: count} -> |> Multi.insert(:add_user, fn %{users_count: count, use_invite: invite} ->
# if no registered users, make first user an admin # if no registered users, make first user an admin
role = if count == 0, do: :admin, else: :user role = if count == 0, do: :admin, else: :user
User.registration_changeset(attrs, invite) |> User.role_changeset(role)
User.registration_changeset(attrs) |> User.role_changeset(role)
end) end)
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
@ -158,7 +157,7 @@ defmodule Lokal.Accounts do
@spec change_user_registration() :: User.changeset() @spec change_user_registration() :: User.changeset()
@spec change_user_registration(attrs :: map()) :: User.changeset() @spec change_user_registration(attrs :: map()) :: User.changeset()
def change_user_registration(attrs \\ %{}) do def change_user_registration(attrs \\ %{}) do
User.registration_changeset(attrs, hash_password: false) User.registration_changeset(attrs, nil, hash_password: false)
end end
## Settings ## Settings

View File

@ -7,7 +7,7 @@ defmodule Lokal.Accounts.Invite do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Ecto.{Changeset, UUID} alias Ecto.{Association, Changeset, UUID}
alias Lokal.Accounts.User alias Lokal.Accounts.User
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@ -18,7 +18,9 @@ defmodule Lokal.Accounts.Invite do
field :uses_left, :integer, default: nil field :uses_left, :integer, default: nil
field :disabled_at, :naive_datetime field :disabled_at, :naive_datetime
belongs_to :user, User belongs_to :created_by, User
has_many :users, User
timestamps() timestamps()
end end
@ -29,8 +31,9 @@ defmodule Lokal.Accounts.Invite do
token: token(), token: token(),
uses_left: integer() | nil, uses_left: integer() | nil,
disabled_at: NaiveDateTime.t(), disabled_at: NaiveDateTime.t(),
user: User.t(), created_by: User.t() | nil | Association.NotLoaded.t(),
user_id: User.id(), created_by_id: User.id() | nil,
users: [User.t()] | Association.NotLoaded.t(),
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@ -43,9 +46,9 @@ defmodule Lokal.Accounts.Invite do
@spec create_changeset(User.t(), token(), attrs :: map()) :: changeset() @spec create_changeset(User.t(), token(), attrs :: map()) :: changeset()
def create_changeset(%User{id: user_id}, token, attrs) do def create_changeset(%User{id: user_id}, token, attrs) do
%__MODULE__{} %__MODULE__{}
|> change(token: token, user_id: user_id) |> change(token: token, created_by_id: user_id)
|> cast(attrs, [:name, :uses_left, :disabled_at]) |> cast(attrs, [:name, :uses_left, :disabled_at])
|> validate_required([:name, :token, :user_id]) |> validate_required([:name, :token, :created_by_id])
|> validate_number(:uses_left, greater_than_or_equal_to: 0) |> validate_number(:uses_left, greater_than_or_equal_to: 0)
end end

View File

@ -98,6 +98,15 @@ defmodule Lokal.Accounts.Invites do
end end
end end
@spec get_use_count(Invite.t(), User.t()) :: non_neg_integer()
def get_use_count(%Invite{id: invite_id}, %User{role: :admin}) do
Repo.one(
from u in User,
where: u.invite_id == ^invite_id,
select: count(u.id)
)
end
@spec decrement_invite_changeset(Invite.t()) :: Invite.changeset() @spec decrement_invite_changeset(Invite.t()) :: Invite.changeset()
defp decrement_invite_changeset(%Invite{uses_left: nil} = invite) do defp decrement_invite_changeset(%Invite{uses_left: nil} = invite) do
invite |> Invite.update_changeset(%{}) invite |> Invite.update_changeset(%{})

View File

@ -6,7 +6,7 @@ defmodule Lokal.Accounts.User do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
import LokalWeb.Gettext import LokalWeb.Gettext
alias Ecto.{Changeset, UUID} alias Ecto.{Association, Changeset, UUID}
alias Lokal.Accounts.{Invite, User} alias Lokal.Accounts.{Invite, User}
@derive {Jason.Encoder, @derive {Jason.Encoder,
@ -28,7 +28,9 @@ defmodule Lokal.Accounts.User do
field :role, Ecto.Enum, values: [:admin, :user], default: :user field :role, Ecto.Enum, values: [:admin, :user], default: :user
field :locale, :string field :locale, :string
has_many :invites, Invite, on_delete: :delete_all has_many :created_invites, Invite, foreign_key: :created_by_id
belongs_to :invite, Invite
timestamps() timestamps()
end end
@ -41,7 +43,9 @@ defmodule Lokal.Accounts.User do
confirmed_at: NaiveDateTime.t(), confirmed_at: NaiveDateTime.t(),
role: role(), role: role(),
locale: String.t() | nil, locale: String.t() | nil,
invites: [Invite.t()], created_invites: [Invite.t()] | Association.NotLoaded.t(),
invite: Invite.t() | nil | Association.NotLoaded.t(),
invite_id: Invite.id() | nil,
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@ -67,11 +71,12 @@ defmodule Lokal.Accounts.User do
validations on a LiveView form), this option can be set to `false`. validations on a LiveView form), this option can be set to `false`.
Defaults to `true`. Defaults to `true`.
""" """
@spec registration_changeset(attrs :: map()) :: changeset() @spec registration_changeset(attrs :: map(), Invite.t() | nil) :: changeset()
@spec registration_changeset(attrs :: map(), opts :: keyword()) :: changeset() @spec registration_changeset(attrs :: map(), Invite.t() | nil, opts :: keyword()) :: changeset()
def registration_changeset(attrs, opts \\ []) do def registration_changeset(attrs, invite, opts \\ []) do
%User{} %User{}
|> cast(attrs, [:email, :password, :locale]) |> cast(attrs, [:email, :password, :locale])
|> put_change(:invite_id, if(invite, do: invite.id))
|> validate_email() |> validate_email()
|> validate_password(opts) |> validate_password(opts)
end end

View File

@ -4,10 +4,19 @@ defmodule LokalWeb.Components.InviteCard do
""" """
use LokalWeb, :component use LokalWeb, :component
alias Lokal.Accounts.{Invite, Invites, User}
alias LokalWeb.Endpoint alias LokalWeb.Endpoint
def invite_card(assigns) do attr :invite, Invite, required: true
assigns = assigns |> assign_new(:code_actions, fn -> [] end) attr :current_user, User, required: true
slot(:inner_block)
slot(:code_actions)
def invite_card(%{invite: invite, current_user: current_user} = assigns) do
assigns =
assigns
|> assign(:use_count, Invites.get_use_count(invite, current_user))
|> assign_new(:code_actions, fn -> [] end)
~H""" ~H"""
<div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4 <div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4
@ -21,8 +30,8 @@ defmodule LokalWeb.Components.InviteCard do
<h2 class="title text-md"> <h2 class="title text-md">
<%= if @invite.uses_left do %> <%= if @invite.uses_left do %>
<%= gettext( <%= gettext(
"Uses Left: %{uses_left}", "Uses Left: %{uses_left_count}",
uses_left: @invite.uses_left uses_left_count: @invite.uses_left
) %> ) %>
<% else %> <% else %>
<%= gettext("Uses Left: Unlimited") %> <%= gettext("Uses Left: Unlimited") %>
@ -39,6 +48,10 @@ defmodule LokalWeb.Components.InviteCard do
filename={@invite.name} filename={@invite.name}
/> />
<h2 :if={@use_count != 0} class="title text-md">
<%= gettext("Uses: %{uses_count}", uses_count: @use_count) %>
</h2>
<div class="flex flex-row flex-wrap justify-center items-center"> <div class="flex flex-row flex-wrap justify-center items-center">
<code <code
id={"code-#{@invite.id}"} id={"code-#{@invite.id}"}

View File

@ -18,7 +18,7 @@
<% end %> <% end %>
<div class="w-full flex flex-row flex-wrap justify-center items-center"> <div class="w-full flex flex-row flex-wrap justify-center items-center">
<.invite_card :for={invite <- @invites} invite={invite}> <.invite_card :for={invite <- @invites} invite={invite} current_user={@current_user}>
<:code_actions> <:code_actions>
<form phx-submit="copy_to_clipboard"> <form phx-submit="copy_to_clipboard">
<button <button

View File

@ -31,7 +31,7 @@ msgstr ""
msgid "Forgot your password?" msgid "Forgot your password?"
msgstr "" msgstr ""
#: lib/lokal_web/components/invite_card.ex:33 #: lib/lokal_web/components/invite_card.ex:42
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Invite Disabled" msgid "Invite Disabled"
msgstr "" msgstr ""
@ -169,12 +169,7 @@ msgstr ""
msgid "User was confirmed at%{confirmed_datetime}" msgid "User was confirmed at%{confirmed_datetime}"
msgstr "" msgstr ""
#: lib/lokal_web/components/invite_card.ex:23 #: lib/lokal_web/components/invite_card.ex:37
#, elixir-autogen, elixir-format
msgid "Uses Left: %{uses_left}"
msgstr ""
#: lib/lokal_web/components/invite_card.ex:28
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Uses Left: Unlimited" msgid "Uses Left: Unlimited"
msgstr "" msgstr ""
@ -278,3 +273,13 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Lokal | %{title}" msgid "Lokal | %{title}"
msgstr "" msgstr ""
#: lib/lokal_web/components/invite_card.ex:32
#, elixir-autogen, elixir-format
msgid "Uses Left: %{uses_left_count}"
msgstr ""
#: lib/lokal_web/components/invite_card.ex:52
#, elixir-autogen, elixir-format
msgid "Uses: %{uses_count}"
msgstr ""

View File

@ -31,7 +31,7 @@ msgstr ""
msgid "Forgot your password?" msgid "Forgot your password?"
msgstr "" msgstr ""
#: lib/lokal_web/components/invite_card.ex:33 #: lib/lokal_web/components/invite_card.ex:42
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Invite Disabled" msgid "Invite Disabled"
msgstr "" msgstr ""
@ -169,12 +169,7 @@ msgstr ""
msgid "User was confirmed at%{confirmed_datetime}" msgid "User was confirmed at%{confirmed_datetime}"
msgstr "" msgstr ""
#: lib/lokal_web/components/invite_card.ex:23 #: lib/lokal_web/components/invite_card.ex:37
#, elixir-autogen, elixir-format, fuzzy
msgid "Uses Left: %{uses_left}"
msgstr ""
#: lib/lokal_web/components/invite_card.ex:28
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Uses Left: Unlimited" msgid "Uses Left: Unlimited"
msgstr "" msgstr ""
@ -278,3 +273,13 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Lokal | %{title}" msgid "Lokal | %{title}"
msgstr "" msgstr ""
#: lib/lokal_web/components/invite_card.ex:32
#, elixir-autogen, elixir-format, fuzzy
msgid "Uses Left: %{uses_left_count}"
msgstr ""
#: lib/lokal_web/components/invite_card.ex:52
#, elixir-autogen, elixir-format
msgid "Uses: %{uses_count}"
msgstr ""

View File

@ -179,22 +179,22 @@ msgstr ""
msgid "You must confirm your account and log in to access this page." msgid "You must confirm your account and log in to access this page."
msgstr "" msgstr ""
#: lib/lokal/accounts/user.ex:137 #: lib/lokal/accounts/user.ex:142
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "did not change" msgid "did not change"
msgstr "" msgstr ""
#: lib/lokal/accounts/user.ex:158 #: lib/lokal/accounts/user.ex:163
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "does not match password" msgid "does not match password"
msgstr "" msgstr ""
#: lib/lokal/accounts/user.ex:195 #: lib/lokal/accounts/user.ex:200
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "is not valid" msgid "is not valid"
msgstr "" msgstr ""
#: lib/lokal/accounts/user.ex:92 #: lib/lokal/accounts/user.ex:97
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must have the @ sign and no spaces" msgid "must have the @ sign and no spaces"
msgstr "" msgstr ""

View File

@ -176,22 +176,22 @@ msgstr ""
msgid "You must confirm your account and log in to access this page." msgid "You must confirm your account and log in to access this page."
msgstr "" msgstr ""
#: lib/lokal/accounts/user.ex:137 #: lib/lokal/accounts/user.ex:142
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "did not change" msgid "did not change"
msgstr "" msgstr ""
#: lib/lokal/accounts/user.ex:158 #: lib/lokal/accounts/user.ex:163
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "does not match password" msgid "does not match password"
msgstr "" msgstr ""
#: lib/lokal/accounts/user.ex:195 #: lib/lokal/accounts/user.ex:200
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "is not valid" msgid "is not valid"
msgstr "" msgstr ""
#: lib/lokal/accounts/user.ex:92 #: lib/lokal/accounts/user.ex:97
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must have the @ sign and no spaces" msgid "must have the @ sign and no spaces"
msgstr "" msgstr ""

View File

@ -0,0 +1,11 @@
defmodule Lokal.Repo.Migrations.RecordInvites do
use Ecto.Migration
def change do
alter table(:users) do
add :invite_id, references(:invites, type: :binary_id)
end
rename table(:invites), :user_id, to: :created_by_id
end
end

View File

@ -5,17 +5,13 @@ defmodule Lokal.InvitesTest do
use Lokal.DataCase use Lokal.DataCase
alias Ecto.Changeset alias Ecto.Changeset
alias Lokal.Accounts
alias Lokal.Accounts.{Invite, Invites} alias Lokal.Accounts.{Invite, Invites}
@moduletag :invites_test @moduletag :invites_test
@valid_attrs %{ @valid_attrs %{
"name" => "some name", "name" => "some name"
"uses_left" => 10
}
@update_attrs %{
"name" => "some updated name",
"uses_left" => 5
} }
@invalid_attrs %{ @invalid_attrs %{
"name" => nil, "name" => nil,
@ -55,6 +51,27 @@ defmodule Lokal.InvitesTest do
refute Invites.valid_invite_token?(token) refute Invites.valid_invite_token?(token)
end end
test "get_use_count/2 returns the correct invite usage",
%{invite: %{token: token} = invite, current_user: current_user} do
assert 0 == Invites.get_use_count(invite, current_user)
assert {:ok, _user} =
Accounts.register_user(
%{"email" => unique_user_email(), "password" => valid_user_password()},
token
)
assert 1 == Invites.get_use_count(invite, current_user)
assert {:ok, _user} =
Accounts.register_user(
%{"email" => unique_user_email(), "password" => valid_user_password()},
token
)
assert 2 == Invites.get_use_count(invite, current_user)
end
test "use_invite/1 successfully uses an unlimited invite", test "use_invite/1 successfully uses an unlimited invite",
%{invite: %{token: token} = invite, current_user: current_user} do %{invite: %{token: token} = invite, current_user: current_user} do
{:ok, invite} = Invites.update_invite(invite, %{uses_left: nil}, current_user) {:ok, invite} = Invites.update_invite(invite, %{uses_left: nil}, current_user)
@ -63,7 +80,9 @@ defmodule Lokal.InvitesTest do
assert {:ok, ^invite} = Invites.use_invite(token) assert {:ok, ^invite} = Invites.use_invite(token)
end end
test "use_invite/1 successfully decrements an invite", %{invite: %{token: token}} do test "use_invite/1 successfully decrements an invite",
%{invite: %{token: token} = invite, current_user: current_user} do
{:ok, _invite} = Invites.update_invite(invite, %{uses_left: 10}, current_user)
assert {:ok, %{uses_left: 9}} = Invites.use_invite(token) assert {:ok, %{uses_left: 9}} = Invites.use_invite(token)
assert {:ok, %{uses_left: 8}} = Invites.use_invite(token) assert {:ok, %{uses_left: 8}} = Invites.use_invite(token)
assert {:ok, %{uses_left: 7}} = Invites.use_invite(token) assert {:ok, %{uses_left: 7}} = Invites.use_invite(token)
@ -83,26 +102,61 @@ defmodule Lokal.InvitesTest do
assert {:error, :invalid_token} = Invites.use_invite(token) assert {:error, :invalid_token} = Invites.use_invite(token)
end end
test "create_invite/1 with valid data creates a invite", test "create_invite/1 with valid data creates an unlimited invite",
%{current_user: current_user} do %{current_user: current_user} do
assert {:ok, %Invite{} = invite} = Invites.create_invite(current_user, @valid_attrs) assert {:ok, %Invite{} = invite} =
Invites.create_invite(current_user, %{
"name" => "some name"
})
assert invite.name == "some name" assert invite.name == "some name"
end end
test "create_invite/1 with valid data creates a limited invite",
%{current_user: current_user} do
assert {:ok, %Invite{} = invite} =
Invites.create_invite(current_user, %{
"name" => "some name",
"uses_left" => 10
})
assert invite.name == "some name"
assert invite.uses_left == 10
end
test "create_invite/1 with invalid data returns error changeset", test "create_invite/1 with invalid data returns error changeset",
%{current_user: current_user} do %{current_user: current_user} do
assert {:error, %Changeset{}} = Invites.create_invite(current_user, @invalid_attrs) assert {:error, %Changeset{}} = Invites.create_invite(current_user, @invalid_attrs)
end end
test "update_invite/2 with valid data updates the invite", test "update_invite/2 can set an invite to be limited",
%{invite: invite, current_user: current_user} do %{invite: invite, current_user: current_user} do
assert {:ok, %Invite{} = new_invite} = assert {:ok, %Invite{} = new_invite} =
Invites.update_invite(invite, @update_attrs, current_user) Invites.update_invite(
invite,
%{"name" => "some updated name", "uses_left" => 5},
current_user
)
assert new_invite.name == "some updated name" assert new_invite.name == "some updated name"
assert new_invite.uses_left == 5 assert new_invite.uses_left == 5
end end
test "update_invite/2 can set an invite to be unlimited",
%{invite: invite, current_user: current_user} do
{:ok, invite} = Invites.update_invite(invite, %{"uses_left" => 5}, current_user)
assert {:ok, %Invite{} = new_invite} =
Invites.update_invite(
invite,
%{"name" => "some updated name", "uses_left" => nil},
current_user
)
assert new_invite.name == "some updated name"
assert new_invite.uses_left |> is_nil()
end
test "update_invite/2 with invalid data returns error changeset", test "update_invite/2 with invalid data returns error changeset",
%{invite: invite, current_user: current_user} do %{invite: invite, current_user: current_user} do
assert {:error, %Changeset{}} = Invites.update_invite(invite, @invalid_attrs, current_user) assert {:error, %Changeset{}} = Invites.update_invite(invite, @invalid_attrs, current_user)

View File

@ -6,7 +6,7 @@ defmodule Lokal.AccountsTest do
use Lokal.DataCase use Lokal.DataCase
alias Ecto.Changeset alias Ecto.Changeset
alias Lokal.Accounts alias Lokal.Accounts
alias Lokal.Accounts.{User, UserToken} alias Lokal.Accounts.{Invites, User, UserToken}
@moduletag :accounts_test @moduletag :accounts_test
@ -102,6 +102,17 @@ defmodule Lokal.AccountsTest do
assert is_nil(user.confirmed_at) assert is_nil(user.confirmed_at)
assert is_nil(user.password) assert is_nil(user.password)
end end
test "records used invite during registration" do
{:ok, %{id: invite_id, token: token}} =
admin_fixture() |> Invites.create_invite(%{"name" => "my invite"})
assert {:ok, %{invite_id: ^invite_id}} =
Accounts.register_user(
%{"email" => unique_user_email(), "password" => valid_user_password()},
token
)
end
end end
describe "change_user_registration/2" do describe "change_user_registration/2" do