From 91c5e17994589f475fd5bdfff3f2c081f3f79580 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 16:57:34 +0200 Subject: [PATCH 01/31] email sync tests --- test/accounts/email_sync_edge_cases_test.exs | 93 ++++++++++ test/accounts/user_email_sync_test.exs | 169 +++++++++++++++++++ test/membership/member_email_sync_test.exs | 127 ++++++++++++++ 3 files changed, 389 insertions(+) create mode 100644 test/accounts/email_sync_edge_cases_test.exs create mode 100644 test/accounts/user_email_sync_test.exs create mode 100644 test/membership/member_email_sync_test.exs diff --git a/test/accounts/email_sync_edge_cases_test.exs b/test/accounts/email_sync_edge_cases_test.exs new file mode 100644 index 0000000..b872235 --- /dev/null +++ b/test/accounts/email_sync_edge_cases_test.exs @@ -0,0 +1,93 @@ +defmodule Mv.Accounts.EmailSyncEdgeCasesTest do + @moduledoc """ + Edge case tests for email synchronization between User and Member. + Tests various boundary conditions and validation scenarios. + """ + use Mv.DataCase, async: false + alias Mv.Accounts + alias Mv.Membership + + describe "Email sync edge cases" do + @valid_user_attrs %{ + email: "user@example.com" + } + + @valid_member_attrs %{ + first_name: "John", + last_name: "Doe", + email: "member@example.com" + } + + test "simultaneous email updates use user email as source of truth" do + # Create linked user and member + {:ok, member} = Membership.create_member(@valid_member_attrs) + + {:ok, user} = + Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id})) + + # Verify link and initial sync + {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id) + assert synced_member.email == "user@example.com" + + # Scenario: Both emails are updated "simultaneously" + # In practice, this tests that when a member email is updated, + # it syncs to user, and user remains the source of truth + + # Update member email first + {:ok, _updated_member} = + Membership.update_member(member, %{email: "member-new@example.com"}) + + # Verify it synced to user + {:ok, user_after_member_update} = Ash.get(Mv.Accounts.User, user.id) + assert to_string(user_after_member_update.email) == "member-new@example.com" + + # Now update user email - this should override + {:ok, _updated_user} = + Accounts.update_user(user_after_member_update, %{email: "user-final@example.com"}) + + # Reload both + {:ok, final_user} = Ash.get(Mv.Accounts.User, user.id) + {:ok, final_member} = Ash.get(Mv.Membership.Member, member.id) + + # User email should be the final truth + assert to_string(final_user.email) == "user-final@example.com" + assert final_member.email == "user-final@example.com" + end + + test "email validation works for both user and member" do + # Test that invalid emails are rejected for both resources + + # Invalid email for user + invalid_user_result = Accounts.create_user(%{email: "not-an-email"}) + assert {:error, %Ash.Error.Invalid{}} = invalid_user_result + + # Invalid email for member + invalid_member_attrs = Map.put(@valid_member_attrs, :email, "also-not-an-email") + invalid_member_result = Membership.create_member(invalid_member_attrs) + assert {:error, %Ash.Error.Invalid{}} = invalid_member_result + + # Valid emails should work + {:ok, _user} = Accounts.create_user(@valid_user_attrs) + {:ok, _member} = Membership.create_member(@valid_member_attrs) + end + + test "identity constraints prevent duplicate emails" do + # Create first user with an email + {:ok, user1} = Accounts.create_user(%{email: "duplicate@example.com"}) + assert to_string(user1.email) == "duplicate@example.com" + + # Try to create second user with same email - should fail due to unique constraint + result = Accounts.create_user(%{email: "duplicate@example.com"}) + assert {:error, %Ash.Error.Invalid{}} = result + + # Same for members + member_attrs = Map.put(@valid_member_attrs, :email, "member-dup@example.com") + {:ok, member1} = Membership.create_member(member_attrs) + assert member1.email == "member-dup@example.com" + + # Try to create second member with same email - should fail + result2 = Membership.create_member(member_attrs) + assert {:error, %Ash.Error.Invalid{}} = result2 + end + end +end diff --git a/test/accounts/user_email_sync_test.exs b/test/accounts/user_email_sync_test.exs new file mode 100644 index 0000000..6d08d61 --- /dev/null +++ b/test/accounts/user_email_sync_test.exs @@ -0,0 +1,169 @@ +defmodule Mv.Accounts.UserEmailSyncTest do + @moduledoc """ + Tests for email synchronization from User to Member. + When a user and member are linked, email changes should sync bidirectionally. + User.email is the source of truth when linking occurs. + """ + use Mv.DataCase, async: false + alias Mv.Accounts + alias Mv.Membership + + describe "User email synchronization to linked Member" do + @valid_user_attrs %{ + email: "user@example.com" + } + + @valid_member_attrs %{ + first_name: "John", + last_name: "Doe", + email: "member@example.com" + } + + test "updating user email syncs to linked member" do + # Create a member + {:ok, member} = Membership.create_member(@valid_member_attrs) + assert member.email == "member@example.com" + + # Create a user linked to the member + {:ok, user} = + Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id})) + + # Verify initial state - member email should be overridden by user email + {:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id) + assert member_after_link.email == "user@example.com" + + # Update user email + {:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"}) + assert to_string(updated_user.email) == "newemail@example.com" + + # Verify member email was also updated + {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id) + assert synced_member.email == "newemail@example.com" + end + + test "creating user linked to member overrides member email" do + # Create a member with their own email + {:ok, member} = Membership.create_member(@valid_member_attrs) + assert member.email == "member@example.com" + + # Create a user linked to this member + {:ok, user} = + Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id})) + + assert to_string(user.email) == "user@example.com" + assert user.member_id == member.id + + # Verify member email was overridden with user email + {:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id) + assert updated_member.email == "user@example.com" + end + + test "linking user to existing member syncs user email to member" do + # Create a standalone member + {:ok, member} = Membership.create_member(@valid_member_attrs) + assert member.email == "member@example.com" + + # Create a standalone user + {:ok, user} = Accounts.create_user(@valid_user_attrs) + assert to_string(user.email) == "user@example.com" + assert user.member_id == nil + + # Link the user to the member + {:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}}) + assert linked_user.member_id == member.id + + # Verify member email was overridden with user email + {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id) + assert synced_member.email == "user@example.com" + end + + test "updating user email when no member linked does not error" do + # Create a standalone user without member link + {:ok, user} = Accounts.create_user(@valid_user_attrs) + assert to_string(user.email) == "user@example.com" + assert user.member_id == nil + + # Update user email - should work fine without error + {:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"}) + assert to_string(updated_user.email) == "newemail@example.com" + assert updated_user.member_id == nil + end + + test "unlinking user from member does not sync email" do + # Create member + {:ok, member} = Membership.create_member(@valid_member_attrs) + + # Create user linked to member + {:ok, user} = + Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id})) + + assert user.member_id == member.id + + # Verify member email was synced + {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id) + assert synced_member.email == "user@example.com" + + # Unlink user from member + {:ok, unlinked_user} = Accounts.update_user(user, %{member: nil}) + assert unlinked_user.member_id == nil + + # Member email should remain unchanged after unlinking + {:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id) + assert member_after_unlink.email == "user@example.com" + end + end + + describe "AshAuthentication compatibility" do + test "AshAuthentication password strategy still works with email" do + # This test ensures that the email field remains accessible for password auth + email = "test@example.com" + password = "securepassword123" + + # Create user with password strategy (simulating registration) + {:ok, user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:register_with_password, %{ + email: email, + password: password + }) + |> Ash.create() + + assert to_string(user.email) == email + assert user.hashed_password != nil + + # Verify we can sign in with email + {:ok, signed_in_user} = + Mv.Accounts.User + |> Ash.Query.for_read(:sign_in_with_password, %{ + email: email, + password: password + }) + |> Ash.read_one() + + assert signed_in_user.id == user.id + assert to_string(signed_in_user.email) == email + end + + test "AshAuthentication OIDC strategy still works with email" do + # This test ensures the OIDC flow can still set email + user_info = %{ + "preferred_username" => "oidc@example.com", + "sub" => "oidc-user-123" + } + + oauth_tokens = %{"access_token" => "mock_token"} + + # Simulate OIDC registration + {:ok, user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:register_with_rauthy, %{ + user_info: user_info, + oauth_tokens: oauth_tokens + }) + |> Ash.create() + + assert to_string(user.email) == "oidc@example.com" + assert user.oidc_id == "oidc-user-123" + end + end +end diff --git a/test/membership/member_email_sync_test.exs b/test/membership/member_email_sync_test.exs new file mode 100644 index 0000000..eeef210 --- /dev/null +++ b/test/membership/member_email_sync_test.exs @@ -0,0 +1,127 @@ +defmodule Mv.Membership.MemberEmailSyncTest do + @moduledoc """ + Tests for email synchronization from Member to User. + When a member and user are linked, email changes should sync bidirectionally. + User.email is the source of truth when linking occurs. + """ + use Mv.DataCase, async: false + alias Mv.Accounts + alias Mv.Membership + + describe "Member email synchronization to linked User" do + @valid_user_attrs %{ + email: "user@example.com" + } + + @valid_member_attrs %{ + first_name: "John", + last_name: "Doe", + email: "member@example.com" + } + + test "updating member email syncs to linked user" do + # Create a user + {:ok, user} = Accounts.create_user(@valid_user_attrs) + assert to_string(user.email) == "user@example.com" + + # Create a member linked to the user + {:ok, member} = + Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id})) + + # Verify initial state - member email should be overridden by user email + {:ok, member_after_create} = Ash.get(Mv.Membership.Member, member.id) + assert member_after_create.email == "user@example.com" + + # Update member email + {:ok, updated_member} = + Membership.update_member(member, %{email: "newmember@example.com"}) + + assert updated_member.email == "newmember@example.com" + + # Verify user email was also updated + {:ok, synced_user} = Ash.get(Mv.Accounts.User, user.id) + assert to_string(synced_user.email) == "newmember@example.com" + end + + test "creating member linked to user syncs user email to member" do + # Create a user with their own email + {:ok, user} = Accounts.create_user(@valid_user_attrs) + assert to_string(user.email) == "user@example.com" + + # Create a member linked to this user + {:ok, member} = + Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id})) + + # Member should have been created with user's email (user is source of truth) + assert member.email == "user@example.com" + + # Verify the link + {:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert loaded_member.user.id == user.id + end + + test "linking member to existing user syncs user email to member" do + # Create a standalone user + {:ok, user} = Accounts.create_user(@valid_user_attrs) + assert to_string(user.email) == "user@example.com" + + # Create a standalone member + {:ok, member} = Membership.create_member(@valid_member_attrs) + assert member.email == "member@example.com" + + # Link the member to the user + {:ok, linked_member} = Membership.update_member(member, %{user: %{id: user.id}}) + + # Verify the link + {:ok, loaded_member} = Ash.get(Mv.Membership.Member, linked_member.id, load: [:user]) + assert loaded_member.user.id == user.id + + # Verify member email was overridden with user email + assert loaded_member.email == "user@example.com" + end + + test "updating member email when no user linked does not error" do + # Create a standalone member without user link + {:ok, member} = Membership.create_member(@valid_member_attrs) + assert member.email == "member@example.com" + + # Load to verify no user link + {:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert loaded_member.user == nil + + # Update member email - should work fine without error + {:ok, updated_member} = + Membership.update_member(member, %{email: "newemail@example.com"}) + + assert updated_member.email == "newemail@example.com" + end + + test "unlinking member from user does not sync email" do + # Create user + {:ok, user} = Accounts.create_user(@valid_user_attrs) + + # Create member linked to user + {:ok, member} = + Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id})) + + # Verify member email was synced to user email + {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id) + assert synced_member.email == "user@example.com" + + # Verify link exists + {:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert loaded_member.user != nil + + # Unlink member from user + {:ok, unlinked_member} = Membership.update_member(member, %{user: nil}) + + # Verify unlink + {:ok, loaded_unlinked} = Ash.get(Mv.Membership.Member, unlinked_member.id, load: [:user]) + assert loaded_unlinked.user == nil + + # User email should remain unchanged after unlinking + {:ok, user_after_unlink} = Ash.get(Mv.Accounts.User, user.id) + assert to_string(user_after_unlink.email) == "user@example.com" + end + end +end From 5a0a261cd602a6d72b877c6e82c017936285e349 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 17:51:31 +0200 Subject: [PATCH 02/31] add action changes for email sync --- lib/accounts/user.ex | 22 +++++ lib/membership/member.ex | 16 ++++ .../changes/override_member_email_on_link.ex | 47 ++++++++++ .../user/changes/sync_email_to_member.ex | 55 +++++++++++ lib/mv/email_sync/helpers.ex | 93 +++++++++++++++++++ .../override_email_from_user_on_link.ex | 45 +++++++++ .../member/changes/sync_email_to_user.ex | 53 +++++++++++ 7 files changed, 331 insertions(+) create mode 100644 lib/mv/accounts/user/changes/override_member_email_on_link.ex create mode 100644 lib/mv/accounts/user/changes/sync_email_to_member.ex create mode 100644 lib/mv/email_sync/helpers.ex create mode 100644 lib/mv/membership/member/changes/override_email_from_user_on_link.ex create mode 100644 lib/mv/membership/member/changes/sync_email_to_user.ex diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 668ddd4..7101e16 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -89,6 +89,9 @@ defmodule Mv.Accounts.User do # cannot be executed atomically. These validations need to query the database and perform # complex checks that are not supported in atomic operations. require_atomic? false + + # Sync email changes to linked member + change Mv.Accounts.User.Changes.SyncEmailToMember end create :create_user do @@ -111,6 +114,9 @@ defmodule Mv.Accounts.User do # If no member provided, that's fine (optional relationship) on_missing: :ignore ) + + # Override member email with user email when linking + change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink end update :update_user do @@ -137,6 +143,11 @@ defmodule Mv.Accounts.User do # If no member provided, remove existing relationship (allows member removal) on_missing: :unrelate ) + + # Sync email changes to linked member + change Mv.Accounts.User.Changes.SyncEmailToMember + # Override member email with user email when linking + change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink end # Admin action for direct password changes in admin panel @@ -185,6 +196,9 @@ defmodule Mv.Accounts.User do |> Ash.Changeset.change_attribute(:email, user_info["preferred_username"]) |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"]) end + + # Override member email with user email when linking (if member relationship exists) + change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink end end @@ -255,6 +269,14 @@ defmodule Mv.Accounts.User do attributes do uuid_primary_key :id + # IMPORTANT: Email Synchronization + # When user and member are linked, emails are automatically synced bidirectionally. + # User.email is the source of truth - when a link is established, member.email + # is overridden to match user.email. Subsequent changes to either email will + # sync to the other resource. + # See: Mv.Accounts.User.Changes.SyncEmailToMember + # Mv.Accounts.User.Changes.OverrideMemberEmailOnLink + # Mv.Membership.Member.Changes.SyncEmailToUser attribute :email, :ci_string do allow_nil? false public? true diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 4cec072..9330922 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -48,6 +48,9 @@ defmodule Mv.Membership.Member do # If no user provided, that's fine (optional relationship) on_missing: :ignore ) + + # Override member email with user email when linking + change Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink end update :update_member do @@ -89,6 +92,11 @@ defmodule Mv.Membership.Member do # If no user provided, remove existing relationship (allows user removal) on_missing: :unrelate ) + + # Override member email with user email when linking + change Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink + # Sync email changes to linked user + change Mv.Membership.Member.Changes.SyncEmailToUser end end @@ -189,6 +197,14 @@ defmodule Mv.Membership.Member do constraints min_length: 1 end + # IMPORTANT: Email Synchronization + # When member and user are linked, emails are automatically synced bidirectionally. + # User.email is the source of truth - when a link is established, member.email + # is overridden to match user.email. Subsequent changes to either email will + # sync to the other resource. + # See: Mv.Membership.Member.Changes.SyncEmailToUser + # Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink + # Mv.Accounts.User.Changes.SyncEmailToMember attribute :email, :string do allow_nil? false constraints min_length: 5, max_length: 254 diff --git a/lib/mv/accounts/user/changes/override_member_email_on_link.ex b/lib/mv/accounts/user/changes/override_member_email_on_link.ex new file mode 100644 index 0000000..7361718 --- /dev/null +++ b/lib/mv/accounts/user/changes/override_member_email_on_link.ex @@ -0,0 +1,47 @@ +defmodule Mv.Accounts.User.Changes.OverrideMemberEmailOnLink do + @moduledoc """ + Overrides member email with user email when linking a user to a member. + + When a user is linked to a member (either during creation or update), + this change ensures that the member's email is updated to match the user's email. + + User.email is the source of truth when a link is established. + + Uses `around_transaction` to guarantee atomicity - both the user + creation/update and member email override happen in the SAME database transaction. + """ + use Ash.Resource.Change + alias Mv.EmailSync.Helpers + + @impl true + def change(changeset, _opts, context) do + # Skip if already syncing to avoid recursion + if Map.get(context, :syncing_email, false) do + changeset + else + # around_transaction receives the changeset (cs) from Ash + # and a callback that executes the actual database operation + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, user} <- Helpers.extract_record(result), + linked_member <- get_linked_member(user) do + Helpers.sync_email_to_linked_record(result, linked_member, user.email) + else + _ -> result + end + end) + end + end + + # Pattern match on nil member_id - no member linked + defp get_linked_member(%{member_id: nil}), do: nil + + # Load linked member by ID + defp get_linked_member(%{member_id: id}) do + case Ash.get(Mv.Membership.Member, id) do + {:ok, member} -> member + {:error, _} -> nil + end + end +end diff --git a/lib/mv/accounts/user/changes/sync_email_to_member.ex b/lib/mv/accounts/user/changes/sync_email_to_member.ex new file mode 100644 index 0000000..553ca91 --- /dev/null +++ b/lib/mv/accounts/user/changes/sync_email_to_member.ex @@ -0,0 +1,55 @@ +defmodule Mv.Accounts.User.Changes.SyncEmailToMember do + @moduledoc """ + Synchronizes user email changes to the linked member. + + When a user's email is updated and the user is linked to a member, + this change automatically updates the member's email to match. + + This ensures bidirectional email synchronization with User.email + as the source of truth. + + Uses `around_transaction` to guarantee atomicity - both the user + and member updates happen in the SAME database transaction. + """ + use Ash.Resource.Change + alias Mv.EmailSync.Helpers + + @impl true + def change(changeset, _opts, context) do + cond do + # Skip if already syncing to avoid recursion + Map.get(context, :syncing_email, false) -> + changeset + + # Only proceed if email is actually changing + not Ash.Changeset.changing_attribute?(changeset, :email) -> + changeset + + # Apply the sync logic + true -> + new_email = Ash.Changeset.get_attribute(changeset, :email) + + # around_transaction receives the changeset (cs) from Ash + # and a callback that executes the actual database operation + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, user} <- Helpers.extract_record(result), + linked_member <- get_linked_member(user) do + Helpers.sync_email_to_linked_record(result, linked_member, new_email) + else + _ -> result + end + end) + end + end + + defp get_linked_member(%{member_id: nil}), do: nil + + defp get_linked_member(%{member_id: member_id}) do + case Ash.get(Mv.Membership.Member, member_id) do + {:ok, member} -> member + {:error, _} -> nil + end + end +end diff --git a/lib/mv/email_sync/helpers.ex b/lib/mv/email_sync/helpers.ex new file mode 100644 index 0000000..7feaf57 --- /dev/null +++ b/lib/mv/email_sync/helpers.ex @@ -0,0 +1,93 @@ +defmodule Mv.EmailSync.Helpers do + @moduledoc """ + Shared helper functions for email synchronization between User and Member. + + Handles the complexity of `around_transaction` callback results and + provides clean abstractions for email updates within transactions. + """ + + require Logger + import Ecto.Changeset + + @doc """ + Extracts the record from an Ash action result. + + Handles both 2-tuple `{:ok, record}` and 4-tuple + `{:ok, record, changeset, notifications}` patterns. + """ + def extract_record({:ok, record, _changeset, _notifications}), do: {:ok, record} + def extract_record({:ok, record}), do: {:ok, record} + def extract_record({:error, _} = error), do: error + + @doc """ + Updates the result with a new record while preserving the original structure. + + If the original result was a 4-tuple, returns a 4-tuple with the updated record. + If it was a 2-tuple, returns a 2-tuple with the updated record. + """ + def update_result_record({:ok, _old_record, changeset, notifications}, new_record) do + {:ok, new_record, changeset, notifications} + end + + def update_result_record({:ok, _old_record}, new_record) do + {:ok, new_record} + end + + @doc """ + Updates an email field directly via Ecto within the current transaction. + + This bypasses Ash's action system to ensure the update happens in the + same database transaction as the parent action. + """ + def update_email_via_ecto(record, new_email) do + record + |> cast(%{email: to_string(new_email)}, [:email]) + |> Mv.Repo.update() + end + + @doc """ + Synchronizes email to a linked record if it exists. + + Returns the original result unchanged, or an error if sync fails. + """ + def sync_email_to_linked_record(result, linked_record, new_email) do + with {:ok, _source} <- extract_record(result), + record when not is_nil(record) <- linked_record, + {:ok, _updated} <- update_email_via_ecto(record, new_email) do + # Successfully synced - return original result unchanged + result + else + nil -> + # No linked record - return original result + result + + {:error, error} -> + # Sync failed - log and propagate error to rollback transaction + Logger.error("Email sync failed: #{inspect(error)}") + {:error, error} + end + end + + @doc """ + Overrides the record's email with the linked email if emails differ. + + Returns updated result with new record, or original result if no update needed. + """ + def override_with_linked_email(result, linked_email) do + with {:ok, record} <- extract_record(result), + true <- record.email != to_string(linked_email), + {:ok, updated_record} <- update_email_via_ecto(record, linked_email) do + # Email was different - return result with updated record + update_result_record(result, updated_record) + else + false -> + # Emails already match - no update needed + result + + {:error, error} -> + # Override failed - log and propagate error + Logger.error("Email override failed: #{inspect(error)}") + {:error, error} + end + end +end diff --git a/lib/mv/membership/member/changes/override_email_from_user_on_link.ex b/lib/mv/membership/member/changes/override_email_from_user_on_link.ex new file mode 100644 index 0000000..b55a696 --- /dev/null +++ b/lib/mv/membership/member/changes/override_email_from_user_on_link.ex @@ -0,0 +1,45 @@ +defmodule Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink do + @moduledoc """ + Overrides member email with user email when linking a member to a user. + + When a member is linked to a user (either during creation or update), + this change ensures that the member's email is updated to match the user's email. + + User.email is the source of truth when a link is established. + + Uses `around_transaction` to guarantee atomicity - both the member + creation/update and email override happen in the SAME database transaction. + """ + use Ash.Resource.Change + alias Mv.EmailSync.Helpers + + @impl true + def change(changeset, _opts, context) do + # Skip if already syncing to avoid recursion + if Map.get(context, :syncing_email, false) do + changeset + else + # around_transaction receives the changeset (cs) from Ash + # and a callback that executes the actual database operation + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, member} <- Helpers.extract_record(result), + {:ok, user} <- load_linked_user(member) do + Helpers.override_with_linked_email(result, user.email) + else + _ -> result + end + end) + end + end + + # Load the linked user, returning error tuple if not linked + defp load_linked_user(member) do + case Ash.load(member, :user) do + {:ok, %{user: user}} when not is_nil(user) -> {:ok, user} + {:ok, _} -> {:error, :no_linked_user} + {:error, _} = error -> error + end + end +end diff --git a/lib/mv/membership/member/changes/sync_email_to_user.ex b/lib/mv/membership/member/changes/sync_email_to_user.ex new file mode 100644 index 0000000..eac41d5 --- /dev/null +++ b/lib/mv/membership/member/changes/sync_email_to_user.ex @@ -0,0 +1,53 @@ +defmodule Mv.Membership.Member.Changes.SyncEmailToUser do + @moduledoc """ + Synchronizes member email changes to the linked user. + + When a member's email is updated and the member is linked to a user, + this change automatically updates the user's email to match. + + This ensures bidirectional email synchronization. + + Uses `around_transaction` to guarantee atomicity - both the member + and user updates happen in the SAME database transaction. + """ + use Ash.Resource.Change + alias Mv.EmailSync.Helpers + + @impl true + def change(changeset, _opts, context) do + cond do + # Skip if already syncing to avoid recursion + Map.get(context, :syncing_email, false) -> + changeset + + # Only proceed if email is actually changing + not Ash.Changeset.changing_attribute?(changeset, :email) -> + changeset + + # Apply the sync logic + true -> + new_email = Ash.Changeset.get_attribute(changeset, :email) + + # around_transaction receives the changeset (cs) from Ash + # and a callback that executes the actual database operation + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, member} <- Helpers.extract_record(result), + linked_user <- get_linked_user(member) do + Helpers.sync_email_to_linked_record(result, linked_user, new_email) + else + _ -> result + end + end) + end + end + + # Load the linked user relationship (returns nil if not linked) + defp get_linked_user(member) do + case Ash.load(member, :user) do + {:ok, %{user: user}} -> user + {:error, _} -> nil + end + end +end From 39afaf3999f66c5353bd9519e56b4ad13e9f74d9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 14:21:23 +0200 Subject: [PATCH 03/31] feat: email uniqueness constraint between user and member --- lib/accounts/user.ex | 19 +- lib/membership/member.ex | 4 + .../email_not_used_by_other_member.ex | 52 +++++ .../email_not_used_by_other_user.ex | 59 +++++ test/accounts/email_uniqueness_test.exs | 201 ++++++++++++++++++ 5 files changed, 329 insertions(+), 6 deletions(-) create mode 100644 lib/mv/accounts/user/validations/email_not_used_by_other_member.ex create mode 100644 lib/mv/membership/member/validations/email_not_used_by_other_user.ex create mode 100644 test/accounts/email_uniqueness_test.exs diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 7101e16..278e71a 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -66,14 +66,17 @@ defmodule Mv.Accounts.User do end actions do - # Default actions kept for framework/tooling integration: - # - :create -> Used by AshAdmin's generated "Create" UI and by generic - # AshPhoenix helpers that assume a default create action. - # It does NOT manage the :member relationship. For admin - # flows that may link an existing member, use :create_user. + # Default actions for framework/tooling integration: # - :read -> Standard read used across the app and by admin tooling. # - :destroy-> Standard delete used by admin tooling and maintenance tasks. - defaults [:read, :create, :destroy] + # + # NOTE: :create is INTENTIONALLY excluded from defaults! + # Using a default :create would bypass email-synchronization logic. + # Always use one of these explicit create actions instead: + # - :create_user (for manual user creation with optional member link) + # - :register_with_password (for password-based registration) + # - :register_with_rauthy (for OIDC-based registration) + defaults [:read, :destroy] # Primary generic update action: # - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix @@ -209,6 +212,10 @@ defmodule Mv.Accounts.User do where: [action_is([:register_with_password, :admin_set_password])], message: "must have length of at least 8" + # Email uniqueness check for all actions that change the email attribute + # Validates that user email is not already used by another (unlinked) member + validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember + # Email validation with EctoCommons.EmailValidator (same as Member) # This ensures consistency between User and Member email validation validate fn changeset, _ -> diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 9330922..a0799fd 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -108,6 +108,10 @@ defmodule Mv.Membership.Member do validate present(:last_name) validate present(:email) + # Email uniqueness check for all actions that change the email attribute + # Validates that member email is not already used by another (unlinked) user + validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser + # Prevent linking to a user that already has a member # This validation prevents "stealing" users from other members by checking # if the target user is already linked to a different member diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex new file mode 100644 index 0000000..cf3c624 --- /dev/null +++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex @@ -0,0 +1,52 @@ +defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do + @moduledoc """ + Validates that the user's email is not already used by another member + (unless that member is linked to this user). + + This prevents email conflicts when syncing between users and members. + """ + use Ash.Resource.Validation + + @impl true + def validate(changeset, _opts, _context) do + case Ash.Changeset.fetch_change(changeset, :email) do + {:ok, new_email} -> + check_email_not_used_by_other_member(changeset, new_email) + + :error -> + # Email not being changed + :ok + end + end + + defp check_email_not_used_by_other_member(changeset, new_email) do + member_id = Ash.Changeset.get_attribute(changeset, :member_id) + + # Check if any member has this email + # Exclude the member linked to this user (if any) + query = + Mv.Membership.Member + |> Ash.Query.filter(email == ^to_string(new_email)) + |> then(fn q -> + if member_id do + Ash.Query.filter(q, id != ^member_id) + else + q + end + end) + + case Ash.read(query) do + {:ok, []} -> + # No conflicting member found + :ok + + {:ok, members} when is_list(members) and length(members) > 0 -> + # Email is already used by another member + {:error, field: :email, message: "is already used by another member", value: new_email} + + {:error, _} -> + # Error reading members - be safe and allow + :ok + end + end +end diff --git a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex new file mode 100644 index 0000000..f48613b --- /dev/null +++ b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex @@ -0,0 +1,59 @@ +defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do + @moduledoc """ + Validates that the member's email is not already used by another user + (unless that user is linked to this member). + + This prevents email conflicts when syncing between users and members. + """ + use Ash.Resource.Validation + + @impl true + def validate(changeset, _opts, _context) do + case Ash.Changeset.fetch_change(changeset, :email) do + {:ok, new_email} -> + check_email_not_used_by_other_user(changeset, new_email) + + :error -> + # Email not being changed + :ok + end + end + + defp check_email_not_used_by_other_user(changeset, new_email) do + # Load the user relationship to check if this member is linked to a user + member_with_user = + case Ash.load(changeset.data, :user) do + {:ok, loaded} -> loaded + {:error, _} -> changeset.data + end + + linked_user_id = if member_with_user.user, do: member_with_user.user.id, else: nil + + # Check if any user has this email (case-insensitive) + # Exclude the user linked to this member (if any) + query = + Mv.Accounts.User + |> Ash.Query.filter(email == ^new_email) + |> then(fn q -> + if linked_user_id do + Ash.Query.filter(q, id != ^linked_user_id) + else + q + end + end) + + case Ash.read(query) do + {:ok, []} -> + # No conflicting user found + :ok + + {:ok, users} when is_list(users) and length(users) > 0 -> + # Email is already used by another user + {:error, field: :email, message: "is already used by another user", value: new_email} + + {:error, _} -> + # Error reading users - be safe and allow + :ok + end + end +end diff --git a/test/accounts/email_uniqueness_test.exs b/test/accounts/email_uniqueness_test.exs new file mode 100644 index 0000000..8665c48 --- /dev/null +++ b/test/accounts/email_uniqueness_test.exs @@ -0,0 +1,201 @@ +defmodule Mv.Accounts.EmailUniquenessTest do + use Mv.DataCase, async: false + + alias Mv.Accounts + alias Mv.Membership + + describe "Email uniqueness validation" do + test "cannot create member with existing unlinked user email" do + # Create a user with email + {:ok, _user} = + Accounts.create_user(%{ + email: "existing@example.com" + }) + + # Try to create member with same email + result = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "existing@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "cannot create user with existing unlinked member email" do + # Create a member with email + {:ok, _member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "existing@example.com" + }) + + # Try to create user with same email + result = + Accounts.create_user(%{ + email: "existing@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "member email cannot be changed to an existing unlinked user email" do + # Create a user with email + {:ok, user} = + Accounts.create_user(%{ + email: "existing_user@example.com" + }) + + # Create a member with different email + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "member@example.com" + }) + + # Try to change member email to existing user email + result = + Membership.update_member(member, %{ + email: "existing_user@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "user email cannot be changed to an existing unlinked member email" do + # Create a member with email + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "existing_member@example.com" + }) + + # Create a user with different email + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com" + }) + + # Try to change user email to existing member email + result = + Accounts.update_user(user, %{ + email: "existing_member@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "member email syncs to linked user email without validation error" do + # Create a user + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com" + }) + + # Create a member linked to this user + # The override change will set member.email = user.email automatically + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "member@example.com", + user: %{id: user.id} + }) + + # Member email should have been overridden to user email + # This happens through our sync mechanism, which should NOT trigger + # the "email already used" validation because it's the same user + {:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id) + assert member_after_link.email == "user@example.com" + end + + test "user email syncs to linked member without validation error" do + # Create a member + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "member@example.com" + }) + + # Create a user linked to this member + # The override change will set member.email = user.email automatically + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com", + member: %{id: member.id} + }) + + # Member email should have been overridden to user email + # This happens through our sync mechanism, which should NOT trigger + # the "email already used" validation because it's the same member + {:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id) + assert member_after_link.email == "user@example.com" + end + + test "two unlinked users cannot have the same email" do + # Create first user + {:ok, _user1} = + Accounts.create_user(%{ + email: "duplicate@example.com" + }) + + # Try to create second user with same email + result = + Accounts.create_user(%{ + email: "duplicate@example.com" + }) + + assert {:error, %Ash.Error.Invalid{}} = result + end + + test "two unlinked members cannot have the same email (members have unique constraint)" do + # Create first member + {:ok, _member1} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "duplicate@example.com" + }) + + # Try to create second member with same email - should fail + result = + Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "duplicate@example.com" + }) + + assert {:error, %Ash.Error.Invalid{}} = result + # Members DO have a unique email constraint at database level + end + end +end From 752272494544d409e8aaadb114bd79470d208793 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 14:33:25 +0200 Subject: [PATCH 04/31] refactor: email sync changes --- .../changes/override_member_email_on_link.ex | 43 +++++--------- .../user/changes/sync_email_to_member.ex | 57 ++++++------------- lib/mv/email_sync/loader.ex | 40 +++++++++++++ .../override_email_from_user_on_link.ex | 43 +++++--------- .../member/changes/sync_email_to_user.ex | 57 ++++++------------- 5 files changed, 102 insertions(+), 138 deletions(-) create mode 100644 lib/mv/email_sync/loader.ex diff --git a/lib/mv/accounts/user/changes/override_member_email_on_link.ex b/lib/mv/accounts/user/changes/override_member_email_on_link.ex index 7361718..b142a96 100644 --- a/lib/mv/accounts/user/changes/override_member_email_on_link.ex +++ b/lib/mv/accounts/user/changes/override_member_email_on_link.ex @@ -1,47 +1,30 @@ defmodule Mv.Accounts.User.Changes.OverrideMemberEmailOnLink do @moduledoc """ - Overrides member email with user email when linking a user to a member. - - When a user is linked to a member (either during creation or update), - this change ensures that the member's email is updated to match the user's email. - + Overrides member email with user email when linking. User.email is the source of truth when a link is established. - - Uses `around_transaction` to guarantee atomicity - both the user - creation/update and member email override happen in the SAME database transaction. """ use Ash.Resource.Change - alias Mv.EmailSync.Helpers + alias Mv.EmailSync.{Helpers, Loader} @impl true def change(changeset, _opts, context) do - # Skip if already syncing to avoid recursion if Map.get(context, :syncing_email, false) do changeset else - # around_transaction receives the changeset (cs) from Ash - # and a callback that executes the actual database operation - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, user} <- Helpers.extract_record(result), - linked_member <- get_linked_member(user) do - Helpers.sync_email_to_linked_record(result, linked_member, user.email) - else - _ -> result - end - end) + override_email(changeset) end end - # Pattern match on nil member_id - no member linked - defp get_linked_member(%{member_id: nil}), do: nil + defp override_email(changeset) do + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) - # Load linked member by ID - defp get_linked_member(%{member_id: id}) do - case Ash.get(Mv.Membership.Member, id) do - {:ok, member} -> member - {:error, _} -> nil - end + with {:ok, user} <- Helpers.extract_record(result), + linked_member <- Loader.get_linked_member(user) do + Helpers.sync_email_to_linked_record(result, linked_member, user.email) + else + _ -> result + end + end) end end diff --git a/lib/mv/accounts/user/changes/sync_email_to_member.ex b/lib/mv/accounts/user/changes/sync_email_to_member.ex index 553ca91..a007dae 100644 --- a/lib/mv/accounts/user/changes/sync_email_to_member.ex +++ b/lib/mv/accounts/user/changes/sync_email_to_member.ex @@ -1,55 +1,32 @@ defmodule Mv.Accounts.User.Changes.SyncEmailToMember do @moduledoc """ Synchronizes user email changes to the linked member. - - When a user's email is updated and the user is linked to a member, - this change automatically updates the member's email to match. - - This ensures bidirectional email synchronization with User.email - as the source of truth. - - Uses `around_transaction` to guarantee atomicity - both the user - and member updates happen in the SAME database transaction. + Uses `around_transaction` for atomicity - both updates in the same transaction. """ use Ash.Resource.Change - alias Mv.EmailSync.Helpers + alias Mv.EmailSync.{Helpers, Loader} @impl true def change(changeset, _opts, context) do cond do - # Skip if already syncing to avoid recursion - Map.get(context, :syncing_email, false) -> - changeset - - # Only proceed if email is actually changing - not Ash.Changeset.changing_attribute?(changeset, :email) -> - changeset - - # Apply the sync logic - true -> - new_email = Ash.Changeset.get_attribute(changeset, :email) - - # around_transaction receives the changeset (cs) from Ash - # and a callback that executes the actual database operation - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, user} <- Helpers.extract_record(result), - linked_member <- get_linked_member(user) do - Helpers.sync_email_to_linked_record(result, linked_member, new_email) - else - _ -> result - end - end) + Map.get(context, :syncing_email, false) -> changeset + not Ash.Changeset.changing_attribute?(changeset, :email) -> changeset + true -> sync_email(changeset) end end - defp get_linked_member(%{member_id: nil}), do: nil + defp sync_email(changeset) do + new_email = Ash.Changeset.get_attribute(changeset, :email) - defp get_linked_member(%{member_id: member_id}) do - case Ash.get(Mv.Membership.Member, member_id) do - {:ok, member} -> member - {:error, _} -> nil - end + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, user} <- Helpers.extract_record(result), + linked_member <- Loader.get_linked_member(user) do + Helpers.sync_email_to_linked_record(result, linked_member, new_email) + else + _ -> result + end + end) end end diff --git a/lib/mv/email_sync/loader.ex b/lib/mv/email_sync/loader.ex new file mode 100644 index 0000000..ecb1038 --- /dev/null +++ b/lib/mv/email_sync/loader.ex @@ -0,0 +1,40 @@ +defmodule Mv.EmailSync.Loader do + @moduledoc """ + Helper functions for loading linked records in email synchronization. + Centralizes the logic for retrieving related User/Member entities. + """ + + @doc """ + Loads the member linked to a user, returns nil if not linked or on error. + """ + def get_linked_member(%{member_id: nil}), do: nil + + def get_linked_member(%{member_id: id}) do + case Ash.get(Mv.Membership.Member, id) do + {:ok, member} -> member + {:error, _} -> nil + end + end + + @doc """ + Loads the user linked to a member, returns nil if not linked or on error. + """ + def get_linked_user(member) do + case Ash.load(member, :user) do + {:ok, %{user: user}} -> user + {:error, _} -> nil + end + end + + @doc """ + Loads the user linked to a member, returning an error tuple if not linked. + Useful when a link is required for the operation. + """ + def load_linked_user!(member) do + case Ash.load(member, :user) do + {:ok, %{user: user}} when not is_nil(user) -> {:ok, user} + {:ok, _} -> {:error, :no_linked_user} + {:error, _} = error -> error + end + end +end diff --git a/lib/mv/membership/member/changes/override_email_from_user_on_link.ex b/lib/mv/membership/member/changes/override_email_from_user_on_link.ex index b55a696..f8ccd98 100644 --- a/lib/mv/membership/member/changes/override_email_from_user_on_link.ex +++ b/lib/mv/membership/member/changes/override_email_from_user_on_link.ex @@ -1,45 +1,30 @@ defmodule Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink do @moduledoc """ - Overrides member email with user email when linking a member to a user. - - When a member is linked to a user (either during creation or update), - this change ensures that the member's email is updated to match the user's email. - + Overrides member email with user email when linking. User.email is the source of truth when a link is established. - - Uses `around_transaction` to guarantee atomicity - both the member - creation/update and email override happen in the SAME database transaction. """ use Ash.Resource.Change - alias Mv.EmailSync.Helpers + alias Mv.EmailSync.{Helpers, Loader} @impl true def change(changeset, _opts, context) do - # Skip if already syncing to avoid recursion if Map.get(context, :syncing_email, false) do changeset else - # around_transaction receives the changeset (cs) from Ash - # and a callback that executes the actual database operation - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, member} <- Helpers.extract_record(result), - {:ok, user} <- load_linked_user(member) do - Helpers.override_with_linked_email(result, user.email) - else - _ -> result - end - end) + override_email(changeset) end end - # Load the linked user, returning error tuple if not linked - defp load_linked_user(member) do - case Ash.load(member, :user) do - {:ok, %{user: user}} when not is_nil(user) -> {:ok, user} - {:ok, _} -> {:error, :no_linked_user} - {:error, _} = error -> error - end + defp override_email(changeset) do + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, member} <- Helpers.extract_record(result), + {:ok, user} <- Loader.load_linked_user!(member) do + Helpers.override_with_linked_email(result, user.email) + else + _ -> result + end + end) end end diff --git a/lib/mv/membership/member/changes/sync_email_to_user.ex b/lib/mv/membership/member/changes/sync_email_to_user.ex index eac41d5..8363584 100644 --- a/lib/mv/membership/member/changes/sync_email_to_user.ex +++ b/lib/mv/membership/member/changes/sync_email_to_user.ex @@ -1,53 +1,32 @@ defmodule Mv.Membership.Member.Changes.SyncEmailToUser do @moduledoc """ Synchronizes member email changes to the linked user. - - When a member's email is updated and the member is linked to a user, - this change automatically updates the user's email to match. - - This ensures bidirectional email synchronization. - - Uses `around_transaction` to guarantee atomicity - both the member - and user updates happen in the SAME database transaction. + Uses `around_transaction` for atomicity - both updates in the same transaction. """ use Ash.Resource.Change - alias Mv.EmailSync.Helpers + alias Mv.EmailSync.{Helpers, Loader} @impl true def change(changeset, _opts, context) do cond do - # Skip if already syncing to avoid recursion - Map.get(context, :syncing_email, false) -> - changeset - - # Only proceed if email is actually changing - not Ash.Changeset.changing_attribute?(changeset, :email) -> - changeset - - # Apply the sync logic - true -> - new_email = Ash.Changeset.get_attribute(changeset, :email) - - # around_transaction receives the changeset (cs) from Ash - # and a callback that executes the actual database operation - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, member} <- Helpers.extract_record(result), - linked_user <- get_linked_user(member) do - Helpers.sync_email_to_linked_record(result, linked_user, new_email) - else - _ -> result - end - end) + Map.get(context, :syncing_email, false) -> changeset + not Ash.Changeset.changing_attribute?(changeset, :email) -> changeset + true -> sync_email(changeset) end end - # Load the linked user relationship (returns nil if not linked) - defp get_linked_user(member) do - case Ash.load(member, :user) do - {:ok, %{user: user}} -> user - {:error, _} -> nil - end + defp sync_email(changeset) do + new_email = Ash.Changeset.get_attribute(changeset, :email) + + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, member} <- Helpers.extract_record(result), + linked_user <- Loader.get_linked_user(member) do + Helpers.sync_email_to_linked_record(result, linked_user, new_email) + else + _ -> result + end + end) end end From 2693f67d3324ab83cfd28601cc628894f03279c6 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 14:34:04 +0200 Subject: [PATCH 05/31] refactor: email validations --- .../email_not_used_by_other_member.ex | 32 ++++--------- .../email_not_used_by_other_user.ex | 46 +++++++------------ 2 files changed, 27 insertions(+), 51 deletions(-) diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex index cf3c624..d3cb776 100644 --- a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex +++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex @@ -1,9 +1,7 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do @moduledoc """ - Validates that the user's email is not already used by another member - (unless that member is linked to this user). - - This prevents email conflicts when syncing between users and members. + Validates that the user's email is not already used by another member. + Allows syncing with linked member (excludes member_id from check). """ use Ash.Resource.Validation @@ -11,42 +9,32 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do def validate(changeset, _opts, _context) do case Ash.Changeset.fetch_change(changeset, :email) do {:ok, new_email} -> - check_email_not_used_by_other_member(changeset, new_email) + member_id = Ash.Changeset.get_attribute(changeset, :member_id) + check_email_uniqueness(new_email, member_id) :error -> - # Email not being changed :ok end end - defp check_email_not_used_by_other_member(changeset, new_email) do - member_id = Ash.Changeset.get_attribute(changeset, :member_id) - - # Check if any member has this email - # Exclude the member linked to this user (if any) + defp check_email_uniqueness(new_email, exclude_member_id) do query = Mv.Membership.Member |> Ash.Query.filter(email == ^to_string(new_email)) - |> then(fn q -> - if member_id do - Ash.Query.filter(q, id != ^member_id) - else - q - end - end) + |> maybe_exclude_id(exclude_member_id) case Ash.read(query) do {:ok, []} -> - # No conflicting member found :ok - {:ok, members} when is_list(members) and length(members) > 0 -> - # Email is already used by another member + {:ok, _} -> {:error, field: :email, message: "is already used by another member", value: new_email} {:error, _} -> - # Error reading members - be safe and allow :ok end end + + defp maybe_exclude_id(query, nil), do: query + defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id) end diff --git a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex index f48613b..6c544b5 100644 --- a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex +++ b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex @@ -1,9 +1,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do @moduledoc """ - Validates that the member's email is not already used by another user - (unless that user is linked to this member). - - This prevents email conflicts when syncing between users and members. + Validates that the member's email is not already used by another user. + Allows syncing with linked user (excludes linked user from check). """ use Ash.Resource.Validation @@ -11,49 +9,39 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do def validate(changeset, _opts, _context) do case Ash.Changeset.fetch_change(changeset, :email) do {:ok, new_email} -> - check_email_not_used_by_other_user(changeset, new_email) + linked_user_id = get_linked_user_id(changeset.data) + check_email_uniqueness(new_email, linked_user_id) :error -> - # Email not being changed :ok end end - defp check_email_not_used_by_other_user(changeset, new_email) do - # Load the user relationship to check if this member is linked to a user - member_with_user = - case Ash.load(changeset.data, :user) do - {:ok, loaded} -> loaded - {:error, _} -> changeset.data - end - - linked_user_id = if member_with_user.user, do: member_with_user.user.id, else: nil - - # Check if any user has this email (case-insensitive) - # Exclude the user linked to this member (if any) + defp check_email_uniqueness(new_email, exclude_user_id) do query = Mv.Accounts.User |> Ash.Query.filter(email == ^new_email) - |> then(fn q -> - if linked_user_id do - Ash.Query.filter(q, id != ^linked_user_id) - else - q - end - end) + |> maybe_exclude_id(exclude_user_id) case Ash.read(query) do {:ok, []} -> - # No conflicting user found :ok - {:ok, users} when is_list(users) and length(users) > 0 -> - # Email is already used by another user + {:ok, _} -> {:error, field: :email, message: "is already used by another user", value: new_email} {:error, _} -> - # Error reading users - be safe and allow :ok end end + + defp maybe_exclude_id(query, nil), do: query + defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id) + + defp get_linked_user_id(member_data) do + case Ash.load(member_data, :user) do + {:ok, %{user: %{id: id}}} -> id + _ -> nil + end + end end From 001fca1d16d30f47e41e562b7c71b7e17e978ecb Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 16:01:48 +0200 Subject: [PATCH 06/31] refactor: email sync changes --- lib/accounts/user.ex | 29 +++++---- lib/membership/member.ex | 27 +++++--- .../changes/override_member_email_on_link.ex | 30 --------- .../user/changes/sync_email_to_member.ex | 32 ---------- .../changes/sync_member_email_to_user.ex} | 19 +++--- .../changes/sync_user_email_to_member.ex | 62 +++++++++++++++++++ .../override_email_from_user_on_link.ex | 30 --------- 7 files changed, 108 insertions(+), 121 deletions(-) delete mode 100644 lib/mv/accounts/user/changes/override_member_email_on_link.ex delete mode 100644 lib/mv/accounts/user/changes/sync_email_to_member.ex rename lib/mv/{membership/member/changes/sync_email_to_user.ex => email_sync/changes/sync_member_email_to_user.ex} (56%) create mode 100644 lib/mv/email_sync/changes/sync_user_email_to_member.ex delete mode 100644 lib/mv/membership/member/changes/override_email_from_user_on_link.ex diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 278e71a..0fc5ab0 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -93,8 +93,11 @@ defmodule Mv.Accounts.User do # complex checks that are not supported in atomic operations. require_atomic? false - # Sync email changes to linked member - change Mv.Accounts.User.Changes.SyncEmailToMember + # Sync email changes to linked member (User → Member) + # Only runs when email is being changed + change Mv.EmailSync.Changes.SyncUserEmailToMember do + where [changing(:email)] + end end create :create_user do @@ -118,8 +121,8 @@ defmodule Mv.Accounts.User do on_missing: :ignore ) - # Override member email with user email when linking - change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink + # Sync user email to member when linking (User → Member) + change Mv.EmailSync.Changes.SyncUserEmailToMember end update :update_user do @@ -147,10 +150,11 @@ defmodule Mv.Accounts.User do on_missing: :unrelate ) - # Sync email changes to linked member - change Mv.Accounts.User.Changes.SyncEmailToMember - # Override member email with user email when linking - change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink + # Sync email changes and handle linking (User → Member) + # Runs when email OR member relationship changes + change Mv.EmailSync.Changes.SyncUserEmailToMember do + where any([changing(:email), changing(:member)]) + end end # Admin action for direct password changes in admin panel @@ -200,8 +204,8 @@ defmodule Mv.Accounts.User do |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"]) end - # Override member email with user email when linking (if member relationship exists) - change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink + # Sync user email to member when linking (User → Member) + change Mv.EmailSync.Changes.SyncUserEmailToMember end end @@ -281,9 +285,8 @@ defmodule Mv.Accounts.User do # User.email is the source of truth - when a link is established, member.email # is overridden to match user.email. Subsequent changes to either email will # sync to the other resource. - # See: Mv.Accounts.User.Changes.SyncEmailToMember - # Mv.Accounts.User.Changes.OverrideMemberEmailOnLink - # Mv.Membership.Member.Changes.SyncEmailToUser + # See: Mv.EmailSync.Changes.SyncUserEmailToMember + # Mv.EmailSync.Changes.SyncMemberEmailToUser attribute :email, :ci_string do allow_nil? false public? true diff --git a/lib/membership/member.ex b/lib/membership/member.ex index a0799fd..56549fc 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -49,8 +49,11 @@ defmodule Mv.Membership.Member do on_missing: :ignore ) - # Override member email with user email when linking - change Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink + # Sync user email to member when linking (User → Member) + # Only runs when user relationship is being changed + change Mv.EmailSync.Changes.SyncUserEmailToMember do + where [changing(:user)] + end end update :update_member do @@ -93,10 +96,17 @@ defmodule Mv.Membership.Member do on_missing: :unrelate ) - # Override member email with user email when linking - change Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink - # Sync email changes to linked user - change Mv.Membership.Member.Changes.SyncEmailToUser + # Sync member email to user when email changes (Member → User) + # Only runs when email is being changed + change Mv.EmailSync.Changes.SyncMemberEmailToUser do + where [changing(:email)] + end + + # Sync user email to member when linking (User → Member) + # Only runs when user relationship is being changed + change Mv.EmailSync.Changes.SyncUserEmailToMember do + where [changing(:user)] + end end end @@ -206,9 +216,8 @@ defmodule Mv.Membership.Member do # User.email is the source of truth - when a link is established, member.email # is overridden to match user.email. Subsequent changes to either email will # sync to the other resource. - # See: Mv.Membership.Member.Changes.SyncEmailToUser - # Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink - # Mv.Accounts.User.Changes.SyncEmailToMember + # See: Mv.EmailSync.Changes.SyncUserEmailToMember + # Mv.EmailSync.Changes.SyncMemberEmailToUser attribute :email, :string do allow_nil? false constraints min_length: 5, max_length: 254 diff --git a/lib/mv/accounts/user/changes/override_member_email_on_link.ex b/lib/mv/accounts/user/changes/override_member_email_on_link.ex deleted file mode 100644 index b142a96..0000000 --- a/lib/mv/accounts/user/changes/override_member_email_on_link.ex +++ /dev/null @@ -1,30 +0,0 @@ -defmodule Mv.Accounts.User.Changes.OverrideMemberEmailOnLink do - @moduledoc """ - Overrides member email with user email when linking. - User.email is the source of truth when a link is established. - """ - use Ash.Resource.Change - alias Mv.EmailSync.{Helpers, Loader} - - @impl true - def change(changeset, _opts, context) do - if Map.get(context, :syncing_email, false) do - changeset - else - override_email(changeset) - end - end - - defp override_email(changeset) do - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, user} <- Helpers.extract_record(result), - linked_member <- Loader.get_linked_member(user) do - Helpers.sync_email_to_linked_record(result, linked_member, user.email) - else - _ -> result - end - end) - end -end diff --git a/lib/mv/accounts/user/changes/sync_email_to_member.ex b/lib/mv/accounts/user/changes/sync_email_to_member.ex deleted file mode 100644 index a007dae..0000000 --- a/lib/mv/accounts/user/changes/sync_email_to_member.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule Mv.Accounts.User.Changes.SyncEmailToMember do - @moduledoc """ - Synchronizes user email changes to the linked member. - Uses `around_transaction` for atomicity - both updates in the same transaction. - """ - use Ash.Resource.Change - alias Mv.EmailSync.{Helpers, Loader} - - @impl true - def change(changeset, _opts, context) do - cond do - Map.get(context, :syncing_email, false) -> changeset - not Ash.Changeset.changing_attribute?(changeset, :email) -> changeset - true -> sync_email(changeset) - end - end - - defp sync_email(changeset) do - new_email = Ash.Changeset.get_attribute(changeset, :email) - - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, user} <- Helpers.extract_record(result), - linked_member <- Loader.get_linked_member(user) do - Helpers.sync_email_to_linked_record(result, linked_member, new_email) - else - _ -> result - end - end) - end -end diff --git a/lib/mv/membership/member/changes/sync_email_to_user.ex b/lib/mv/email_sync/changes/sync_member_email_to_user.ex similarity index 56% rename from lib/mv/membership/member/changes/sync_email_to_user.ex rename to lib/mv/email_sync/changes/sync_member_email_to_user.ex index 8363584..c1e5aea 100644 --- a/lib/mv/membership/member/changes/sync_email_to_user.ex +++ b/lib/mv/email_sync/changes/sync_member_email_to_user.ex @@ -1,17 +1,22 @@ -defmodule Mv.Membership.Member.Changes.SyncEmailToUser do +defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do @moduledoc """ - Synchronizes member email changes to the linked user. - Uses `around_transaction` for atomicity - both updates in the same transaction. + Synchronizes Member.email → User.email + + Trigger conditions are configured in resources via `where` clauses: + - Member resource: Use `where: [changing(:email)]` + + Used by Member resource for bidirectional email sync. """ use Ash.Resource.Change alias Mv.EmailSync.{Helpers, Loader} @impl true def change(changeset, _opts, context) do - cond do - Map.get(context, :syncing_email, false) -> changeset - not Ash.Changeset.changing_attribute?(changeset, :email) -> changeset - true -> sync_email(changeset) + # Only recursion protection needed - trigger logic is in `where` clauses + if Map.get(context, :syncing_email, false) do + changeset + else + sync_email(changeset) end end diff --git a/lib/mv/email_sync/changes/sync_user_email_to_member.ex b/lib/mv/email_sync/changes/sync_user_email_to_member.ex new file mode 100644 index 0000000..be7dd2c --- /dev/null +++ b/lib/mv/email_sync/changes/sync_user_email_to_member.ex @@ -0,0 +1,62 @@ +defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do + @moduledoc """ + Synchronizes User.email → Member.email + User.email is always the source of truth. + + Trigger conditions are configured in resources via `where` clauses: + - User resource: Use `where: [changing(:email)]` or `where: any([changing(:email), changing(:member)])` + - Member resource: Use `where: [changing(:user)]` + + Can be used by both User and Member resources. + """ + use Ash.Resource.Change + alias Mv.EmailSync.{Helpers, Loader} + + @impl true + def change(changeset, _opts, context) do + # Only recursion protection needed - trigger logic is in `where` clauses + if Map.get(context, :syncing_email, false) do + changeset + else + sync_email(changeset) + end + end + + defp sync_email(changeset) do + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, record} <- Helpers.extract_record(result), + {:ok, user, member} <- get_user_and_member(record) do + # When called from Member-side, we need to update the member in the result + # When called from User-side, we update the linked member in DB only + case record do + %Mv.Membership.Member{} -> + # Member-side: Override member email in result with user email + Helpers.override_with_linked_email(result, user.email) + + %Mv.Accounts.User{} -> + # User-side: Sync user email to linked member in DB + Helpers.sync_email_to_linked_record(result, member, user.email) + end + else + _ -> result + end + end) + end + + # Retrieves user and member - works for both resource types + defp get_user_and_member(%Mv.Accounts.User{} = user) do + case Loader.get_linked_member(user) do + nil -> {:error, :no_member} + member -> {:ok, user, member} + end + end + + defp get_user_and_member(%Mv.Membership.Member{} = member) do + case Loader.load_linked_user!(member) do + {:ok, user} -> {:ok, user, member} + error -> error + end + end +end diff --git a/lib/mv/membership/member/changes/override_email_from_user_on_link.ex b/lib/mv/membership/member/changes/override_email_from_user_on_link.ex deleted file mode 100644 index f8ccd98..0000000 --- a/lib/mv/membership/member/changes/override_email_from_user_on_link.ex +++ /dev/null @@ -1,30 +0,0 @@ -defmodule Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink do - @moduledoc """ - Overrides member email with user email when linking. - User.email is the source of truth when a link is established. - """ - use Ash.Resource.Change - alias Mv.EmailSync.{Helpers, Loader} - - @impl true - def change(changeset, _opts, context) do - if Map.get(context, :syncing_email, false) do - changeset - else - override_email(changeset) - end - end - - defp override_email(changeset) do - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, member} <- Helpers.extract_record(result), - {:ok, user} <- Loader.load_linked_user!(member) do - Helpers.override_with_linked_email(result, user.email) - else - _ -> result - end - end) - end -end From 1495ef45927657d233338bdfbf4eade2c96e6f19 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 20 Oct 2025 14:38:00 +0200 Subject: [PATCH 07/31] fix validation behaviour --- .../email_not_used_by_other_member.ex | 40 +- .../email_not_used_by_other_user.ex | 31 +- test/accounts/email_uniqueness_test.exs | 357 ++++++++++++++++-- 3 files changed, 369 insertions(+), 59 deletions(-) diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex index d3cb776..d42b2c1 100644 --- a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex +++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex @@ -1,26 +1,46 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do @moduledoc """ Validates that the user's email is not already used by another member. - Allows syncing with linked member (excludes member_id from check). + Only validates when: + - User is already linked to a member (member_id != nil) AND email is changing + - User is being linked to a member (member relationship is changing) + + This allows creating users with the same email as unlinked members. """ use Ash.Resource.Validation @impl true def validate(changeset, _opts, _context) do - case Ash.Changeset.fetch_change(changeset, :email) do - {:ok, new_email} -> - member_id = Ash.Changeset.get_attribute(changeset, :member_id) - check_email_uniqueness(new_email, member_id) + email_changing? = Ash.Changeset.changing_attribute?(changeset, :email) + member_changing? = Ash.Changeset.changing_relationship?(changeset, :member) - :error -> - :ok + member_id = Ash.Changeset.get_attribute(changeset, :member_id) + is_linked? = not is_nil(member_id) + + # Only validate if: + # 1. User is linked AND email is changing + # 2. User is being linked/unlinked (member relationship changing) + should_validate? = (is_linked? and email_changing?) or member_changing? + + if should_validate? do + case Ash.Changeset.fetch_change(changeset, :email) do + {:ok, new_email} -> + check_email_uniqueness(new_email, member_id) + + :error -> + # No email change, get current email + current_email = Ash.Changeset.get_attribute(changeset, :email) + check_email_uniqueness(current_email, member_id) + end + else + :ok end end - defp check_email_uniqueness(new_email, exclude_member_id) do + defp check_email_uniqueness(email, exclude_member_id) do query = Mv.Membership.Member - |> Ash.Query.filter(email == ^to_string(new_email)) + |> Ash.Query.filter(email == ^to_string(email)) |> maybe_exclude_id(exclude_member_id) case Ash.read(query) do @@ -28,7 +48,7 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do :ok {:ok, _} -> - {:error, field: :email, message: "is already used by another member", value: new_email} + {:error, field: :email, message: "is already used by another member", value: email} {:error, _} -> :ok diff --git a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex index 6c544b5..54fa243 100644 --- a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex +++ b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex @@ -1,26 +1,37 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do @moduledoc """ Validates that the member's email is not already used by another user. - Allows syncing with linked user (excludes linked user from check). + Only validates when: + - Member is already linked to a user (user != nil) AND email is changing + - Member is being linked to a user (user relationship is changing) + + This allows creating members with the same email as unlinked users. """ use Ash.Resource.Validation @impl true def validate(changeset, _opts, _context) do - case Ash.Changeset.fetch_change(changeset, :email) do - {:ok, new_email} -> - linked_user_id = get_linked_user_id(changeset.data) - check_email_uniqueness(new_email, linked_user_id) + email_changing? = Ash.Changeset.changing_attribute?(changeset, :email) - :error -> - :ok + linked_user_id = get_linked_user_id(changeset.data) + is_linked? = not is_nil(linked_user_id) + + # Only validate if member is already linked AND email is changing + # Do NOT validate when member is being linked (email will be overridden from user) + should_validate? = is_linked? and email_changing? + + if should_validate? do + new_email = Ash.Changeset.get_attribute(changeset, :email) + check_email_uniqueness(new_email, linked_user_id) + else + :ok end end - defp check_email_uniqueness(new_email, exclude_user_id) do + defp check_email_uniqueness(email, exclude_user_id) do query = Mv.Accounts.User - |> Ash.Query.filter(email == ^new_email) + |> Ash.Query.filter(email == ^email) |> maybe_exclude_id(exclude_user_id) case Ash.read(query) do @@ -28,7 +39,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do :ok {:ok, _} -> - {:error, field: :email, message: "is already used by another user", value: new_email} + {:error, field: :email, message: "is already used by another user", value: email} {:error, _} -> :ok diff --git a/test/accounts/email_uniqueness_test.exs b/test/accounts/email_uniqueness_test.exs index 8665c48..a16ebdd 100644 --- a/test/accounts/email_uniqueness_test.exs +++ b/test/accounts/email_uniqueness_test.exs @@ -4,32 +4,26 @@ defmodule Mv.Accounts.EmailUniquenessTest do alias Mv.Accounts alias Mv.Membership - describe "Email uniqueness validation" do - test "cannot create member with existing unlinked user email" do + describe "Email uniqueness validation - Creation" do + test "CAN create member with existing unlinked user email" do # Create a user with email {:ok, _user} = Accounts.create_user(%{ email: "existing@example.com" }) - # Try to create member with same email - result = + # Create member with same email - should succeed + {:ok, member} = Membership.create_member(%{ first_name: "John", last_name: "Doe", email: "existing@example.com" }) - assert {:error, %Ash.Error.Invalid{} = error} = result - - assert error.errors - |> Enum.any?(fn e -> - e.field == :email and - (String.contains?(e.message, "already") or String.contains?(e.message, "used")) - end) + assert to_string(member.email) == "existing@example.com" end - test "cannot create user with existing unlinked member email" do + test "CAN create user with existing unlinked member email" do # Create a member with email {:ok, _member} = Membership.create_member(%{ @@ -38,29 +32,25 @@ defmodule Mv.Accounts.EmailUniquenessTest do email: "existing@example.com" }) - # Try to create user with same email - result = + # Create user with same email - should succeed + {:ok, user} = Accounts.create_user(%{ email: "existing@example.com" }) - assert {:error, %Ash.Error.Invalid{} = error} = result - - assert error.errors - |> Enum.any?(fn e -> - e.field == :email and - (String.contains?(e.message, "already") or String.contains?(e.message, "used")) - end) + assert to_string(user.email) == "existing@example.com" end + end - test "member email cannot be changed to an existing unlinked user email" do + describe "Email uniqueness validation - Updating unlinked entities" do + test "unlinked member email CAN be changed to an existing unlinked user email" do # Create a user with email - {:ok, user} = + {:ok, _user} = Accounts.create_user(%{ email: "existing_user@example.com" }) - # Create a member with different email + # Create an unlinked member with different email {:ok, member} = Membership.create_member(%{ first_name: "John", @@ -68,42 +58,68 @@ defmodule Mv.Accounts.EmailUniquenessTest do email: "member@example.com" }) - # Try to change member email to existing user email - result = + # Change member email to existing user email - should succeed (member is unlinked) + {:ok, updated_member} = Membership.update_member(member, %{ email: "existing_user@example.com" }) - assert {:error, %Ash.Error.Invalid{} = error} = result - - assert error.errors - |> Enum.any?(fn e -> - e.field == :email and - (String.contains?(e.message, "already") or String.contains?(e.message, "used")) - end) + assert to_string(updated_member.email) == "existing_user@example.com" end - test "user email cannot be changed to an existing unlinked member email" do + test "unlinked user email CAN be changed to an existing unlinked member email" do # Create a member with email - {:ok, member} = + {:ok, _member} = Membership.create_member(%{ first_name: "John", last_name: "Doe", email: "existing_member@example.com" }) - # Create a user with different email + # Create an unlinked user with different email {:ok, user} = Accounts.create_user(%{ email: "user@example.com" }) - # Try to change user email to existing member email - result = + # Change user email to existing member email - should succeed (user is unlinked) + {:ok, updated_user} = Accounts.update_user(user, %{ email: "existing_member@example.com" }) + assert to_string(updated_user.email) == "existing_member@example.com" + end + + test "unlinked member email CANNOT be changed to an existing linked user email" do + # Create a user and link it to a member - this makes the user "linked" + {:ok, user} = + Accounts.create_user(%{ + email: "linked_user@example.com" + }) + + {:ok, _member_a} = + Membership.create_member(%{ + first_name: "Member", + last_name: "A", + email: "temp@example.com", + user: %{id: user.id} + }) + + # Create an unlinked member with different email + {:ok, member_b} = + Membership.create_member(%{ + first_name: "Member", + last_name: "B", + email: "member_b@example.com" + }) + + # Try to change unlinked member's email to linked user's email - should fail + result = + Membership.update_member(member_b, %{ + email: "linked_user@example.com" + }) + assert {:error, %Ash.Error.Invalid{} = error} = result assert error.errors @@ -113,6 +129,269 @@ defmodule Mv.Accounts.EmailUniquenessTest do end) end + test "unlinked user email CANNOT be changed to an existing linked member email" do + # Create a user and link it to a member - this makes the member "linked" + {:ok, user_a} = + Accounts.create_user(%{ + email: "user_a@example.com" + }) + + {:ok, _member_a} = + Membership.create_member(%{ + first_name: "Member", + last_name: "A", + email: "temp@example.com", + user: %{id: user_a.id} + }) + + # Reload user to get updated member_id and linked member email + {:ok, user_a_reloaded} = Ash.get(Mv.Accounts.User, user_a.id) + {:ok, user_a_with_member} = Ash.load(user_a_reloaded, :member) + linked_member_email = to_string(user_a_with_member.member.email) + + # Create an unlinked user with different email + {:ok, user_b} = + Accounts.create_user(%{ + email: "user_b@example.com" + }) + + # Try to change unlinked user's email to linked member's email - should fail + result = + Accounts.update_user(user_b, %{ + email: linked_member_email + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + end + + describe "Email uniqueness validation - Creating with linked emails" do + test "CANNOT create member with existing linked user email" do + # Create a user and link it to a member + {:ok, user} = + Accounts.create_user(%{ + email: "linked@example.com" + }) + + {:ok, _member} = + Membership.create_member(%{ + first_name: "First", + last_name: "Member", + email: "temp@example.com", + user: %{id: user.id} + }) + + # Try to create a new member with the linked user's email - should fail + result = + Membership.create_member(%{ + first_name: "Second", + last_name: "Member", + email: "linked@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "CANNOT create user with existing linked member email" do + # Create a user and link it to a member + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com" + }) + + {:ok, _member} = + Membership.create_member(%{ + first_name: "Member", + last_name: "One", + email: "temp@example.com", + user: %{id: user.id} + }) + + # Reload user to get the linked member's email + {:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id) + {:ok, user_with_member} = Ash.load(user_reloaded, :member) + linked_member_email = to_string(user_with_member.member.email) + + # Try to create a new user with the linked member's email - should fail + result = + Accounts.create_user(%{ + email: linked_member_email + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + end + + describe "Email uniqueness validation - Updating linked entities" do + test "linked member email CANNOT be changed to an existing user email" do + # Create a user with email + {:ok, _other_user} = + Accounts.create_user(%{ + email: "other_user@example.com" + }) + + # Create a user and link it to a member + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com" + }) + + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "temp@example.com", + user: %{id: user.id} + }) + + # Try to change linked member's email to other user's email - should fail + result = + Membership.update_member(member, %{ + email: "other_user@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "linked user email CANNOT be changed to an existing member email" do + # Create a member with email + {:ok, _other_member} = + Membership.create_member(%{ + first_name: "Jane", + last_name: "Doe", + email: "other_member@example.com" + }) + + # Create a user and link it to a member + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com" + }) + + {:ok, _member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "temp@example.com", + user: %{id: user.id} + }) + + # Reload user to get updated member_id + {:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id) + + # Try to change linked user's email to other member's email - should fail + result = + Accounts.update_user(user_reloaded, %{ + email: "other_member@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + end + + describe "Email uniqueness validation - Linking" do + test "CANNOT link user to member if user email is already used by another unlinked member" do + # Create a member with email + {:ok, _other_member} = + Membership.create_member(%{ + first_name: "Jane", + last_name: "Doe", + email: "duplicate@example.com" + }) + + # Create a user with same email + {:ok, user} = + Accounts.create_user(%{ + email: "duplicate@example.com" + }) + + # Create a member to link with the user + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Smith", + email: "john@example.com" + }) + + # Try to link user to member - should fail because user.email is already used by other_member + result = + Accounts.update_user(user, %{ + member: %{id: member.id} + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "CAN link member to user even if member email is used by another user (member email gets overridden)" do + # Create a user with email + {:ok, _other_user} = + Accounts.create_user(%{ + email: "duplicate@example.com" + }) + + # Create a member with same email + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "duplicate@example.com" + }) + + # Create a user to link with the member + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com" + }) + + # Link member to user - should succeed because member.email will be overridden + {:ok, updated_member} = + Membership.update_member(member, %{ + user: %{id: user.id} + }) + + # Member email should now be the same as user email + {:ok, member_reloaded} = Ash.get(Mv.Membership.Member, updated_member.id) + assert to_string(member_reloaded.email) == "user@example.com" + end + end + + describe "Email syncing" do test "member email syncs to linked user email without validation error" do # Create a user {:ok, user} = @@ -148,7 +427,7 @@ defmodule Mv.Accounts.EmailUniquenessTest do # Create a user linked to this member # The override change will set member.email = user.email automatically - {:ok, user} = + {:ok, _user} = Accounts.create_user(%{ email: "user@example.com", member: %{id: member.id} From 37fcc26b22396b95f18fccc9abfc82fddb3531ea Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 20 Oct 2025 14:53:38 +0200 Subject: [PATCH 08/31] add seed test --- test/seeds_test.exs | 46 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 test/seeds_test.exs diff --git a/test/seeds_test.exs b/test/seeds_test.exs new file mode 100644 index 0000000..5c589ae --- /dev/null +++ b/test/seeds_test.exs @@ -0,0 +1,46 @@ +defmodule Mv.SeedsTest do + use Mv.DataCase, async: false + + describe "Seeds script" do + test "runs successfully without errors" do + # Run the seeds script - should not raise any errors + assert Code.eval_file("priv/repo/seeds.exs") + + # Basic smoke test: ensure some data was created + {:ok, users} = Ash.read(Mv.Accounts.User) + {:ok, members} = Ash.read(Mv.Membership.Member) + {:ok, property_types} = Ash.read(Mv.Membership.PropertyType) + + assert length(users) > 0, "Seeds should create at least one user" + assert length(members) > 0, "Seeds should create at least one member" + assert length(property_types) > 0, "Seeds should create at least one property type" + end + + test "can be run multiple times (idempotent)" do + # Run seeds first time + assert Code.eval_file("priv/repo/seeds.exs") + + # Count records + {:ok, users_count_1} = Ash.read(Mv.Accounts.User) + {:ok, members_count_1} = Ash.read(Mv.Membership.Member) + {:ok, property_types_count_1} = Ash.read(Mv.Membership.PropertyType) + + # Run seeds second time - should not raise errors + assert Code.eval_file("priv/repo/seeds.exs") + + # Count records again - should be the same (upsert, not duplicate) + {:ok, users_count_2} = Ash.read(Mv.Accounts.User) + {:ok, members_count_2} = Ash.read(Mv.Membership.Member) + {:ok, property_types_count_2} = Ash.read(Mv.Membership.PropertyType) + + assert length(users_count_1) == length(users_count_2), + "Users count should remain same after re-running seeds" + + assert length(members_count_1) == length(members_count_2), + "Members count should remain same after re-running seeds" + + assert length(property_types_count_1) == length(property_types_count_2), + "PropertyTypes count should remain same after re-running seeds" + end + end +end From 899039b3ee720e8712be9f00d8ba1bd013b83d14 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 23 Oct 2025 13:13:12 +0200 Subject: [PATCH 09/31] add docs --- docs/email-sync.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/email-sync.md diff --git a/docs/email-sync.md b/docs/email-sync.md new file mode 100644 index 0000000..c191ff4 --- /dev/null +++ b/docs/email-sync.md @@ -0,0 +1,49 @@ +## Core Rules + +1. **User.email is source of truth** - Always overrides member email when linking +2. **DB constraints** - Prevent duplicates within same table (users.email, members.email) +3. **Custom validations** - Prevent cross-table conflicts only for linked entities +4. **Sync is bidirectional**: User ↔ Member (but User always wins on link) + +--- + +## Decision Tree + +``` +Action: Create/Update/Link Entity with Email X +│ +├─ Does Email X violate DB constraint (same table)? +│ └─ YES → ❌ FAIL (two users or two members with same email) +│ +├─ Is Entity currently linked? (or being linked?) +│ │ +│ ├─ NO (unlinked entity) +│ │ └─ ✅ SUCCESS (no custom validation) +│ │ +│ └─ YES (linked or linking) +│ │ +│ ├─ Action: Update Linked User Email +│ │ ├─ Email used by other member? → ❌ FAIL (validation) +│ │ └─ Email unique? → ✅ SUCCESS + sync to member +│ │ +│ ├─ Action: Update Linked Member Email +│ │ ├─ Email used by other user? → ❌ FAIL (validation) +│ │ └─ Email unique? → ✅ SUCCESS + sync to user +│ │ +│ ├─ Action: Link User to Member (both directions) +│ │ ├─ User email used by other member? → ❌ FAIL (validation) +│ │ └─ Otherwise → ✅ SUCCESS + override member email + +``` + +## Sync Triggers + +| Action | Sync Direction | When | +|--------|---------------|------| +| Update linked user email | User → Member | Email changed | +| Update linked member email | Member → User | Email changed | +| Link user to member | User → Member | Always (override) | +| Link member to user | User → Member | Always (override) | +| Unlink | None | Emails stay as-is | + + From 05812807d6d88f945676aa36a0f8ae6d9f178a28 Mon Sep 17 00:00:00 2001 From: carla Date: Fri, 26 Sep 2025 11:10:14 +0200 Subject: [PATCH 10/31] feat: sort header for members list --- .../live/components/sort_header_component.ex | 64 +++++++ lib/mv_web/live/member_live/index.ex | 173 ++++++++++++++---- lib/mv_web/live/member_live/index.html.heex | 142 ++++++++++++-- 3 files changed, 327 insertions(+), 52 deletions(-) create mode 100644 lib/mv_web/live/components/sort_header_component.ex diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex new file mode 100644 index 0000000..147001e --- /dev/null +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -0,0 +1,64 @@ +defmodule MvWeb.Components.SortHeaderComponent do + @moduledoc """ + Sort Header that can be used as column header and sorts a table: + Props: + - field: atom() # Ash‑Field for sorting + - label: string() # Column Heading (can be aan heex templyte) + - sort_field: atom() | nil # current sort-field from parent liveview + - sort_order: :asc | :desc | nil # current sorting order + """ + use MvWeb, :live_component + + @impl true + def update(assigns, socket) do + {:ok, assign(socket, assigns)} + end + + @impl true + def render(assigns) do + ~H""" + + """ + end + + @impl true + def handle_event("sort", %{"field" => field_str}, socket) do + send(self(), {:sort, field_str}) + {:noreply, socket} + end + + # ------------------------------------------------- + # Hilfsfunktionen für ARIA‑Attribute & Icon‑SVG + # ------------------------------------------------- + defp aria_sort(field, sort_field, dir) when field == sort_field do + case dir do + :asc -> "ascending" + :desc -> "descending" + end + end + + defp aria_sort(_, _, _), do: "none" +end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 47a36ef..066b5e3 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -5,18 +5,18 @@ defmodule MvWeb.MemberLive.Index do import MvWeb.TableComponents @impl true - def mount(_params, _session, socket) do - members = Ash.read!(Mv.Membership.Member) - sorted = Enum.sort_by(members, & &1.first_name) - - {:ok, - socket - |> assign(:page_title, gettext("Members")) + def mount(params, _session, socket) do + socket = + socket + |> assign(:page_title, gettext("Members")) |> assign(:query, "") - |> assign(:sort_field, :first_name) - |> assign(:sort_order, :asc) - |> assign(:members, sorted) - |> assign(:selected_members, [])} + |> assign_new(:sort_field, fn -> :first_name end) + |> assign_new(:sort_order, fn -> :asc end) + |> assign(:selected_members, []) + + # We call handle params to use the query from the URL + {:noreply, socket} = handle_params(params, nil, socket) + {:ok, socket} end # ----------------------------------------------------------------- @@ -45,6 +45,11 @@ defmodule MvWeb.MemberLive.Index do # Handle Events # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + # Handle Events + # ----------------------------------------------------------------- + + # Delete a member @impl true def handle_event("delete", %{"id" => id}, socket) do member = Ash.get!(Mv.Membership.Member, id) @@ -67,32 +72,7 @@ defmodule MvWeb.MemberLive.Index do {:noreply, assign(socket, :selected_members, selected)} end - # Sorts the list of members according to a field, when you click on the column header - @impl true - def handle_event("sort", %{"field" => field_str}, socket) do - members = socket.assigns.members - field = String.to_existing_atom(field_str) - - new_order = - if socket.assigns.sort_field == field do - toggle_order(socket.assigns.sort_order) - else - :asc - end - - sorted_members = - members - |> Enum.sort_by(&Map.get(&1, field), sort_fun(new_order)) - - {:noreply, - socket - |> assign(:sort_field, field) - |> assign(:sort_order, new_order) - |> assign(:members, sorted_members)} - end - - # Selects all members in the list of members - + # Selects all members in the list of members @impl true def handle_event("select_all", _params, socket) do members = socket.assigns.members @@ -109,8 +89,123 @@ defmodule MvWeb.MemberLive.Index do {:noreply, assign(socket, :selected_members, selected)} end + # ----------------------------------------------------------------- + # Handle Infos from Child Components + # ----------------------------------------------------------------- + + # Sorts the list of members according to a field, when you click on the column header + @impl true + def handle_info({:sort, field_str}, socket) do + field = String.to_existing_atom(field_str) + + {new_order, new_field} = + if socket.assigns.sort_field == field do + {toggle_order(socket.assigns.sort_order), field} + else + {:asc, field} + end + + active_id = :"sort_#{new_field}" + + # Update the SortHeader to + send_update(MvWeb.Components.SortHeaderComponent, + id: active_id, + sort_field: new_field, + sort_order: new_order + ) + + # Build the URL with queries + query_params = %{ + "sort_field" => Atom.to_string(new_field), + "sort_order" => Atom.to_string(new_order) + } + + # "/members" is the path you defined in router.ex + new_path = "/members?" <> URI.encode_query(query_params) + + # Push the new URL + {:noreply, + push_patch(socket, + to: new_path, + # replace true + replace: true + )} + end + + # ----------------------------------------------------------------- + # Handle Params from the URL + # ----------------------------------------------------------------- + @impl true + def handle_params(params, _url, socket) do + socket = + socket + |> maybe_update_sort(params) + |> load_members() + + {:noreply, socket} + end + + # ------------------------------------------------------------- + # FUNCTIONS + # ------------------------------------------------------------- + # Load members eg based on a query for sorting + defp load_members(socket) do + query = + Mv.Membership.Member + |> Ash.Query.new() + |> Ash.Query.select([ + :id, + :first_name, + :last_name, + :email, + :street, + :house_number, + :postal_code, + :city, + :phone_number, + :join_date + ]) + |> maybe_sort(socket.assigns.sort_field, socket.assigns.sort_order) + + members = Ash.read!(query) + assign(socket, :members, members) + end + + defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do + field = + try do + String.to_existing_atom(sf) + rescue + ArgumentError -> socket.assigns.sort_field + end + + order = if so in ["asc", "desc"], do: String.to_atom(so), else: socket.assigns.sort_order + + IO.inspect(order) + + socket + |> assign(:sort_field, field) + |> assign(:sort_order, order) + end + + # ------------------------------------------------------------- + # Helper Functions + # ------------------------------------------------------------- + + # Functions to toggle sorting order defp toggle_order(:asc), do: :desc defp toggle_order(:desc), do: :asc - defp sort_fun(:asc), do: &<=/2 - defp sort_fun(:desc), do: &>=/2 + defp toggle_order(nil), do: :asc + + # Function to turn a string into an atom only if it already exists + defp maybe_atom(nil), do: nil + defp maybe_atom(atom) when is_atom(atom), do: atom + defp maybe_atom(str) when is_binary(str), do: String.to_existing_atom(str) + + # Function to sort the column if needed + defp maybe_sort(query, nil, _), do: query + defp maybe_sort(query, field, :asc), do: Ash.Query.sort(query, [{field, :asc}]) + defp maybe_sort(query, field, :desc), do: Ash.Query.sort(query, [{field, :desc}]) + # no changes + defp maybe_update_sort(socket, _), do: socket end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 410728a..cb2ccd8 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -52,23 +52,139 @@ <:col :let={member} label={ - sort_button(%{ - field: :first_name, - label: gettext("Name"), - sort_field: @sort_field, - sort_order: @sort_order - }) + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_first_name} + field={:first_name} + label={gettext("First name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ } > {member.first_name} {member.last_name} - <:col :let={member} label={gettext("Email")}>{member.email} - <:col :let={member} label={gettext("Street")}>{member.street} - <:col :let={member} label={gettext("House Number")}>{member.house_number} - <:col :let={member} label={gettext("Postal Code")}>{member.postal_code} - <:col :let={member} label={gettext("City")}>{member.city} - <:col :let={member} label={gettext("Phone Number")}>{member.phone_number} - <:col :let={member} label={gettext("Join Date")}>{member.join_date} + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_email} + field={:email} + label={gettext("Email")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.email} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_street} + field={:street} + label={gettext("Street")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.street} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_house_number} + field={:house_number} + label={gettext("House Number")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.house_number} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_postal_code} + field={:postal_code} + label={gettext("Postal Code")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.postal_code} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_city} + field={:city} + label={gettext("City")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.city} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_phone_number} + field={:phone_number} + label={gettext("Phone Number")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.phone_number} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_join_date} + field={:join_date} + label={gettext("Join Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.join_date} + <:action :let={member}>
From b4b96695fefdb2adf56783a991fdb38934dc1d2c Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 30 Sep 2025 16:19:36 +0200 Subject: [PATCH 11/31] docs: formatting, docs and accessibility fix --- .../live/components/sort_header_component.ex | 25 ++++++------- lib/mv_web/live/member_live/index.ex | 36 ++++++++----------- 2 files changed, 26 insertions(+), 35 deletions(-) diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index 147001e..e69357e 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -14,32 +14,29 @@ defmodule MvWeb.Components.SortHeaderComponent do {:ok, assign(socket, assigns)} end + # Check if we can add the aria-sort label directly to the daisyUI header + # aria-sort={aria_sort(@field, @sort_field, @sort_order)} @impl true def render(assigns) do ~H""" """ end @@ -55,10 +52,10 @@ defmodule MvWeb.Components.SortHeaderComponent do # ------------------------------------------------- defp aria_sort(field, sort_field, dir) when field == sort_field do case dir do - :asc -> "ascending" - :desc -> "descending" + :asc -> gettext("ascending") + :desc -> gettext("descending") end end - defp aria_sort(_, _, _), do: "none" + defp aria_sort(_, _, _), do: gettext("Click to sort") end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 066b5e3..52b16ae 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -171,6 +171,21 @@ defmodule MvWeb.MemberLive.Index do assign(socket, :members, members) end + # ------------------------------------------------------------- + # Helper Functions + # ------------------------------------------------------------- + + # Functions to toggle sorting order + defp toggle_order(:asc), do: :desc + defp toggle_order(:desc), do: :asc + defp toggle_order(nil), do: :asc + + # Function to sort the column if needed + defp maybe_sort(query, nil, _), do: query + defp maybe_sort(query, field, :asc), do: Ash.Query.sort(query, [{field, :asc}]) + defp maybe_sort(query, field, :desc), do: Ash.Query.sort(query, [{field, :desc}]) + + # Function to maybe update the sort defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = try do @@ -181,31 +196,10 @@ defmodule MvWeb.MemberLive.Index do order = if so in ["asc", "desc"], do: String.to_atom(so), else: socket.assigns.sort_order - IO.inspect(order) - socket |> assign(:sort_field, field) |> assign(:sort_order, order) end - # ------------------------------------------------------------- - # Helper Functions - # ------------------------------------------------------------- - - # Functions to toggle sorting order - defp toggle_order(:asc), do: :desc - defp toggle_order(:desc), do: :asc - defp toggle_order(nil), do: :asc - - # Function to turn a string into an atom only if it already exists - defp maybe_atom(nil), do: nil - defp maybe_atom(atom) when is_atom(atom), do: atom - defp maybe_atom(str) when is_binary(str), do: String.to_existing_atom(str) - - # Function to sort the column if needed - defp maybe_sort(query, nil, _), do: query - defp maybe_sort(query, field, :asc), do: Ash.Query.sort(query, [{field, :asc}]) - defp maybe_sort(query, field, :desc), do: Ash.Query.sort(query, [{field, :desc}]) - # no changes defp maybe_update_sort(socket, _), do: socket end From 651b1cdaebff5a8f55de2db298b2a6a5bac11c28 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 30 Sep 2025 16:19:52 +0200 Subject: [PATCH 12/31] test: added tests --- .../components/sort_header_component_test.exs | 12 ++++++ test/mv_web/member_live/index_test.exs | 37 ++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 test/mv_web/components/sort_header_component_test.exs diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs new file mode 100644 index 0000000..9b1c006 --- /dev/null +++ b/test/mv_web/components/sort_header_component_test.exs @@ -0,0 +1,12 @@ +defmodule MvWeb.Components.SortHeaderComponentTest do + use MvWeb.ConnCase, async: true + use Phoenix.Component + import Phoenix.LiveViewTest + + test "renders sort header with correct attributes", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + assert view |> element("[data-testid='first_name']") + end +end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 240e15e..f697d6e 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -56,7 +56,6 @@ defmodule MvWeb.MemberLive.IndexTest do test "shows translated flash message after creating a member in English", %{conn: conn} do conn = conn_with_oidc_user(conn) - conn = Plug.Test.init_test_session(conn, locale: "en") {:ok, form_view, _html} = live(conn, "/members/new") form_data = %{ @@ -75,6 +74,42 @@ defmodule MvWeb.MemberLive.IndexTest do assert has_element?(index_view, "#flash-group", "Member create successfully") end + describe "sorting interaction" do + test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # The component data test ids are built as "" + # First click – should sort ASC + view + |> element("[data-testid='email']") + |> render_click() + + # The LiveView pushes a patch with the new query params + assert_patch(view, "/members?sort_field=email&sort_order=asc") + + # Second click – toggles to DESC + view + |> element("[data-testid='email']") + |> render_click() + + assert_patch(view, "/members?sort_field=email&sort_order=desc") + end + end + + describe "URL param handling" do + test "handle_params reads sort query and applies it", %{conn: conn} do + conn = conn_with_oidc_user(conn) + url = "/members?sort_field=email&sort_order=desc" + + conn = get(conn, url) + + # The LiveView must have parsed the params and stored them as atoms. + assert conn.assigns.sort_field == :email + assert conn.assigns.sort_order == :desc + end + end + test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") From 2a3cc167e7018339403e85db9733095810fcf0d6 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 30 Sep 2025 16:23:33 +0200 Subject: [PATCH 13/31] chore: updated translation --- priv/gettext/de/LC_MESSAGES/auth.po | 3 + priv/gettext/de/LC_MESSAGES/default.po | 58 ++++++++------ priv/gettext/default.pot | 51 +++++++----- priv/gettext/en/LC_MESSAGES/auth.po | 3 + priv/gettext/en/LC_MESSAGES/default.po | 105 ++++++++----------------- 5 files changed, 101 insertions(+), 119 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index f7eef3e..967755e 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -61,3 +61,6 @@ msgstr "Anmelden..." msgid "Your password has successfully been reset" msgstr "Das Passwort wurde erfolgreich zurückgesetzt" + +#~ msgid "Sign in with Rauthy" +#~ msgstr "Anmelden mit der Vereinscloud" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index bd86f61..c8c219a 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -15,7 +15,7 @@ msgstr "" msgid "Actions" msgstr "Aktionen" -#: lib/mv_web/live/member_live/index.html.heex:84 +#: lib/mv_web/live/member_live/index.html.heex:193 #: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" @@ -28,19 +28,19 @@ msgid "Attempting to reconnect" msgstr "Verbindung wird wiederhergestellt" #: lib/mv_web/live/member_live/form.ex:25 -#: lib/mv_web/live/member_live/index.html.heex:69 -#: lib/mv_web/live/member_live/show.ex:37 +#: lib/mv_web/live/member_live/index.html.heex:138 +#: lib/mv_web/live/member_live/show.ex:36 #, elixir-autogen, elixir-format msgid "City" msgstr "Stadt" -#: lib/mv_web/live/member_live/index.html.heex:86 +#: lib/mv_web/live/member_live/index.html.heex:195 #: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "Löschen" -#: lib/mv_web/live/member_live/index.html.heex:78 +#: lib/mv_web/live/member_live/index.html.heex:187 #: lib/mv_web/live/user_live/form.ex:109 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format @@ -54,8 +54,8 @@ msgid "Edit Member" msgstr "Mitglied bearbeiten" #: lib/mv_web/live/member_live/form.ex:18 -#: lib/mv_web/live/member_live/index.html.heex:65 -#: lib/mv_web/live/member_live/show.ex:28 +#: lib/mv_web/live/member_live/index.html.heex:70 +#: lib/mv_web/live/member_live/show.ex:27 #: lib/mv_web/live/user_live/form.ex:14 #: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/show.ex:25 @@ -70,8 +70,8 @@ msgid "First Name" msgstr "Vorname" #: lib/mv_web/live/member_live/form.ex:22 -#: lib/mv_web/live/member_live/index.html.heex:71 -#: lib/mv_web/live/member_live/show.ex:34 +#: lib/mv_web/live/member_live/index.html.heex:172 +#: lib/mv_web/live/member_live/show.ex:33 #, elixir-autogen, elixir-format msgid "Join Date" msgstr "Beitrittsdatum" @@ -87,7 +87,7 @@ msgstr "Nachname" msgid "New Member" msgstr "Neues Mitglied" -#: lib/mv_web/live/member_live/index.html.heex:75 +#: lib/mv_web/live/member_live/index.html.heex:184 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -127,8 +127,8 @@ msgid "Exit Date" msgstr "Austrittsdatum" #: lib/mv_web/live/member_live/form.ex:27 -#: lib/mv_web/live/member_live/index.html.heex:67 -#: lib/mv_web/live/member_live/show.ex:39 +#: lib/mv_web/live/member_live/index.html.heex:104 +#: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "House Number" msgstr "Hausnummer" @@ -146,15 +146,15 @@ msgid "Paid" msgstr "Bezahlt" #: lib/mv_web/live/member_live/form.ex:21 -#: lib/mv_web/live/member_live/index.html.heex:70 -#: lib/mv_web/live/member_live/show.ex:33 +#: lib/mv_web/live/member_live/index.html.heex:155 +#: lib/mv_web/live/member_live/show.ex:32 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "Telefonnummer" #: lib/mv_web/live/member_live/form.ex:28 -#: lib/mv_web/live/member_live/index.html.heex:68 -#: lib/mv_web/live/member_live/show.ex:40 +#: lib/mv_web/live/member_live/index.html.heex:121 +#: lib/mv_web/live/member_live/show.ex:39 #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "Postleitzahl" @@ -173,8 +173,8 @@ msgid "Saving..." msgstr "Speichern..." #: lib/mv_web/live/member_live/form.ex:26 -#: lib/mv_web/live/member_live/index.html.heex:66 -#: lib/mv_web/live/member_live/show.ex:38 +#: lib/mv_web/live/member_live/index.html.heex:87 +#: lib/mv_web/live/member_live/show.ex:37 #, elixir-autogen, elixir-format msgid "Street" msgstr "Straße" @@ -317,14 +317,13 @@ msgstr "Benutzer*innen auflisten" msgid "Member" msgstr "Mitglied" -#: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:14 +#: lib/mv_web/components/layouts/navbar.ex:14 +#: lib/mv_web/live/member_live/index.ex:8 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "Mitglieder" -#: lib/mv_web/live/member_live/index.html.heex:57 #: lib/mv_web/live/property_type_live/form.ex:16 #, elixir-autogen, elixir-format msgid "Name" @@ -469,11 +468,13 @@ msgid "Value type" msgstr "Wertetyp" #: lib/mv_web/components/table_components.ex:30 +#: lib/mv_web/live/components/sort_header_component.ex:55 #, elixir-autogen, elixir-format msgid "ascending" msgstr "aufsteigend" #: lib/mv_web/components/table_components.ex:30 +#: lib/mv_web/live/components/sort_header_component.ex:56 #, elixir-autogen, elixir-format msgid "descending" msgstr "absteigend" @@ -600,10 +601,15 @@ msgstr "Dunklen Modus umschalten" #: lib/mv_web/live/components/search_bar_component.ex:15 #: lib/mv_web/live/member_live/index.html.heex:15 #, elixir-autogen, elixir-format -msgid "Search..." -msgstr "" +msgid "Click to sort" +msgstr "Klicke um zu sortieren" -#: lib/mv_web/components/layouts/navbar.ex:20 +#: lib/mv_web/live/member_live/index.html.heex:53 #, elixir-autogen, elixir-format, fuzzy -msgid "Users" -msgstr "Benutzer*innen" +msgid "First name" +msgstr "Vorname" + +#~ #: lib/mv_web/auth_overrides.ex:30 +#~ #, elixir-autogen, elixir-format +#~ msgid "or" +#~ msgstr "oder" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 93c5d95..4c5438a 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -16,7 +16,7 @@ msgstr "" msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:84 +#: lib/mv_web/live/member_live/index.html.heex:193 #: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" @@ -29,19 +29,19 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:25 -#: lib/mv_web/live/member_live/index.html.heex:69 -#: lib/mv_web/live/member_live/show.ex:37 +#: lib/mv_web/live/member_live/index.html.heex:138 +#: lib/mv_web/live/member_live/show.ex:36 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:86 +#: lib/mv_web/live/member_live/index.html.heex:195 #: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:78 +#: lib/mv_web/live/member_live/index.html.heex:187 #: lib/mv_web/live/user_live/form.ex:109 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format @@ -55,8 +55,8 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:18 -#: lib/mv_web/live/member_live/index.html.heex:65 -#: lib/mv_web/live/member_live/show.ex:28 +#: lib/mv_web/live/member_live/index.html.heex:70 +#: lib/mv_web/live/member_live/show.ex:27 #: lib/mv_web/live/user_live/form.ex:14 #: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/show.ex:25 @@ -71,8 +71,8 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:22 -#: lib/mv_web/live/member_live/index.html.heex:71 -#: lib/mv_web/live/member_live/show.ex:34 +#: lib/mv_web/live/member_live/index.html.heex:172 +#: lib/mv_web/live/member_live/show.ex:33 #, elixir-autogen, elixir-format msgid "Join Date" msgstr "" @@ -88,7 +88,7 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:75 +#: lib/mv_web/live/member_live/index.html.heex:184 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -128,8 +128,8 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:27 -#: lib/mv_web/live/member_live/index.html.heex:67 -#: lib/mv_web/live/member_live/show.ex:39 +#: lib/mv_web/live/member_live/index.html.heex:104 +#: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "House Number" msgstr "" @@ -147,15 +147,15 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:21 -#: lib/mv_web/live/member_live/index.html.heex:70 -#: lib/mv_web/live/member_live/show.ex:33 +#: lib/mv_web/live/member_live/index.html.heex:155 +#: lib/mv_web/live/member_live/show.ex:32 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:28 -#: lib/mv_web/live/member_live/index.html.heex:68 -#: lib/mv_web/live/member_live/show.ex:40 +#: lib/mv_web/live/member_live/index.html.heex:121 +#: lib/mv_web/live/member_live/show.ex:39 #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "" @@ -174,8 +174,8 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:26 -#: lib/mv_web/live/member_live/index.html.heex:66 -#: lib/mv_web/live/member_live/show.ex:38 +#: lib/mv_web/live/member_live/index.html.heex:87 +#: lib/mv_web/live/member_live/show.ex:37 #, elixir-autogen, elixir-format msgid "Street" msgstr "" @@ -318,14 +318,13 @@ msgstr "" msgid "Member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:14 +#: lib/mv_web/components/layouts/navbar.ex:14 +#: lib/mv_web/live/member_live/index.ex:8 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:57 #: lib/mv_web/live/property_type_live/form.ex:16 #, elixir-autogen, elixir-format msgid "Name" @@ -470,11 +469,13 @@ msgid "Value type" msgstr "" #: lib/mv_web/components/table_components.ex:30 +#: lib/mv_web/live/components/sort_header_component.ex:55 #, elixir-autogen, elixir-format msgid "ascending" msgstr "" #: lib/mv_web/components/table_components.ex:30 +#: lib/mv_web/live/components/sort_header_component.ex:56 #, elixir-autogen, elixir-format msgid "descending" msgstr "" @@ -607,4 +608,12 @@ msgstr "" #: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format msgid "Users" +#: lib/mv_web/live/components/sort_header_component.ex:60 +#, elixir-autogen, elixir-format +msgid "Click to sort" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex:53 +#, elixir-autogen, elixir-format +msgid "First name" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po index 21bb4a4..59ce742 100644 --- a/priv/gettext/en/LC_MESSAGES/auth.po +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -58,3 +58,6 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" + +#~ msgid "Sign in with Rauthy" +#~ msgstr "Sign in with Vereinscloud" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index ac30f5d..451ba84 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -16,7 +16,7 @@ msgstr "" msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:84 +#: lib/mv_web/live/member_live/index.html.heex:193 #: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" @@ -29,19 +29,19 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:25 -#: lib/mv_web/live/member_live/index.html.heex:69 -#: lib/mv_web/live/member_live/show.ex:37 +#: lib/mv_web/live/member_live/index.html.heex:138 +#: lib/mv_web/live/member_live/show.ex:36 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:86 +#: lib/mv_web/live/member_live/index.html.heex:195 #: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:78 +#: lib/mv_web/live/member_live/index.html.heex:187 #: lib/mv_web/live/user_live/form.ex:109 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format @@ -55,8 +55,8 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:18 -#: lib/mv_web/live/member_live/index.html.heex:65 -#: lib/mv_web/live/member_live/show.ex:28 +#: lib/mv_web/live/member_live/index.html.heex:70 +#: lib/mv_web/live/member_live/show.ex:27 #: lib/mv_web/live/user_live/form.ex:14 #: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/show.ex:25 @@ -71,8 +71,8 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:22 -#: lib/mv_web/live/member_live/index.html.heex:71 -#: lib/mv_web/live/member_live/show.ex:34 +#: lib/mv_web/live/member_live/index.html.heex:172 +#: lib/mv_web/live/member_live/show.ex:33 #, elixir-autogen, elixir-format msgid "Join Date" msgstr "" @@ -88,7 +88,7 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:75 +#: lib/mv_web/live/member_live/index.html.heex:184 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -128,8 +128,8 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:27 -#: lib/mv_web/live/member_live/index.html.heex:67 -#: lib/mv_web/live/member_live/show.ex:39 +#: lib/mv_web/live/member_live/index.html.heex:104 +#: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "House Number" msgstr "" @@ -147,15 +147,15 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:21 -#: lib/mv_web/live/member_live/index.html.heex:70 -#: lib/mv_web/live/member_live/show.ex:33 +#: lib/mv_web/live/member_live/index.html.heex:155 +#: lib/mv_web/live/member_live/show.ex:32 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:28 -#: lib/mv_web/live/member_live/index.html.heex:68 -#: lib/mv_web/live/member_live/show.ex:40 +#: lib/mv_web/live/member_live/index.html.heex:121 +#: lib/mv_web/live/member_live/show.ex:39 #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "" @@ -174,8 +174,8 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:26 -#: lib/mv_web/live/member_live/index.html.heex:66 -#: lib/mv_web/live/member_live/show.ex:38 +#: lib/mv_web/live/member_live/index.html.heex:87 +#: lib/mv_web/live/member_live/show.ex:37 #, elixir-autogen, elixir-format msgid "Street" msgstr "" @@ -318,14 +318,13 @@ msgstr "" msgid "Member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:14 +#: lib/mv_web/components/layouts/navbar.ex:14 +#: lib/mv_web/live/member_live/index.ex:8 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:57 #: lib/mv_web/live/property_type_live/form.ex:16 #, elixir-autogen, elixir-format msgid "Name" @@ -470,11 +469,13 @@ msgid "Value type" msgstr "" #: lib/mv_web/components/table_components.ex:30 +#: lib/mv_web/live/components/sort_header_component.ex:55 #, elixir-autogen, elixir-format msgid "ascending" msgstr "" #: lib/mv_web/components/table_components.ex:30 +#: lib/mv_web/live/components/sort_header_component.ex:56 #, elixir-autogen, elixir-format msgid "descending" msgstr "" @@ -554,57 +555,17 @@ msgstr "Set Password" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "User will be created without a password. Check 'Set Password' to add one." -#: lib/mv_web/live/user_live/show.ex:30 +#: lib/mv_web/live/components/sort_header_component.ex:60 +#, elixir-autogen, elixir-format +msgid "Click to sort" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex:53 #, elixir-autogen, elixir-format, fuzzy -msgid "Linked Member" +msgid "First name" msgstr "" -#: lib/mv_web/live/member_live/show.ex:41 -#, elixir-autogen, elixir-format -msgid "Linked User" -msgstr "" - -#: lib/mv_web/live/user_live/show.ex:40 -#, elixir-autogen, elixir-format -msgid "No member linked" -msgstr "" - -#: lib/mv_web/live/member_live/show.ex:51 -#, elixir-autogen, elixir-format -msgid "No user linked" -msgstr "" - -#: lib/mv_web/live/member_live/show.ex:14 -#: lib/mv_web/live/member_live/show.ex:16 -#, elixir-autogen, elixir-format -msgid "Back to members list" -msgstr "" - -#: lib/mv_web/live/user_live/show.ex:13 -#: lib/mv_web/live/user_live/show.ex:15 -#, elixir-autogen, elixir-format -msgid "Back to users list" -msgstr "" - -#: lib/mv_web/components/layouts/navbar.ex:27 -#: lib/mv_web/components/layouts/navbar.ex:33 -#, elixir-autogen, elixir-format, fuzzy -msgid "Select language" -msgstr "" - -#: lib/mv_web/components/layouts/navbar.ex:40 -#: lib/mv_web/components/layouts/navbar.ex:60 -#, elixir-autogen, elixir-format -msgid "Toggle dark mode" -msgstr "" - -#: lib/mv_web/live/components/search_bar_component.ex:15 -#: lib/mv_web/live/member_live/index.html.heex:15 -#, elixir-autogen, elixir-format -msgid "Search..." -msgstr "" - -#: lib/mv_web/components/layouts/navbar.ex:20 -#, elixir-autogen, elixir-format, fuzzy -msgid "Users" -msgstr "" +#~ #: lib/mv_web/auth_overrides.ex:30 +#~ #, elixir-autogen, elixir-format +#~ msgid "or" +#~ msgstr "" From c687868e5c11940e6f1f256de6308e9e93a4b44a Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 30 Sep 2025 16:46:03 +0200 Subject: [PATCH 14/31] formatting --- lib/mv_web/live/member_live/index.ex | 47 +++++++++++----------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 52b16ae..7a0de39 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -2,14 +2,13 @@ defmodule MvWeb.MemberLive.Index do use MvWeb, :live_view import Ash.Expr import Ash.Query - import MvWeb.TableComponents @impl true def mount(params, _session, socket) do socket = socket |> assign(:page_title, gettext("Members")) - |> assign(:query, "") + |> assign(:query, "") |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) |> assign(:selected_members, []) @@ -19,32 +18,6 @@ defmodule MvWeb.MemberLive.Index do {:ok, socket} end - # ----------------------------------------------------------------- - # Receive messages from any toolbar component - # ----------------------------------------------------------------- - - # Function to handle search - @impl true - def handle_info({:search_changed, q}, socket) do - members = - if String.trim(q) == "" do - Ash.read!(Mv.Membership.Member) - else - Mv.Membership.Member - |> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q))) - |> Ash.read!() - end - - {:noreply, - socket - |> assign(:query, q) - |> assign(:members, members)} - end - - # ----------------------------------------------------------------- - # Handle Events - # ----------------------------------------------------------------- - # ----------------------------------------------------------------- # Handle Events # ----------------------------------------------------------------- @@ -132,6 +105,24 @@ defmodule MvWeb.MemberLive.Index do )} end + # Function to handle search + @impl true + def handle_info({:search_changed, q}, socket) do + members = + if String.trim(q) == "" do + Ash.read!(Mv.Membership.Member) + else + Mv.Membership.Member + |> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q))) + |> Ash.read!() + end + + {:noreply, + socket + |> assign(:query, q) + |> assign(:members, members)} + end + # ----------------------------------------------------------------- # Handle Params from the URL # ----------------------------------------------------------------- From 215b4fd265f192e454b25913dd9a134daf01f3a5 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 23 Oct 2025 15:43:08 +0200 Subject: [PATCH 15/31] fix: keep search term while sorting --- .../live/components/sort_header_component.ex | 48 ++++++----- lib/mv_web/live/member_live/index.ex | 84 ++++++++++++++----- 2 files changed, 89 insertions(+), 43 deletions(-) diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index e69357e..3439d91 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -2,9 +2,9 @@ defmodule MvWeb.Components.SortHeaderComponent do @moduledoc """ Sort Header that can be used as column header and sorts a table: Props: - - field: atom() # Ash‑Field for sorting - - label: string() # Column Heading (can be aan heex templyte) - - sort_field: atom() | nil # current sort-field from parent liveview + - field: atom() # Ash Field for sorting + - label: string() # Column Heading (can be an heex template) + - sort_field: atom() | nil # current sort field from parent liveview - sort_order: :asc | :desc | nil # current sorting order """ use MvWeb, :live_component @@ -19,25 +19,27 @@ defmodule MvWeb.Components.SortHeaderComponent do @impl true def render(assigns) do ~H""" - +
+ +
""" end @@ -48,7 +50,7 @@ defmodule MvWeb.Components.SortHeaderComponent do end # ------------------------------------------------- - # Hilfsfunktionen für ARIA‑Attribute & Icon‑SVG + # Hilfsfunktionen für ARIA Attribute & Icon SVG # ------------------------------------------------- defp aria_sort(field, sort_field, dir) when field == sort_field do case dir do diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 7a0de39..4a05e29 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -4,7 +4,7 @@ defmodule MvWeb.MemberLive.Index do import Ash.Query @impl true - def mount(params, _session, socket) do + def mount(_params, _session, socket) do socket = socket |> assign(:page_title, gettext("Members")) @@ -14,7 +14,6 @@ defmodule MvWeb.MemberLive.Index do |> assign(:selected_members, []) # We call handle params to use the query from the URL - {:noreply, socket} = handle_params(params, nil, socket) {:ok, socket} end @@ -70,6 +69,7 @@ defmodule MvWeb.MemberLive.Index do @impl true def handle_info({:sort, field_str}, socket) do field = String.to_existing_atom(field_str) + old_field = socket.assigns.sort_field {new_order, new_field} = if socket.assigns.sort_field == field do @@ -79,28 +79,38 @@ defmodule MvWeb.MemberLive.Index do end active_id = :"sort_#{new_field}" + old_id = :"sort_#{old_field}" - # Update the SortHeader to + # Update the new SortHeader send_update(MvWeb.Components.SortHeaderComponent, id: active_id, sort_field: new_field, sort_order: new_order ) + # Reset the current SortHeader + send_update(MvWeb.Components.SortHeaderComponent, + id: old_id, + sort_field: new_field, + sort_order: new_order + ) + + existing_search_query = socket.assigns.query + # Build the URL with queries query_params = %{ + "query" => existing_search_query, "sort_field" => Atom.to_string(new_field), "sort_order" => Atom.to_string(new_order) } - # "/members" is the path you defined in router.ex - new_path = "/members?" <> URI.encode_query(query_params) + # Set the new path with params + new_path = ~p"/members?#{query_params}" # Push the new URL {:noreply, push_patch(socket, to: new_path, - # replace true replace: true )} end @@ -108,19 +118,27 @@ defmodule MvWeb.MemberLive.Index do # Function to handle search @impl true def handle_info({:search_changed, q}, socket) do - members = - if String.trim(q) == "" do - Ash.read!(Mv.Membership.Member) - else - Mv.Membership.Member - |> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q))) - |> Ash.read!() - end + socket = load_members(socket, q) + existing_field_query = socket.assigns.sort_field + existing_sort_query = socket.assigns.sort_order + + # Build the URL with queries + query_params = %{ + "query" => q, + "sort_field" => existing_field_query, + "sort_order" => existing_sort_query + } + + # Set the new path with params + new_path = ~p"/members?#{query_params}" + + # Push the new URL {:noreply, - socket - |> assign(:query, q) - |> assign(:members, members)} + push_patch(socket, + to: new_path, + replace: true + )} end # ----------------------------------------------------------------- @@ -130,8 +148,9 @@ defmodule MvWeb.MemberLive.Index do def handle_params(params, _url, socket) do socket = socket + |> maybe_update_search(params) |> maybe_update_sort(params) - |> load_members() + |> load_members(params["query"]) {:noreply, socket} end @@ -140,7 +159,7 @@ defmodule MvWeb.MemberLive.Index do # FUNCTIONS # ------------------------------------------------------------- # Load members eg based on a query for sorting - defp load_members(socket) do + defp load_members(socket, search_query) do query = Mv.Membership.Member |> Ash.Query.new() @@ -156,7 +175,12 @@ defmodule MvWeb.MemberLive.Index do :phone_number, :join_date ]) - |> maybe_sort(socket.assigns.sort_field, socket.assigns.sort_order) + + # Apply the search filter first + query = apply_search_filter(query, search_query) + + # Apply sorting based on current socket state + query = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order) members = Ash.read!(query) assign(socket, :members, members) @@ -166,6 +190,16 @@ defmodule MvWeb.MemberLive.Index do # Helper Functions # ------------------------------------------------------------- + # Function to apply search query + defp apply_search_filter(query, search_query) do + if search_query && String.trim(search_query) != "" do + query + |> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^search_query))) + else + query + end + end + # Functions to toggle sorting order defp toggle_order(:asc), do: :desc defp toggle_order(:desc), do: :asc @@ -193,4 +227,14 @@ defmodule MvWeb.MemberLive.Index do end defp maybe_update_sort(socket, _), do: socket + + # Function to update search parameters + defp maybe_update_search(socket, %{"query" => query}) when query != "" do + assign(socket, :query, query) + end + + defp maybe_update_search(socket, _params) do + # Keep the previous search query if no new one is provided + socket + end end From b858263176d7af1e31801c253a06cd4f5edc4e73 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 23 Oct 2025 15:44:08 +0200 Subject: [PATCH 16/31] fix: keep search term on refresh and enter --- lib/mv_web/live/components/search_bar_component.ex | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/mv_web/live/components/search_bar_component.ex b/lib/mv_web/live/components/search_bar_component.ex index 3eb5246..c45a1e5 100644 --- a/lib/mv_web/live/components/search_bar_component.ex +++ b/lib/mv_web/live/components/search_bar_component.ex @@ -8,10 +8,10 @@ defmodule MvWeb.Components.SearchBarComponent do use MvWeb, :live_component @impl true - def update(_assigns, socket) do + def update(%{query: query}, socket) do socket = socket - |> assign_new(:query, fn -> "" end) + |> assign_new(:query, fn -> query || "" end) |> assign_new(:placeholder, fn -> gettext("Search...") end) {:ok, socket} @@ -20,7 +20,7 @@ defmodule MvWeb.Components.SearchBarComponent do @impl true def render(assigns) do ~H""" -