From b0614aae228302534382ec556de9be9cd1496321 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 15:06:08 +0200 Subject: [PATCH 01/15] feat: Add tests for user-member relationship --- .../user_member_relationship_test.exs | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 test/accounts/user_member_relationship_test.exs diff --git a/test/accounts/user_member_relationship_test.exs b/test/accounts/user_member_relationship_test.exs new file mode 100644 index 0000000..f85a704 --- /dev/null +++ b/test/accounts/user_member_relationship_test.exs @@ -0,0 +1,193 @@ +defmodule Mv.Accounts.UserMemberRelationshipTest do + use Mv.DataCase, async: false + alias Mv.Accounts + alias Mv.Membership + + describe "User-Member Relationship - Basic Tests" do + @valid_user_attrs %{ + email: "test@example.com" + } + + @valid_member_attrs %{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + } + + test "user can exist without member" do + {:ok, user} = Accounts.create_user(@valid_user_attrs) + assert user.member_id == nil + + # Load the relationship to test it + {:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member]) + assert user_with_member.member == nil + end + + test "member can exist without user" do + {:ok, member} = Membership.create_member(@valid_member_attrs) + assert member.id != nil + assert member.first_name == "John" + + # Load the relationship to test it + {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert member_with_user.user == nil + end + end + + describe "User-Member Relationship - Linking Tests" do + @valid_user_attrs %{ + email: "test1@example.com" + } + + @valid_member_attrs %{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + } + + test "user can be linked to member during user creation" do + {:ok, member} = Membership.create_member(@valid_member_attrs) + + user_attrs = Map.put(@valid_user_attrs, :member, %{id: member.id}) + {:ok, user} = Accounts.create_user(user_attrs) + + # Load the relationship to test it + {:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member]) + assert user_with_member.member.id == member.id + end + + test "member can be linked to user during member creation using manage_relationship" do + {:ok, user} = Accounts.create_user(@valid_user_attrs) + + member_attrs = Map.put(@valid_member_attrs, :user, %{id: user.id}) + {:ok, member} = Membership.create_member(member_attrs) + + # Load the relationship to test it + {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert member_with_user.user.id == user.id + end + + test "user can be linked to member during update" do + {:ok, user} = Accounts.create_user(@valid_user_attrs) + {:ok, member} = Membership.create_member(@valid_member_attrs) + + {:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}}) + + # Load the relationship to test it + {:ok, user_with_member} = Ash.get(Mv.Accounts.User, updated_user.id, load: [:member]) + assert user_with_member.member.id == member.id + end + + test "member can be linked to user during update using manage_relationship" do + {:ok, user} = Accounts.create_user(@valid_user_attrs) + {:ok, member} = Membership.create_member(@valid_member_attrs) + + {:ok, _updated_member} = Membership.update_member(member, %{user: %{id: user.id}}) + + # Load the relationship to test it + {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert member_with_user.user.id == user.id + end + end + + describe "User-Member Relationship - Inverse Relationship Tests" do + @valid_user_attrs %{ + email: "test2@example.com" + } + + @valid_member_attrs %{ + first_name: "Bob", + last_name: "Smith", + email: "bob@example.com" + } + + test "ash resolves inverse relationship automatically" do + {:ok, member} = Membership.create_member(@valid_member_attrs) + + user_attrs = Map.put(@valid_user_attrs, :member, %{id: member.id}) + {:ok, user} = Accounts.create_user(user_attrs) + + # Load relationships + {:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member]) + {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + + assert user_with_member.member.id == member.id + assert member_with_user.user.id == user.id + end + + test "member can find associated user" do + {:ok, member} = Membership.create_member(@valid_member_attrs) + + {:ok, user} = Accounts.create_user(%{email: "test3@example.com", member: %{id: member.id}}) + {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert member_with_user.user.id == user.id + end + end + + describe "User-Member Relationship - Preventing Duplicates" do + @valid_user_attrs %{ + email: "test4@example.com" + } + + @valid_member_attrs %{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + } + + test "prevents overwriting a member of already linked user on update" do + {:ok, existing_member} = Membership.create_member(@valid_member_attrs) + + user_attrs = Map.put(@valid_user_attrs, :member, %{id: existing_member.id}) + {:ok, user} = Accounts.create_user(user_attrs) + + {:ok, member2} = + Membership.create_member(%{ + first_name: "Dave", + last_name: "Wilson", + email: "dave@example.com" + }) + + assert {:error, %Ash.Error.Invalid{}} = Accounts.update_user(user, %{member: %{id: member2.id}}) + end + + test "prevents linking user to already linked member on update" do + {:ok, existing_user} = Accounts.create_user(@valid_user_attrs) + {:ok, member} = Membership.create_member(@valid_member_attrs) + + {:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}}) + + {:ok, user2} = Accounts.create_user(%{email: "test5@example.com"}) + + assert {:error, %Ash.Error.Invalid{}} = Accounts.update_user(user2, %{member: %{id: member.id}}) + end + + test "prevents linking member to already linked user on creation" do + {:ok, existing_member} = Membership.create_member(@valid_member_attrs) + + user_attrs = Map.put(@valid_user_attrs, :member, %{id: existing_member.id}) + {:ok, user} = Accounts.create_user(user_attrs) + + assert {:error, %Ash.Error.Invalid{}} = + Membership.create_member(%{ + first_name: "Dave", + last_name: "Wilson", + email: "dave@example.com", + user: %{id: user.id} + }) + end + + test "prevents linking user to already linked member on creation" do + {:ok, existing_user} = Accounts.create_user(@valid_user_attrs) + {:ok, member} = Membership.create_member(@valid_member_attrs) + + {:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}}) + + assert {:error, %Ash.Error.Invalid{}} = + Accounts.create_user(%{ + email: "test5@example.com", + member: %{id: member.id} + }) + end + end +end From bec7e705e86b55983f942ccb9891745586a4bfd2 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 16:09:49 +0200 Subject: [PATCH 02/15] feat: member user relation --- lib/accounts/user.ex | 68 ++++++++- lib/membership/member.ex | 71 +++++++++ .../20250926164519_member_relation.exs | 17 +++ .../repo/users/20250926164519.json | 141 ++++++++++++++++++ 4 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 priv/repo/migrations/20250926164519_member_relation.exs create mode 100644 priv/resource_snapshots/repo/users/20250926164519.json diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index b085407..c65b882 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -60,15 +60,54 @@ defmodule Mv.Accounts.User do end actions do - defaults [:read, :create, :destroy, :update] + defaults [:read, :create, :destroy] + + update :update do + primary? true + require_atomic? false + end create :create_user do + # Only accept email directly - member_id is NOT in accept list + # This prevents direct foreign key manipulation, forcing use of manage_relationship accept [:email] + # Allow member to be passed as argument for relationship management + argument :member, :map, allow_nil?: true upsert? true + + # Manage the member relationship during user creation + change manage_relationship(:member, :member, + # Look up existing member and relate to it + on_lookup: :relate, + # Error if member doesn't exist in database + on_no_match: :error, + # If member already linked to this user, ignore (shouldn't happen in create) + on_match: :ignore, + # If no member provided, that's fine (optional relationship) + on_missing: :ignore + ) end update :update_user do + # Only accept email directly - member_id is NOT in accept list + # This prevents direct foreign key manipulation, forcing use of manage_relationship accept [:email] + # Allow member to be passed as argument for relationship management + argument :member, :map, allow_nil?: true + # Required because custom validation function cannot be done atomically + require_atomic? false + + # Manage the member relationship during user update + change manage_relationship(:member, :member, + # Look up existing member and relate to it + on_lookup: :relate, + # Error if member doesn't exist in database + on_no_match: :error, + # If same member provided, that's fine (allows updates with same member) + on_match: :ignore, + # If no member provided, remove existing relationship (allows member removal) + on_missing: :unrelate + ) end # Admin action for direct password changes in admin panel @@ -76,6 +115,7 @@ defmodule Mv.Accounts.User do update :admin_set_password do accept [:email] argument :password, :string, allow_nil?: false, sensitive?: true + require_atomic? false # Set the strategy context that HashPasswordChange expects change set_context(%{strategy_name: :password}) @@ -125,6 +165,28 @@ defmodule Mv.Accounts.User do validate string_length(:password, min: 8) do where action_is([:register_with_password, :admin_set_password]) end + + # Prevent overwriting existing member relationship + # This validation ensures race condition safety by requiring explicit two-step process: + # 1. Remove existing member (set member to nil) + # 2. Add new member + # This prevents accidental overwrites when multiple admins work simultaneously + validate fn changeset, _context -> + member_arg = Ash.Changeset.get_argument(changeset, :member) + current_member_id = changeset.data.member_id + + # Only trigger if: + # - member argument is provided AND has an ID + # - user currently has a member + # - the new member ID is different from current member ID + if member_arg && member_arg[:id] && current_member_id && + member_arg[:id] != current_member_id do + {:error, + field: :member, message: "User already has a member. Remove existing member first."} + else + :ok + end + end end def validate_oidc_id_present(changeset, _context) do @@ -146,12 +208,16 @@ defmodule Mv.Accounts.User do end relationships do + # 1:1 relationship - User can optionally belong to one Member + # This automatically creates a `member_id` attribute in the User table + # The relationship is optional (allow_nil? true by default) belongs_to :member, Mv.Membership.Member end identities do identity :unique_email, [:email] identity :unique_oidc_id, [:oidc_id] + identity :unique_member, [:member_id] end # You can customize this if you wish, but this is a safe default that diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 583f173..5641528 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -13,7 +13,11 @@ defmodule Mv.Membership.Member do create :create_member do primary? true + # Properties can be created along with member argument :properties, {:array, :map} + # Allow user to be passed as argument for relationship management + # user_id is NOT in accept list to prevent direct foreign key manipulation + argument :user, :map, allow_nil?: true accept [ :first_name, @@ -32,12 +36,29 @@ defmodule Mv.Membership.Member do ] change manage_relationship(:properties, type: :create) + + # Manage the user relationship during member creation + change manage_relationship(:user, :user, + # Look up existing user and relate to it + on_lookup: :relate, + # Error if user doesn't exist in database + on_no_match: :error, + # Error if user is already linked to another member (prevents "stealing") + on_match: :error, + # If no user provided, that's fine (optional relationship) + on_missing: :ignore + ) end update :update_member do primary? true + # Required because custom validation function cannot be done atomically require_atomic? false + # Properties can be updated or created along with member argument :properties, {:array, :map} + # Allow user to be passed as argument for relationship management + # user_id is NOT in accept list to prevent direct foreign key manipulation + argument :user, :map, allow_nil?: true accept [ :first_name, @@ -56,6 +77,18 @@ defmodule Mv.Membership.Member do ] change manage_relationship(:properties, on_match: :update, on_no_match: :create) + + # Manage the user relationship during member update + change manage_relationship(:user, :user, + # Look up existing user and relate to it + on_lookup: :relate, + # Error if user doesn't exist in database + on_no_match: :error, + # Error if user is already linked to another member (prevents "stealing") + on_match: :error, + # If no user provided, remove existing relationship (allows user removal) + on_missing: :unrelate + ) end end @@ -67,6 +100,40 @@ defmodule Mv.Membership.Member do validate present(:last_name) validate present(:email) + # 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 + # This is necessary because manage_relationship's on_match: :error only checks + # if the user is already linked to THIS specific member, not ANY member + validate fn changeset, _context -> + user_arg = Ash.Changeset.get_argument(changeset, :user) + + if user_arg && user_arg[:id] do + user_id = user_arg[:id] + current_member_id = changeset.data.id + + # Check the current state of the user in the database + case Ash.get(Mv.Accounts.User, user_id) do + # User is free to be linked + {:ok, %{member_id: nil}} -> + :ok + + # User already linked to this member (update scenario) + {:ok, %{member_id: ^current_member_id}} -> + :ok + + {:ok, %{member_id: _other_member_id}} -> + # User is linked to a different member - prevent "stealing" + {:error, field: :user, message: "User is already linked to another member"} + + {:error, _} -> + {:error, field: :user, message: "User not found"} + end + else + :ok + end + end + # Birth date not in the future validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0), where: [present(:birth_date)], @@ -170,5 +237,9 @@ defmodule Mv.Membership.Member do relationships do has_many :properties, Mv.Membership.Property + # 1:1 relationship - Member can optionally have one User + # This references the User's member_id attribute + # The relationship is optional (allow_nil? true by default) + has_one :user, Mv.Accounts.User end end diff --git a/priv/repo/migrations/20250926164519_member_relation.exs b/priv/repo/migrations/20250926164519_member_relation.exs new file mode 100644 index 0000000..daaa24c --- /dev/null +++ b/priv/repo/migrations/20250926164519_member_relation.exs @@ -0,0 +1,17 @@ +defmodule Mv.Repo.Migrations.MemberRelation do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create unique_index(:users, [:member_id], name: "users_unique_member_index") + end + + def down do + drop_if_exists unique_index(:users, [:member_id], name: "users_unique_member_index") + end +end diff --git a/priv/resource_snapshots/repo/users/20250926164519.json b/priv/resource_snapshots/repo/users/20250926164519.json new file mode 100644 index 0000000..7eb68f2 --- /dev/null +++ b/priv/resource_snapshots/repo/users/20250926164519.json @@ -0,0 +1,141 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "citext" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "hashed_password", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "oidc_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "users_member_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "FDEBD4840449609DDA8B50D6741C2EEDE9D81DFBC1E26D4BC77DBD9B5A8EA4DC", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_oidc_id_index", + "keys": [ + { + "type": "atom", + "value": "oidc_id" + } + ], + "name": "unique_oidc_id", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + } + ], + "name": "unique_member", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "users" +} \ No newline at end of file From 21e7a4688188cec59c6cd37689bf9a2a0631c220 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 19:49:01 +0200 Subject: [PATCH 03/15] WIP tests --- test/accounts/user_member_relationship_test.exs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test/accounts/user_member_relationship_test.exs b/test/accounts/user_member_relationship_test.exs index f85a704..19cbe62 100644 --- a/test/accounts/user_member_relationship_test.exs +++ b/test/accounts/user_member_relationship_test.exs @@ -148,7 +148,8 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do email: "dave@example.com" }) - assert {:error, %Ash.Error.Invalid{}} = Accounts.update_user(user, %{member: %{id: member2.id}}) + assert {:error, %Ash.Error.Invalid{}} = + Accounts.update_user(user, %{member: %{id: member2.id}}) end test "prevents linking user to already linked member on update" do @@ -159,7 +160,8 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do {:ok, user2} = Accounts.create_user(%{email: "test5@example.com"}) - assert {:error, %Ash.Error.Invalid{}} = Accounts.update_user(user2, %{member: %{id: member.id}}) + assert {:error, %Ash.Error.Invalid{}} = + Accounts.update_user(user2, %{member: %{id: member.id}}) end test "prevents linking member to already linked user on creation" do @@ -184,10 +186,10 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do {:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}}) assert {:error, %Ash.Error.Invalid{}} = - Accounts.create_user(%{ - email: "test5@example.com", - member: %{id: member.id} - }) + Accounts.create_user(%{ + email: "test5@example.com", + member: %{id: member.id} + }) end end end From 968013d0edfa22a0c333246068d474d7fbc78a48 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 19:55:06 +0200 Subject: [PATCH 04/15] feat: add member-user link in member view and user view --- lib/mv_web/live/member_live/show.ex | 15 ++++++++++++++- lib/mv_web/live/user_live/show.ex | 17 ++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 304709c..ee4a1a3 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -37,6 +37,19 @@ defmodule MvWeb.MemberLive.Show do <:item title={gettext("Street")}>{@member.street} <:item title={gettext("House Number")}>{@member.house_number} <:item title={gettext("Postal Code")}>{@member.postal_code} + <:item title={gettext("Linked User")}> + <%= if @member.user do %> + <.link + navigate={~p"/users/#{@member.user}"} + class="text-blue-600 hover:text-blue-800 underline" + > + <.icon name="hero-user" class="h-4 w-4 inline mr-1" /> + {@member.user.email} + + <% else %> + {gettext("No user linked")} + <% end %> +

