mitgliederverwaltung/lib/membership/setting.ex
Moritz 162d06da21
All checks were successful
continuous-integration/drone/push Build is passing
feat: implement automatic cycle generation for members
- Add CycleGenerator module with advisory lock mechanism
- Add SetMembershipFeeStartDate change for auto-calculation
- Extend Settings with include_joining_cycle and default_membership_fee_type_id
- Add scheduled job skeleton for future Oban integration
2025-12-11 21:26:58 +01:00

186 lines
6.3 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`.
- `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]
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]
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