Attach errors to :field, :show_in_overview, :member_field_required. Set updated_at in SQL UPDATE. Add trailing newline to snapshot JSON.
179 lines
5.2 KiB
Elixir
179 lines
5.2 KiB
Elixir
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
|
|
@moduledoc """
|
|
Ash change that atomically updates visibility and required for a single member field.
|
|
|
|
Updates both `member_field_visibility` and `member_field_required` JSONB maps
|
|
in one SQL UPDATE to avoid lost updates when saving from the settings UI.
|
|
|
|
## Arguments
|
|
- `field` - The member field name as a string (e.g., "street", "first_name")
|
|
- `show_in_overview` - Boolean value indicating visibility in member overview
|
|
- `required` - Boolean value indicating whether the field is required in member forms
|
|
|
|
## Example
|
|
settings
|
|
|> Ash.Changeset.for_update(:update_single_member_field, %{},
|
|
arguments: %{field: "first_name", show_in_overview: true, required: true}
|
|
)
|
|
|> 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),
|
|
{:ok, required} <- get_and_validate_boolean(changeset, :required) do
|
|
add_after_action(changeset, field, show_in_overview, required)
|
|
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: :field,
|
|
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: :field,
|
|
message: "Invalid member field: #{field}"
|
|
)}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp get_and_validate_boolean(changeset, :show_in_overview = arg_name) do
|
|
do_validate_boolean(changeset, arg_name, :show_in_overview)
|
|
end
|
|
|
|
defp get_and_validate_boolean(changeset, :required = arg_name) do
|
|
do_validate_boolean(changeset, arg_name, :member_field_required)
|
|
end
|
|
|
|
defp do_validate_boolean(changeset, arg_name, error_field) do
|
|
case Ash.Changeset.get_argument(changeset, arg_name) do
|
|
nil ->
|
|
{:error,
|
|
add_error(
|
|
changeset,
|
|
field: error_field,
|
|
message: "#{arg_name} argument is required"
|
|
)}
|
|
|
|
value when is_boolean(value) ->
|
|
{:ok, value}
|
|
|
|
_ ->
|
|
{:error,
|
|
add_error(
|
|
changeset,
|
|
field: error_field,
|
|
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, required) do
|
|
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
|
|
# Update both JSONB columns in one statement
|
|
sql = """
|
|
UPDATE settings
|
|
SET
|
|
member_field_visibility = jsonb_set(
|
|
COALESCE(member_field_visibility, '{}'::jsonb),
|
|
ARRAY[$1::text],
|
|
to_jsonb($2::boolean),
|
|
true
|
|
),
|
|
member_field_required = jsonb_set(
|
|
COALESCE(member_field_required, '{}'::jsonb),
|
|
ARRAY[$1::text],
|
|
to_jsonb($3::boolean),
|
|
true
|
|
),
|
|
updated_at = (now() AT TIME ZONE 'utc')
|
|
WHERE id = $4
|
|
RETURNING member_field_visibility, member_field_required
|
|
"""
|
|
|
|
uuid_binary = Ecto.UUID.dump!(settings.id)
|
|
|
|
case SQL.query(Mv.Repo, sql, [field, show_in_overview, required, uuid_binary]) do
|
|
{:ok, %{rows: [[updated_visibility, updated_required] | _]}} ->
|
|
vis = normalize_jsonb_result(updated_visibility)
|
|
req = normalize_jsonb_result(updated_required)
|
|
|
|
updated_settings = %{
|
|
settings
|
|
| member_field_visibility: vis,
|
|
member_field_required: req
|
|
}
|
|
|
|
{:ok, updated_settings}
|
|
|
|
{:ok, %{rows: []}} ->
|
|
{:error,
|
|
Invalid.exception(
|
|
field: :member_field_required,
|
|
message: "Settings not found"
|
|
)}
|
|
|
|
{:error, error} ->
|
|
Logger.error("Failed to atomically update member field settings: #{inspect(error)}")
|
|
|
|
{:error,
|
|
Invalid.exception(
|
|
field: :member_field_required,
|
|
message: "Failed to update member field settings"
|
|
)}
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp normalize_jsonb_result(updated_jsonb) do
|
|
case updated_jsonb do
|
|
map when is_map(map) ->
|
|
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
|
|
|
|
{:ok, _} ->
|
|
%{}
|
|
|
|
{:error, reason} ->
|
|
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
|
|
%{}
|
|
end
|
|
|
|
_ ->
|
|
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
|
|
%{}
|
|
end
|
|
end
|
|
end
|