defmodule Mobilizon.GraphQL.Resolvers.Group do @moduledoc """ Handles the group-related GraphQL calls. """ import Mobilizon.Users.Guards alias Mobilizon.Config alias Mobilizon.{Actors, Events} alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Federation.ActivityPub.Actions alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor alias Mobilizon.GraphQL.API alias Mobilizon.Users.User alias Mobilizon.Web.Upload import Mobilizon.Web.Gettext require Logger @doc """ Find a group """ @spec find_group( any, %{:preferred_username => binary, optional(any) => any}, Absinthe.Resolution.t() ) :: {:error, :group_not_found} | {:ok, Actor.t()} def find_group( parent, %{preferred_username: name} = args, %{ context: %{ current_actor: %Actor{id: actor_id} } } ) do case ActivityPubActor.find_or_make_group_from_nickname(name) do {:ok, %Actor{id: group_id, suspended: false} = group} -> if Actors.is_member?(actor_id, group_id) do {:ok, group} else find_group(parent, args, nil) end {:ok, %Actor{}} -> {:error, :group_not_found} {:error, err} -> Logger.debug("Unable to find group, #{inspect(err)}") {:error, :group_not_found} end end def find_group(_parent, %{preferred_username: name}, _resolution) do case ActivityPubActor.find_or_make_group_from_nickname(name) do {:ok, %Actor{suspended: false} = actor} -> %Actor{} = actor = restrict_fields_for_non_member_request(actor) {:ok, actor} {:ok, %Actor{}} -> {:error, :group_not_found} {:error, err} -> Logger.debug("Unable to find group, #{inspect(err)}") {:error, :group_not_found} end end def find_group_by_id(_parent, %{id: id}, %{ context: %{ current_actor: %Actor{id: actor_id} } }) do with %Actor{suspended: false, id: group_id} = group <- Actors.get_actor_with_preload(id), true <- Actors.is_member?(actor_id, group_id) do {:ok, group} else _ -> {:error, :group_not_found} end end def find_group_by_id(_parent, _args, _resolution) do {:error, :group_not_found} end @doc """ Get a group """ @spec get_group(any(), map(), Absinthe.Resolution.t()) :: {:ok, Actor.t()} | {:error, String.t()} def get_group(_parent, %{id: id}, %{context: %{current_user: %User{role: role}}}) do case Actors.get_actor_with_preload(id, true) do %Actor{type: :Group, suspended: suspended} = actor -> if suspended == false or is_moderator(role) do {:ok, actor} else {:error, dgettext("errors", "Group with ID %{id} not found", id: id)} end nil -> {:error, dgettext("errors", "Group with ID %{id} not found", id: id)} end end @doc """ Lists all groups """ @spec list_groups(any(), map(), Absinthe.Resolution.t()) :: {:ok, Page.t(Actor.t())} | {:error, String.t()} def list_groups( _parent, %{ preferred_username: preferred_username, name: name, domain: domain, local: local, suspended: suspended, page: page, limit: limit }, %{ context: %{current_user: %User{role: role}} } ) when is_moderator(role) do {:ok, Actors.list_actors(:Group, preferred_username, name, domain, local, suspended, page, limit)} end def list_groups(_parent, _args, _resolution), do: {:error, dgettext("errors", "You may not list groups unless moderator.")} # TODO Move me to somewhere cleaner @spec save_attached_pictures(map()) :: map() defp save_attached_pictures(args) do Enum.reduce([:avatar, :banner], args, fn key, args -> if is_map(args) && Map.has_key?(args, key) && !is_nil(args[key][:media]) do pic = args[key][:media] with {:ok, %{name: name, url: url, content_type: content_type, size: _size}} <- Upload.store(pic.file, type: key, description: pic.alt) do Logger.debug("Uploaded #{name} to #{url}") Map.put(args, key, %{name: name, url: url, content_type: content_type}) end else Logger.debug("No picture upload") args end end) end @doc """ Create a new group. The creator is automatically added as admin """ @spec create_group(any(), map(), Absinthe.Resolution.t()) :: {:ok, Actor.t()} | {:error, String.t()} def create_group( _parent, args, %{ context: %{ current_actor: %Actor{id: creator_actor_id} = creator_actor, current_user: %User{role: role} = _resolution } } ) do if can_create_group?(role) do args = args |> Map.update(:preferred_username, "", &String.downcase/1) |> Map.put(:creator_actor, creator_actor) |> Map.put(:creator_actor_id, creator_actor_id) with {:picture, args} when is_map(args) <- {:picture, save_attached_pictures(args)}, {:ok, _activity, %Actor{type: :Group} = group} <- API.Groups.create_group(args) do {:ok, group} else {:picture, {:error, :file_too_large}} -> {:error, dgettext("errors", "The provided picture is too heavy")} {:error, err} -> {:error, err} end else {:error, dgettext("errors", "Only admins can create groups")} end end def create_group(_parent, _args, _resolution) do {:error, "You need to be logged-in to create a group"} end @spec can_create_group?(atom()) :: boolean() defp can_create_group?(role) do if Config.only_admin_can_create_groups?() do is_admin(role) else true end end @doc """ Update a group. The creator is automatically added as admin """ @spec update_group(any(), map(), Absinthe.Resolution.t()) :: {:ok, Actor.t()} | {:error, String.t()} def update_group( _parent, %{id: group_id} = args, %{ context: %{ current_actor: %Actor{} = updater_actor } } ) do if Actors.is_administrator?(updater_actor.id, group_id) do args = Map.put(args, :updater_actor, updater_actor) case save_attached_pictures(args) do {:error, :file_too_large} -> {:error, dgettext("errors", "The provided picture is too heavy")} args when is_map(args) -> case API.Groups.update_group(args) do {:ok, _activity, %Actor{type: :Group} = group} -> {:ok, group} {:error, %Ecto.Changeset{} = changeset} -> {:error, changeset} {:error, err} -> Logger.info("Failed to update group #{inspect(group_id)}") Logger.debug(inspect(err)) {:error, dgettext("errors", "Failed to update the group")} end end else Logger.info( "Profile #{updater_actor.id} tried to update group #{group_id}, but they are not admin" ) {:error, dgettext("errors", "Profile is not administrator for the group")} end end def update_group(_parent, _args, _resolution) do {:error, dgettext("errors", "You need to be logged-in to update a group")} end @doc """ Delete an existing group """ @spec delete_group(any(), map(), Absinthe.Resolution.t()) :: {:ok, %{id: integer()}} | {:error, String.t()} def delete_group( _parent, %{group_id: group_id}, %{ context: %{ current_actor: %Actor{id: actor_id} = actor } } ) do with {:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id), {:ok, %Member{} = member} <- Actors.get_member(actor_id, group.id), {:is_admin, true} <- {:is_admin, Member.is_administrator(member)}, {:ok, _activity, group} <- Actions.Delete.delete(group, actor, true) do {:ok, %{id: group.id}} else {:error, :group_not_found} -> {:error, dgettext("errors", "Group not found")} {:error, :member_not_found} -> {:error, dgettext("errors", "Current profile is not a member of this group")} {:is_admin, false} -> {:error, dgettext("errors", "Current profile is not an administrator of the selected group")} end end def delete_group(_parent, _args, _resolution) do {:error, dgettext("errors", "You need to be logged-in to delete a group")} end @doc """ Join an existing group """ @spec join_group(any(), map(), Absinthe.Resolution.t()) :: {:ok, Member.t()} | {:error, String.t()} def join_group(_parent, %{group_id: group_id} = args, %{ context: %{current_actor: %Actor{} = actor} }) do with {:ok, %Actor{type: :Group} = group} <- Actors.get_group_by_actor_id(group_id), {:error, :member_not_found} <- Actors.get_member(actor.id, group.id), {:is_able_to_join, true} <- {:is_able_to_join, Member.can_be_joined(group)}, {:ok, _activity, %Member{} = member} <- Actions.Join.join(group, actor, true, args) do {:ok, member} else {:error, :group_not_found} -> {:error, dgettext("errors", "Group not found")} {:is_able_to_join, false} -> {:error, dgettext("errors", "You cannot join this group")} {:ok, %Member{}} -> {:error, dgettext("errors", "You are already a member of this group")} end end def join_group(_parent, _args, _resolution) do {:error, dgettext("errors", "You need to be logged-in to join a group")} end @doc """ Leave a existing group """ @spec leave_group(any(), map(), Absinthe.Resolution.t()) :: {:ok, Member.t()} | {:error, String.t()} def leave_group( _parent, %{group_id: group_id}, %{ context: %{ current_actor: %Actor{} = actor } } ) do with {:group, %Actor{type: :Group} = group} <- {:group, Actors.get_actor(group_id)}, {:ok, _activity, %Member{} = member} <- Actions.Leave.leave(group, actor, true) do {:ok, member} else {:error, :member_not_found} -> {:error, dgettext("errors", "Member not found")} {:group, nil} -> {:error, dgettext("errors", "Group not found")} # Actions.Leave.leave can also return nil if the member isn't found. Probably something to fix. nil -> {:error, dgettext("errors", "Member not found")} {:error, :is_not_only_admin} -> {:error, dgettext("errors", "You can't leave this group because you are the only administrator")} end end def leave_group(_parent, _args, _resolution) do {:error, dgettext("errors", "You need to be logged-in to leave a group")} end @doc """ Follow a group """ @spec follow_group(any(), map(), Absinthe.Resolution.t()) :: {:ok, Follower.t()} | {:error, String.t()} def follow_group(_parent, %{group_id: group_id, notify: _notify}, %{ context: %{current_actor: %Actor{} = actor} }) do case Actors.get_actor(group_id) do %Actor{type: :Group} = group -> case Actions.Follow.follow(actor, group) do {:ok, _activity, %Follower{} = follower} -> {:ok, follower} {:error, :already_following} -> {:error, dgettext("errors", "You are already following this group")} end nil -> {:error, dgettext("errors", "Group not found")} end end def follow_group(_parent, _args, _resolution) do {:error, dgettext("errors", "You need to be logged-in to follow a group")} end @doc """ Update a group follow """ @spec update_group_follow(any(), map(), Absinthe.Resolution.t()) :: {:ok, Member.t()} | {:error, String.t()} def update_group_follow(_parent, %{follow_id: follow_id, notify: notify}, %{ context: %{current_actor: %Actor{} = actor} }) do case Actors.get_follower(follow_id) do %Follower{} = follower -> if follower.actor_id == actor.id do # Update notify Actors.update_follower(follower, %{notify: notify}) else {:error, dgettext("errors", "Follow does not match your account")} end nil -> {:error, dgettext("errors", "Follow not found")} end end def update_group_follow(_parent, _args, _resolution) do {:error, dgettext("errors", "You need to be logged-in to update a group follow")} end @doc """ Unfollow a group """ @spec unfollow_group(any(), map(), Absinthe.Resolution.t()) :: {:ok, Follower.t()} | {:error, String.t()} def unfollow_group(_parent, %{group_id: group_id}, %{ context: %{current_actor: %Actor{} = actor} }) do case Actors.get_actor(group_id) do %Actor{type: :Group} = group -> with {:ok, _activity, %Follower{} = follower} <- Actions.Follow.unfollow(actor, group) do {:ok, follower} end nil -> {:error, dgettext("errors", "Group not found")} end end def unfollow_group(_parent, _args, _resolution) do {:error, dgettext("errors", "You need to be logged-in to unfollow a group")} end @spec find_events_for_group(Actor.t(), map(), Absinthe.Resolution.t()) :: {:ok, Page.t(Event.t())} def find_events_for_group( %Actor{id: group_id} = group, %{ page: page, limit: limit } = args, %{ context: %{ current_user: %User{}, current_actor: %Actor{id: actor_id} } } ) do if Actors.is_member?(actor_id, group_id) do {:ok, Events.list_organized_events_for_group( group, :all, Map.get(args, :after_datetime), Map.get(args, :before_datetime), page, limit )} else find_events_for_group(group, args, nil) end end def find_events_for_group( %Actor{} = group, %{ page: page, limit: limit } = args, _resolution ) do {:ok, Events.list_organized_events_for_group( group, :public, Map.get(args, :after_datetime), Map.get(args, :before_datetime), page, limit )} end @spec restrict_fields_for_non_member_request(Actor.t()) :: Actor.t() defp restrict_fields_for_non_member_request(%Actor{} = group) do %Actor{ group | followers: [], followings: [], organized_events: [], comments: [], feed_tokens: [] } end end