diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 74735e4..2583718 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -64,6 +64,8 @@ defmodule Mv.Membership do define :update_single_member_field_visibility, action: :update_single_member_field_visibility + + define :update_single_member_field, action: :update_single_member_field end resource Mv.Membership.Group do @@ -257,6 +259,46 @@ defmodule Mv.Membership do |> Ash.update(domain: __MODULE__) end + @doc """ + Atomically updates visibility and required for a single member field. + + Updates both `member_field_visibility` and `member_field_required` in one + operation. Use this when saving from the member field settings form. + + ## Parameters + + - `settings` - The settings record to update + - `field` - The member field name as a string (e.g., "first_name", "street") + - `show_in_overview` - Boolean value indicating visibility in member overview + - `required` - Boolean value indicating whether the field is required in member forms + + ## Returns + + - `{:ok, updated_settings}` - Successfully updated settings + - `{:error, error}` - Validation or update error + + ## Examples + + iex> {:ok, settings} = Mv.Membership.get_settings() + iex> {:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true) + iex> updated.member_field_required["first_name"] + true + + """ + def update_single_member_field(settings, + field: field, + show_in_overview: show_in_overview, + required: required + ) do + settings + |> Ash.Changeset.new() + |> Ash.Changeset.set_argument(:field, field) + |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) + |> Ash.Changeset.set_argument(:required, required) + |> Ash.Changeset.for_update(:update_single_member_field, %{}) + |> Ash.update(domain: __MODULE__) + end + @doc """ Gets a group by its slug. diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index f56daa0..154288b 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -11,6 +11,8 @@ defmodule Mv.Membership.Setting do - `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) @@ -42,6 +44,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 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}) """ @@ -68,6 +73,7 @@ defmodule Mv.Membership.Setting do accept [ :club_name, :member_field_visibility, + :member_field_required, :include_joining_cycle, :default_membership_fee_type_id, :vereinfacht_api_url, @@ -84,6 +90,7 @@ defmodule Mv.Membership.Setting do accept [ :club_name, :member_field_visibility, + :member_field_required, :include_joining_cycle, :default_membership_fee_type_id, :vereinfacht_api_url, @@ -109,6 +116,17 @@ defmodule Mv.Membership.Setting do 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 @@ -162,6 +180,44 @@ defmodule Mv.Membership.Setting do 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 default_membership_fee_type_id exists if set validate fn changeset, context -> fee_type_id = @@ -219,6 +275,12 @@ defmodule Mv.Membership.Setting do 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 diff --git a/lib/membership/setting/changes/update_single_member_field.ex b/lib/membership/setting/changes/update_single_member_field.ex new file mode 100644 index 0000000..a479164 --- /dev/null +++ b/lib/membership/setting/changes/update_single_member_field.ex @@ -0,0 +1,170 @@ +defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do + @moduledoc """ + Ash change that atomically updates visibility and required for a single member field. + + Updates both `member_field_visibility` and `member_field_required` JSONB maps + in one SQL UPDATE to avoid lost updates when saving from the settings UI. + + ## Arguments + - `field` - The member field name as a string (e.g., "street", "first_name") + - `show_in_overview` - Boolean value indicating visibility in member overview + - `required` - Boolean value indicating whether the field is required in member forms + + ## Example + settings + |> Ash.Changeset.for_update(:update_single_member_field, %{}, + arguments: %{field: "first_name", show_in_overview: true, required: true} + ) + |> Ash.update(domain: Mv.Membership) + """ + use Ash.Resource.Change + + alias Ash.Error.Invalid + alias Ecto.Adapters.SQL + require Logger + + def change(changeset, _opts, _context) do + with {:ok, field} <- get_and_validate_field(changeset), + {:ok, show_in_overview} <- get_and_validate_boolean(changeset, :show_in_overview), + {:ok, required} <- get_and_validate_boolean(changeset, :required) do + add_after_action(changeset, field, show_in_overview, required) + else + {:error, updated_changeset} -> updated_changeset + end + end + + defp get_and_validate_field(changeset) do + case Ash.Changeset.get_argument(changeset, :field) do + nil -> + {:error, + add_error(changeset, + field: :member_field_visibility, + message: "field argument is required" + )} + + field -> + valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + if field in valid_fields do + {:ok, field} + else + {:error, + add_error( + changeset, + field: :member_field_visibility, + message: "Invalid member field: #{field}" + )} + end + end + end + + defp get_and_validate_boolean(changeset, arg_name) do + case Ash.Changeset.get_argument(changeset, arg_name) do + nil -> + {:error, + add_error( + changeset, + field: :member_field_visibility, + message: "#{arg_name} argument is required" + )} + + value when is_boolean(value) -> + {:ok, value} + + _ -> + {:error, + add_error( + changeset, + field: :member_field_visibility, + message: "#{arg_name} must be a boolean" + )} + end + end + + defp add_error(changeset, opts) do + Ash.Changeset.add_error(changeset, opts) + end + + defp add_after_action(changeset, field, show_in_overview, required) do + Ash.Changeset.after_action(changeset, fn _changeset, settings -> + # Update both JSONB columns in one statement + sql = """ + UPDATE settings + SET + member_field_visibility = jsonb_set( + COALESCE(member_field_visibility, '{}'::jsonb), + ARRAY[$1::text], + to_jsonb($2::boolean), + true + ), + member_field_required = jsonb_set( + COALESCE(member_field_required, '{}'::jsonb), + ARRAY[$1::text], + to_jsonb($3::boolean), + true + ) + WHERE id = $4 + RETURNING member_field_visibility, member_field_required + """ + + uuid_binary = Ecto.UUID.dump!(settings.id) + + case SQL.query(Mv.Repo, sql, [field, show_in_overview, required, uuid_binary]) do + {:ok, %{rows: [[updated_visibility, updated_required] | _]}} -> + vis = normalize_jsonb_result(updated_visibility) + req = normalize_jsonb_result(updated_required) + + updated_settings = %{ + settings + | member_field_visibility: vis, + member_field_required: req + } + + {:ok, updated_settings} + + {:ok, %{rows: []}} -> + {:error, + Invalid.exception( + field: :member_field_visibility, + message: "Settings not found" + )} + + {:error, error} -> + Logger.error("Failed to atomically update member field settings: #{inspect(error)}") + + {:error, + Invalid.exception( + field: :member_field_visibility, + message: "Failed to update member field settings" + )} + end + end) + end + + defp normalize_jsonb_result(updated_jsonb) do + case updated_jsonb do + map when is_map(map) -> + Enum.reduce(map, %{}, fn + {k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v) + {k, v}, acc -> Map.put(acc, k, v) + end) + + binary when is_binary(binary) -> + case Jason.decode(binary) do + {:ok, decoded} when is_map(decoded) -> + decoded + + {:ok, _} -> + %{} + + {:error, reason} -> + Logger.warning("Failed to decode JSONB: #{inspect(reason)}") + %{} + end + + _ -> + Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}") + %{} + end + end +end diff --git a/priv/repo/migrations/20260223195453_add_member_field_required_to_settings.exs b/priv/repo/migrations/20260223195453_add_member_field_required_to_settings.exs new file mode 100644 index 0000000..b6696fe --- /dev/null +++ b/priv/repo/migrations/20260223195453_add_member_field_required_to_settings.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.AddMemberFieldRequiredToSettings do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:settings) do + add :member_field_required, :map + end + end + + def down do + alter table(:settings) do + remove :member_field_required + end + end +end diff --git a/priv/resource_snapshots/repo/settings/20260223195453.json b/priv/resource_snapshots/repo/settings/20260223195453.json new file mode 100644 index 0000000..770e8ec --- /dev/null +++ b/priv/resource_snapshots/repo/settings/20260223195453.json @@ -0,0 +1,152 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "club_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "member_field_visibility", + "type": "map" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "include_joining_cycle", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "default_membership_fee_type_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_api_url", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_api_key", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_club_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_app_url", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "4C29CEF273C1180162E7231A7F7CCE5DABD035E121648E48B6FBE30AE5191FF0", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "settings" +} \ No newline at end of file