defmodule Mv.Accounts.User.Changes.AssignDefaultRole do @moduledoc """ Assigns the default "Mitglied" role to new users if no role is explicitly provided. This change runs during user creation actions (`:create_user`, `:register_with_password`, `:register_with_rauthy`) and ensures that all users have a role assigned. ## Behavior - Skips assignment if `role_id` is already set in the changeset, data, or arguments - Loads the "Mitglied" role without authorization (safe for system operations) - Returns unchanged changeset if "Mitglied" role doesn't exist (test environments) - Adds error to changeset on unexpected failures ## Important Notes - Works with upserts: When combined with `upsert_fields`, only new users get the role - Uses `authorize?: false` to avoid circular dependencies during user creation - The "Mitglied" role must exist (created by seeds or migration) ## Examples # Automatically assigns "Mitglied" role during user creation: {:ok, user} = User |> Ash.Changeset.for_create(:create_user, %{email: "new@example.com"}) |> Ash.create() # User now has "Mitglied" role assigned {:ok, user_with_role} = Ash.load(user, :role) assert user_with_role.role.name == "Mitglied" # Skips assignment if role is already set: {:ok, user} = User |> Ash.Changeset.for_create(:create_user, %{email: "admin@example.com", role_id: admin_role.id}) |> Ash.create() # User has the explicitly set role, not "Mitglied" {:ok, user_with_role} = Ash.load(user, :role) assert user_with_role.role.name == "Admin" """ use Ash.Resource.Change @impl true @spec change(Ash.Changeset.t(), keyword(), map()) :: Ash.Changeset.t() def change(changeset, _opts, _context) do # Check role_id in changeset attributes (for new assignments) role_id_in_changeset = Ash.Changeset.get_attribute(changeset, :role_id) # Check role_id in existing data (for upserts) role_id_in_data = Map.get(changeset.data, :role_id) # Check if role is being set via argument role_arg = Ash.Changeset.get_argument(changeset, :role) # Check if role relationship is already being managed # Relationships are stored as a list of tuples: [{record_or_changes, opts}] role_relationship = Map.get(changeset.relationships || %{}, :role) # Skip if role is already set anywhere (changeset, data, argument, or relationship) has_role = not is_nil(role_id_in_changeset) or not is_nil(role_id_in_data) or not is_nil(role_arg) or (not is_nil(role_relationship) and role_relationship != []) if has_role do changeset else assign_default_role(changeset) end end @spec assign_default_role(Ash.Changeset.t()) :: Ash.Changeset.t() defp assign_default_role(changeset) do # Load the "Mitglied" role without authorization # This is safe because: # 1. We're only reading a public system role (no sensitive data) # 2. This runs during user creation (bootstrap phase) # 3. Using SystemActor here would create circular dependency (SystemActor needs a user) case Mv.Authorization.Role.get_mitglied_role() do {:ok, %Mv.Authorization.Role{} = mitglied_role} -> # Assign the role using manage_relationship # Note: :append_and_remove is the correct type for Ash 2.0+ (replaces :replace) Ash.Changeset.manage_relationship(changeset, :role, mitglied_role, type: :append_and_remove ) {:ok, nil} -> # Role doesn't exist - skip assignment (common in test environments) # In production, the migration will have created the role changeset {:error, error} -> # Unexpected error during role lookup Ash.Changeset.add_error(changeset, field: :role_id, message: "Failed to load default role: #{inspect(error)}" ) end end end