defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do @moduledoc """ Ash change that atomically updates a single field in the member_field_visibility JSONB map. This change uses PostgreSQL's jsonb_set function to atomically update a single key in the JSONB map, preventing lost updates in concurrent scenarios. ## Arguments - `field` - The member field name as a string (e.g., "street", "house_number") - `show_in_overview` - Boolean value indicating visibility ## Example settings |> Ash.Changeset.for_update(:update_single_member_field_visibility, %{}, arguments: %{field: "street", show_in_overview: false} ) |> 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) do add_after_action(changeset, field, show_in_overview) 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) do # Use after_action to execute atomic SQL update Ash.Changeset.after_action(changeset, fn _changeset, settings -> # Use PostgreSQL jsonb_set for atomic update # jsonb_set(target, path, new_value, create_missing?) # path is an array: ['field_name'] # new_value must be JSON: to_jsonb(boolean) sql = """ UPDATE settings SET member_field_visibility = jsonb_set( COALESCE(member_field_visibility, '{}'::jsonb), ARRAY[$1::text], to_jsonb($2::boolean), true ) WHERE id = $3 RETURNING member_field_visibility """ # Convert UUID string to binary for PostgreSQL uuid_binary = Ecto.UUID.dump!(settings.id) JsonbResult.run_update( sql: sql, params: [field, show_in_overview, uuid_binary], on_row: fn [updated_jsonb | _] -> %{settings | member_field_visibility: JsonbResult.normalize(updated_jsonb)} end, error_field: :member_field_visibility, not_found_message: "Settings not found", error_message: "Failed to update visibility", log_message: "Failed to atomically update member_field_visibility" ) end) end end