549 lines
19 KiB
Elixir
549 lines
19 KiB
Elixir
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`.
|
|
- `member_field_required` - JSONB map storing which member fields are required in forms
|
|
(e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional.
|
|
- `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)
|
|
- `join_form_enabled` - Whether the public /join page is active (default: false)
|
|
- `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is
|
|
either a member field name string (e.g. "email") or a custom field UUID. Email is always
|
|
included and always required; normalization enforces this automatically.
|
|
- `join_form_field_required` - Map of field ID => required boolean for the join form.
|
|
Email is always forced 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.
|
|
|
|
## 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 visibility and required for a single member field (e.g. from settings UI)
|
|
{:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
|
|
|
|
# Update membership fee settings
|
|
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
|
"""
|
|
# primary_read_warning?: false — We use a custom read prepare that selects only public
|
|
# attributes and explicitly excludes smtp_password. Ash warns when the primary read does
|
|
# not load all attributes; we intentionally omit the password for security.
|
|
use Ash.Resource,
|
|
domain: Mv.Membership,
|
|
data_layer: AshPostgres.DataLayer,
|
|
primary_read_warning?: false
|
|
|
|
# Used in join_form_field_ids validation (compile-time to avoid recompiling regex and list on every validation)
|
|
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
@valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
|
|
|
alias Ash.Resource.Info, as: ResourceInfo
|
|
|
|
postgres do
|
|
table "settings"
|
|
repo Mv.Repo
|
|
end
|
|
|
|
resource do
|
|
description "Global application settings (singleton resource)"
|
|
end
|
|
|
|
# Attributes excluded from the default read (sensitive data). Same pattern as smtp_password:
|
|
# read only via explicit select when needed; never loaded into default get_settings().
|
|
@excluded_from_read [:smtp_password, :oidc_client_secret]
|
|
|
|
actions do
|
|
read :read do
|
|
primary? true
|
|
|
|
# Exclude sensitive attributes (e.g. smtp_password) from default reads. Config reads
|
|
# them via explicit select when needed. Uses all attribute names minus excluded so
|
|
# the list stays correct when new attributes are added to the resource.
|
|
prepare fn query, _context ->
|
|
select_attrs =
|
|
__MODULE__
|
|
|> ResourceInfo.attribute_names()
|
|
|> MapSet.to_list()
|
|
|> Kernel.--(@excluded_from_read)
|
|
|
|
Ash.Query.select(query, select_attrs)
|
|
end
|
|
end
|
|
|
|
# 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,
|
|
:member_field_required,
|
|
:include_joining_cycle,
|
|
:default_membership_fee_type_id,
|
|
:vereinfacht_api_url,
|
|
:vereinfacht_api_key,
|
|
:vereinfacht_club_id,
|
|
:vereinfacht_app_url,
|
|
:oidc_client_id,
|
|
:oidc_base_url,
|
|
:oidc_redirect_uri,
|
|
:oidc_client_secret,
|
|
:oidc_admin_group_name,
|
|
:oidc_groups_claim,
|
|
:oidc_only,
|
|
:smtp_host,
|
|
:smtp_port,
|
|
:smtp_username,
|
|
:smtp_password,
|
|
:smtp_ssl,
|
|
:smtp_from_name,
|
|
:smtp_from_email,
|
|
:join_form_enabled,
|
|
:join_form_field_ids,
|
|
:join_form_field_required
|
|
]
|
|
|
|
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
|
|
end
|
|
|
|
update :update do
|
|
primary? true
|
|
require_atomic? false
|
|
|
|
accept [
|
|
:club_name,
|
|
:member_field_visibility,
|
|
:member_field_required,
|
|
:include_joining_cycle,
|
|
:default_membership_fee_type_id,
|
|
:vereinfacht_api_url,
|
|
:vereinfacht_api_key,
|
|
:vereinfacht_club_id,
|
|
:vereinfacht_app_url,
|
|
:oidc_client_id,
|
|
:oidc_base_url,
|
|
:oidc_redirect_uri,
|
|
:oidc_client_secret,
|
|
:oidc_admin_group_name,
|
|
:oidc_groups_claim,
|
|
:oidc_only,
|
|
:smtp_host,
|
|
:smtp_port,
|
|
:smtp_username,
|
|
:smtp_password,
|
|
:smtp_ssl,
|
|
:smtp_from_name,
|
|
:smtp_from_email,
|
|
:join_form_enabled,
|
|
:join_form_field_ids,
|
|
:join_form_field_required
|
|
]
|
|
|
|
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
|
|
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_single_member_field_visibility do
|
|
description "Atomically updates a single field in the member_field_visibility JSONB map"
|
|
require_atomic? false
|
|
|
|
argument :field, :string, allow_nil?: false
|
|
argument :show_in_overview, :boolean, allow_nil?: false
|
|
|
|
change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility
|
|
end
|
|
|
|
update :update_single_member_field do
|
|
description "Atomically updates visibility and required for a single member field"
|
|
require_atomic? false
|
|
|
|
argument :field, :string, allow_nil?: false
|
|
argument :show_in_overview, :boolean, allow_nil?: false
|
|
argument :required, :boolean, allow_nil?: false
|
|
|
|
change Mv.Membership.Setting.Changes.UpdateSingleMemberField
|
|
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 member_field_required map structure and content
|
|
validate fn changeset, _context ->
|
|
required_config = Ash.Changeset.get_attribute(changeset, :member_field_required)
|
|
|
|
if required_config && is_map(required_config) do
|
|
invalid_values =
|
|
Enum.filter(required_config, fn {_key, value} ->
|
|
not is_boolean(value)
|
|
end)
|
|
|
|
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
|
|
|
invalid_keys =
|
|
Enum.filter(required_config, 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_required,
|
|
message: "All values in member_field_required must be booleans"}
|
|
|
|
not Enum.empty?(invalid_keys) ->
|
|
{:error,
|
|
field: :member_field_required,
|
|
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
|
|
|
|
true ->
|
|
:ok
|
|
end
|
|
else
|
|
:ok
|
|
end
|
|
end,
|
|
on: [:create, :update]
|
|
|
|
# Validate join_form_field_ids: each entry must be a known member field name
|
|
# or a UUID-format string (custom field ID). Normalization (NormalizeJoinFormSettings
|
|
# change) runs before validations, so email is already present when this runs.
|
|
validate fn changeset, _context ->
|
|
field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids)
|
|
|
|
if is_list(field_ids) and field_ids != [] do
|
|
invalid_ids =
|
|
Enum.reject(field_ids, fn id ->
|
|
is_binary(id) and
|
|
(id in @valid_join_form_member_fields or Regex.match?(@uuid_pattern, id))
|
|
end)
|
|
|
|
if Enum.empty?(invalid_ids) do
|
|
:ok
|
|
else
|
|
{:error,
|
|
field: :join_form_field_ids,
|
|
message:
|
|
"Invalid field identifiers: #{inspect(invalid_ids)}. Use member field names or custom field UUIDs."}
|
|
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
|
|
# Check existence only; action is already restricted by policy (e.g. admin).
|
|
opts = [domain: Mv.MembershipFees, authorize?: false]
|
|
|
|
case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id, opts) 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."
|
|
|
|
attribute :member_field_required, :map,
|
|
allow_nil?: true,
|
|
public?: true,
|
|
description:
|
|
"Configuration for which member fields are required in forms (JSONB map). Keys are member field names (strings), values are booleans. Email is always required."
|
|
|
|
# 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
|
|
|
|
# Vereinfacht accounting software integration (can be overridden by ENV)
|
|
attribute :vereinfacht_api_url, :string do
|
|
allow_nil? true
|
|
public? true
|
|
description "Vereinfacht API base URL (e.g. https://api.verein.visuel.dev/api/v1)"
|
|
end
|
|
|
|
attribute :vereinfacht_api_key, :string do
|
|
allow_nil? true
|
|
public? false
|
|
description "Vereinfacht API key (Bearer token)"
|
|
sensitive? true
|
|
end
|
|
|
|
attribute :vereinfacht_club_id, :string do
|
|
allow_nil? true
|
|
public? true
|
|
description "Vereinfacht club ID for multi-tenancy"
|
|
end
|
|
|
|
attribute :vereinfacht_app_url, :string do
|
|
allow_nil? true
|
|
public? true
|
|
|
|
description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
|
|
end
|
|
|
|
# OIDC authentication (can be overridden by ENV)
|
|
attribute :oidc_client_id, :string do
|
|
allow_nil? true
|
|
public? true
|
|
description "OIDC client ID (e.g. from OIDC_CLIENT_ID)"
|
|
end
|
|
|
|
attribute :oidc_base_url, :string do
|
|
allow_nil? true
|
|
public? true
|
|
description "OIDC provider base URL (e.g. from OIDC_BASE_URL)"
|
|
end
|
|
|
|
attribute :oidc_redirect_uri, :string do
|
|
allow_nil? true
|
|
public? true
|
|
description "OIDC redirect URI for callback (e.g. from OIDC_REDIRECT_URI)"
|
|
end
|
|
|
|
attribute :oidc_client_secret, :string do
|
|
allow_nil? true
|
|
public? false
|
|
description "OIDC client secret (e.g. from OIDC_CLIENT_SECRET)"
|
|
sensitive? true
|
|
end
|
|
|
|
attribute :oidc_admin_group_name, :string do
|
|
allow_nil? true
|
|
public? true
|
|
description "OIDC group name that maps to Admin role (e.g. from OIDC_ADMIN_GROUP_NAME)"
|
|
end
|
|
|
|
attribute :oidc_groups_claim, :string do
|
|
allow_nil? true
|
|
public? true
|
|
description "JWT claim name for group list (e.g. from OIDC_GROUPS_CLAIM, default 'groups')"
|
|
end
|
|
|
|
attribute :oidc_only, :boolean do
|
|
allow_nil? false
|
|
default false
|
|
public? true
|
|
|
|
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
|
|
end
|
|
|
|
# SMTP configuration (can be overridden by ENV)
|
|
attribute :smtp_host, :string do
|
|
allow_nil? true
|
|
public? true
|
|
description "SMTP server hostname (e.g. smtp.example.com)"
|
|
end
|
|
|
|
attribute :smtp_port, :integer do
|
|
allow_nil? true
|
|
public? true
|
|
description "SMTP server port (e.g. 587 for TLS, 465 for SSL, 25 for plain)"
|
|
end
|
|
|
|
attribute :smtp_username, :string do
|
|
allow_nil? true
|
|
public? true
|
|
description "SMTP authentication username"
|
|
end
|
|
|
|
attribute :smtp_password, :string do
|
|
allow_nil? true
|
|
public? false
|
|
description "SMTP authentication password (sensitive)"
|
|
sensitive? true
|
|
end
|
|
|
|
attribute :smtp_ssl, :string do
|
|
allow_nil? true
|
|
public? true
|
|
description "SMTP TLS/SSL mode: 'tls', 'ssl', or 'none'"
|
|
end
|
|
|
|
attribute :smtp_from_name, :string do
|
|
allow_nil? true
|
|
public? true
|
|
|
|
description "Display name for the transactional email sender (e.g. 'Mila'). Overrides MAIL_FROM_NAME env."
|
|
end
|
|
|
|
attribute :smtp_from_email, :string do
|
|
allow_nil? true
|
|
public? true
|
|
|
|
description "Email address for the transactional email sender. Must be owned by the SMTP user. Overrides MAIL_FROM_EMAIL env."
|
|
end
|
|
|
|
# Join form (Beitrittsformular) settings
|
|
attribute :join_form_enabled, :boolean do
|
|
allow_nil? false
|
|
default false
|
|
public? true
|
|
|
|
description "When true, the public /join page is active and new members can submit a request."
|
|
end
|
|
|
|
attribute :join_form_field_ids, {:array, :string} do
|
|
allow_nil? true
|
|
default []
|
|
public? true
|
|
|
|
description "Ordered list of field IDs shown on the join form. Each entry is a member field name (e.g. 'email') or a custom field UUID. Email is always present after normalization."
|
|
end
|
|
|
|
attribute :join_form_field_required, :map do
|
|
allow_nil? true
|
|
public? true
|
|
|
|
description "Map of field ID => required boolean for the join form. Email is always true after normalization."
|
|
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
|