diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 583f173..8284aff 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -18,7 +18,7 @@ defmodule Mv.Membership.Member do accept [ :first_name, :last_name, - :email, + :member_email, :birth_date, :paid, :phone_number, @@ -42,7 +42,7 @@ defmodule Mv.Membership.Member do accept [ :first_name, :last_name, - :email, + :member_email, :birth_date, :paid, :phone_number, @@ -65,7 +65,6 @@ defmodule Mv.Membership.Member do # First name and last name must not be empty validate present(:first_name) validate present(:last_name) - validate present(:email) # Birth date not in the future validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0), @@ -92,21 +91,21 @@ defmodule Mv.Membership.Member do where: [present(:postal_code)], message: "must consist of 5 digits" - # Email validation with EctoCommons.EmailValidator + # Email validation with EctoCommons.EmailValidator (only for member_email) validate fn changeset, _ -> - email = Ash.Changeset.get_attribute(changeset, :email) + member_email = Ash.Changeset.get_attribute(changeset, :member_email) changeset2 = {%{}, %{email: :string}} - |> Ecto.Changeset.cast(%{email: email}, [:email]) + |> Ecto.Changeset.cast(%{email: member_email}, [:email]) |> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow]) if changeset2.valid? do :ok else - {:error, field: :email, message: "is not a valid email"} + {:error, field: :member_email, message: "is not a valid email"} end - end + end, where: [present(:member_email)] end attributes do @@ -122,8 +121,9 @@ defmodule Mv.Membership.Member do constraints min_length: 1 end - attribute :email, :string do - allow_nil? false + # Internal email field for members without users + attribute :member_email, :string do + allow_nil? true constraints min_length: 5, max_length: 254 end @@ -170,5 +170,11 @@ defmodule Mv.Membership.Member do relationships do has_many :properties, Mv.Membership.Property + has_one :user, Mv.Accounts.User end + + calculations do + calculate :email, :string, Mv.Membership.MemberEmailCalculation + end + end diff --git a/lib/membership/member_email_calculation.ex b/lib/membership/member_email_calculation.ex new file mode 100644 index 0000000..1554a6d --- /dev/null +++ b/lib/membership/member_email_calculation.ex @@ -0,0 +1,19 @@ +defmodule Mv.Membership.MemberEmailCalculation do + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context) do + # We need member_email and user.email + [:member_email, user: [:email]] + end + + @impl true + def calculate(records, _opts, _context) do + Enum.map(records, fn record -> + case record.user do + %{email: user_email} when is_binary(user_email) -> user_email + _ -> record.member_email + end + end) + end +end \ No newline at end of file diff --git a/priv/repo/migrations/20250805131958_add_member_email_to_members.exs b/priv/repo/migrations/20250805131958_add_member_email_to_members.exs new file mode 100644 index 0000000..24ce69b --- /dev/null +++ b/priv/repo/migrations/20250805131958_add_member_email_to_members.exs @@ -0,0 +1,25 @@ +defmodule Mv.Repo.Migrations.AddMemberEmailToMembers do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + rename table(:members), :email, to: :member_email + + alter table(:members) do + modify :member_email, :text, null: true + end + end + + def down do + alter table(:members) do + modify :email, :text, null: false + end + + rename table(:members), :member_email, to: :email + end +end diff --git a/priv/resource_snapshots/repo/members/20250805131958.json b/priv/resource_snapshots/repo/members/20250805131958.json new file mode 100644 index 0000000..86cc148 --- /dev/null +++ b/priv/resource_snapshots/repo/members/20250805131958.json @@ -0,0 +1,187 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "first_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "member_email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "birth_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "paid", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "phone_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "exit_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "street", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "house_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "postal_code", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "F783CF3A9A24DD4D635BD2820236F3DB9A95F7FA6EBA94A3C15A3F054D579999", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "members" +} \ No newline at end of file diff --git a/test/membership/member_email_test.exs b/test/membership/member_email_test.exs new file mode 100644 index 0000000..bb8c1f8 --- /dev/null +++ b/test/membership/member_email_test.exs @@ -0,0 +1,53 @@ +defmodule Mv.Membership.MemberEmailTest do + use Mv.DataCase, async: true + alias Mv.Membership + + describe "member_email and computed email field" do + test "email shows member_email when no user is assigned" do + {:ok, member} = + Membership.create_member(%{ + first_name: "Test", + last_name: "Member", + member_email: "memberonly@example.com" + }) + + # Load the email calculation + member = Ash.load!(member, :email, domain: Membership) + + assert member.member_email == "memberonly@example.com" + assert member.email == "memberonly@example.com" + end + + test "updating member_email updates the computed email when no user is assigned" do + {:ok, member} = + Membership.create_member(%{ + first_name: "Update", + last_name: "Test", + member_email: "old@example.com" + }) + + {:ok, member} = + Membership.update_member(member, %{member_email: "new@example.com"}) + + # Load the email calculation + member = Ash.load!(member, :email, domain: Membership) + + assert member.member_email == "new@example.com" + assert member.email == "new@example.com" + end + + test "member can be created without member_email" do + {:ok, member} = + Membership.create_member(%{ + first_name: "No", + last_name: "Email" + }) + + # Load the email calculation + member = Ash.load!(member, :email, domain: Membership) + + assert member.member_email == nil + assert member.email == nil + end + end +end