defmodule Mobilizon.Discussions.Comment do @moduledoc """ Represents an actor comment (for instance on an event or on a group). """ use Ecto.Schema import Ecto.Changeset import Mobilizon.Storage.Ecto, only: [maybe_add_published_at: 1] alias Mobilizon.Actors.Actor alias Mobilizon.Conversations.Conversation alias Mobilizon.Discussions.{Comment, CommentVisibility, Discussion} alias Mobilizon.Events.{Event, Tag} alias Mobilizon.Medias.Media alias Mobilizon.Mention use Mobilizon.Web, :verified_routes @type t :: %__MODULE__{ text: String.t(), url: String.t(), id: integer(), local: boolean, visibility: atom(), uuid: Ecto.UUID.t(), actor: Actor.t(), attributed_to: Actor.t(), event: Event.t(), tags: [Tag.t()], mentions: [Mention.t()], media: [Media.t()], in_reply_to_comment: t, origin_comment: t, language: String.t() } # When deleting an event we only nihilify everything @required_attrs [:url] @creation_required_attrs @required_attrs ++ [:text, :actor_id, :published_at] @optional_attrs [ :text, :actor_id, :event_id, :in_reply_to_comment_id, :origin_comment_id, :attributed_to_id, :deleted_at, :local, :is_announcement, :discussion_id, :conversation_id, :language, :visibility ] @attrs @required_attrs ++ @optional_attrs schema "comments" do field(:text, :string) field(:url, :string) field(:local, :boolean, default: true) field(:visibility, CommentVisibility, default: :public) field(:uuid, Ecto.UUID) field(:total_replies, :integer, virtual: true, default: 0) field(:deleted_at, :utc_datetime) field(:published_at, :utc_datetime) field(:is_announcement, :boolean, default: false) field(:language, :string, default: "und") belongs_to(:actor, Actor, foreign_key: :actor_id) belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id) belongs_to(:event, Event, foreign_key: :event_id) belongs_to(:in_reply_to_comment, Comment, foreign_key: :in_reply_to_comment_id) belongs_to(:origin_comment, Comment, foreign_key: :origin_comment_id) belongs_to(:discussion, Discussion, type: :binary_id) belongs_to(:conversation, Conversation) has_many(:replies, Comment, foreign_key: :origin_comment_id) many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete) has_many(:mentions, Mention, on_replace: :delete) many_to_many(:media, Media, join_through: "comments_medias", on_replace: :delete) timestamps(type: :utc_datetime) end @doc """ Returns the id of the first comment in the discussion or conversation. """ @spec get_thread_id(t) :: integer def get_thread_id(%__MODULE__{id: id, origin_comment_id: origin_comment_id}) do origin_comment_id || id end @doc false @spec changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t() def changeset(%__MODULE__{} = comment, attrs) do comment |> common_changeset(attrs) |> validate_required(@creation_required_attrs) end def update_changeset(%__MODULE__{} = comment, attrs) do comment |> changeset(attrs) # TODO handle comment edits # |> put_change(:edits, comment.edits + 1) end @spec delete_changeset(t) :: Ecto.Changeset.t() def delete_changeset(%__MODULE__{} = comment) do comment |> change() |> put_change(:text, nil) |> put_change(:actor_id, nil) |> put_change(:deleted_at, DateTime.utc_now() |> DateTime.truncate(:second)) end @doc """ Checks whether an comment can be managed. """ @spec can_be_managed_by?(t, integer | String.t()) :: boolean() def can_be_managed_by?(%__MODULE__{actor_id: creator_actor_id}, actor_id) when creator_actor_id == actor_id do creator_actor_id == actor_id end def can_be_managed_by?(_comment, _actor), do: false defp common_changeset(%__MODULE__{} = comment, attrs) do comment |> cast(attrs, @attrs) |> maybe_add_published_at() |> maybe_generate_uuid() |> maybe_generate_url() |> put_assoc(:media, Map.get(attrs, :media, [])) |> put_tags(attrs) |> put_mentions(attrs) |> unique_constraint(:url, name: :comments_url_index) end @spec maybe_generate_uuid(Ecto.Changeset.t()) :: Ecto.Changeset.t() defp maybe_generate_uuid(%Ecto.Changeset{} = changeset) do case fetch_field(changeset, :uuid) do :error -> put_change(changeset, :uuid, Ecto.UUID.generate()) {:data, nil} -> put_change(changeset, :uuid, Ecto.UUID.generate()) _ -> changeset end end @spec maybe_generate_url(Ecto.Changeset.t()) :: Ecto.Changeset.t() defp maybe_generate_url(%Ecto.Changeset{} = changeset) do with res when res in [:error, {:data, nil}] <- fetch_field(changeset, :url), {changes, uuid} when changes in [:changes, :data] <- fetch_field(changeset, :uuid), url <- generate_url(uuid) do put_change(changeset, :url, url) else _ -> changeset end end @spec generate_url(String.t()) :: String.t() defp generate_url(uuid), do: url(~p"/comments/#{uuid}") @spec put_tags(Ecto.Changeset.t(), map) :: Ecto.Changeset.t() defp put_tags(changeset, %{"tags" => tags}), do: put_assoc(changeset, :tags, Enum.map(tags, &process_tag/1)) defp put_tags(changeset, %{tags: tags}), do: put_assoc(changeset, :tags, Enum.map(tags, &process_tag/1)) defp put_tags(changeset, _), do: changeset @spec put_mentions(Ecto.Changeset.t(), map) :: Ecto.Changeset.t() defp put_mentions(changeset, %{"mentions" => mentions}), do: put_assoc(changeset, :mentions, Enum.map(mentions, &process_mention/1)) defp put_mentions(changeset, %{mentions: mentions}), do: put_assoc(changeset, :mentions, Enum.map(mentions, &process_mention/1)) defp put_mentions(changeset, _), do: changeset # We need a changeset instead of a raw struct because of slug which is generated in changeset defp process_tag(tag) do Tag.changeset(%Tag{}, tag) end defp process_mention(mention) do Mention.changeset(%Mention{}, mention) end end