defmodule ToweropsWeb.GraphQL.Resolvers.Member do @moduledoc "GraphQL resolvers for member and invitation management." alias Towerops.Organizations alias Towerops.Organizations.Invitation alias Towerops.Repo alias ToweropsWeb.GraphQL.Resolvers.Helpers alias ToweropsWeb.ScopedResource # Roles that are allowed to send invitations @invite_roles [:owner, :admin] # Role hierarchy for checking if inviter can assign a given role @role_rank %{owner: 0, admin: 1, executive: 2, technician: 3, member: 4, viewer: 5} def list(_parent, _args, %{context: %{organization_id: org_id}}) do members = Organizations.list_organization_members(org_id) {:ok, members} end def list(_parent, _args, _resolution), do: Helpers.authentication_error() def invite(_parent, %{email: email} = args, %{context: %{organization_id: org_id, user: user}}) do role = Map.get(args, :role, "technician") with {:ok, _membership} <- authorize_invite(org_id, user.id, role) do case Organizations.create_invitation(%{ email: email, role: role, organization_id: org_id, invited_by_id: user.id }) do {:ok, invitation} -> {:ok, invitation} {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} end end end def invite(_parent, _args, _resolution), do: Helpers.authentication_error() defp authorize_invite(org_id, user_id, requested_role) do case Organizations.get_membership(org_id, user_id) do nil -> {:error, "not a member of this organization"} membership when membership.role not in @invite_roles -> {:error, "only admins and owners can send invitations"} membership -> authorize_role(membership, requested_role) end end defp authorize_role(membership, requested_role) do case parse_role(requested_role) do nil -> {:error, "invalid role: #{requested_role}"} requested_role_atom -> if @role_rank[membership.role] <= @role_rank[requested_role_atom] do {:ok, membership} else {:error, "cannot assign a role higher than your own"} end end end defp parse_role(role) when is_binary(role) do atom = String.to_existing_atom(role) if Map.has_key?(@role_rank, atom), do: atom rescue ArgumentError -> nil end def cancel_invitation(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do with {:ok, invitation} <- fetch_org_invitation(id, org_id) do case Repo.delete(invitation) do {:ok, _} -> {:ok, %{success: true, message: "Invitation cancelled"}} {:error, _} -> {:ok, %{success: false, message: "Could not cancel invitation"}} end end end def cancel_invitation(_parent, _args, _resolution), do: Helpers.authentication_error() def remove(_parent, %{id: user_id}, %{context: %{organization_id: org_id}}) do case Organizations.remove_member(org_id, user_id) do {:ok, _} -> {:ok, %{success: true, message: "Member removed"}} {:error, :cannot_remove_owner} -> {:error, "Cannot remove owner"} {:error, :not_found} -> {:error, "Member not found"} end end def remove(_parent, _args, _resolution), do: Helpers.authentication_error() def update_role(_parent, %{id: user_id, role: role}, %{context: %{organization_id: org_id}}) do case Organizations.update_member_role(org_id, user_id, role) do {:ok, membership} -> membership = Repo.preload(membership, :user) {:ok, membership} {:error, :cannot_change_owner_role} -> {:error, "Cannot change owner role"} {:error, :not_found} -> {:error, "Member not found"} {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} end end def update_role(_parent, _args, _resolution), do: Helpers.authentication_error() defp fetch_org_invitation(id, org_id) do case ScopedResource.fetch(Invitation, id, org_id) do {:ok, invitation} -> {:ok, invitation} {:error, _reason} -> {:error, "Invitation not found"} end end end