defmodule Mv.Membership.Setting do @moduledoc """ Ash resource representing global application settings. ## Overview Settings is a singleton resource that stores global configuration for the association, such as the club name and branding information. There should only ever be one settings record in the database. ## Attributes - `club_name` - The name of the association/club (required, cannot be empty) - `member_field_visibility` - JSONB map storing visibility configuration for member fields (e.g., `%{street: false, house_number: false}`). Fields not in the map default to `true`. ## Singleton Pattern This resource uses a singleton pattern - there should only be one settings record. The resource is designed to be read and updated, but not created or destroyed through normal CRUD operations. Initial settings should be seeded. ## Environment Variable Support The `club_name` can be set via the `ASSOCIATION_NAME` environment variable. If set, the environment variable value is used as a fallback when no database value exists. Database values always take precedence over environment variables. ## Examples # Get current settings {:ok, settings} = Mv.Membership.get_settings() settings.club_name # => "My Club" # Update club name {:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"}) # Update member field visibility {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{street: false, house_number: false}) """ use Ash.Resource, domain: Mv.Membership, data_layer: AshPostgres.DataLayer postgres do table "settings" repo Mv.Repo end resource do description "Global application settings (singleton resource)" end actions do defaults [:read] # Internal create action - not exposed via code interface # Used only as fallback in get_settings/0 if settings don't exist # Settings should normally be created via seed script create :create do accept [:club_name, :member_field_visibility] end update :update do primary? true require_atomic? false accept [:club_name, :member_field_visibility] end update :update_member_field_visibility do description "Updates the visibility configuration for member fields in the overview" require_atomic? false accept [:member_field_visibility] change fn changeset, _context -> visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) if visibility && is_map(visibility) do valid_fields = Mv.Constants.member_fields() # Normalize keys to atoms (JSONB may return string keys) invalid_keys = Enum.filter(visibility, fn {key, _value} -> atom_key = if is_atom(key) do key else try do String.to_existing_atom(key) rescue ArgumentError -> nil end end atom_key && atom_key not in valid_fields end) |> Enum.map(fn {key, _value} -> key end) if Enum.empty?(invalid_keys) do changeset else Ash.Changeset.add_error( changeset, field: :member_field_visibility, message: "Invalid member field keys: #{inspect(invalid_keys)}" ) end else changeset end end end end validations do validate present(:club_name), on: [:create, :update] validate string_length(:club_name, min: 1), on: [:create, :update] # Validate that member_field_visibility map contains only boolean values # This allows dynamic fields without hardcoding specific field names validate fn changeset, _context -> visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) if visibility && is_map(visibility) do invalid_entries = Enum.filter(visibility, fn {_key, value} -> not is_boolean(value) end) if Enum.empty?(invalid_entries) do :ok else {:error, field: :member_field_visibility, message: "All values in member_field_visibility must be booleans"} end else :ok end end, on: [:create, :update] end attributes do uuid_primary_key :id attribute :club_name, :string, allow_nil?: false, public?: true, description: "The name of the association/club", constraints: [ trim?: true, min_length: 1 ] attribute :member_field_visibility, :map, allow_nil?: true, public?: true, description: "Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans." timestamps() end end