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/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex
index 304709c..b8992cf 100644
--- a/lib/mv_web/live/member_live/show.ex
+++ b/lib/mv_web/live/member_live/show.ex
@@ -37,6 +37,16 @@ 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 +77,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..1001b94 100644
--- a/lib/mv_web/live/user_live/show.ex
+++ b/lib/mv_web/live/user_live/show.ex
@@ -26,6 +26,16 @@ 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 +43,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
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/repo/seeds.exs b/priv/repo/seeds.exs
index cb38969..3ff747e 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -92,3 +92,86 @@ for member_attrs <- [
] do
Membership.create_member!(member_attrs)
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 only if the users don't already have members
+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
+ Membership.create_member!(Map.put(member_attrs_without_user, :user, %{id: user.id}))
+ else
+ # User already has a member, just create the member without linking
+ Membership.create_member!(member_attrs_without_user)
+ 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!")
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
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