Merge branch 'main' into bugfix/274_required_custom_fields
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
08f563a412
76 changed files with 13847 additions and 1688 deletions
|
|
@ -84,7 +84,7 @@ defmodule Mv.Membership.Member do
|
|||
argument :user, :map, allow_nil?: true
|
||||
|
||||
# Accept member fields plus membership_fee_type_id (belongs_to FK)
|
||||
accept @member_fields ++ [:membership_fee_type_id]
|
||||
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
|
||||
|
||||
change manage_relationship(:custom_field_values, type: :create)
|
||||
|
||||
|
|
@ -105,6 +105,31 @@ defmodule Mv.Membership.Member do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:user)]
|
||||
end
|
||||
|
||||
# Auto-assign default membership fee type if not explicitly set
|
||||
change Mv.Membership.Member.Changes.SetDefaultMembershipFeeType
|
||||
|
||||
# Auto-calculate membership_fee_start_date if not manually set
|
||||
# Requires both join_date and membership_fee_type_id to be present
|
||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
||||
|
||||
# Trigger cycle generation after member creation
|
||||
# Only runs if membership_fee_type_id is set
|
||||
# Note: Cycle generation runs asynchronously to not block the action,
|
||||
# but in test environment it runs synchronously for DB sandbox compatibility
|
||||
change after_transaction(fn _changeset, result, _context ->
|
||||
case result do
|
||||
{:ok, member} ->
|
||||
if member.membership_fee_type_id && member.join_date do
|
||||
handle_cycle_generation(member)
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
|
||||
result
|
||||
end)
|
||||
end
|
||||
|
||||
update :update_member do
|
||||
|
|
@ -118,7 +143,7 @@ defmodule Mv.Membership.Member do
|
|||
argument :user, :map, allow_nil?: true
|
||||
|
||||
# Accept member fields plus membership_fee_type_id (belongs_to FK)
|
||||
accept @member_fields ++ [:membership_fee_type_id]
|
||||
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
|
||||
|
||||
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
||||
|
||||
|
|
@ -145,6 +170,69 @@ defmodule Mv.Membership.Member do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:user)]
|
||||
end
|
||||
|
||||
# Validate that membership fee type changes only allow same-interval types
|
||||
change Mv.MembershipFees.Changes.ValidateSameInterval do
|
||||
where [changing(:membership_fee_type_id)]
|
||||
end
|
||||
|
||||
# Auto-calculate membership_fee_start_date when membership_fee_type_id is set
|
||||
# and membership_fee_start_date is not already set
|
||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
||||
where [changing(:membership_fee_type_id)]
|
||||
end
|
||||
|
||||
# Trigger cycle regeneration when membership_fee_type_id changes
|
||||
# This deletes future unpaid cycles and regenerates them with the new type/amount
|
||||
# Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
|
||||
# CycleGenerator uses advisory locks and transactions internally to prevent race conditions
|
||||
# Notifications are returned to Ash and sent automatically after commit
|
||||
change after_action(fn changeset, member, _context ->
|
||||
fee_type_changed =
|
||||
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
|
||||
|
||||
if fee_type_changed && member.membership_fee_type_id && member.join_date do
|
||||
case regenerate_cycles_on_type_change(member) do
|
||||
{:ok, notifications} ->
|
||||
# Return notifications to Ash - they will be sent automatically after commit
|
||||
{:ok, member, notifications}
|
||||
|
||||
{:error, reason} ->
|
||||
require Logger
|
||||
|
||||
Logger.warning(
|
||||
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
{:ok, member}
|
||||
end
|
||||
else
|
||||
{:ok, member}
|
||||
end
|
||||
end)
|
||||
|
||||
# Trigger cycle regeneration when join_date or exit_date changes
|
||||
# Regenerates cycles based on new dates
|
||||
# Note: Cycle generation runs synchronously in test environment, asynchronously in production
|
||||
# CycleGenerator uses advisory locks and transactions internally to prevent race conditions
|
||||
# If both join_date and exit_date are changed simultaneously, this hook runs only once
|
||||
# (Ash ensures each after_transaction hook runs once per action, regardless of how many attributes changed)
|
||||
change after_transaction(fn changeset, result, _context ->
|
||||
case result do
|
||||
{:ok, member} ->
|
||||
join_date_changed = Ash.Changeset.changing_attribute?(changeset, :join_date)
|
||||
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
|
||||
|
||||
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
|
||||
handle_cycle_generation(member)
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
|
||||
result
|
||||
end)
|
||||
end
|
||||
|
||||
# Action to handle fuzzy search on specific fields
|
||||
|
|
@ -298,7 +386,7 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Join date not in the future
|
||||
# Join date not in future
|
||||
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
|
||||
where: [present(:join_date)],
|
||||
message: "cannot be in the future"
|
||||
|
|
@ -386,10 +474,6 @@ defmodule Mv.Membership.Member do
|
|||
constraints min_length: 5, max_length: 254
|
||||
end
|
||||
|
||||
attribute :paid, :boolean do
|
||||
allow_nil? true
|
||||
end
|
||||
|
||||
attribute :phone_number, :string do
|
||||
allow_nil? true
|
||||
end
|
||||
|
|
@ -455,6 +539,50 @@ defmodule Mv.Membership.Member do
|
|||
has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
|
||||
end
|
||||
|
||||
calculations do
|
||||
calculate :current_cycle_status, :atom do
|
||||
description "Status of the current cycle (the one that is active today)"
|
||||
# Automatically load cycles with all attributes and membership_fee_type
|
||||
load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
|
||||
|
||||
calculation fn [member], _context ->
|
||||
case get_current_cycle(member) do
|
||||
nil -> [nil]
|
||||
cycle -> [cycle.status]
|
||||
end
|
||||
end
|
||||
|
||||
constraints one_of: [:unpaid, :paid, :suspended]
|
||||
end
|
||||
|
||||
calculate :last_cycle_status, :atom do
|
||||
description "Status of the last completed cycle (the most recent cycle that has ended)"
|
||||
# Automatically load cycles with all attributes and membership_fee_type
|
||||
load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
|
||||
|
||||
calculation fn [member], _context ->
|
||||
case get_last_completed_cycle(member) do
|
||||
nil -> [nil]
|
||||
cycle -> [cycle.status]
|
||||
end
|
||||
end
|
||||
|
||||
constraints one_of: [:unpaid, :paid, :suspended]
|
||||
end
|
||||
|
||||
calculate :overdue_count, :integer do
|
||||
description "Count of unpaid cycles that have already ended (cycle_end < today)"
|
||||
# Automatically load cycles with all attributes and membership_fee_type
|
||||
load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
|
||||
|
||||
calculation fn [member], _context ->
|
||||
overdue = get_overdue_cycles(member)
|
||||
count = if is_list(overdue), do: length(overdue), else: 0
|
||||
[count]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Define identities for upsert operations
|
||||
identities do
|
||||
identity :unique_email, [:email]
|
||||
|
|
@ -501,6 +629,345 @@ defmodule Mv.Membership.Member do
|
|||
|
||||
def show_in_overview?(_), do: true
|
||||
|
||||
# Helper functions for cycle status calculations
|
||||
#
|
||||
# These functions expect membership_fee_cycles to be loaded with membership_fee_type
|
||||
# preloaded. The calculations explicitly load this relationship, but if called
|
||||
# directly, ensure membership_fee_type is loaded or the functions will return
|
||||
# nil/[] when membership_fee_type is missing.
|
||||
|
||||
@doc false
|
||||
@spec get_current_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
|
||||
def get_current_cycle(member) do
|
||||
today = Date.utc_today()
|
||||
|
||||
# Check if cycles are already loaded
|
||||
cycles = Map.get(member, :membership_fee_cycles)
|
||||
|
||||
if is_list(cycles) and cycles != [] do
|
||||
Enum.find(cycles, ¤t_cycle?(&1, today))
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if a cycle is the current cycle (active today)
|
||||
defp current_cycle?(cycle, today) do
|
||||
case Map.get(cycle, :membership_fee_type) do
|
||||
%{interval: interval} ->
|
||||
cycle_end =
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
|
||||
Date.compare(cycle.cycle_start, today) in [:lt, :eq] and
|
||||
Date.compare(today, cycle_end) in [:lt, :eq]
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
|
||||
def get_last_completed_cycle(member) do
|
||||
today = Date.utc_today()
|
||||
|
||||
# Check if cycles are already loaded
|
||||
cycles = Map.get(member, :membership_fee_cycles)
|
||||
|
||||
if is_list(cycles) and cycles != [] do
|
||||
cycles
|
||||
|> filter_completed_cycles(today)
|
||||
|> sort_cycles_by_end_date()
|
||||
|> List.first()
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Filters cycles that have ended (cycle_end < today)
|
||||
defp filter_completed_cycles(cycles, today) do
|
||||
Enum.filter(cycles, fn cycle ->
|
||||
case Map.get(cycle, :membership_fee_type) do
|
||||
%{interval: interval} ->
|
||||
cycle_end =
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
|
||||
Date.compare(today, cycle_end) == :gt
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Sorts cycles by end date in descending order
|
||||
defp sort_cycles_by_end_date(cycles) do
|
||||
Enum.sort_by(
|
||||
cycles,
|
||||
fn cycle ->
|
||||
interval = Map.get(cycle, :membership_fee_type).interval
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
end,
|
||||
{:desc, Date}
|
||||
)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()]
|
||||
def get_overdue_cycles(member) do
|
||||
today = Date.utc_today()
|
||||
|
||||
# Check if cycles are already loaded
|
||||
cycles = Map.get(member, :membership_fee_cycles)
|
||||
|
||||
if is_list(cycles) and cycles != [] do
|
||||
filter_overdue_cycles(cycles, today)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Filters cycles that are unpaid and have ended (cycle_end < today)
|
||||
defp filter_overdue_cycles(cycles, today) do
|
||||
Enum.filter(cycles, fn cycle ->
|
||||
case Map.get(cycle, :membership_fee_type) do
|
||||
%{interval: interval} ->
|
||||
cycle_end =
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
|
||||
cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Regenerates cycles when membership fee type changes
|
||||
# Deletes future unpaid cycles and regenerates them with the new type/amount
|
||||
# Uses advisory lock to prevent concurrent modifications
|
||||
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
|
||||
# to be sent after transaction commits
|
||||
@doc false
|
||||
def regenerate_cycles_on_type_change(member) do
|
||||
today = Date.utc_today()
|
||||
lock_key = :erlang.phash2(member.id)
|
||||
|
||||
# Use advisory lock to prevent concurrent deletion and regeneration
|
||||
# This ensures atomicity when multiple updates happen simultaneously
|
||||
if Mv.Repo.in_transaction?() do
|
||||
regenerate_cycles_in_transaction(member, today, lock_key)
|
||||
else
|
||||
regenerate_cycles_new_transaction(member, today, lock_key)
|
||||
end
|
||||
end
|
||||
|
||||
# Already in transaction: use advisory lock directly
|
||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
||||
end
|
||||
|
||||
# Not in transaction: start new transaction with advisory lock
|
||||
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
||||
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
||||
Mv.Repo.transaction(fn ->
|
||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
|
||||
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
||||
{:ok, notifications} ->
|
||||
# Return notifications - they will be sent by the caller
|
||||
notifications
|
||||
|
||||
{:error, reason} ->
|
||||
Mv.Repo.rollback(reason)
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:ok, notifications} -> {:ok, notifications}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# Performs the actual cycle deletion and regeneration
|
||||
# Returns {:ok, notifications} or {:error, reason}
|
||||
# notifications are collected to be sent after transaction commits
|
||||
defp do_regenerate_cycles_on_type_change(member, today, opts) do
|
||||
require Ash.Query
|
||||
|
||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||
|
||||
# Find all unpaid cycles for this member
|
||||
# We need to check cycle_end for each cycle using its own interval
|
||||
all_unpaid_cycles_query =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.Query.filter(status == :unpaid)
|
||||
|> Ash.Query.load([:membership_fee_type])
|
||||
|
||||
case Ash.read(all_unpaid_cycles_query) do
|
||||
{:ok, all_unpaid_cycles} ->
|
||||
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
|
||||
delete_and_regenerate_cycles(cycles_to_delete, member.id, today, skip_lock?: skip_lock?)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# Filters cycles that haven't ended yet (cycle_end >= today)
|
||||
# These are the "future" cycles that should be regenerated
|
||||
defp filter_future_cycles(all_unpaid_cycles, today) do
|
||||
Enum.filter(all_unpaid_cycles, fn cycle ->
|
||||
case cycle.membership_fee_type do
|
||||
%{interval: interval} ->
|
||||
cycle_end =
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
|
||||
Date.compare(today, cycle_end) in [:lt, :eq]
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Deletes future cycles and regenerates them with the new type/amount
|
||||
# Passes today to ensure consistent date across deletion and regeneration
|
||||
# Returns {:ok, notifications} or {:error, reason}
|
||||
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
|
||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||
|
||||
if Enum.empty?(cycles_to_delete) do
|
||||
# No cycles to delete, just regenerate
|
||||
regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
||||
else
|
||||
case delete_cycles(cycles_to_delete) do
|
||||
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
|
||||
defp delete_cycles(cycles_to_delete) do
|
||||
delete_results =
|
||||
Enum.map(cycles_to_delete, fn cycle ->
|
||||
Ash.destroy(cycle)
|
||||
end)
|
||||
|
||||
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
|
||||
{:error, :deletion_failed}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# Regenerates cycles with new type/amount
|
||||
# Passes today to ensure consistent date across deletion and regeneration
|
||||
# skip_lock?: true means advisory lock is already set by caller
|
||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||
defp regenerate_cycles(member_id, today, opts) do
|
||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||
member_id,
|
||||
today: today,
|
||||
skip_lock?: skip_lock?
|
||||
) do
|
||||
{:ok, _cycles, notifications} when is_list(notifications) ->
|
||||
{:ok, notifications}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# Handles cycle generation for a member, choosing sync or async execution
|
||||
# based on environment (test vs production)
|
||||
# This function encapsulates the common logic for cycle generation
|
||||
# to avoid code duplication across different hooks
|
||||
defp handle_cycle_generation(member) do
|
||||
if Mv.Config.sql_sandbox?() do
|
||||
handle_cycle_generation_sync(member)
|
||||
else
|
||||
handle_cycle_generation_async(member)
|
||||
end
|
||||
end
|
||||
|
||||
# Runs cycle generation synchronously (for test environment)
|
||||
defp handle_cycle_generation_sync(member) do
|
||||
require Logger
|
||||
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||
member.id,
|
||||
today: Date.utc_today()
|
||||
) do
|
||||
{:ok, cycles, notifications} ->
|
||||
send_notifications_if_any(notifications)
|
||||
log_cycle_generation_success(member, cycles, notifications, sync: true)
|
||||
|
||||
{:error, reason} ->
|
||||
log_cycle_generation_error(member, reason, sync: true)
|
||||
end
|
||||
end
|
||||
|
||||
# Runs cycle generation asynchronously (for production environment)
|
||||
defp handle_cycle_generation_async(member) do
|
||||
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
{:ok, cycles, notifications} ->
|
||||
send_notifications_if_any(notifications)
|
||||
log_cycle_generation_success(member, cycles, notifications, sync: false)
|
||||
|
||||
{:error, reason} ->
|
||||
log_cycle_generation_error(member, reason, sync: false)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Sends notifications if any are present
|
||||
defp send_notifications_if_any(notifications) do
|
||||
if Enum.any?(notifications) do
|
||||
Ash.Notifier.notify(notifications)
|
||||
end
|
||||
end
|
||||
|
||||
# Logs successful cycle generation
|
||||
defp log_cycle_generation_success(member, cycles, notifications, sync: sync?) do
|
||||
require Logger
|
||||
|
||||
sync_label = if sync?, do: "", else: " (async)"
|
||||
|
||||
Logger.debug(
|
||||
"Successfully generated cycles for member#{sync_label}",
|
||||
member_id: member.id,
|
||||
cycles_count: length(cycles),
|
||||
notifications_count: length(notifications)
|
||||
)
|
||||
end
|
||||
|
||||
# Logs cycle generation errors
|
||||
defp log_cycle_generation_error(member, reason, sync: sync?) do
|
||||
require Logger
|
||||
|
||||
sync_label = if sync?, do: "", else: " (async)"
|
||||
|
||||
Logger.error(
|
||||
"Failed to generate cycles for member#{sync_label}",
|
||||
member_id: member.id,
|
||||
member_email: member.email,
|
||||
error: inspect(reason),
|
||||
error_type: error_type(reason)
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to extract error type for structured logging
|
||||
defp error_type(%{__struct__: struct_name}), do: struct_name
|
||||
defp error_type(error) when is_atom(error), do: error
|
||||
defp error_type(_), do: :unknown
|
||||
|
||||
# Normalizes visibility config map keys from strings to atoms.
|
||||
# JSONB in PostgreSQL converts atom keys to string keys when storing.
|
||||
defp normalize_visibility_config(config) when is_map(config) do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
defmodule Mv.Membership.Member.Changes.SetDefaultMembershipFeeType do
|
||||
@moduledoc """
|
||||
Ash change that automatically assigns the default membership fee type to new members
|
||||
if no membership_fee_type_id is explicitly provided.
|
||||
|
||||
This change reads the default_membership_fee_type_id from global settings and
|
||||
assigns it to the member if membership_fee_type_id is nil.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
# Only set default if membership_fee_type_id is not already set
|
||||
current_type_id = Ash.Changeset.get_attribute(changeset, :membership_fee_type_id)
|
||||
|
||||
if is_nil(current_type_id) do
|
||||
apply_default_membership_fee_type(changeset)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_default_membership_fee_type(changeset) do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
if settings.default_membership_fee_type_id do
|
||||
Ash.Changeset.force_change_attribute(
|
||||
changeset,
|
||||
:membership_fee_type_id,
|
||||
settings.default_membership_fee_type_id
|
||||
)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
|
||||
{:error, _error} ->
|
||||
# If settings can't be loaded, continue without default
|
||||
# This prevents member creation from failing if settings are misconfigured
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -4,13 +4,15 @@ defmodule Mv.Membership.Setting do
|
|||
|
||||
## 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.
|
||||
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.
|
||||
|
|
@ -22,6 +24,12 @@ defmodule Mv.Membership.Setting do
|
|||
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
|
||||
|
|
@ -33,6 +41,9 @@ defmodule Mv.Membership.Setting do
|
|||
|
||||
# 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,
|
||||
|
|
@ -54,13 +65,24 @@ defmodule Mv.Membership.Setting do
|
|||
# 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]
|
||||
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]
|
||||
|
||||
accept [
|
||||
:club_name,
|
||||
:member_field_visibility,
|
||||
:include_joining_cycle,
|
||||
:default_membership_fee_type_id
|
||||
]
|
||||
end
|
||||
|
||||
update :update_member_field_visibility do
|
||||
|
|
@ -68,6 +90,14 @@ defmodule Mv.Membership.Setting do
|
|||
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
|
||||
|
|
@ -113,6 +143,41 @@ defmodule Mv.Membership.Setting do
|
|||
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
|
||||
|
|
@ -133,6 +198,26 @@ defmodule Mv.Membership.Setting do
|
|||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
defmodule Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId do
|
||||
@moduledoc """
|
||||
Ash change that normalizes empty strings to nil for default_membership_fee_type_id.
|
||||
|
||||
HTML forms submit empty select values as empty strings (""), but the database
|
||||
expects nil for optional UUID fields. This change converts "" to nil.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
default_fee_type_id = Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
|
||||
|
||||
if default_fee_type_id == "" do
|
||||
Ash.Changeset.force_change_attribute(changeset, :default_membership_fee_type_id, nil)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue