feat: add atomic update for single member field visibility
This commit is contained in:
parent
9af7381843
commit
4a1042ab1a
3 changed files with 177 additions and 24 deletions
|
|
@ -40,6 +40,8 @@ defmodule Mv.Membership.Member do
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
alias Mv.Membership.Helpers.VisibilityConfig
|
||||||
|
|
||||||
# Module constants
|
# Module constants
|
||||||
@member_search_limit 10
|
@member_search_limit 10
|
||||||
|
|
||||||
|
|
@ -607,7 +609,7 @@ defmodule Mv.Membership.Member do
|
||||||
{:ok, settings} ->
|
{:ok, settings} ->
|
||||||
visibility_config = settings.member_field_visibility || %{}
|
visibility_config = settings.member_field_visibility || %{}
|
||||||
# Normalize map keys to atoms (JSONB may return string keys)
|
# 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
|
# Get value from normalized config, use field-specific default
|
||||||
Map.get(normalized_config, field, default_visibility)
|
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(error) when is_atom(error), do: error
|
||||||
defp error_type(_), do: :unknown
|
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 """
|
@doc """
|
||||||
Performs fuzzy search on members using PostgreSQL trigram similarity.
|
Performs fuzzy search on members using PostgreSQL trigram similarity.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,16 @@ defmodule Mv.Membership.Setting do
|
||||||
accept [:member_field_visibility]
|
accept [:member_field_visibility]
|
||||||
end
|
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
|
update :update_membership_fee_settings do
|
||||||
description "Updates the membership fee configuration"
|
description "Updates the membership fee configuration"
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue