From bbc094daaa51b8116ae1ad400baf12ccaa40998a Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 17 Dec 2025 13:31:15 +0100 Subject: [PATCH] fix: add validation for required custom fields --- lib/membership/member.ex | 71 +++ lib/mv_web/live/member_live/form.ex | 1 + .../member_required_custom_fields_test.exs | 447 ++++++++++++++++++ 3 files changed, 519 insertions(+) create mode 100644 test/membership/member_required_custom_fields_test.exs diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 5816d19..73820f6 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -329,6 +329,63 @@ defmodule Mv.Membership.Member do {:error, field: :email, message: "is not a valid email"} end end + + # Validate required custom fields + validate fn changeset, _ -> + custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values) + + # If argument is not provided (nil), check existing values from member data (update scenario) + # If argument is provided (empty list or list with values), use those values + provided_values = + if is_nil(custom_field_values_arg) do + # Update scenario: check existing custom field values from member + case Ash.load(changeset.data, :custom_field_values) do + {:ok, %{custom_field_values: existing_values}} -> + Enum.reduce(existing_values, %{}, fn cfv, acc -> + value = if is_map(cfv.value), do: Map.get(cfv.value, :value), else: nil + Map.put(acc, cfv.custom_field_id, value) + end) + + _ -> + %{} + end + else + # Create or update scenario: use provided argument values + Enum.reduce(custom_field_values_arg, %{}, fn cfv, acc -> + custom_field_id = Map.get(cfv, "custom_field_id") + value_map = Map.get(cfv, "value", %{}) + actual_value = Map.get(value_map, "value") + Map.put(acc, custom_field_id, actual_value) + end) + end + + # Load all required custom fields + case Mv.Membership.list_custom_fields() do + {:ok, all_custom_fields} -> + required_custom_fields = Enum.filter(all_custom_fields, & &1.required) + + # Check each required custom field + missing_fields = + Enum.filter(required_custom_fields, fn cf -> + value = Map.get(provided_values, cf.id) + not value_present?(value, cf.value_type) + end) + + if Enum.empty?(missing_fields) do + :ok + else + missing_names = Enum.map_join(missing_fields, ", ", & &1.name) + + {:error, + field: :custom_field_values, + message: "Required custom fields missing: #{missing_names}"} + end + + {:error, _} -> + # If we can't load custom fields, skip validation (shouldn't happen in normal operation) + :ok + end + end end attributes do @@ -665,4 +722,18 @@ defmodule Mv.Membership.Member do query end end + + # Helper function to check if a value is present for a given custom field type + # Boolean: false is valid, only nil is invalid + # String: nil or empty strings are invalid + # Integer: nil is invalid, 0 is valid + # Date: nil is invalid + # Email: nil is invalid + defp value_present?(nil, _type), do: false + defp value_present?(value, :boolean), do: not is_nil(value) + defp value_present?(value, :string), do: is_binary(value) and String.trim(value) != "" + defp value_present?(value, :integer), do: not is_nil(value) + defp value_present?(value, :date), do: not is_nil(value) + defp value_present?(value, :email), do: not is_nil(value) + defp value_present?(_value, _type), do: false end diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 87148ad..3378504 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -144,6 +144,7 @@ defmodule MvWeb.MemberLive.Form do field={value_form[:value]} label={cf.name} type={custom_field_input_type(cf.value_type)} + required={cf.required} /> Ash.Changeset.for_create(:create, %{ + name: "required_string", + value_type: :string, + required: true + }) + |> Ash.create() + + {:ok, required_integer_field} = + Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "required_integer", + value_type: :integer, + required: true + }) + |> Ash.create() + + {:ok, required_boolean_field} = + Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "required_boolean", + value_type: :boolean, + required: true + }) + |> Ash.create() + + {:ok, required_date_field} = + Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "required_date", + value_type: :date, + required: true + }) + |> Ash.create() + + {:ok, optional_field} = + Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "optional_string", + value_type: :string, + required: false + }) + |> Ash.create() + + %{ + required_string_field: required_string_field, + required_integer_field: required_integer_field, + required_boolean_field: required_boolean_field, + required_date_field: required_date_field, + optional_field: optional_field + } + end + + describe "create_member with required custom fields" do + @valid_attrs %{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + } + + test "fails when required custom field is missing", %{required_string_field: field} do + attrs = Map.put(@valid_attrs, :custom_field_values, []) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "fails when required string custom field has nil value", %{ + required_string_field: field + } do + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "string", "value" => nil} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "fails when required string custom field has empty string value", %{ + required_string_field: field + } do + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "string", "value" => ""} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "fails when required string custom field has whitespace-only value", %{ + required_string_field: field + } do + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "string", "value" => " "} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "succeeds when required string custom field has valid value", %{ + required_string_field: field + } do + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "string", "value" => "test value"} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "fails when required integer custom field has nil value", %{ + required_integer_field: field + } do + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "integer", "value" => nil} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "succeeds when required integer custom field has zero value", %{ + required_integer_field: field + } do + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "integer", "value" => 0} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "succeeds when required integer custom field has positive value", %{ + required_integer_field: field + } do + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "integer", "value" => 42} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "fails when required boolean custom field has nil value", %{ + required_boolean_field: field + } do + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "boolean", "value" => nil} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "succeeds when required boolean custom field has false value", %{ + required_boolean_field: field + } do + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "boolean", "value" => false} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "succeeds when required boolean custom field has true value", %{ + required_boolean_field: field + } do + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "boolean", "value" => true} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "fails when required date custom field has nil value", %{ + required_date_field: field + } do + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "date", "value" => nil} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "succeeds when required date custom field has valid date value", %{ + required_date_field: field + } do + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "date", "value" => ~D[2020-01-01]} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "succeeds when multiple required custom fields are provided", %{ + required_string_field: string_field, + required_integer_field: integer_field, + required_boolean_field: boolean_field + } do + custom_field_values = [ + %{ + "custom_field_id" => string_field.id, + "value" => %{"type" => "string", "value" => "test"} + }, + %{ + "custom_field_id" => integer_field.id, + "value" => %{"type" => "integer", "value" => 42} + }, + %{ + "custom_field_id" => boolean_field.id, + "value" => %{"type" => "boolean", "value" => true} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "fails when one of multiple required custom fields is missing", %{ + required_string_field: string_field, + required_integer_field: integer_field + } do + custom_field_values = [ + %{ + "custom_field_id" => string_field.id, + "value" => %{"type" => "string", "value" => "test"} + } + # Missing required_integer_field + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ integer_field.name + end + + test "succeeds when optional custom field is missing", %{optional_field: field} do + attrs = Map.put(@valid_attrs, :custom_field_values, []) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "succeeds when optional custom field has nil value", %{optional_field: field} do + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "string", "value" => nil} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + end + + describe "update_member with required custom fields" do + test "fails when removing a required custom field value", %{ + required_string_field: field + } do + # Create member with required custom field + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "string", "value" => "test"} + } + ] + + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com", + custom_field_values: custom_field_values + }) + + # Try to update without the required custom field + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Membership.update_member(member, %{custom_field_values: []}) + + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "fails when setting required custom field value to empty", %{ + required_string_field: field + } do + # Create member with required custom field + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "string", "value" => "test"} + } + ] + + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com", + custom_field_values: custom_field_values + }) + + # Try to update with empty value + updated_custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "string", "value" => ""} + } + ] + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Membership.update_member(member, %{ + custom_field_values: updated_custom_field_values + }) + + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "succeeds when updating required custom field to valid value", %{ + required_string_field: field + } do + # Create member with required custom field + custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "string", "value" => "old value"} + } + ] + + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com", + custom_field_values: custom_field_values + }) + + # Update with new valid value + updated_custom_field_values = [ + %{ + "custom_field_id" => field.id, + "value" => %{"type" => "string", "value" => "new value"} + } + ] + + assert {:ok, _updated_member} = + Membership.update_member(member, %{ + custom_field_values: updated_custom_field_values + }) + end + end + + # Helper function for error evaluation + defp error_message(errors, field) do + errors + |> Enum.filter(fn err -> Map.get(err, :field) == field end) + |> Enum.map_join(" ", &Map.get(&1, :message, "")) + end +end