diff --git a/lib/graphql/resolvers/invitation.ex b/lib/graphql/resolvers/invitation.ex new file mode 100644 index 000000000..1a473d3d6 --- /dev/null +++ b/lib/graphql/resolvers/invitation.ex @@ -0,0 +1,57 @@ +defmodule Mobilizon.GraphQL.Resolvers.Invitation do + @moduledoc """ + Handles the invitation-related GraphQL calls + """ + + alias Mobilizon.Actors + alias Mobilizon.Actors.Actor + alias Mobilizon.Invitations + import Mobilizon.Web.Gettext + + defp authorize_group_admin(%Actor{id: actor_id}, group_id) do + if Actors.administrator?(actor_id, group_id) do + :ok + else + {:error, dgettext("errors", "Profile is not administrator for the group")} + end + end + + def create_invitation(_parent, %{group_id: group_id} = args, %{ + context: %{current_actor: %Actor{} = updater_actor} + }) do + with :ok <- authorize_group_admin(updater_actor, group_id), + {:ok, invitation} <- Invitations.create_invitation(args) do + {:ok, invitation} + else + {:error, _} -> + {:error, dgettext("errors", "could not create invitation")} + + error -> + error + end + end + + def list_invitations(_parent, %{group_id: group_id}, %{ + context: %{current_actor: %Actor{} = updater_actor} + }) do + with :ok <- authorize_group_admin(updater_actor, group_id) do + {:ok, Invitations.list_invitations(group_id)} + end + end + + def update_invitation(_parent, %{group_id: group_id, token: token} = args, %{ + context: %{current_actor: %Actor{} = updater_actor} + }) do + with :ok <- authorize_group_admin(updater_actor, group_id) do + Invitations.update_invitation_by_token(group_id, token, args) + end + end + + def delete_invitation(_parent, %{group_id: group_id, token: token}, %{ + context: %{current_actor: %Actor{} = updater_actor} + }) do + with :ok <- authorize_group_admin(updater_actor, group_id) do + Invitations.delete_invitation_by_token(group_id, token) + end + end +end diff --git a/lib/graphql/schema.ex b/lib/graphql/schema.ex index c136c4215..15b3ef191 100644 --- a/lib/graphql/schema.ex +++ b/lib/graphql/schema.ex @@ -56,6 +56,7 @@ defmodule Mobilizon.GraphQL.Schema do import_types(Schema.FollowedGroupActivityType) import_types(Schema.AuthApplicationType) import_types(Schema.ConversationType) + import_types(Schema.InvitationType) @desc "A struct containing the id of the deleted object" object :deleted_object do @@ -177,6 +178,7 @@ defmodule Mobilizon.GraphQL.Schema do import_fields(:post_queries) import_fields(:statistics_queries) import_fields(:auth_application_queries) + import_fields(:invitation_queries) end @desc """ @@ -205,6 +207,7 @@ defmodule Mobilizon.GraphQL.Schema do import_fields(:push_mutations) import_fields(:activity_setting_mutations) import_fields(:auth_application_mutations) + import_fields(:invitation_mutations) end @desc """ diff --git a/lib/graphql/schema/invitation.ex b/lib/graphql/schema/invitation.ex new file mode 100644 index 000000000..bc8fbf74b --- /dev/null +++ b/lib/graphql/schema/invitation.ex @@ -0,0 +1,50 @@ +defmodule Mobilizon.GraphQL.Schema.InvitationType do + @moduledoc """ + Schema representation for Invitation + """ + use Absinthe.Schema.Notation + alias Mobilizon.GraphQL.Resolvers.Invitation + + @desc "A local invitation to a Mobilizon group" + object :invitation do + meta(:authorize, :user) + field(:token, :string, description: "The invitation token") + field(:label, :string, description: "The invitation label") + end + + object :invitation_mutations do + @desc "Create an invitation for a group" + field :create_invitation, type: :invitation do + arg(:group_id, non_null(:id), description: "ID of the group") + arg(:label, :string, description: "Label") + middleware(Rajska.QueryAuthorization, permit: :user, scope: false) + resolve(&Invitation.create_invitation/3) + end + + @desc "Update an invitation for a group" + field :update_invitation, type: :invitation do + arg(:group_id, non_null(:id), description: "ID of the group") + arg(:token, :string, description: "Token") + arg(:label, :string, description: "Label") + middleware(Rajska.QueryAuthorization, permit: :user, scope: false) + resolve(&Invitation.update_invitation/3) + end + + @desc "Delete an invitation for a group" + field :delete_invitation, type: :invitation do + arg(:group_id, non_null(:id), description: "ID of the group") + arg(:token, :string, description: "Token") + middleware(Rajska.QueryAuthorization, permit: :user, scope: false) + resolve(&Invitation.delete_invitation/3) + end + end + + object :invitation_queries do + @desc "List all invitations for a group" + field :list_invitations, non_null(list_of(:invitation)) do + arg(:group_id, non_null(:id), description: "ID of the group") + middleware(Rajska.QueryAuthorization, permit: :user, scope: false) + resolve(&Invitation.list_invitations/3) + end + end +end diff --git a/lib/mobilizon/invitations/invitation.ex b/lib/mobilizon/invitations/invitation.ex new file mode 100644 index 000000000..293d19e45 --- /dev/null +++ b/lib/mobilizon/invitations/invitation.ex @@ -0,0 +1,29 @@ +defmodule Mobilizon.Invitation do + @moduledoc """ + Represents a local invitation to a group. + """ + + use Ecto.Schema + import Ecto.Changeset + alias Mobilizon.Actors.Actor + + schema "invitations" do + # We need read_after_writes because the uuid is generated by + # the database gen_random_uuid() function + field :token, :string, read_after_writes: true + + # label can't be NULL in database + # default: "" permit to set it to "" if label is nil + field :label, :string, default: "" + + belongs_to :group, Actor + + timestamps() + end + + def changeset(invitation, attrs) do + invitation + |> cast(attrs, [:label, :group_id]) + |> validate_required([:group_id]) + end +end diff --git a/lib/mobilizon/invitations/invitations.ex b/lib/mobilizon/invitations/invitations.ex new file mode 100644 index 000000000..1b38ff6f3 --- /dev/null +++ b/lib/mobilizon/invitations/invitations.ex @@ -0,0 +1,42 @@ +defmodule Mobilizon.Invitations do + @moduledoc """ + The Invitations context. + """ + + alias Mobilizon.Invitation + alias Mobilizon.Storage.Repo + import Ecto.Query + + def get_invitation(id), do: Repo.get(Invitation, id) + + def create_invitation(attrs \\ %{}) do + %Invitation{} + |> Invitation.changeset(attrs) + |> Repo.insert() + end + + def update_invitation_by_token(group_id, token, attrs) do + case Repo.get_by(Mobilizon.Invitation, token: token, group_id: group_id) do + nil -> + {:error, "Invitation not found"} + + %Mobilizon.Invitation{} = invitation -> + invitation + |> Invitation.changeset(attrs) + |> Repo.update() + end + end + + def delete_invitation_by_token(group_id, token) do + case Repo.get_by(Mobilizon.Invitation, token: token, group_id: group_id) do + nil -> {:error, "Invitation not found"} + invitation -> Repo.delete(invitation) + end + end + + def list_invitations(group_id) do + Invitation + |> where([i], i.group_id == ^group_id) + |> Repo.all() + end +end diff --git a/priv/repo/migrations/20251027133219_create_invitations.exs b/priv/repo/migrations/20251027133219_create_invitations.exs new file mode 100644 index 000000000..8cc3e7a6f --- /dev/null +++ b/priv/repo/migrations/20251027133219_create_invitations.exs @@ -0,0 +1,15 @@ +defmodule Mobilizon.Storage.Repo.Migrations.CreateInvitations do + use Ecto.Migration + + def change do + create table(:invitations) do + add(:label, :string, default: "", null: false) + add(:token, :string, default: fragment("gen_random_uuid()"), null: false) + add(:group_id, references(:actors, on_delete: :delete_all), null: false) + + timestamps() + end + + create(unique_index(:invitations, [:token])) + end +end