refactor: adds schemales changeset and validation constant

This commit is contained in:
carla 2026-01-19 11:43:51 +01:00
parent 14a8417fdf
commit 7da037d81d
6 changed files with 158 additions and 34 deletions

62
docs/email-validation.md Normal file
View file

@ -0,0 +1,62 @@
# Email Validation Strategy
We use `EctoCommons.EmailValidator` with both `:html_input` and `:pow` checks, defined centrally in `Mv.Constants.email_validator_checks/0`.
## Checks Used
- `:html_input` - Pragmatic validation matching browser `<input type="email">` behavior
- `:pow` - Stricter validation following email spec, supports internationalization (Unicode)
## Rationale
Using both checks ensures:
- **Compatibility with common email providers** (`:html_input`) - Matches what users expect from web forms
- **Compliance with email standards** (`:pow`) - Follows RFC 5322 and related specifications
- **Support for international email addresses** (`:pow`) - Allows Unicode characters in email addresses
This dual approach provides a balance between user experience (accepting common email formats) and technical correctness (validating against email standards).
## Usage
The checks are used consistently across all email validation points:
- `Mv.Membership.Import.MemberCSV.validate_row/3` - CSV import validation
- `Mv.Membership.Member` validations - Member resource validation
- `Mv.Accounts.User` validations - User resource validation
All three locations use `Mv.Constants.email_validator_checks()` to ensure consistency.
## Implementation Details
### CSV Import Validation
The CSV import uses a schemaless changeset for email validation:
```elixir
changeset =
{%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: Map.get(member_attrs, :email)}, [:email])
|> Ecto.Changeset.update_change(:email, &String.trim/1)
|> Ecto.Changeset.validate_required([:email])
|> EctoCommons.EmailValidator.validate_email(:email, checks: Mv.Constants.email_validator_checks())
```
This approach:
- Trims whitespace before validation
- Validates email is required
- Validates email format using the centralized checks
- Provides consistent error messages via Gettext
### Resource Validations
Both `Member` and `User` resources use similar schemaless changesets within their Ash validations, ensuring consistent validation behavior across the application.
## Changing the Validation Strategy
To change the email validation checks, update the `@email_validator_checks` constant in `Mv.Constants`. This will automatically apply to all validation points.
**Note:** Changing the validation strategy may affect existing data. Consider:
- Whether existing emails will still be valid
- Migration strategy for invalid emails
- User communication if validation becomes stricter

View file

@ -290,7 +290,9 @@ defmodule Mv.Accounts.User do
changeset2 = changeset2 =
{%{}, %{email: :string}} {%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: email_string}, [:email]) |> Ecto.Changeset.cast(%{email: email_string}, [:email])
|> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow]) |> EctoCommons.EmailValidator.validate_email(:email,
checks: Mv.Constants.email_validator_checks()
)
if changeset2.valid? do if changeset2.valid? do
:ok :ok

View file

@ -453,7 +453,9 @@ defmodule Mv.Membership.Member do
changeset2 = changeset2 =
{%{}, %{email: :string}} {%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: email}, [:email]) |> Ecto.Changeset.cast(%{email: email}, [:email])
|> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow]) |> EctoCommons.EmailValidator.validate_email(:email,
checks: Mv.Constants.email_validator_checks()
)
if changeset2.valid? do if changeset2.valid? do
:ok :ok

View file

@ -19,6 +19,8 @@ defmodule Mv.Constants do
@custom_field_prefix "custom_field_" @custom_field_prefix "custom_field_"
@email_validator_checks [:html_input, :pow]
def member_fields, do: @member_fields def member_fields, do: @member_fields
@doc """ @doc """
@ -30,4 +32,23 @@ defmodule Mv.Constants do
"custom_field_" "custom_field_"
""" """
def custom_field_prefix, do: @custom_field_prefix def custom_field_prefix, do: @custom_field_prefix
@doc """
Returns the email validator checks used for EctoCommons.EmailValidator.
We use both `:html_input` and `:pow` checks:
- `:html_input` - Pragmatic validation matching browser `<input type="email">` behavior
- `:pow` - Stricter validation following email spec, supports internationalization (Unicode)
Using both ensures:
- Compatibility with common email providers (html_input)
- Compliance with email standards (pow)
- Support for international email addresses (pow)
## Examples
iex> Mv.Constants.email_validator_checks()
[:html_input, :pow]
"""
def email_validator_checks, do: @email_validator_checks
end end

