Compare commits

...

4 commits

Author SHA1 Message Date
84abe0a4b5
feat: seed member user relations 2025-09-26 19:57:11 +02:00
e2bbe1fb40
feat: add member-user link in member view and user view 2025-09-26 19:55:06 +02:00
21e7a46881
WIP tests 2025-09-26 19:49:01 +02:00
bec7e705e8
feat: member user relation 2025-09-26 19:48:29 +02:00
8 changed files with 411 additions and 9 deletions

View file

@ -60,15 +60,54 @@ defmodule Mv.Accounts.User do
end end
actions do actions do
defaults [:read, :create, :destroy, :update] defaults [:read, :create, :destroy]
update :update do
primary? true
require_atomic? false
end
create :create_user do 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] accept [:email]
# Allow member to be passed as argument for relationship management
argument :member, :map, allow_nil?: true
upsert? 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 end
update :update_user do 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] 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 end
# Admin action for direct password changes in admin panel # Admin action for direct password changes in admin panel
@ -76,6 +115,7 @@ defmodule Mv.Accounts.User do
update :admin_set_password do update :admin_set_password do
accept [:email] accept [:email]
argument :password, :string, allow_nil?: false, sensitive?: true argument :password, :string, allow_nil?: false, sensitive?: true
require_atomic? false
# Set the strategy context that HashPasswordChange expects # Set the strategy context that HashPasswordChange expects
change set_context(%{strategy_name: :password}) change set_context(%{strategy_name: :password})
@ -125,6 +165,28 @@ defmodule Mv.Accounts.User do
validate string_length(:password, min: 8) do validate string_length(:password, min: 8) do
where action_is([:register_with_password, :admin_set_password]) where action_is([:register_with_password, :admin_set_password])
end 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 end
def validate_oidc_id_present(changeset, _context) do def validate_oidc_id_present(changeset, _context) do
@ -146,12 +208,16 @@ defmodule Mv.Accounts.User do
end end
relationships do 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 belongs_to :member, Mv.Membership.Member
end end
identities do identities do
identity :unique_email, [:email] identity :unique_email, [:email]
identity :unique_oidc_id, [:oidc_id] identity :unique_oidc_id, [:oidc_id]
identity :unique_member, [:member_id]
end end
# You can customize this if you wish, but this is a safe default that # You can customize this if you wish, but this is a safe default that

View file

@ -13,7 +13,11 @@ defmodule Mv.Membership.Member do
create :create_member do create :create_member do
primary? true primary? true
# Properties can be created along with member
argument :properties, {:array, :map} 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 [ accept [
:first_name, :first_name,
@ -32,12 +36,29 @@ defmodule Mv.Membership.Member do
] ]
change manage_relationship(:properties, type: :create) 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 end
update :update_member do update :update_member do
primary? true primary? true
# Required because custom validation function cannot be done atomically
require_atomic? false require_atomic? false
# Properties can be updated or created along with member
argument :properties, {:array, :map} 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 [ accept [
:first_name, :first_name,
@ -56,6 +77,18 @@ defmodule Mv.Membership.Member do
] ]
change manage_relationship(:properties, on_match: :update, on_no_match: :create) 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
end end
@ -67,6 +100,40 @@ defmodule Mv.Membership.Member do
validate present(:last_name) validate present(:last_name)
validate present(:email) 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 # Birth date not in the future
validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0), validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:birth_date)], where: [present(:birth_date)],
@ -170,5 +237,9 @@ defmodule Mv.Membership.Member do
relationships do relationships do
has_many :properties, Mv.Membership.Property 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
end end

View file

@ -37,6 +37,16 @@ defmodule MvWeb.MemberLive.Show do
<:item title={gettext("Street")}>{@member.street}</:item> <:item title={gettext("Street")}>{@member.street}</:item>
<:item title={gettext("House Number")}>{@member.house_number}</:item> <:item title={gettext("House Number")}>{@member.house_number}</:item>
<:item title={gettext("Postal Code")}>{@member.postal_code}</:item> <:item title={gettext("Postal Code")}>{@member.postal_code}</:item>
<: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}
</.link>
<% else %>
<span class="text-gray-500 italic">{gettext("No user linked")}</span>
<% end %>
</:item>
</.list> </.list>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Properties")}</h3> <h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Properties")}</h3>
@ -67,7 +77,7 @@ defmodule MvWeb.MemberLive.Show do
query = query =
Mv.Membership.Member Mv.Membership.Member
|> filter(id == ^id) |> filter(id == ^id)
|> load(properties: [:property_type]) |> load([:user, properties: [:property_type]])
member = Ash.read_one!(query) member = Ash.read_one!(query)

View file

@ -26,6 +26,16 @@ defmodule MvWeb.UserLive.Show do
<:item title={gettext("Password Authentication")}> <:item title={gettext("Password Authentication")}>
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")} {if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
</:item> </:item>
<: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}
</.link>
<% else %>
<span class="text-gray-500 italic">{gettext("No member linked")}</span>
<% end %>
</:item>
</.list> </.list>
</Layouts.app> </Layouts.app>
""" """
@ -33,9 +43,11 @@ defmodule MvWeb.UserLive.Show do
@impl true @impl true
def mount(%{"id" => id}, _session, socket) do def mount(%{"id" => id}, _session, socket) do
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Show User")) |> assign(:page_title, gettext("Show User"))
|> assign(:user, Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts))} |> assign(:user, user)}
end end
end end

View file

@ -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

View file

@ -92,3 +92,86 @@ for member_attrs <- [
] do ] do
Membership.create_member!(member_attrs) Membership.create_member!(member_attrs)
end 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!")

View file

@ -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"
}

View file

@ -148,7 +148,8 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
email: "dave@example.com" 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 end
test "prevents linking user to already linked member on update" do 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"}) {: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 end
test "prevents linking member to already linked user on creation" do 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}}) {:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}})
assert {:error, %Ash.Error.Invalid{}} = assert {:error, %Ash.Error.Invalid{}} =
Accounts.create_user(%{ Accounts.create_user(%{
email: "test5@example.com", email: "test5@example.com",
member: %{id: member.id} member: %{id: member.id}
}) })
end end
end end
end end