Merge branch 'main' into bugfix/274_required_custom_fields
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2026-01-02 13:47:24 +01:00
commit 08f563a412
76 changed files with 13847 additions and 1688 deletions

View file

@ -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, &current_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

View file

@ -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

View file

@ -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

View file

@ -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