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, branding information, and membership fee settings. 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`. - `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true) - `default_membership_fee_type_id` - Default membership fee type for new members (optional) ## 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. ## Membership Fee Settings - `include_joining_cycle`: When true, members pay from their joining cycle. When false, they pay from the next full cycle after joining. - `default_membership_fee_type_id`: The membership fee type automatically assigned to new members. Can be nil if no default is set. ## 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}) # Update membership fee settings {:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: 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, :include_joining_cycle, :default_membership_fee_type_id ] end update :update do primary? true require_atomic? false accept [ :club_name, :member_field_visibility, :include_joining_cycle, :default_membership_fee_type_id ] 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] end update :update_membership_fee_settings do description "Updates the membership fee configuration" require_atomic? false accept [:include_joining_cycle, :default_membership_fee_type_id] change Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId end end validations do validate present(:club_name), on: [:create, :update] validate string_length(:club_name, min: 1), on: [:create, :update] # Validate member_field_visibility map structure and content validate fn changeset, _context -> visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) if visibility && is_map(visibility) do # Validate all values are booleans invalid_values = Enum.filter(visibility, fn {_key, value} -> not is_boolean(value) end) # Validate all keys are valid member fields valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) invalid_keys = Enum.filter(visibility, fn {key, _value} -> key not in valid_field_strings end) |> Enum.map(fn {key, _value} -> key end) cond do not Enum.empty?(invalid_values) -> {:error, field: :member_field_visibility, message: "All values in member_field_visibility must be booleans"} not Enum.empty?(invalid_keys) -> {:error, field: :member_field_visibility, message: "Invalid member field keys: #{inspect(invalid_keys)}"} true -> :ok end else :ok end end, on: [:create, :update] # Validate default_membership_fee_type_id exists if set validate fn changeset, _context -> fee_type_id = Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id) if fee_type_id do case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id) do {:ok, _} -> :ok {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} -> {:error, field: :default_membership_fee_type_id, message: "Membership fee type not found"} {:error, err} -> # Log unexpected errors (DB timeout, connection errors, etc.) require Logger Logger.warning( "Unexpected error when validating default_membership_fee_type_id: #{inspect(err)}" ) # Return generic error to user {:error, field: :default_membership_fee_type_id, message: "Could not validate membership fee type"} end else # Optional, can be nil :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." # Membership fee settings attribute :include_joining_cycle, :boolean do allow_nil? false default true public? true description "Whether to include the joining cycle in membership fee generation" end attribute :default_membership_fee_type_id, :uuid do allow_nil? true public? true description "Default membership fee type ID for new members" end timestamps() end relationships do # Optional relationship to the default membership fee type # Note: We use manual FK (default_membership_fee_type_id attribute) instead of belongs_to # to avoid circular dependency between Membership and MembershipFees domains end end