diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 51da8ff..d2ea07d 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -40,6 +40,8 @@ defmodule Mv.Membership.Member do import Ash.Expr require Logger + alias Mv.Membership.Helpers.VisibilityConfig + # Module constants @member_search_limit 10 @@ -607,7 +609,7 @@ defmodule Mv.Membership.Member do {:ok, settings} -> visibility_config = settings.member_field_visibility || %{} # Normalize map keys to atoms (JSONB may return string keys) - normalized_config = normalize_visibility_config(visibility_config) + normalized_config = VisibilityConfig.normalize(visibility_config) # Get value from normalized config, use field-specific default Map.get(normalized_config, field, default_visibility) @@ -959,29 +961,6 @@ defmodule Mv.Membership.Member do defp error_type(error) when is_atom(error), do: error defp error_type(_), do: :unknown - # Normalizes visibility config map keys from strings to atoms. - # JSONB in PostgreSQL converts atom keys to string keys when storing. - defp normalize_visibility_config(config) when is_map(config) do - Enum.reduce(config, %{}, fn - {key, value}, acc when is_atom(key) -> - Map.put(acc, key, value) - - {key, value}, acc when is_binary(key) -> - try do - atom_key = String.to_existing_atom(key) - Map.put(acc, atom_key, value) - rescue - ArgumentError -> - acc - end - - _, acc -> - acc - end) - end - - defp normalize_visibility_config(_), do: %{} - @doc """ Performs fuzzy search on members using PostgreSQL trigram similarity. diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index eedc47c..4ba0794 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -91,6 +91,16 @@ defmodule Mv.Membership.Setting do accept [:member_field_visibility] end + update :update_single_member_field_visibility do + description "Atomically updates a single field in the member_field_visibility JSONB map" + require_atomic? false + + argument :field, :string, allow_nil?: false + argument :show_in_overview, :boolean, allow_nil?: false + + change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility + end + update :update_membership_fee_settings do description "Updates the membership fee configuration" require_atomic? false diff --git a/lib/membership/setting/changes/update_single_member_field_visibility.ex b/lib/membership/setting/changes/update_single_member_field_visibility.ex new file mode 100644 index 0000000..e047cdf --- /dev/null +++ b/lib/membership/setting/changes/update_single_member_field_visibility.ex @@ -0,0 +1,164 @@ +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