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 Mv.Membership.Setting.Changes.JsonbResult 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: :field, 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: :field, message: "Invalid member field: #{field}" )} end end end defp get_and_validate_boolean(changeset, :show_in_overview = arg_name) do do_validate_boolean(changeset, arg_name, :show_in_overview) end defp get_and_validate_boolean(changeset, :required = arg_name) do do_validate_boolean(changeset, arg_name, :member_field_required) end defp do_validate_boolean(changeset, arg_name, error_field) do case Ash.Changeset.get_argument(changeset, arg_name) do nil -> {:error, add_error( changeset, field: error_field, message: "#{arg_name} argument is required" )} value when is_boolean(value) -> {:ok, value} _ -> {:error, add_error( changeset, field: error_field, 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 ), updated_at = (now() AT TIME ZONE 'utc') WHERE id = $4 RETURNING member_field_visibility, member_field_required """ uuid_binary = Ecto.UUID.dump!(settings.id) JsonbResult.run_update( sql: sql, params: [field, show_in_overview, required, uuid_binary], on_row: fn [updated_visibility, updated_required | _] -> %{ settings | member_field_visibility: JsonbResult.normalize(updated_visibility), member_field_required: JsonbResult.normalize(updated_required) } end, error_field: :member_field_required, not_found_message: "Settings not found", error_message: "Failed to update member field settings", log_message: "Failed to atomically update member field settings" ) end) end end