{gettext("Custom Properties")}

@@ -67,7 +80,7 @@ defmodule MvWeb.MemberLive.Show do query = Mv.Membership.Member |> filter(id == ^id) - |> load(properties: [:property_type]) + |> load([:user, properties: [:property_type]]) member = Ash.read_one!(query) diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex index 3bf6baf..056103b 100644 --- a/lib/mv_web/live/user_live/show.ex +++ b/lib/mv_web/live/user_live/show.ex @@ -26,6 +26,19 @@ defmodule MvWeb.UserLive.Show do <:item title={gettext("Password Authentication")}> {if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")} + <:item title={gettext("Linked Member")}> + <%= if @user.member do %> + <.link + navigate={~p"/members/#{@user.member}"} + class="text-blue-600 hover:text-blue-800 underline" + > + <.icon name="hero-users" class="h-4 w-4 inline mr-1" /> + {@user.member.first_name} {@user.member.last_name} + + <% else %> + {gettext("No member linked")} + <% end %> + """ @@ -33,9 +46,11 @@ defmodule MvWeb.UserLive.Show do @impl true def mount(%{"id" => id}, _session, socket) do + user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member]) + {:ok, socket |> assign(:page_title, gettext("Show User")) - |> assign(:user, Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts))} + |> assign(:user, user)} end end From e943f2dc2259b455e8dafc3f5f3ee749d64ca395 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 19:57:11 +0200 Subject: [PATCH 05/15] feat: seed member user relations --- priv/repo/seeds.exs | 95 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index cb38969..d850c7c 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -48,7 +48,7 @@ Accounts.create_user!(%{email: "admin@mv.local"}, upsert?: true, upsert_identity |> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"}) |> Ash.update!() -# Create sample members for testing +# Create sample members for testing - use upsert to prevent duplicates for member_attrs <- [ %{ first_name: "Hans", @@ -90,5 +90,96 @@ for member_attrs <- [ house_number: "8" } ] do - Membership.create_member!(member_attrs) + # Use upsert to prevent duplicates based on email + Membership.create_member!(member_attrs, upsert?: true, upsert_identity: :unique_email) end + +# Create additional users for user-member linking examples +additional_users = [ + %{email: "hans.mueller@example.de"}, + %{email: "greta.schmidt@example.de"}, + %{email: "maria.weber@example.de"}, + %{email: "thomas.klein@example.de"} +] + +created_users = + Enum.map(additional_users, fn user_attrs -> + Accounts.create_user!(user_attrs, upsert?: true, upsert_identity: :unique_email) + |> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"}) + |> Ash.update!() + end) + +# Create members with linked users to demonstrate the 1:1 relationship +# Only create if users don't already have members +linked_members = [ + %{ + first_name: "Maria", + last_name: "Weber", + email: "maria.weber@example.de", + birth_date: ~D[1992-07-14], + join_date: ~D[2023-03-15], + paid: true, + phone_number: "+49301357924", + city: "Frankfurt", + street: "Goetheplatz", + house_number: "5", + postal_code: "60313", + notes: "Linked to user account", + # Link to the third user (maria.weber@example.de) + user: Enum.at(created_users, 2) + }, + %{ + first_name: "Thomas", + last_name: "Klein", + email: "thomas.klein@example.de", + birth_date: ~D[1988-12-03], + join_date: ~D[2023-04-01], + paid: false, + phone_number: "+49302468135", + city: "Köln", + street: "Rheinstraße", + house_number: "23", + postal_code: "50667", + notes: "Linked to user account - needs payment follow-up", + # Link to the fourth user (thomas.klein@example.de) + user: Enum.at(created_users, 3) + } +] + +# Create the linked members - use upsert to prevent duplicates +Enum.each(linked_members, fn member_attrs -> + user = member_attrs.user + member_attrs_without_user = Map.delete(member_attrs, :user) + + # Check if user already has a member + if user.member_id == nil do + # User is free, create member and link - use upsert to prevent duplicates + Membership.create_member!( + Map.put(member_attrs_without_user, :user, %{id: user.id}), + upsert?: true, + upsert_identity: :unique_email + ) + else + # User already has a member, just create the member without linking - use upsert to prevent duplicates + Membership.create_member!(member_attrs_without_user, + upsert?: true, + upsert_identity: :unique_email + ) + end +end) + +IO.puts("✅ Seeds completed successfully!") +IO.puts("📝 Created sample data:") +IO.puts(" - Property types: String, Date, Boolean, Email") +IO.puts(" - Admin user: admin@mv.local (password: testpassword)") +IO.puts(" - Sample members: Hans, Greta, Friedrich") + +IO.puts( + " - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de" +) + +IO.puts( + " - Linked members: Maria Weber ↔ maria.weber@example.de, Thomas Klein ↔ thomas.klein@example.de" +) + +IO.puts("🔗 Visit the application to see user-member relationships in action!") From e0d7de471237fed4fbbb05d4e248002271498d5f Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 20:07:47 +0200 Subject: [PATCH 06/15] feat: make member emails unique --- lib/membership/member.ex | 5 + ...0926180341_add_unique_email_to_members.exs | 17 ++ .../repo/members/20250926180341.json | 202 ++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 priv/repo/migrations/20250926180341_add_unique_email_to_members.exs create mode 100644 priv/resource_snapshots/repo/members/20250926180341.json diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 5641528..7b898a8 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -242,4 +242,9 @@ defmodule Mv.Membership.Member do # The relationship is optional (allow_nil? true by default) has_one :user, Mv.Accounts.User end + + # Define identities for upsert operations + identities do + identity :unique_email, [:email] + end end diff --git a/priv/repo/migrations/20250926180341_add_unique_email_to_members.exs b/priv/repo/migrations/20250926180341_add_unique_email_to_members.exs new file mode 100644 index 0000000..51b874f --- /dev/null +++ b/priv/repo/migrations/20250926180341_add_unique_email_to_members.exs @@ -0,0 +1,17 @@ +defmodule Mv.Repo.Migrations.AddUniqueEmailToMembers do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create unique_index(:members, [:email], name: "members_unique_email_index") + end + + def down do + drop_if_exists unique_index(:members, [:email], name: "members_unique_email_index") + end +end diff --git a/priv/resource_snapshots/repo/members/20250926180341.json b/priv/resource_snapshots/repo/members/20250926180341.json new file mode 100644 index 0000000..3582051 --- /dev/null +++ b/priv/resource_snapshots/repo/members/20250926180341.json @@ -0,0 +1,202 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "first_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "birth_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "paid", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "phone_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "exit_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "street", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "house_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "postal_code", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "5F070A1E5BEE9883AE864FB5A4A5E81F487A1C57D41576C23BAC8D933005D565", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "members_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "members" +} \ No newline at end of file From 35d0b0e41350a135ad21747aaf76e25d7d2fe5c1 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 20:18:32 +0200 Subject: [PATCH 07/15] feat: add translation --- priv/gettext/de/LC_MESSAGES/auth.po | 3 -- priv/gettext/de/LC_MESSAGES/default.po | 41 +++++++++++----- priv/gettext/de/LC_MESSAGES/errors.po | 66 +++++++++++++------------- priv/gettext/default.pot | 27 ++++++++--- priv/gettext/en/LC_MESSAGES/auth.po | 3 -- priv/gettext/en/LC_MESSAGES/default.po | 29 ++++++++--- 6 files changed, 104 insertions(+), 65 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index 0f2202d..b794f37 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -61,6 +61,3 @@ 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 1a7cf7e..073a047 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -48,7 +48,7 @@ msgid "Edit" msgstr "Bearbeite" #: lib/mv_web/live/member_live/show.ex:18 -#: lib/mv_web/live/member_live/show.ex:81 +#: lib/mv_web/live/member_live/show.ex:94 #, elixir-autogen, elixir-format msgid "Edit Member" msgstr "Mitglied bearbeiten" @@ -115,7 +115,7 @@ msgid "Birth Date" msgstr "Geburtsdatum" #: lib/mv_web/live/member_live/form.ex:30 -#: lib/mv_web/live/member_live/show.ex:42 +#: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Custom Properties" msgstr "Eigene Eigenschaften" @@ -194,7 +194,7 @@ msgstr "ID" msgid "No" msgstr "Nein" -#: lib/mv_web/live/member_live/show.ex:80 +#: lib/mv_web/live/member_live/show.ex:93 #, elixir-autogen, elixir-format, fuzzy msgid "Show Member" msgstr "Mitglied anzeigen" @@ -373,9 +373,9 @@ msgid "Profil" msgstr "Profil" #: lib/mv_web/live/property_live/form.ex:207 -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Property %{action} successfully" -msgstr "Mitglied %{action} erfolgreich" +msgstr "Eigenschaft %{action} erfolgreich" #: lib/mv_web/live/property_live/form.ex:18 #, elixir-autogen, elixir-format @@ -422,7 +422,7 @@ msgstr "Einstellungen" msgid "Save User" msgstr "Benutzer speichern" -#: lib/mv_web/live/user_live/show.ex:38 +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Show User" msgstr "Benutzer anzeigen" @@ -438,14 +438,14 @@ msgid "Unsupported value type: %{type}" msgstr "Nicht unterstützter Wertetyp: %{type}" #: lib/mv_web/live/property_live/form.ex:10 -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Use this form to manage property records in your database." -msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften." +msgstr "Dieses Formular dient zur Verwaltung von Eigenschaften in der Datenbank." #: lib/mv_web/live/property_type_live/form.ex:11 -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Use this form to manage property_type records in your database." -msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften." +msgstr "Dieses Formular dient zur Verwaltung von Eigenschaftstypen in der Datenbank." #: lib/mv_web/live/user_live/form.ex:10 #, elixir-autogen, elixir-format @@ -553,7 +553,22 @@ msgstr "Passwort setzen" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "Benutzer wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen." -#: lib/mv_web/auth_overrides.ex:30 +#: lib/mv_web/live/user_live/show.ex:29 #, elixir-autogen, elixir-format -msgid "or" -msgstr "oder" +msgid "Linked Member" +msgstr "Verknüpftes Mitglied" + +#: lib/mv_web/live/member_live/show.ex:40 +#, elixir-autogen, elixir-format +msgid "Linked User" +msgstr "Verknüpfter Benutzer" + +#: lib/mv_web/live/user_live/show.ex:39 +#, elixir-autogen, elixir-format +msgid "No member linked" +msgstr "Kein Mitglied verknüpft" + +#: lib/mv_web/live/member_live/show.ex:50 +#, elixir-autogen, elixir-format +msgid "No user linked" +msgstr "Kein Benutzer verknüpft" diff --git a/priv/gettext/de/LC_MESSAGES/errors.po b/priv/gettext/de/LC_MESSAGES/errors.po index 844c4f5..b9332ab 100644 --- a/priv/gettext/de/LC_MESSAGES/errors.po +++ b/priv/gettext/de/LC_MESSAGES/errors.po @@ -12,101 +12,101 @@ msgstr "" ## From Ecto.Changeset.cast/4 msgid "can't be blank" -msgstr "" +msgstr "darf nicht leer sein" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" -msgstr "" +msgstr "ist bereits vergeben" ## From Ecto.Changeset.put_change/3 msgid "is invalid" -msgstr "" +msgstr "ist ungültig" ## From Ecto.Changeset.validate_acceptance/3 msgid "must be accepted" -msgstr "" +msgstr "muss akzeptiert werden" ## From Ecto.Changeset.validate_format/3 msgid "has invalid format" -msgstr "" +msgstr "hat ein ungültiges Format" ## From Ecto.Changeset.validate_subset/3 msgid "has an invalid entry" -msgstr "" +msgstr "hat einen ungültigen Eintrag" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" -msgstr "" +msgstr "ist reserviert" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" -msgstr "" +msgstr "stimmt nicht mit der Bestätigung überein" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" -msgstr "" +msgstr "ist noch mit diesem Eintrag verknüpft" msgid "are still associated with this entry" -msgstr "" +msgstr "sind noch mit diesem Eintrag verknüpft" ## From Ecto.Changeset.validate_length/3 msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte %{count} Element haben" +msgstr[1] "sollte %{count} Elemente haben" msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte %{count} Zeichen haben" +msgstr[1] "sollte %{count} Zeichen haben" msgid "should be %{count} byte(s)" msgid_plural "should be %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte %{count} Byte haben" +msgstr[1] "sollte %{count} Bytes haben" msgid "should have at least %{count} item(s)" msgid_plural "should have at least %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte mindestens %{count} Element haben" +msgstr[1] "sollte mindestens %{count} Elemente haben" msgid "should be at least %{count} character(s)" msgid_plural "should be at least %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte mindestens %{count} Zeichen haben" +msgstr[1] "sollte mindestens %{count} Zeichen haben" msgid "should be at least %{count} byte(s)" msgid_plural "should be at least %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte mindestens %{count} Byte haben" +msgstr[1] "sollte mindestens %{count} Bytes haben" msgid "should have at most %{count} item(s)" msgid_plural "should have at most %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte höchstens %{count} Element haben" +msgstr[1] "sollte höchstens %{count} Elemente haben" msgid "should be at most %{count} character(s)" msgid_plural "should be at most %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte höchstens %{count} Zeichen haben" +msgstr[1] "sollte höchstens %{count} Zeichen haben" msgid "should be at most %{count} byte(s)" msgid_plural "should be at most %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte höchstens %{count} Byte haben" +msgstr[1] "sollte höchstens %{count} Bytes haben" ## From Ecto.Changeset.validate_number/3 msgid "must be less than %{number}" -msgstr "" +msgstr "muss kleiner als %{number} sein" msgid "must be greater than %{number}" -msgstr "" +msgstr "muss größer als %{number} sein" msgid "must be less than or equal to %{number}" -msgstr "" +msgstr "muss kleiner oder gleich %{number} sein" msgid "must be greater than or equal to %{number}" -msgstr "" +msgstr "muss größer oder gleich %{number} sein" msgid "must be equal to %{number}" -msgstr "" +msgstr "muss gleich %{number} sein" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a9bfb08..42e85d8 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -49,7 +49,7 @@ msgid "Edit" msgstr "" #: lib/mv_web/live/member_live/show.ex:18 -#: lib/mv_web/live/member_live/show.ex:81 +#: lib/mv_web/live/member_live/show.ex:94 #, elixir-autogen, elixir-format msgid "Edit Member" msgstr "" @@ -116,7 +116,7 @@ msgid "Birth Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:30 -#: lib/mv_web/live/member_live/show.ex:42 +#: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Custom Properties" msgstr "" @@ -195,7 +195,7 @@ msgstr "" msgid "No" msgstr "" -#: lib/mv_web/live/member_live/show.ex:80 +#: lib/mv_web/live/member_live/show.ex:93 #, elixir-autogen, elixir-format msgid "Show Member" msgstr "" @@ -423,7 +423,7 @@ msgstr "" msgid "Save User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:38 +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Show User" msgstr "" @@ -554,7 +554,22 @@ msgstr "" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "" -#: lib/mv_web/auth_overrides.ex:30 +#: lib/mv_web/live/user_live/show.ex:29 #, elixir-autogen, elixir-format -msgid "or" +msgid "Linked Member" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex:40 +#, elixir-autogen, elixir-format +msgid "Linked User" +msgstr "" + +#: lib/mv_web/live/user_live/show.ex:39 +#, elixir-autogen, elixir-format +msgid "No member linked" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex:50 +#, elixir-autogen, elixir-format +msgid "No user linked" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po index 1e4e801..21bb4a4 100644 --- a/priv/gettext/en/LC_MESSAGES/auth.po +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -58,6 +58,3 @@ 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 2f09378..e8adaaf 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -49,7 +49,7 @@ msgid "Edit" msgstr "" #: lib/mv_web/live/member_live/show.ex:18 -#: lib/mv_web/live/member_live/show.ex:81 +#: lib/mv_web/live/member_live/show.ex:94 #, elixir-autogen, elixir-format msgid "Edit Member" msgstr "" @@ -116,7 +116,7 @@ msgid "Birth Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:30 -#: lib/mv_web/live/member_live/show.ex:42 +#: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Custom Properties" msgstr "" @@ -195,7 +195,7 @@ msgstr "" msgid "No" msgstr "" -#: lib/mv_web/live/member_live/show.ex:80 +#: lib/mv_web/live/member_live/show.ex:93 #, elixir-autogen, elixir-format, fuzzy msgid "Show Member" msgstr "" @@ -423,7 +423,7 @@ msgstr "" msgid "Save User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:38 +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format, fuzzy msgid "Show User" msgstr "" @@ -554,7 +554,22 @@ 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/auth_overrides.ex:30 -#, elixir-autogen, elixir-format -msgid "or" +#: lib/mv_web/live/user_live/show.ex:29 +#, elixir-autogen, elixir-format, fuzzy +msgid "Linked Member" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex:40 +#, elixir-autogen, elixir-format +msgid "Linked User" +msgstr "" + +#: lib/mv_web/live/user_live/show.ex:39 +#, elixir-autogen, elixir-format +msgid "No member linked" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex:50 +#, elixir-autogen, elixir-format +msgid "No user linked" msgstr "" From 380382ea13a8075d903e549e6331768809b25d1a Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 20:33:13 +0200 Subject: [PATCH 08/15] fix: axe-core critical and major issues --- lib/mv_web/components/layouts/navbar.ex | 20 +++++- lib/mv_web/live/member_live/show.ex | 3 +- lib/mv_web/live/user_live/show.ex | 3 +- priv/gettext/de/LC_MESSAGES/default.po | 96 +++++++++++++++---------- priv/gettext/default.pot | 96 +++++++++++++++---------- priv/gettext/en/LC_MESSAGES/default.po | 96 +++++++++++++++---------- 6 files changed, 201 insertions(+), 113 deletions(-) diff --git a/lib/mv_web/components/layouts/navbar.ex b/lib/mv_web/components/layouts/navbar.ex index b917ddc..f80d405 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -18,13 +18,20 @@ defmodule MvWeb.Layouts.Navbar do
-
-