# Bootstrap seeds: run in all environments (dev, test, prod). # Creates only data required for system startup: fee types, custom fields, # roles, admin user, system user, global settings. No members, no groups. alias Mv.Accounts alias Mv.Membership alias Mv.MembershipFees.MembershipFeeType require Ash.Query # 1. Membership fee types (authorize?: false for bootstrap) # Names without interval to avoid duplication in UI; interval is shown separately. fee_type_configs = [ %{ name: "Standard", amount: Decimal.new("120.00"), interval: :yearly, description: "Standard jährlicher Mitgliedsbeitrag" }, %{ name: "Ermäßigt", amount: Decimal.new("80.00"), interval: :yearly, description: "Ermäßigter jährlicher Mitgliedsbeitrag" }, %{ name: "Unterstützer", amount: Decimal.new("60.00"), interval: :half_yearly, description: "Unterstützerbeitrag halbjährlich" }, %{ name: "Fördermitglied", amount: Decimal.new("30.00"), interval: :quarterly, description: "Fördermitgliedschaft quartalsweise" }, %{ name: "Probemitgliedschaft", amount: Decimal.new("10.00"), interval: :monthly, description: "Probemitgliedschaft monatlich" } ] for attrs <- fee_type_configs do MembershipFeeType |> Ash.Changeset.for_create(:create, attrs) |> Ash.create!( upsert?: true, upsert_identity: :unique_name, authorize?: false, domain: Mv.MembershipFees ) end # Resolve default fee type (Standard, 120€ yearly) for settings # Filter by name and interval to avoid ambiguity if multiple "Standard" types exist default_fee_type = Mv.MembershipFees.MembershipFeeType |> Ash.Query.filter(name == "Standard" and interval == :yearly) |> Ash.read_one!(authorize?: false, domain: Mv.MembershipFees) # 2. Custom fields (authorize?: false for bootstrap) # Only Geburtsdatum is shown in overview by default; others hidden to avoid clutter. custom_field_configs = [ %{ name: "Geburtsdatum", value_type: :date, description: "Geburtsdatum der/des Mitglieds", required: false, show_in_overview: true }, %{ name: "Datenschutzerklärung akzeptiert", value_type: :boolean, description: "Angabe, ob Datenschutzerklärung akzeptiert wurde", required: false, show_in_overview: false }, %{ name: "SEPA-Mandat", value_type: :boolean, description: "SEPA-Lastschriftmandat erteilt", required: false, show_in_overview: false }, %{ name: "Rechnungs-E-Mail", value_type: :email, description: "E-Mail-Adresse für Rechnungen", required: false, show_in_overview: false }, %{ name: "IBAN", value_type: :string, description: "IBAN für Lastschrift", required: false, show_in_overview: false }, %{ name: "Stunden ehrenamtlich", value_type: :integer, description: "Geleistete ehrenamtliche Stunden", required: false, show_in_overview: false } ] for attrs <- custom_field_configs do Membership.create_custom_field!( attrs, upsert?: true, upsert_identity: :unique_name, authorize?: false ) end # 3. Admin email and password fallback for dev/test admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" System.put_env("ADMIN_EMAIL", admin_email) if Mix.env() in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and is_nil(System.get_env("ADMIN_PASSWORD_FILE")) do System.put_env("ADMIN_PASSWORD", "testpassword") end # 4. Authorization roles (German descriptions) role_configs = [ %{ name: "Mitglied", description: "Standardrolle für Mitglieder mit Zugriff nur auf die eigenen Daten", permission_set_name: "own_data", is_system_role: true }, %{ name: "Vorstand", description: "Vorstandsmitglied mit Lesezugriff auf alle Mitgliederdaten", permission_set_name: "read_only", is_system_role: false }, %{ name: "Kassenwart", description: "Kassenwart mit voller Mitglieder- und Zahlungsverwaltung", permission_set_name: "normal_user", is_system_role: false }, %{ name: "Buchhaltung", description: "Buchhaltung mit Lesezugriff für Prüfungen", permission_set_name: "read_only", is_system_role: false }, %{ name: "Admin", description: "Administrator mit uneingeschränktem Zugriff", permission_set_name: "admin", is_system_role: false } ] Enum.each(role_configs, fn role_data -> role_name = role_data.name case Mv.Authorization.Role |> Ash.Query.filter(name == ^role_name) |> Ash.read_one(authorize?: false, domain: Mv.Authorization) do {:ok, existing_role} when not is_nil(existing_role) -> if existing_role.permission_set_name != role_data.permission_set_name or existing_role.description != role_data.description do existing_role |> Ash.Changeset.for_update(:update_role, %{ description: role_data.description, permission_set_name: role_data.permission_set_name }) |> Ash.update!(authorize?: false, domain: Mv.Authorization) end {:ok, nil} -> Mv.Authorization.Role |> Ash.Changeset.for_create(:create_role_with_system_flag, role_data) |> Ash.create!(authorize?: false, domain: Mv.Authorization) {:error, error} -> IO.puts("Warning: Failed to check for role #{role_data.name}: #{inspect(error)}") end end) admin_role = case Mv.Authorization.Role |> Ash.Query.filter(name == "Admin") |> Ash.read_one(authorize?: false, domain: Mv.Authorization) do {:ok, role} when not is_nil(role) -> role _ -> nil end if is_nil(admin_role) do raise "Failed to create or find admin role. Cannot proceed with bootstrap." end # 5. Admin user Mv.Release.seed_admin() admin_user_with_role = case Accounts.User |> Ash.Query.filter(email == ^admin_email) |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do {:ok, user} when not is_nil(user) -> user |> Ash.load!(:role, authorize?: false) {:ok, nil} -> raise "Admin user not found after creation/assignment" {:error, error} -> raise "Failed to load admin user: #{inspect(error)}" end # 6. System user system_user_email = Mv.Helpers.SystemActor.system_user_email() case Accounts.User |> Ash.Query.filter(email == ^system_user_email) |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do {:ok, existing_system_user} when not is_nil(existing_system_user) -> existing_system_user |> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.update!(authorize?: false) {:ok, nil} -> Accounts.create_user!(%{email: system_user_email}, upsert?: true, upsert_identity: :unique_email, authorize?: false ) |> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.update!(authorize?: false) {:error, error} -> IO.puts("Warning: Failed to create system user: #{inspect(error)}") IO.puts("SystemActor will fall back to admin user (#{admin_email})") end # 7. Global settings (with default membership fee type and default field visibility) # By default hide exit_date, notes, country, membership_fee_start_date in overview (like exit_date). default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name" default_hidden_in_overview = %{ "exit_date" => false, "notes" => false, "country" => false, "membership_fee_start_date" => false } case Membership.get_settings() do {:ok, existing_settings} -> updates = %{} |> then(fn acc -> if existing_settings.club_name != default_club_name, do: Map.put(acc, :club_name, default_club_name), else: acc end) |> then(fn acc -> if existing_settings.default_membership_fee_type_id != default_fee_type.id, do: Map.put(acc, :default_membership_fee_type_id, default_fee_type.id), else: acc end) |> then(fn acc -> visibility_config = existing_settings.member_field_visibility || %{} # Ensure default-hidden fields are set if not already present (string or atom keys) has_key = fn vis, k -> try do Map.has_key?(vis, k) or Map.has_key?(vis, String.to_existing_atom(k)) rescue ArgumentError -> Map.has_key?(vis, k) end end merged = Enum.reduce(default_hidden_in_overview, visibility_config, fn {key, val}, vis -> if has_key.(vis, key), do: vis, else: Map.put(vis, key, val) end) if merged != visibility_config, do: Map.put(acc, :member_field_visibility, merged), else: acc end) if map_size(updates) > 0 do {:ok, _} = Membership.update_settings(existing_settings, updates) end {:ok, nil} -> {:ok, _} = Membership.Setting |> Ash.Changeset.for_create(:create, %{ club_name: default_club_name, member_field_visibility: default_hidden_in_overview, default_membership_fee_type_id: default_fee_type.id }) |> Ash.create!() end IO.puts("✅ Bootstrap seeds completed.") IO.puts( " - Fee types: 5 (Standard, Ermäßigt, Unterstützer, Fördermitglied, Probemitgliedschaft)" ) IO.puts( " - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)" ) IO.puts(" - Roles: 5 (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)") IO.puts(" - Default fee type: Standard (120€ yearly)")