View file

@ -334,45 +334,80 @@ defmodule Mv.Membership.Import.MemberCSV do
member_attrs = Map.get(row_map, :member, %{}) member_attrs = Map.get(row_map, :member, %{})
custom_attrs = Map.get(row_map, :custom, %{}) custom_attrs = Map.get(row_map, :custom, %{})
# Trim all string values in member map # Validate email using schemaless changeset
trimmed_member = trim_string_values(member_attrs) changeset =
{%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: Map.get(member_attrs, :email)}, [:email])
|> Ecto.Changeset.update_change(:email, &String.trim/1)
|> Ecto.Changeset.validate_required([:email])
|> EctoCommons.EmailValidator.validate_email(:email,
checks: Mv.Constants.email_validator_checks()
)
# Validate email presence (after trim) if changeset.valid? do
email = Map.get(trimmed_member, :email) # Apply trimmed email back to member_attrs
trimmed_email = Ecto.Changeset.get_change(changeset, :email)
trimmed_member = Map.put(member_attrs, :email, trimmed_email) |> trim_string_values()
{:ok, %{member: trimmed_member, custom: custom_attrs}}
else
# Extract first error
error = extract_changeset_error(changeset, csv_line_number)
{:error, error}
end
end
cond do # Extracts the first error from a changeset and converts it to a MemberCSV.Error struct
is_nil(email) or email == "" -> defp extract_changeset_error(changeset, csv_line_number) do
{:error, case Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end) do
%{email: [message | _]} ->
# Email-specific error
%Error{ %Error{
csv_line_number: csv_line_number, csv_line_number: csv_line_number,
field: :email, field: :email,
message: gettext("Email is required.") message: gettext_error_message(message)
}} }
not valid_email_format?(email) -> errors when map_size(errors) > 0 ->
{:error, # Get first error (any field)
{field, [message | _]} = Enum.at(Enum.to_list(errors), 0)
%Error{
csv_line_number: csv_line_number,
field: String.to_existing_atom(to_string(field)),
message: gettext_error_message(message)
}
_ ->
# Fallback
%Error{ %Error{
csv_line_number: csv_line_number, csv_line_number: csv_line_number,
field: :email, field: :email,
message: gettext("Email is invalid.") message: gettext("Email is invalid.")
}} }
end
end
# Maps changeset error messages to appropriate Gettext messages
defp gettext_error_message(message) when is_binary(message) do
cond do
String.contains?(String.downcase(message), "required") or
String.contains?(String.downcase(message), "can't be blank") ->
gettext("Email is required.")
String.contains?(String.downcase(message), "invalid") or
String.contains?(String.downcase(message), "not a valid") ->
gettext("Email is invalid.")
true -> true ->
{:ok, %{member: trimmed_member, custom: custom_attrs}} message
end end
end end
# Validates email format using EctoCommons.EmailValidator defp gettext_error_message(_), do: gettext("Email is invalid.")
defp valid_email_format?(email) when is_binary(email) do
changeset =
{%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: email}, [:email])
|> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow])
changeset.valid?
end
defp valid_email_format?(_), do: false
# Processes a single row and creates member with custom field values # Processes a single row and creates member with custom field values
defp process_row( defp process_row(

View file

@ -403,6 +403,7 @@ defmodule Mv.Membership.Import.MemberCSVTest do
}, },
custom: %{} custom: %{}
} }
csv_line_number = 2 csv_line_number = 2
opts = [] opts = []
@ -417,6 +418,7 @@ defmodule Mv.Membership.Import.MemberCSVTest do
member: %{email: "john@example.com"}, member: %{email: "john@example.com"},
custom: %{"field1" => "value1"} custom: %{"field1" => "value1"}
} }
csv_line_number = 2 csv_line_number = 2
opts = [] opts = []