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: :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) 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_required, message: "Settings not found" )} {:error, error} -> Logger.error("Failed to atomically update member field settings: #{inspect(error)}") {:error, Invalid.exception( field: :member_field_required, 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