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 %> + + + + + + + + + + + + <%= for invite <- @invites do %> + + + + + + + + <% end %> + +
NameTokenUses left
<%= 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?"] %> +
+ +<%= 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 %> + + + +<%= 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