diff --git a/.iex.exs b/.iex.exs index 3d76b6b3..8f7059e6 100644 --- a/.iex.exs +++ b/.iex.exs @@ -22,6 +22,7 @@ alias EpochtalkServer.Models.{ Notification, Permission, Post, + PostDraft, Poll, PollAnswer, PollResponse, diff --git a/lib/epochtalk_server/models/poll_answer.ex b/lib/epochtalk_server/models/poll_answer.ex index 0574dab1..e92f6dab 100644 --- a/lib/epochtalk_server/models/poll_answer.ex +++ b/lib/epochtalk_server/models/poll_answer.ex @@ -1,7 +1,6 @@ defmodule EpochtalkServer.Models.PollAnswer do use Ecto.Schema import Ecto.Changeset - # import Ecto.Query alias EpochtalkServer.Repo alias EpochtalkServer.Models.PollAnswer alias EpochtalkServer.Models.Poll diff --git a/lib/epochtalk_server/models/post_draft.ex b/lib/epochtalk_server/models/post_draft.ex new file mode 100644 index 00000000..d1600a33 --- /dev/null +++ b/lib/epochtalk_server/models/post_draft.ex @@ -0,0 +1,85 @@ +defmodule EpochtalkServer.Models.PostDraft do + use Ecto.Schema + import Ecto.Query + import Ecto.Changeset + alias EpochtalkServer.Repo + alias EpochtalkServer.Models.User + alias EpochtalkServer.Models.PostDraft + + @moduledoc """ + `PostDraft` model, for performing actions relating to a `User`'s `Post` draft + """ + @type t :: %__MODULE__{ + user_id: non_neg_integer | nil, + draft: String.t() | nil, + updated_at: NaiveDateTime.t() | nil + } + @derive {Jason.Encoder, + only: [ + :user_id, + :draft, + :updated_at + ]} + @schema_prefix "posts" + @primary_key false + schema "user_drafts" do + belongs_to :user, User + field :draft, :string + field :updated_at, :naive_datetime + end + + ## === Changeset Functions === + + @doc """ + Create a changeset for upserting a `PostDraft` + """ + @spec upsert_changeset(draft :: PostDraft.t(), attrs :: map() | nil) :: Ecto.Changeset.t() + def upsert_changeset(draft, attrs \\ %{}) do + updated_at = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + + draft = + draft + |> Map.put(:updated_at, updated_at) + + draft + |> cast(attrs, [:user_id, :draft, :updated_at]) + |> validate_required([:user_id, :updated_at]) + |> validate_length(:draft, min: 1, max: 64_000) + |> unique_constraint(:user_id, name: :user_drafts_user_id_index) + |> foreign_key_constraint(:user_id, name: :user_drafts_user_id_fkey) + end + + ## === Database Functions === + + @doc """ + Returns `PostDraft` Data given a `User` id + """ + @spec by_user_id(user_id :: integer) :: t() | nil + def by_user_id(user_id) do + query = + from p in PostDraft, + where: p.user_id == ^user_id + + Repo.one(query) + end + + @doc """ + Used to upsert a `PostDraft` for a specific `User` + """ + @spec upsert(user_id :: non_neg_integer, attrs :: map()) :: + {:ok, t()} | {:error, Ecto.Changeset.t()} + def upsert(user_id, attrs) when is_integer(user_id) do + post_draft_cs = upsert_changeset(%PostDraft{user_id: user_id}, attrs) + + Repo.insert( + post_draft_cs, + on_conflict: [ + set: [ + draft: Map.get(post_draft_cs.changes, :draft), + updated_at: Map.get(post_draft_cs.data, :updated_at) + ] + ], + conflict_target: [:user_id] + ) + end +end diff --git a/lib/epochtalk_server_web/controllers/mention.ex b/lib/epochtalk_server_web/controllers/mention.ex index 1e0aa15d..6a683cdc 100644 --- a/lib/epochtalk_server_web/controllers/mention.ex +++ b/lib/epochtalk_server_web/controllers/mention.ex @@ -18,7 +18,7 @@ defmodule EpochtalkServerWeb.Controllers.Mention do limit <- Validate.cast(attrs, "limit", :integer, min: 1), extended <- Validate.cast(attrs, "extended", :boolean), :ok <- ACL.allow!(conn, "mentions.page"), - {:auth, user} <- {:auth, Guardian.Plug.current_resource(conn)}, + {:auth, %{} = user} <- {:auth, Guardian.Plug.current_resource(conn)}, {:ok, mentions, data} <- Mention.page_by_user_id(user.id, page, per_page: limit, extended: extended), do: diff --git a/lib/epochtalk_server_web/controllers/notification.ex b/lib/epochtalk_server_web/controllers/notification.ex index 618f1f4e..8a9ffbdf 100644 --- a/lib/epochtalk_server_web/controllers/notification.ex +++ b/lib/epochtalk_server_web/controllers/notification.ex @@ -14,7 +14,7 @@ defmodule EpochtalkServerWeb.Controllers.Notification do Used to retrieve `Notification` counts for a specific `User` """ def counts(conn, attrs) do - with {:auth, user} <- {:auth, Guardian.Plug.current_resource(conn)}, + with {:auth, %{} = user} <- {:auth, Guardian.Plug.current_resource(conn)}, :ok <- ACL.allow!(conn, "notifications.counts"), max <- Validate.cast(attrs, "max", :integer, min: 1) do render(conn, :counts, data: Notification.counts_by_user_id(user.id, max: max || 99)) @@ -39,7 +39,7 @@ defmodule EpochtalkServerWeb.Controllers.Notification do Used to dismiss `Notification` counts for a specific `User` """ def dismiss(conn, %{"id" => id}) do - with {:auth, user} <- {:auth, Guardian.Plug.current_resource(conn)}, + with {:auth, %{} = user} <- {:auth, Guardian.Plug.current_resource(conn)}, :ok <- ACL.allow!(conn, "notifications.dismiss"), {_count, nil} <- Notification.dismiss(id) do EpochtalkServerWeb.Endpoint.broadcast("user:#{user.id}", "refreshMentions", %{}) @@ -62,7 +62,7 @@ defmodule EpochtalkServerWeb.Controllers.Notification do end def dismiss(conn, %{"type" => type}) do - with {:auth, user} <- {:auth, Guardian.Plug.current_resource(conn)}, + with {:auth, %{} = user} <- {:auth, Guardian.Plug.current_resource(conn)}, :ok <- ACL.allow!(conn, "notifications.dismiss"), {_count, nil} <- Notification.dismiss_type_by_user_id(user.id, type) do EpochtalkServerWeb.Endpoint.broadcast("user:#{user.id}", "refreshMentions", %{}) diff --git a/lib/epochtalk_server_web/controllers/post.ex b/lib/epochtalk_server_web/controllers/post.ex index 54be894c..e93728a3 100644 --- a/lib/epochtalk_server_web/controllers/post.ex +++ b/lib/epochtalk_server_web/controllers/post.ex @@ -40,7 +40,7 @@ defmodule EpochtalkServerWeb.Controllers.Post do # * Ensure mentions and notifications are created def create(conn, attrs) do # Authorizations Checks - with {:auth, user} <- {:auth, Guardian.Plug.current_resource(conn)}, + with {:auth, %{} = user} <- {:auth, Guardian.Plug.current_resource(conn)}, :ok <- ACL.allow!(conn, "posts.create"), # normally we use model changesets for validating POST requests parameters, # this is an exception to save us from doing excessive processing between here @@ -144,7 +144,7 @@ defmodule EpochtalkServerWeb.Controllers.Post do """ def update(conn, attrs) do # Authorizations Checks - with {:auth, user} <- {:auth, Guardian.Plug.current_resource(conn)}, + with {:auth, %{} = user} <- {:auth, Guardian.Plug.current_resource(conn)}, :ok <- ACL.allow!(conn, "posts.update"), # normally we use model changesets for validating POST requests parameters, # this is an exception to save us from doing excessive processing between here diff --git a/lib/epochtalk_server_web/controllers/post_draft.ex b/lib/epochtalk_server_web/controllers/post_draft.ex new file mode 100644 index 00000000..3c7a88b5 --- /dev/null +++ b/lib/epochtalk_server_web/controllers/post_draft.ex @@ -0,0 +1,41 @@ +defmodule EpochtalkServerWeb.Controllers.PostDraft do + use EpochtalkServerWeb, :controller + + @moduledoc """ + Controller for `PostDraft` related API requests + """ + alias EpochtalkServer.Auth.Guardian + alias EpochtalkServerWeb.ErrorHelpers + alias EpochtalkServer.Models.PostDraft + + @doc """ + Used to upsert a specific `PostDraft` + + Returns `PostDraft` on success + """ + def upsert(conn, attrs) do + with {:auth, %{} = user} <- {:auth, Guardian.Plug.current_resource(conn)}, + {:ok, draft_data} <- PostDraft.upsert(user.id, attrs) do + render(conn, :upsert, %{draft_data: draft_data, user_id: user.id}) + else + {:auth, nil} -> + ErrorHelpers.render_json_error(conn, 403, "Not logged in, cannot upsert post draft") + + {:error, data} -> + ErrorHelpers.render_json_error(conn, 400, data) + end + end + + @doc """ + Get `PostDraft` by `User` id + """ + def by_user_id(conn, _) do + with {:auth, %{} = user} <- {:auth, Guardian.Plug.current_resource(conn)}, + draft_data <- PostDraft.by_user_id(user.id) do + render(conn, :by_user_id, %{draft_data: draft_data, user_id: user.id}) + else + {:auth, nil} -> + ErrorHelpers.render_json_error(conn, 403, "Not logged in, cannot get post draft") + end + end +end diff --git a/lib/epochtalk_server_web/controllers/preference.ex b/lib/epochtalk_server_web/controllers/preference.ex index f2f22222..0e7405a2 100644 --- a/lib/epochtalk_server_web/controllers/preference.ex +++ b/lib/epochtalk_server_web/controllers/preference.ex @@ -12,7 +12,7 @@ defmodule EpochtalkServerWeb.Controllers.Preference do Used to retrieve preferences of a specific `User` """ def preferences(conn, _attrs) do - with {:auth, user} <- {:auth, Guardian.Plug.current_resource(conn)}, + with {:auth, %{} = user} <- {:auth, Guardian.Plug.current_resource(conn)}, do: render(conn, :preferences, preferences: Preference.by_user_id(user.id)), else: ({:auth, nil} -> diff --git a/lib/epochtalk_server_web/controllers/role.ex b/lib/epochtalk_server_web/controllers/role.ex index bfb560ce..17fa61dd 100644 --- a/lib/epochtalk_server_web/controllers/role.ex +++ b/lib/epochtalk_server_web/controllers/role.ex @@ -16,7 +16,7 @@ defmodule EpochtalkServerWeb.Controllers.Role do Returns id of `Role` on success """ def update(conn, attrs) do - with {:auth, _user} <- {:auth, Guardian.Plug.current_resource(conn)}, + with {:auth, %{} = _user} <- {:auth, Guardian.Plug.current_resource(conn)}, :ok <- ACL.allow!(conn, "roles.update"), {:ok, _role_permission_data} <- RolePermission.modify_by_role(attrs), {:ok, _role_data} <- Role.update(attrs) do @@ -34,7 +34,7 @@ defmodule EpochtalkServerWeb.Controllers.Role do Get all `Role`s """ def all(conn, _) do - with {:auth, _user} <- {:auth, Guardian.Plug.current_resource(conn)}, + with {:auth, %{} = _user} <- {:auth, Guardian.Plug.current_resource(conn)}, roles <- Role.all() do render(conn, :all, roles: roles) else diff --git a/lib/epochtalk_server_web/controllers/thread.ex b/lib/epochtalk_server_web/controllers/thread.ex index 8a389c1d..6988697f 100644 --- a/lib/epochtalk_server_web/controllers/thread.ex +++ b/lib/epochtalk_server_web/controllers/thread.ex @@ -52,7 +52,7 @@ defmodule EpochtalkServerWeb.Controllers.Thread do # authorization checks with :ok <- ACL.allow!(conn, "threads.create"), board_id <- Validate.cast(attrs, "board_id", :integer, required: true), - {:auth, user} <- {:auth, Guardian.Plug.current_resource(conn)}, + {:auth, %{} = user} <- {:auth, Guardian.Plug.current_resource(conn)}, user_priority <- ACL.get_user_priority(conn), {:can_read, {:ok, true}} <- {:can_read, Board.get_read_access_by_id(board_id, user_priority)}, diff --git a/lib/epochtalk_server_web/json/post_draft_json.ex b/lib/epochtalk_server_web/json/post_draft_json.ex new file mode 100644 index 00000000..5645b3c6 --- /dev/null +++ b/lib/epochtalk_server_web/json/post_draft_json.ex @@ -0,0 +1,38 @@ +defmodule EpochtalkServerWeb.Controllers.PostDraftJSON do + @moduledoc """ + Renders and formats `PostDraft` data, in JSON format for frontend + """ + + @doc """ + Renders `PostDraft` upsert result data in JSON + + ## Example + iex> draft_data = %{ + iex> user_id: 99, + iex> draft: "Hello world", + iex> updated_at: ~N[2024-03-12 01:28:15] + iex> } + iex> EpochtalkServerWeb.Controllers.PostDraftJSON.upsert(%{draft_data: draft_data}) + draft_data + """ + def upsert(%{draft_data: draft_data}), do: draft_data + + @doc """ + Renders `PostDraft` by_user_id result data in JSON + + ## Example + iex> EpochtalkServerWeb.Controllers.PostDraftJSON.by_user_id(%{draft_data: nil, user_id: 98}) + %{user_id: 98, draft: nil, updated_at: nil} + iex> draft_data = %{ + iex> user_id: 98, + iex> draft: "Hello world", + iex> updated_at: ~N[2024-03-12 01:28:15] + iex> } + iex> EpochtalkServerWeb.Controllers.PostDraftJSON.by_user_id(%{draft_data: draft_data, user_id: 98}) + draft_data + """ + def by_user_id(%{draft_data: nil, user_id: user_id}), + do: %{user_id: user_id, draft: nil, updated_at: nil} + + def by_user_id(%{draft_data: draft_data, user_id: _user_id}), do: draft_data +end diff --git a/lib/epochtalk_server_web/router.ex b/lib/epochtalk_server_web/router.ex index 4ba044e3..116ab89b 100644 --- a/lib/epochtalk_server_web/router.ex +++ b/lib/epochtalk_server_web/router.ex @@ -64,6 +64,8 @@ defmodule EpochtalkServerWeb.Router do get "/register/username/:username", User, :username get "/register/email/:email", User, :email get "/posts", Post, :by_thread + get "/posts/draft", PostDraft, :by_user_id + put "/posts/draft", PostDraft, :upsert post "/posts", Post, :create post "/posts/:id", Post, :update post "/register", User, :register diff --git a/test/epochtalk_server_web/controllers/post_draft_test.exs b/test/epochtalk_server_web/controllers/post_draft_test.exs new file mode 100644 index 00000000..a77270af --- /dev/null +++ b/test/epochtalk_server_web/controllers/post_draft_test.exs @@ -0,0 +1,91 @@ +defmodule Test.EpochtalkServerWeb.Controllers.PostDraft do + use Test.Support.ConnCase, async: true + import Test.Support.Factory + + setup %{users: %{admin_user: admin_user}} do + draft_data = + build(:post_draft, %{ + user_id: admin_user.id, + draft: "draft test" + }) + + {:ok, draft_data: draft_data} + end + + describe "by_user_id/1" do + test "when unauthenticated, returns Unauthorized error", %{conn: conn} do + response = + conn + |> get(Routes.post_draft_path(conn, :by_user_id)) + |> json_response(403) + + assert response["error"] == "Forbidden" + assert response["message"] == "Not logged in, cannot get post draft" + end + + @tag :authenticated + test "when authenticated with no existing post draft, returns empty post draft", + %{conn: conn, users: %{user: user}} do + response = + conn + |> get(Routes.post_draft_path(conn, :by_user_id)) + |> json_response(200) + + assert response["user_id"] == user.id + assert response["draft"] == nil + assert response["updated_at"] == nil + end + + @tag authenticated: :admin + test "when authenticated with an existing post draft, returns existing post draft", + %{conn: conn, users: %{admin_user: admin_user}, draft_data: draft_data} do + response = + conn + |> get(Routes.post_draft_path(conn, :by_user_id)) + |> json_response(200) + + assert response["user_id"] == admin_user.id + assert response["draft"] == draft_data.draft + assert response["updated_at"] != nil + end + end + + describe "upsert/1" do + test "when unauthenticated, returns Unauthorized error", %{conn: conn} do + response = + conn + |> put(Routes.post_draft_path(conn, :upsert), %{"draft" => "Hello World"}) + |> json_response(403) + + assert response["error"] == "Forbidden" + assert response["message"] == "Not logged in, cannot upsert post draft" + end + + @tag :authenticated + test "when authenticated with no existing post draft, creates and returns new post draft", + %{conn: conn, users: %{user: user}} do + response = + conn + |> put(Routes.post_draft_path(conn, :upsert), %{"draft" => "Hello World"}) + |> json_response(200) + + assert response["user_id"] == user.id + assert response["draft"] == "Hello World" + assert response["updated_at"] != nil + end + + @tag authenticated: :admin + test "when authenticated with an existing post draft, overrides existing and returns new post draft", + %{conn: conn, users: %{admin_user: admin_user}, draft_data: draft_data} do + response = + conn + |> put(Routes.post_draft_path(conn, :upsert), %{"draft" => "Hello World"}) + |> json_response(200) + + assert response["user_id"] == admin_user.id + assert response["draft"] != draft_data.draft + assert response["draft"] == "Hello World" + assert response["updated_at"] != nil + end + end +end diff --git a/test/epochtalk_server_web/json/post_draft_json_test.exs b/test/epochtalk_server_web/json/post_draft_json_test.exs new file mode 100644 index 00000000..d1120a70 --- /dev/null +++ b/test/epochtalk_server_web/json/post_draft_json_test.exs @@ -0,0 +1,7 @@ +defmodule Test.EpochtalkServerWeb.Controllers.PostDraftJSON do + use Test.Support.ConnCase, async: true + alias EpochtalkServerWeb.Controllers.PostDraftJSON + + # Specify that we want to use doctests: + doctest PostDraftJSON +end diff --git a/test/support/factories/post_draft.ex b/test/support/factories/post_draft.ex new file mode 100644 index 00000000..8b809a4f --- /dev/null +++ b/test/support/factories/post_draft.ex @@ -0,0 +1,22 @@ +defmodule Test.Support.Factories.PostDraft do + @moduledoc """ + Factory for `PostDraft` + """ + alias EpochtalkServer.Models.PostDraft + + defmacro __using__(_opts) do + quote do + def post_draft_factory( + %{ + user_id: user_id, + draft: draft + } = attrs + ) do + PostDraft.upsert(user_id, %{"draft" => draft}) + |> case do + {:ok, draft} -> draft + end + end + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 14f4022f..5fe4c778 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -14,6 +14,7 @@ defmodule Test.Support.Factory do use Test.Support.Factories.Thread use Test.Support.Factories.Mention use Test.Support.Factories.Notification + use Test.Support.Factories.PostDraft use Test.Support.Factories.BannedAddress use Test.Support.Factories.ModerationLog end