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