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
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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