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 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) 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) case SQL.query(Mv.Repo, sql, [field, show_in_overview, uuid_binary]) do {:ok, %{rows: [[updated_jsonb] | _]}} -> updated_visibility = normalize_jsonb_result(updated_jsonb) # Update the settings struct with the new visibility updated_settings = %{settings | member_field_visibility: updated_visibility} {: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_visibility: #{inspect(error)}") {:error, Invalid.exception( field: :member_field_visibility, message: "Failed to update visibility" )} end end) end defp normalize_jsonb_result(updated_jsonb) do case updated_jsonb do map when is_map(map) -> # Convert atom keys to strings if needed 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 # Not a map after decode {:ok, _} -> %{} {:error, reason} -> Logger.warning("Failed to decode JSONB: #{inspect(reason)}") %{} end _ -> Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}") %{} end end end