diff --git a/lib/cannery/invites.ex b/lib/cannery/invites.ex
new file mode 100644
index 00000000..e7e49027
--- /dev/null
+++ b/lib/cannery/invites.ex
@@ -0,0 +1,151 @@
+defmodule Cannery.Invites do
+ @moduledoc """
+ The Invites context.
+ """
+
+ import Ecto.Query, warn: false
+ alias Cannery.{Accounts, Repo}
+ alias Cannery.Invites.Invite
+
+ @invite_token_length 20
+
+ @doc """
+ Returns the list of invites.
+
+ ## Examples
+
+ iex> list_invites()
+ [%Invite{}, ...]
+
+ """
+ def list_invites, do: Repo.all(Invite)
+
+ @doc """
+ Gets a single invite.
+
+ Raises `Ecto.NoResultsError` if the Invite does not exist.
+
+ ## Examples
+
+ iex> get_invite!(123)
+ %Invite{}
+
+ iex> get_invite!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_invite!(id), do: Repo.get!(Invite, id)
+
+ @doc """
+ Returns a valid invite or nil based on the attempted token
+
+ ## Examples
+
+ iex> get_invite_by_token("valid_token")
+ %Invite{}
+
+ iex> get_invite_by_token("invalid_token")
+ nil
+ """
+ @spec get_invite_by_token(String.t()) :: Invite.t() | nil
+ def get_invite_by_token(nil), do: nil
+ def get_invite_by_token(""), do: nil
+ def get_invite_by_token(token), do: Repo.get_by(Invite, token: token, disabled_at: nil)
+
+ @doc """
+ Uses invite by decrementing uses_left, or markes invite invalid if it's been completely used
+
+ """
+ @spec use_invite!(Invite.t()) :: Invite.t()
+ def use_invite!(%Invite{uses_left: uses_left} = invite) do
+ new_uses_left = uses_left - 1
+
+ attrs =
+ if new_uses_left <= 0 do
+ now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
+ %{"uses_left" => 0, "disabled_at" => now}
+ else
+ %{"uses_left" => new_uses_left}
+ end
+
+ invite |> Invite.changeset(attrs) |> Repo.update!()
+ end
+
+ @doc """
+ Creates a invite.
+
+ ## Examples
+
+ iex> create_invite(%Accounts.User{id: "1"}, %{field: value})
+ {:ok, %Invite{}}
+
+ iex> create_invite("1", %{field: value})
+ {:ok, %Invite{}}
+
+ iex> create_invite(%Accounts.User{id: "1"}, %{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ @spec create_invite(Accounts.User.t() | Ecto.UUID.t(), map()) :: Invite.t()
+ def create_invite(%Accounts.User{id: user_id}, attrs) do
+ create_invite(user_id, attrs)
+ end
+
+ def create_invite(user_id, attrs) when not (user_id |> is_nil()) do
+ attrs =
+ attrs
+ |> Map.merge(%{
+ "user_id" => user_id,
+ "token" => :crypto.strong_rand_bytes(@invite_token_length)
+ })
+
+ %Invite{} |> Invite.changeset(attrs) |> Repo.insert()
+ end
+
+ @doc """
+ Updates a invite.
+
+ ## Examples
+
+ iex> update_invite(invite, %{field: new_value})
+ {:ok, %Invite{}}
+
+ iex> update_invite(invite, %{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_invite(%Invite{} = invite, attrs) do
+ invite
+ |> Invite.changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Deletes a invite.
+
+ ## Examples
+
+ iex> delete_invite(invite)
+ {:ok, %Invite{}}
+
+ iex> delete_invite(invite)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def delete_invite(%Invite{} = invite) do
+ Repo.delete(invite)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking invite changes.
+
+ ## Examples
+
+ iex> change_invite(invite)
+ %Ecto.Changeset{data: %Invite{}}
+
+ """
+ def change_invite(%Invite{} = invite, attrs \\ %{}) do
+ Invite.changeset(invite, attrs)
+ end
+end
diff --git a/lib/cannery/invites/invite.ex b/lib/cannery/invites/invite.ex
new file mode 100644
index 00000000..26462cdf
--- /dev/null
+++ b/lib/cannery/invites/invite.ex
@@ -0,0 +1,34 @@
+defmodule Cannery.Invites.Invite do
+ use Ecto.Schema
+ import Ecto.Changeset
+ alias Cannery.{Accounts}
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "invites" do
+ field :name, :string
+ field :token, :string
+ field :uses_left, :integer, default: nil
+ field :disabled_at, :naive_datetime
+ belongs_to :user, Accounts.User
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(invite, attrs) do
+ invite
+ |> cast(attrs, [:name, :token, :uses_left, :disabled_at, :user_id])
+ |> validate_required([:name, :token, :user_id])
+ end
+
+ @type t :: %{
+ id: Ecto.UUID.t(),
+ name: String.t(),
+ token: String.t(),
+ uses_left: integer() | nil,
+ disabled_at: NaiveDateTime.t(),
+ user_id: Ecto.UUID.t(),
+ user: Accounts.User.t()
+ }
+end
diff --git a/lib/cannery_web/live/invite_live/form_component.ex b/lib/cannery_web/live/invite_live/form_component.ex
new file mode 100644
index 00000000..37731e40
--- /dev/null
+++ b/lib/cannery_web/live/invite_live/form_component.ex
@@ -0,0 +1,55 @@
+defmodule CanneryWeb.InviteLive.FormComponent do
+ use CanneryWeb, :live_component
+
+ alias Cannery.Invites
+
+ @impl true
+ def update(%{invite: invite} = assigns, socket) do
+ changeset = Invites.change_invite(invite)
+
+ {:ok,
+ socket
+ |> assign(assigns)
+ |> assign(:changeset, changeset)}
+ end
+
+ @impl true
+ def handle_event("validate", %{"invite" => invite_params}, socket) do
+ changeset =
+ socket.assigns.invite
+ |> Invites.change_invite(invite_params)
+ |> Map.put(:action, :validate)
+
+ {:noreply, assign(socket, :changeset, changeset)}
+ end
+
+ def handle_event("save", %{"invite" => invite_params}, socket) do
+ save_invite(socket, socket.assigns.action, invite_params)
+ end
+
+ defp save_invite(socket, :edit, invite_params) do
+ case Invites.update_invite(socket.assigns.invite, invite_params) do
+ {:ok, _invite} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Invite updated successfully")
+ |> push_redirect(to: socket.assigns.return_to)}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, :changeset, changeset)}
+ end
+ end
+
+ defp save_invite(socket, :new, invite_params) do
+ case Invites.create_invite(socket.assigns.current_user, invite_params) do
+ {:ok, _invite} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Invite created successfully")
+ |> push_redirect(to: socket.assigns.return_to)}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, changeset: changeset)}
+ end
+ end
+end
diff --git a/lib/cannery_web/live/invite_live/form_component.html.leex b/lib/cannery_web/live/invite_live/form_component.html.leex
new file mode 100644
index 00000000..46aea755
--- /dev/null
+++ b/lib/cannery_web/live/invite_live/form_component.html.leex
@@ -0,0 +1,14 @@
+
<%= @title %>
+
+<%= f = form_for @changeset, "#",
+ id: "invite-form",
+ phx_target: @myself,
+ phx_change: "validate",
+ phx_submit: "save" %>
+
+ <%= label f, :name, class: "title text-lg text-primary-500" %>
+ <%= text_input f, :name, class: "input input-primary" %>
+ <%= error_tag f, :name %>
+
+ <%= submit "Save", phx_disable_with: "Saving..." %>
+
diff --git a/lib/cannery_web/live/invite_live/index.ex b/lib/cannery_web/live/invite_live/index.ex
new file mode 100644
index 00000000..7c6c0ab0
--- /dev/null
+++ b/lib/cannery_web/live/invite_live/index.ex
@@ -0,0 +1,46 @@
+defmodule CanneryWeb.InviteLive.Index do
+ use CanneryWeb, :live_view
+
+ alias Cannery.Invites
+ alias Cannery.Invites.Invite
+
+ @impl true
+ def mount(_params, session, socket) do
+ {:ok, socket |> assign_defaults(session) |> assign(invites: list_invites())}
+ end
+
+ @impl true
+ def handle_params(params, _url, socket) do
+ {:noreply, apply_action(socket, socket.assigns.live_action, params)}
+ end
+
+ defp apply_action(socket, :edit, %{"id" => id}) do
+ socket
+ |> assign(:page_title, "Edit Invite")
+ |> assign(:invite, Invites.get_invite!(id))
+ end
+
+ defp apply_action(socket, :new, _params) do
+ socket
+ |> assign(:page_title, "New Invite")
+ |> assign(:invite, %Invite{})
+ end
+
+ defp apply_action(socket, :index, _params) do
+ socket
+ |> assign(:page_title, "Listing Invites")
+ |> assign(:invite, nil)
+ end
+
+ @impl true
+ def handle_event("delete", %{"id" => id}, socket) do
+ invite = Invites.get_invite!(id)
+ {:ok, _} = Invites.delete_invite(invite)
+
+ {:noreply, assign(socket, :invites, list_invites())}
+ end
+
+ defp list_invites do
+ Invites.list_invites()
+ end
+end
diff --git a/lib/cannery_web/live/invite_live/index.html.leex b/lib/cannery_web/live/invite_live/index.html.leex
new file mode 100644
index 00000000..d130327d
--- /dev/null
+++ b/lib/cannery_web/live/invite_live/index.html.leex
@@ -0,0 +1,38 @@
+Listing Invites
+
+<%= if @live_action in [:new, :edit] do %>
+ <%= live_modal CanneryWeb.InviteLive.FormComponent,
+ id: @invite.id || :new,
+ title: @page_title,
+ action: @live_action,
+ invite: @invite,
+ return_to: Routes.invite_index_path(@socket, :index) %>
+<% end %>
+
+
+
+
+ Name |
+ Token |
+ Uses left |
+ |
+
+
+
+ <%= for invite <- @invites do %>
+
+ <%= invite.name %> |
+ <%= invite.token %> |
+ <%= invite.uses_left || "Unlimited" %> |
+
+
+ <%= live_redirect "Show", to: Routes.invite_show_path(@socket, :show, invite) %>
+ <%= live_patch "Edit", to: Routes.invite_index_path(@socket, :edit, invite) %>
+ <%= link "Delete", to: "#", phx_click: "delete", phx_value_id: invite.id, data: [confirm: "Are you sure?"] %>
+ |
+
+ <% end %>
+
+
+
+<%= live_patch "New Invite", to: Routes.invite_index_path(@socket, :new) %>
diff --git a/lib/cannery_web/live/invite_live/show.ex b/lib/cannery_web/live/invite_live/show.ex
new file mode 100644
index 00000000..5e3150cf
--- /dev/null
+++ b/lib/cannery_web/live/invite_live/show.ex
@@ -0,0 +1,21 @@
+defmodule CanneryWeb.InviteLive.Show do
+ use CanneryWeb, :live_view
+
+ alias Cannery.Invites
+
+ @impl true
+ def mount(_params, session, socket) do
+ {:ok, socket |> assign_defaults(session)}
+ end
+
+ @impl true
+ def handle_params(%{"id" => id}, _, socket) do
+ {:noreply,
+ socket
+ |> assign(:page_title, page_title(socket.assigns.live_action))
+ |> assign(:invite, Invites.get_invite!(id))}
+ end
+
+ defp page_title(:show), do: "Show Invite"
+ defp page_title(:edit), do: "Edit Invite"
+end
diff --git a/lib/cannery_web/live/invite_live/show.html.leex b/lib/cannery_web/live/invite_live/show.html.leex
new file mode 100644
index 00000000..2944516d
--- /dev/null
+++ b/lib/cannery_web/live/invite_live/show.html.leex
@@ -0,0 +1,27 @@
+Show Invite
+
+<%= if @live_action in [:edit] do %>
+ <%= live_modal CanneryWeb.InviteLive.FormComponent,
+ id: @invite.id,
+ title: @page_title,
+ action: @live_action,
+ invite: @invite,
+ return_to: Routes.invite_show_path(@socket, :show, @invite) %>
+<% end %>
+
+
+
+ -
+ Name:
+ <%= @invite.name %>
+
+
+ -
+ Token:
+ <%= @invite.token %>
+
+
+
+
+<%= live_patch "Edit", to: Routes.invite_show_path(@socket, :edit, @invite), class: "button" %>
+<%= live_redirect "Back", to: Routes.invite_index_path(@socket, :index) %>
diff --git a/priv/repo/migrations/20210904211727_create_invites.exs b/priv/repo/migrations/20210904211727_create_invites.exs
new file mode 100644
index 00000000..5594385f
--- /dev/null
+++ b/priv/repo/migrations/20210904211727_create_invites.exs
@@ -0,0 +1,18 @@
+defmodule Cannery.Repo.Migrations.CreateInvites do
+ use Ecto.Migration
+
+ def change do
+ create table(:invites, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :name, :string
+ add :token, :string
+ add :uses_left, :integer, default: nil
+ add :disabled_at, :naive_datetime, default: nil
+ add :user_id, references(:users, on_delete: :nothing, type: :binary_id)
+
+ timestamps()
+ end
+
+ create index(:invites, [:user_id])
+ end
+end
diff --git a/test/cannery/invites_test.exs b/test/cannery/invites_test.exs
new file mode 100644
index 00000000..4d90b0ca
--- /dev/null
+++ b/test/cannery/invites_test.exs
@@ -0,0 +1,66 @@
+defmodule Cannery.InvitesTest do
+ use Cannery.DataCase
+
+ alias Cannery.Invites
+
+ describe "invites" do
+ alias Cannery.Invites.Invite
+
+ @valid_attrs %{name: "some name", token: "some token"}
+ @update_attrs %{name: "some updated name", token: "some updated token"}
+ @invalid_attrs %{name: nil, token: nil}
+
+ def invite_fixture(attrs \\ %{}) do
+ {:ok, invite} =
+ attrs
+ |> Enum.into(@valid_attrs)
+ |> Invites.create_invite()
+
+ invite
+ end
+
+ test "list_invites/0 returns all invites" do
+ invite = invite_fixture()
+ assert Invites.list_invites() == [invite]
+ end
+
+ test "get_invite!/1 returns the invite with given id" do
+ invite = invite_fixture()
+ assert Invites.get_invite!(invite.id) == invite
+ end
+
+ test "create_invite/1 with valid data creates a invite" do
+ assert {:ok, %Invite{} = invite} = Invites.create_invite(@valid_attrs)
+ assert invite.name == "some name"
+ assert invite.token == "some token"
+ end
+
+ test "create_invite/1 with invalid data returns error changeset" do
+ assert {:error, %Ecto.Changeset{}} = Invites.create_invite(@invalid_attrs)
+ end
+
+ test "update_invite/2 with valid data updates the invite" do
+ invite = invite_fixture()
+ assert {:ok, %Invite{} = invite} = Invites.update_invite(invite, @update_attrs)
+ assert invite.name == "some updated name"
+ assert invite.token == "some updated token"
+ end
+
+ test "update_invite/2 with invalid data returns error changeset" do
+ invite = invite_fixture()
+ assert {:error, %Ecto.Changeset{}} = Invites.update_invite(invite, @invalid_attrs)
+ assert invite == Invites.get_invite!(invite.id)
+ end
+
+ test "delete_invite/1 deletes the invite" do
+ invite = invite_fixture()
+ assert {:ok, %Invite{}} = Invites.delete_invite(invite)
+ assert_raise Ecto.NoResultsError, fn -> Invites.get_invite!(invite.id) end
+ end
+
+ test "change_invite/1 returns a invite changeset" do
+ invite = invite_fixture()
+ assert %Ecto.Changeset{} = Invites.change_invite(invite)
+ end
+ end
+end
diff --git a/test/cannery_web/live/invite_live_test.exs b/test/cannery_web/live/invite_live_test.exs
new file mode 100644
index 00000000..838817aa
--- /dev/null
+++ b/test/cannery_web/live/invite_live_test.exs
@@ -0,0 +1,116 @@
+defmodule CanneryWeb.InviteLiveTest do
+ use CanneryWeb.ConnCase
+
+ import Phoenix.LiveViewTest
+
+ alias Cannery.Invites
+
+ @create_attrs %{name: "some name", token: "some token"}
+ @update_attrs %{name: "some updated name", token: "some updated token"}
+ @invalid_attrs %{name: nil, token: nil}
+
+ defp fixture(:invite) do
+ {:ok, invite} = Invites.create_invite(@create_attrs)
+ invite
+ end
+
+ defp create_invite(_) do
+ invite = fixture(:invite)
+ %{invite: invite}
+ end
+
+ describe "Index" do
+ setup [:create_invite]
+
+ test "lists all invites", %{conn: conn, invite: invite} do
+ {:ok, _index_live, html} = live(conn, Routes.invite_index_path(conn, :index))
+
+ assert html =~ "Listing Invites"
+ assert html =~ invite.name
+ end
+
+ test "saves new invite", %{conn: conn} do
+ {:ok, index_live, _html} = live(conn, Routes.invite_index_path(conn, :index))
+
+ assert index_live |> element("a", "New Invite") |> render_click() =~
+ "New Invite"
+
+ assert_patch(index_live, Routes.invite_index_path(conn, :new))
+
+ assert index_live
+ |> form("#invite-form", invite: @invalid_attrs)
+ |> render_change() =~ "can't be blank"
+
+ {:ok, _, html} =
+ index_live
+ |> form("#invite-form", invite: @create_attrs)
+ |> render_submit()
+ |> follow_redirect(conn, Routes.invite_index_path(conn, :index))
+
+ assert html =~ "Invite created successfully"
+ assert html =~ "some name"
+ end
+
+ test "updates invite in listing", %{conn: conn, invite: invite} do
+ {:ok, index_live, _html} = live(conn, Routes.invite_index_path(conn, :index))
+
+ assert index_live |> element("#invite-#{invite.id} a", "Edit") |> render_click() =~
+ "Edit Invite"
+
+ assert_patch(index_live, Routes.invite_index_path(conn, :edit, invite))
+
+ assert index_live
+ |> form("#invite-form", invite: @invalid_attrs)
+ |> render_change() =~ "can't be blank"
+
+ {:ok, _, html} =
+ index_live
+ |> form("#invite-form", invite: @update_attrs)
+ |> render_submit()
+ |> follow_redirect(conn, Routes.invite_index_path(conn, :index))
+
+ assert html =~ "Invite updated successfully"
+ assert html =~ "some updated name"
+ end
+
+ test "deletes invite in listing", %{conn: conn, invite: invite} do
+ {:ok, index_live, _html} = live(conn, Routes.invite_index_path(conn, :index))
+
+ assert index_live |> element("#invite-#{invite.id} a", "Delete") |> render_click()
+ refute has_element?(index_live, "#invite-#{invite.id}")
+ end
+ end
+
+ describe "Show" do
+ setup [:create_invite]
+
+ test "displays invite", %{conn: conn, invite: invite} do
+ {:ok, _show_live, html} = live(conn, Routes.invite_show_path(conn, :show, invite))
+
+ assert html =~ "Show Invite"
+ assert html =~ invite.name
+ end
+
+ test "updates invite within modal", %{conn: conn, invite: invite} do
+ {:ok, show_live, _html} = live(conn, Routes.invite_show_path(conn, :show, invite))
+
+ assert show_live |> element("a", "Edit") |> render_click() =~
+ "Edit Invite"
+
+ assert_patch(show_live, Routes.invite_show_path(conn, :edit, invite))
+
+ assert show_live
+ |> form("#invite-form", invite: @invalid_attrs)
+ |> render_change() =~ "can't be blank"
+
+ {:ok, _, html} =
+ show_live
+ |> form("#invite-form", invite: @update_attrs)
+ |> render_submit()
+ |> follow_redirect(conn, Routes.invite_show_path(conn, :show, invite))
+
+ assert html =~ "Invite updated successfully"
+ assert html =~ "some updated name"
+ end
+ end
+end