defmodule Mobilizon.Discussions do @moduledoc """ The discussions context """ import EctoEnum import Ecto.Query alias Ecto.Changeset alias Ecto.Multi alias Mobilizon.Actors.Actor alias Mobilizon.Discussions.{Comment, Discussion} alias Mobilizon.Storage.{Page, Repo} defenum( CommentVisibility, :comment_visibility, [ :public, :unlisted, :private, :moderated, :invite ] ) defenum( CommentModeration, :comment_moderation, [ :allow_all, :moderated, :closed ] ) @comment_preloads [ :actor, :event, :attributed_to, :in_reply_to_comment, :origin_comment, :replies, :tags, :mentions, :discussion, :media ] @discussion_preloads [ :last_comment, :comments, :creator, :actor ] @public_visibility [:public, :unlisted] @doc """ Callback for Absinthe Ecto Dataloader """ # sobelow_skip ["SQL.Query"] @spec data :: Dataloader.Ecto.t() def data do Dataloader.Ecto.new(Repo, query: &query/2) end @doc """ Query for comment dataloader We only get first comment of thread, and count replies. Read: https://hexdocs.pm/absinthe/ecto.html#dataloader """ @spec query(atom(), map()) :: Ecto.Query.t() def query(Comment, %{top_level: true}) do Comment |> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id) |> where([c, _], is_nil(c.in_reply_to_comment_id)) # TODO: This was added because we don't want to count deleted comments in total_replies. # However, it also excludes all top-level comments with deleted replies from being selected # |> where([_, r], is_nil(r.deleted_at)) |> group_by([c], c.id) |> order_by([c], desc: :is_announcement, asc: :published_at) |> select([c, r], %{c | total_replies: count(r.id)}) end def query(Comment, _) do order_by(Comment, [c], asc: :is_announcement, asc: :published_at) end def query(queryable, _) do queryable end @doc """ Gets a single comment. """ @spec get_comment(integer | String.t()) :: Comment.t() | nil def get_comment(nil), do: nil def get_comment(id), do: Repo.get(Comment, id) @doc """ Gets a single comment. Raises `Ecto.NoResultsError` if the comment does not exist. """ @spec get_comment!(integer | String.t()) :: Comment.t() def get_comment!(id), do: Repo.get!(Comment, id) @doc """ Get a single comment by it's ID and all associations preloaded """ @spec get_comment_with_preload(String.t() | integer() | nil) :: Comment.t() | nil def get_comment_with_preload(nil), do: nil def get_comment_with_preload(id) do Comment |> where(id: ^id) |> preload_for_comment() |> Repo.one() end @doc """ Gets a comment by its URL. """ @spec get_comment_from_url(String.t()) :: Comment.t() | nil def get_comment_from_url(url), do: Repo.get_by(Comment, url: url) @doc """ Gets a comment by its URL. Raises `Ecto.NoResultsError` if the comment does not exist. """ @spec get_comment_from_url!(String.t()) :: Comment.t() def get_comment_from_url!(url), do: Repo.get_by!(Comment, url: url) @doc """ Gets a comment by its URL, with all associations loaded. """ @spec get_comment_from_url_with_preload(String.t()) :: {:ok, Comment.t()} | {:error, :comment_not_found} def get_comment_from_url_with_preload(url) do query = from(c in Comment, where: c.url == ^url) comment = query |> preload_for_comment() |> Repo.one() case comment do %Comment{} = comment -> {:ok, comment} nil -> {:error, :comment_not_found} end end @doc """ Gets a comment by its URL, with all associations loaded. Raises `Ecto.NoResultsError` if the comment does not exist. """ @spec get_comment_from_url_with_preload!(String.t()) :: Comment.t() def get_comment_from_url_with_preload!(url) do Comment |> Repo.get_by!(url: url) |> Repo.preload(@comment_preloads) end @doc """ Gets a comment by its UUID, with all associations loaded. """ @spec get_comment_from_uuid_with_preload(String.t()) :: Comment.t() | nil def get_comment_from_uuid_with_preload(uuid) do Comment |> Repo.get_by(uuid: uuid) |> Repo.preload(@comment_preloads) end @doc """ Get all comment threads under an event """ @spec get_threads(String.t() | integer()) :: [Comment.t()] def get_threads(event_id) do Comment |> where([c, _], c.event_id == ^event_id and is_nil(c.origin_comment_id)) |> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id) |> group_by([c], c.id) |> select([c, r], %{c | total_replies: count(r.id)}) |> Repo.all() end @doc """ Gets paginated replies for root comment """ @spec get_thread_replies(integer()) :: [Comment.t()] def get_thread_replies(parent_id) do parent_id |> public_replies_for_thread_query() |> Repo.all() end @doc """ Get a comment or create it """ @spec get_or_create_comment(map()) :: {:ok, Comment.t()} def get_or_create_comment(%{"url" => url} = attrs) do case Repo.get_by(Comment, url: url) do %Comment{} = comment -> {:ok, Repo.preload(comment, @comment_preloads)} nil -> create_comment(attrs) end end @doc """ Creates a comment. """ @spec create_comment(map) :: {:ok, Comment.t()} | {:error, Changeset.t()} def create_comment(attrs \\ %{}) do with {:ok, %Comment{} = comment} <- %Comment{} |> Comment.changeset(attrs) |> Repo.insert(), %Comment{} = comment <- Repo.preload(comment, @comment_preloads) do {:ok, comment} end end @doc """ Updates a comment. """ @spec update_comment(Comment.t(), map) :: {:ok, Comment.t()} | {:error, Changeset.t()} def update_comment(%Comment{} = comment, attrs) do comment |> Comment.update_changeset(attrs) |> Repo.update() end @doc """ Deletes a comment But actually just empty the fields so that threads are not broken. """ @spec delete_comment(Comment.t(), Keyword.t()) :: {:ok, Comment.t()} | {:error, Changeset.t()} def delete_comment(%Comment{} = comment, options \\ []) do if Keyword.get(options, :force, false) == false do with {:ok, %Comment{} = comment} <- comment |> Comment.delete_changeset() |> Repo.update(), %Comment{} = comment <- get_comment_with_preload(comment.id) do {:ok, comment} end else Repo.delete(comment) end end @doc """ Returns the list of public comments. """ @spec list_comments :: [Comment.t()] def list_comments do Repo.all(from(c in Comment, where: c.visibility == ^:public)) end @doc """ Returns a paginated list of local comments """ @spec list_local_comments(integer | nil, integer | nil) :: Page.t() def list_local_comments(page \\ nil, limit \\ nil) do Comment |> where([c], c.visibility == ^:public) |> where([c], is_nil(c.deleted_at)) |> where([c], is_nil(c.discussion_id)) |> preload_for_comment() |> Page.build_page(page, limit) end @doc """ Returns the list of public comments for the actor. """ @spec list_public_comments_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t() def list_public_comments_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do actor_id |> public_comments_for_actor_query() |> Page.build_page(page, limit) end @doc """ Returns the list of comments by an actor and a list of ids. """ @spec list_comments_by_actor_and_ids(integer | String.t(), [integer | String.t()]) :: [Comment.t()] def list_comments_by_actor_and_ids(actor_id, comment_ids \\ []) def list_comments_by_actor_and_ids(_actor_id, []), do: [] def list_comments_by_actor_and_ids(actor_id, comment_ids) do Comment |> where([c], c.id in ^comment_ids) |> where([c], c.actor_id == ^actor_id) |> Repo.all() end @doc """ Get all the comments contained into a discussion """ @spec get_comments_for_discussion(integer, integer | nil, integer | nil) :: Page.t() def get_comments_for_discussion(discussion_id, page \\ nil, limit \\ nil) do Comment |> where([c], c.discussion_id == ^discussion_id) |> order_by(asc: :inserted_at) |> Page.build_page(page, limit) end @doc """ Counts local comments under events """ @spec count_local_comments_under_events :: integer def count_local_comments_under_events do count_local_comments_query() |> filter_comments_under_events() |> Repo.one() end @doc """ Counts all comments. """ @spec count_comments_under_events :: integer def count_comments_under_events do count_comments_query() |> filter_comments_under_events() |> Repo.one() end @doc """ Get a discussion by it's ID """ @spec get_discussion(String.t() | integer()) :: Discussion.t() | nil def get_discussion(discussion_id) do Discussion |> Repo.get(discussion_id) |> Repo.preload(@discussion_preloads) end @doc """ Get a discussion by it's URL """ @spec get_discussion_by_url(String.t() | nil) :: Discussion.t() | nil def get_discussion_by_url(nil), do: nil def get_discussion_by_url(discussion_url) do Discussion |> Repo.get_by(url: discussion_url) |> Repo.preload(@discussion_preloads) end @doc """ Get a discussion by it's slug """ @spec get_discussion_by_slug(String.t()) :: Discussion.t() | nil def get_discussion_by_slug(discussion_slug) do Discussion |> Repo.get_by(slug: discussion_slug) |> Repo.preload(@discussion_preloads) end @doc """ Get a paginated list of discussions for a group actor """ @spec find_discussions_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t() def find_discussions_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do Discussion |> where([c], c.actor_id == ^actor_id) |> preload(^@discussion_preloads) |> order_by(desc: :updated_at) |> Page.build_page(page, limit) end @doc """ Creates a discussion. """ @spec create_discussion(map()) :: {:ok, Discussion.t()} | {:error, atom(), Changeset.t(), map()} def create_discussion(attrs) do with {:ok, %{comment: %Comment{} = _comment, discussion: %Discussion{} = discussion}} <- Multi.new() |> Multi.insert( :comment, Comment.changeset( %Comment{}, Map.merge(attrs, %{actor_id: attrs.creator_id, attributed_to_id: attrs.actor_id}) ) ) |> Multi.insert(:discussion, fn %{comment: %Comment{id: comment_id}} -> Discussion.changeset( %Discussion{}, Map.merge(attrs, %{last_comment_id: comment_id}) ) end) |> Multi.update(:comment_discussion, fn %{ comment: %Comment{} = comment, discussion: %Discussion{ id: discussion_id, url: discussion_url } } -> Changeset.change(comment, %{discussion_id: discussion_id, url: discussion_url}) end) |> Repo.transaction(), %Discussion{} = discussion <- Repo.preload(discussion, @discussion_preloads) do {:ok, discussion} end end @doc """ Create a response to a discussion """ @spec reply_to_discussion(Discussion.t(), map()) :: {:ok, Discussion.t()} | {:error, atom(), Ecto.Changeset.t(), map()} def reply_to_discussion(%Discussion{id: discussion_id} = discussion, attrs \\ %{}) do attrs = Map.merge(attrs, %{ discussion_id: discussion_id, actor_id: Map.get(attrs, :creator_id, Map.get(attrs, :actor_id)) }) changeset = Comment.changeset( %Comment{}, attrs ) with {:ok, %{comment: %Comment{} = comment, discussion: %Discussion{} = discussion}} <- Multi.new() |> Multi.insert( :comment, changeset ) |> Multi.update(:discussion, fn %{comment: %Comment{id: comment_id}} -> Discussion.changeset( discussion, %{last_comment_id: comment_id} ) end) |> Repo.transaction(), # Discussion is not updated %Comment{} = comment <- Repo.preload(comment, @comment_preloads) do {:ok, %Discussion{discussion | last_comment: comment}} end end @doc """ Update a discussion. Only their title for now. """ @spec update_discussion(Discussion.t(), map()) :: {:ok, Discussion.t()} | {:error, Changeset.t()} def update_discussion(%Discussion{} = discussion, attrs \\ %{}) do discussion |> Discussion.changeset(attrs) |> Repo.update() end @doc """ Delete a discussion. """ @spec delete_discussion(Discussion.t()) :: {:ok, %{comments: {integer() | nil, any()}}} | {:error, :comments, Changeset.t(), map()} def delete_discussion(%Discussion{id: discussion_id}) do Multi.new() |> Multi.delete_all(:comments, fn _ -> where(Comment, [c], c.discussion_id == ^discussion_id) end) # |> Multi.delete(:discussion, discussion) |> Repo.transaction() end @spec public_comments_for_actor_query(String.t() | integer()) :: Ecto.Query.t() defp public_comments_for_actor_query(actor_id) do Comment |> where([c], c.actor_id == ^actor_id and c.visibility in ^@public_visibility) |> order_by([c], desc: :id) |> preload_for_comment() end @spec public_replies_for_thread_query(String.t() | integer()) :: Ecto.Query.t() defp public_replies_for_thread_query(comment_id) do Comment |> where([c], c.origin_comment_id == ^comment_id and c.visibility in ^@public_visibility) |> group_by([c], [c.in_reply_to_comment_id, c.id]) |> preload_for_comment() end @spec count_local_comments_query :: Ecto.Query.t() defp count_local_comments_query do count_comments_query() |> where([c], local: true) end @spec count_comments_query :: Ecto.Query.t() defp count_comments_query do from( c in Comment, select: count(c.id), where: c.visibility in ^@public_visibility ) end @spec filter_comments_under_events(Ecto.Queryable.t()) :: Ecto.Query.t() defp filter_comments_under_events(query) do where(query, [c], is_nil(c.discussion_id) and not is_nil(c.event_id)) end @spec preload_for_comment(Ecto.Queryable.t()) :: Ecto.Query.t() defp preload_for_comment(query), do: preload(query, ^@comment_preloads) end