create logical link between users and members closes #164 #172

Merged
moritz merged 14 commits from feature/user-member-relation into main 2025-10-16 16:29:49 +02:00
27 changed files with 2057 additions and 245 deletions

View file

@ -12,6 +12,12 @@ defmodule Mv.Accounts.User do
postgres do
table "users"
repo Mv.Repo
references do
# When a member is deleted, set the user's member_id to NULL
# This allows users to continue existing even if their linked member is removed
reference :member, on_delete: :nilify
end
end
@doc """
@ -60,15 +66,77 @@ defmodule Mv.Accounts.User do
end
actions do
defaults [:read, :create, :destroy, :update]
# 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.
# - :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]
# Primary generic update action:
# - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
# helpers that assume a default update action.
# - Intended for simple attribute updates (e.g., :email) and scenarios
# that do NOT need to manage the :member relationship.
# - For linking/unlinking a member (and the related validations), prefer
# the specialized :update_user action below.
update :update do
primary? true
# Required because custom validation functions (email validation, member relationship validation)
# 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
end
create :create_user do
description "Creates a new user with optional member relationship. The member relationship is managed through the :member argument."
# 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
description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one."
# 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 functions (email validation, member relationship validation)
# 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
# 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 +144,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})
@ -122,8 +191,54 @@ defmodule Mv.Accounts.User do
# Global validations - applied to all relevant actions
validations do
# Password strength policy: minimum 8 characters for all password-related actions
validate string_length(:password, min: 8) do
where action_is([:register_with_password, :admin_set_password])
validate string_length(:password, min: 8),
where: [action_is([:register_with_password, :admin_set_password])],
message: "must have length of at least 8"
# Email validation with EctoCommons.EmailValidator (same as Member)
# This ensures consistency between User and Member email validation
validate fn changeset, _ ->
# Get email from attribute (Ash.CiString) and convert to string
email = Ash.Changeset.get_attribute(changeset, :email)
email_string = if email, do: to_string(email), else: nil
# Only validate if email is present
if email_string do
changeset2 =
{%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: email_string}, [:email])
|> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow])
if changeset2.valid? do
:ok
else
{:error, field: :email, message: "is not a valid email"}
end
else
:ok
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
@ -140,18 +255,28 @@ defmodule Mv.Accounts.User do
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false, public?: true
attribute :email, :ci_string do
allow_nil? false
public? true
# Same constraints as Member email for consistency
constraints min_length: 5, max_length: 254
end
attribute :hashed_password, :string, sensitive?: true, allow_nil?: true
attribute :oidc_id, :string, allow_nil?: true
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

View file

@ -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)],
@ -175,5 +242,14 @@ 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
# Define identities for upsert operations
identities do
identity :unique_email, [:email]
end
end

View file

@ -42,4 +42,10 @@ defmodule Mv.Membership.Property do
calculations do
calculate :value_to_string, :string, expr(value[:value] <> "")
end
# Ensure a member can only have one property per property type
# For example: A member can have only one "email" property, one "phone" property, etc.
identities do
identity :unique_property_per_member, [:member_id, :property_type_id]
end
end

View file

@ -16,20 +16,27 @@ defmodule MvWeb.Layouts.Navbar do
<div class="flex-1">
<a class="btn btn-ghost text-xl">Mitgliederverwaltung</a>
<ul class="menu menu-horizontal bg-base-200">
<li><a href="/members">{gettext("Members")}</a></li>
<li><a>Transaktionen</a></li>
<li><.link navigate="/members">{gettext("Members")}</.link></li>
<li><.link navigate="/users">{gettext("Users")}</.link></li>
</ul>
</div>
<div class="flex gap-2">
<form method="post" action="/set_locale" class="mr-4">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select name="locale" onchange="this.form.submit()" class="select select-sm">
<label class="sr-only" for="locale-select">{gettext("Select language")}</label>
<select
id="locale-select"
name="locale"
onchange="this.form.submit()"
class="select select-sm"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
</select>
</form>
<!-- Daisy UI Theme Toggle for dark and light mode-->
<label class="flex cursor-pointer gap-2">
<label class="flex cursor-pointer gap-2" aria-label={gettext("Toggle dark mode")}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
@ -40,11 +47,17 @@ defmodule MvWeb.Layouts.Navbar do
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" />
</svg>
<input type="checkbox" value="dark" class="toggle theme-controller" />
<input
type="checkbox"
value="dark"
class="toggle theme-controller"
aria-label={gettext("Toggle dark mode")}
/>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
@ -55,6 +68,7 @@ defmodule MvWeb.Layouts.Navbar do
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>

View file

@ -11,8 +11,9 @@ defmodule MvWeb.MemberLive.Show do
<:subtitle>{gettext("This is a member record from your database.")}</:subtitle>
<:actions>
<.button navigate={~p"/members"}>
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to members list")}</span>
</.button>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> {gettext("Edit Member")}
@ -37,6 +38,19 @@ defmodule MvWeb.MemberLive.Show do
<:item title={gettext("Street")}>{@member.street}</:item>
<:item title={gettext("House Number")}>{@member.house_number}</: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>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Properties")}</h3>
@ -67,7 +81,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)

View file

@ -10,8 +10,9 @@ defmodule MvWeb.UserLive.Show do
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
<:actions>
<.button navigate={~p"/users"}>
<.button navigate={~p"/users"} aria-label={gettext("Back to users list")}>
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to users list")}</span>
</.button>
<.button variant="primary" navigate={~p"/users/#{@user}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
@ -26,6 +27,19 @@ defmodule MvWeb.UserLive.Show do
<:item title={gettext("Password Authentication")}>
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
</: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>
</Layouts.app>
"""
@ -33,9 +47,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

View file

@ -27,10 +27,10 @@ msgid "Forgot your password?"
msgstr "Passwort vergessen?"
msgid "If this user exists in our database you will contacted with a sign-in link shortly."
msgstr "Falls dieser Benutzer bekannt ist, wird jetzt eine Email mit Anmelde-Link versendet."
msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit Anmelde-Link versendet."
msgid "If this user exists in our system, you will be contacted with reset instructions shortly."
msgstr "Falls dieser Benutzer bekannt ist, wird jetzt eine Email mit einer Anleitung zum Zurücksetzen versendet."
msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer Anleitung zum Zurücksetzen versendet."
msgid "Need an account?"
msgstr "Konto anlegen?"
@ -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"

View file

@ -15,69 +15,69 @@ msgstr ""
msgid "Actions"
msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/member_live/index.html.heex:84
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr "Bist du sicher?"
#: lib/mv_web/components/layouts.ex:71
#: lib/mv_web/components/layouts.ex:83
#: lib/mv_web/components/layouts.ex:80
#: lib/mv_web/components/layouts.ex:92
#, elixir-autogen, elixir-format
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:62
#: lib/mv_web/live/member_live/show.ex:36
#: lib/mv_web/live/member_live/index.html.heex:69
#: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format
msgid "City"
msgstr "Stadt"
#: lib/mv_web/live/member_live/index.html.heex:79
#: lib/mv_web/live/member_live/index.html.heex:86
#: 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:71
#: lib/mv_web/live/member_live/index.html.heex:78
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
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:19
#: lib/mv_web/live/member_live/show.ex:95
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/index.html.heex:58
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/member_live/index.html.heex:65
#: lib/mv_web/live/member_live/show.ex:28
#: 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:24
#: lib/mv_web/live/user_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Email"
msgstr "E-Mail"
#: lib/mv_web/live/member_live/form.ex:16
#: lib/mv_web/live/member_live/show.ex:25
#: lib/mv_web/live/member_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "First Name"
msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/index.html.heex:64
#: lib/mv_web/live/member_live/show.ex:33
#: lib/mv_web/live/member_live/index.html.heex:71
#: lib/mv_web/live/member_live/show.ex:34
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr "Beitrittsdatum"
#: lib/mv_web/live/member_live/form.ex:17
#: lib/mv_web/live/member_live/show.ex:26
#: lib/mv_web/live/member_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Last Name"
msgstr "Nachname"
@ -87,18 +87,18 @@ msgstr "Nachname"
msgid "New Member"
msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex:68
#: lib/mv_web/live/member_live/index.html.heex:75
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
msgstr "Anzeigen"
#: lib/mv_web/components/layouts.ex:78
#: lib/mv_web/components/layouts.ex:87
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr "Etwas ist schiefgelaufen!"
#: lib/mv_web/components/layouts.ex:66
#: lib/mv_web/components/layouts.ex:75
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr "Keine Internetverbindung gefunden"
@ -109,52 +109,52 @@ msgid "close"
msgstr "schließen"
#: lib/mv_web/live/member_live/form.ex:19
#: lib/mv_web/live/member_live/show.ex:28
#: lib/mv_web/live/member_live/show.ex:29
#, elixir-autogen, elixir-format
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:56
#, elixir-autogen, elixir-format
msgid "Custom Properties"
msgstr "Eigene Eigenschaften"
#: lib/mv_web/live/member_live/form.ex:23
#: lib/mv_web/live/member_live/show.ex:34
#: lib/mv_web/live/member_live/show.ex:35
#, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/index.html.heex:60
#: lib/mv_web/live/member_live/show.ex:38
#: lib/mv_web/live/member_live/index.html.heex:67
#: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr "Hausnummer"
#: lib/mv_web/live/member_live/form.ex:24
#: lib/mv_web/live/member_live/show.ex:35
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "Notes"
msgstr "Notizen"
#: lib/mv_web/live/member_live/form.ex:20
#: lib/mv_web/live/member_live/show.ex:29
#: lib/mv_web/live/member_live/show.ex:30
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/index.html.heex:63
#: lib/mv_web/live/member_live/show.ex:32
#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:33
#, 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:61
#: lib/mv_web/live/member_live/show.ex:39
#: lib/mv_web/live/member_live/index.html.heex:68
#: lib/mv_web/live/member_live/show.ex:40
#, 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:59
#: lib/mv_web/live/member_live/show.ex:37
#: lib/mv_web/live/member_live/index.html.heex:66
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "Street"
msgstr "Straße"
@ -184,17 +184,17 @@ msgstr "Straße"
msgid "Use this form to manage member records and their properties."
msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften."
#: lib/mv_web/live/member_live/show.ex:24
#: lib/mv_web/live/member_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Id"
msgstr "ID"
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "No"
msgstr "Nein"
#: lib/mv_web/live/member_live/show.ex:80
#: lib/mv_web/live/member_live/show.ex:94
#, elixir-autogen, elixir-format, fuzzy
msgid "Show Member"
msgstr "Mitglied anzeigen"
@ -204,7 +204,7 @@ msgstr "Mitglied anzeigen"
msgid "This is a member record from your database."
msgstr "Dies ist ein Mitglied aus deiner Datenbank."
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr "Ja"
@ -281,17 +281,17 @@ msgstr "Eigenschaftstyp auswählen"
msgid "Description"
msgstr "Beschreibung"
#: lib/mv_web/live/user_live/show.ex:17
#: lib/mv_web/live/user_live/show.ex:18
#, elixir-autogen, elixir-format
msgid "Edit User"
msgstr "Benutzer bearbeiten"
msgstr "Benutzer*in bearbeiten"
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Enabled"
msgstr "Aktiviert"
#: lib/mv_web/live/user_live/show.ex:23
#: lib/mv_web/live/user_live/show.ex:24
#, elixir-autogen, elixir-format
msgid "ID"
msgstr "ID"
@ -301,7 +301,7 @@ msgstr "ID"
msgid "Immutable"
msgstr "Unveränderlich"
#: lib/mv_web/components/layouts/navbar.ex:73
#: lib/mv_web/components/layouts/navbar.ex:94
#, elixir-autogen, elixir-format
msgid "Logout"
msgstr "Abmelden"
@ -310,21 +310,21 @@ msgstr "Abmelden"
#: lib/mv_web/live/user_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Listing Users"
msgstr "Benutzer auflisten"
msgstr "Benutzer*innen auflisten"
#: lib/mv_web/live/property_live/form.ex:27
#, elixir-autogen, elixir-format
msgid "Member"
msgstr "Mitglied"
#: lib/mv_web/components/layouts/navbar.ex:14
#: lib/mv_web/live/member_live/index.ex:12
#: lib/mv_web/components/layouts/navbar.ex:19
#: lib/mv_web/live/member_live/index.ex:14
#: 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:50
#: 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"
@ -333,14 +333,14 @@ msgstr "Name"
#: lib/mv_web/live/user_live/index.html.heex:6
#, elixir-autogen, elixir-format
msgid "New User"
msgstr "Neuer Benutzer"
msgstr "Neue*r Benutzer*in"
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Not enabled"
msgstr "Nicht aktiviert"
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr "Nicht gesetzt"
@ -352,12 +352,12 @@ msgid "Note"
msgstr "Hinweis"
#: lib/mv_web/live/user_live/index.html.heex:52
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "OIDC ID"
msgstr "OIDC ID"
#: lib/mv_web/live/user_live/show.ex:26
#: lib/mv_web/live/user_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Password Authentication"
msgstr "Passwort-Authentifizierung"
@ -367,15 +367,15 @@ msgstr "Passwort-Authentifizierung"
msgid "Please select a property type first"
msgstr "Bitte wählen Sie zuerst einen Eigenschaftstyp"
#: lib/mv_web/components/layouts/navbar.ex:69
#: lib/mv_web/components/layouts/navbar.ex:89
#, elixir-autogen, elixir-format
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
@ -402,17 +402,17 @@ msgstr "Eigenschaft speichern"
msgid "Save Property type"
msgstr "Eigenschaftstyp speichern"
#: lib/mv_web/live/member_live/index.html.heex:27
#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr "Alle Mitglieder auswählen"
#: lib/mv_web/live/member_live/index.html.heex:41
#: lib/mv_web/live/member_live/index.html.heex:48
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr "Mitglied auswählen"
#: lib/mv_web/components/layouts/navbar.ex:72
#: lib/mv_web/components/layouts/navbar.ex:92
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr "Einstellungen"
@ -420,17 +420,17 @@ msgstr "Einstellungen"
#: lib/mv_web/live/user_live/form.ex:93
#, elixir-autogen, elixir-format
msgid "Save User"
msgstr "Benutzer speichern"
msgstr "Benutzer*in speichern"
#: lib/mv_web/live/user_live/show.ex:38
#: lib/mv_web/live/user_live/show.ex:54
#, elixir-autogen, elixir-format
msgid "Show User"
msgstr "Benutzer anzeigen"
msgstr "Benutzer*in anzeigen"
#: lib/mv_web/live/user_live/show.ex:10
#, elixir-autogen, elixir-format
msgid "This is a user record from your database."
msgstr "Dies ist ein Benutzer-Datensatz aus Ihrer Datenbank."
msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank."
#: lib/mv_web/live/property_live/form.ex:95
#, elixir-autogen, elixir-format
@ -438,25 +438,25 @@ 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
msgid "Use this form to manage user records in your database."
msgstr "Verwenden Sie dieses Formular, um Benutzer-Datensätze zu verwalten."
msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
#: lib/mv_web/live/user_live/form.ex:110
#: lib/mv_web/live/user_live/show.ex:9
#, elixir-autogen, elixir-format
msgid "User"
msgstr "Benutzer"
msgstr "Benutzer*in"
#: lib/mv_web/live/property_live/form.ex:59
#, elixir-autogen, elixir-format
@ -481,17 +481,17 @@ msgstr "absteigend"
#: lib/mv_web/live/user_live/form.ex:109
#, elixir-autogen, elixir-format
msgid "New"
msgstr "Neuer"
msgstr "Neue*r"
#: lib/mv_web/live/user_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Admin Note"
msgstr "Administrator-Hinweis"
msgstr "Administrator*innen-Hinweis"
#: lib/mv_web/live/user_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
msgstr "Als Administrator können Sie direkt ein neues Passwort für diesen Benutzer setzen, wobei das gleiche sichere Ash Authentication System verwendet wird."
msgstr "Als Administrator*in können Sie direkt ein neues Passwort für diese*n Benutzer*in setzen, wobei das gleiche sichere Ash Authentication System verwendet wird."
#: lib/mv_web/live/user_live/form.ex:55
#, elixir-autogen, elixir-format
@ -506,7 +506,7 @@ msgstr "Passwort ändern"
#: lib/mv_web/live/user_live/form.ex:75
#, elixir-autogen, elixir-format
msgid "Check 'Change Password' above to set a new password for this user."
msgstr "Aktivieren Sie 'Passwort ändern' oben, um ein neues Passwort für diesen Benutzer zu setzen."
msgstr "Aktivieren Sie 'Passwort ändern' oben, um ein neues Passwort für diese*n Benutzer*in zu setzen."
#: lib/mv_web/live/user_live/form.ex:45
#, elixir-autogen, elixir-format
@ -536,12 +536,12 @@ msgstr "Passwort-Anforderungen"
#: lib/mv_web/live/user_live/index.html.heex:21
#, elixir-autogen, elixir-format
msgid "Select all users"
msgstr "Alle Benutzer auswählen"
msgstr "Alle Benutzer*innen auswählen"
#: lib/mv_web/live/user_live/index.html.heex:35
#, elixir-autogen, elixir-format
msgid "Select user"
msgstr "Benutzer auswählen"
msgstr "Benutzer*in auswählen"
#: lib/mv_web/live/user_live/form.ex:27
#, elixir-autogen, elixir-format
@ -551,9 +551,59 @@ msgstr "Passwort setzen"
#: lib/mv_web/live/user_live/form.ex:83
#, elixir-autogen, elixir-format
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."
msgstr "Benutzer*in 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:30
#, elixir-autogen, elixir-format
msgid "or"
msgstr "oder"
msgid "Linked Member"
msgstr "Verknüpftes Mitglied"
#: lib/mv_web/live/member_live/show.ex:41
#, elixir-autogen, elixir-format
msgid "Linked User"
msgstr "Verknüpfte*r Benutzer*in"
#: lib/mv_web/live/user_live/show.ex:40
#, elixir-autogen, elixir-format
msgid "No member linked"
msgstr "Kein Mitglied verknüpft"
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format
msgid "No user linked"
msgstr "Keine*r Benutzer*in verknüpft"
#: 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 "Zurück zur Mitgliederliste"
#: 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 "Zurück zur Benutzer*innen-Liste"
#: lib/mv_web/components/layouts/navbar.ex:27
#: lib/mv_web/components/layouts/navbar.ex:33
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr "Sprache auswählen"
#: 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 "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 ""
#: lib/mv_web/components/layouts/navbar.ex:20
#, elixir-autogen, elixir-format, fuzzy
msgid "Users"
msgstr "Benutzer*innen"

View file

@ -12,101 +12,146 @@ 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"
## Ash Framework - Standard constraint messages
msgid "length must be greater than or equal to %{min}"
msgstr "muss mindestens %{min} Zeichen lang sein"
msgid "length must be less than or equal to %{max}"
msgstr "darf höchstens %{max} Zeichen lang sein"
msgid "must be present"
msgstr "muss vorhanden sein"
## Custom validation messages from Mv.Accounts.User
msgid "User already has a member. Remove existing member first."
msgstr "Benutzer*in hat bereits ein Mitglied. Entfernen Sie zuerst das vorhandene Mitglied."
msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field"
msgstr "OIDC user_info darf kein leeres 'sub' oder 'id' Feld enthalten"
## Custom validation messages from Mv.Membership.Member
msgid "User is already linked to another member"
msgstr "Benutzer*in ist bereits mit einem anderen Mitglied verknüpft"
msgid "User not found"
msgstr "Benutzer*in nicht gefunden"
msgid "cannot be in the future"
msgstr "darf nicht in der Zukunft liegen"
msgid "cannot be before join date"
msgstr "darf nicht vor dem Beitrittsdatum liegen"
msgid "is not a valid phone number"
msgstr "ist keine gültige Telefonnummer"
msgid "must consist of 5 digits"
msgstr "muss aus 5 Ziffern bestehen"
msgid "is not a valid email"
msgstr "ist keine gültige E-Mail-Adresse"
msgid "must have length of at least 8"
msgstr "muss mindestens 8 Zeichen lang sein"
msgid "is required"
msgstr "ist erforderlich"

View file

@ -16,69 +16,69 @@ msgstr ""
msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/member_live/index.html.heex:84
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr ""
#: lib/mv_web/components/layouts.ex:71
#: lib/mv_web/components/layouts.ex:83
#: lib/mv_web/components/layouts.ex:80
#: lib/mv_web/components/layouts.ex:92
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/index.html.heex:62
#: lib/mv_web/live/member_live/show.ex:36
#: lib/mv_web/live/member_live/index.html.heex:69
#: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:79
#: lib/mv_web/live/member_live/index.html.heex:86
#: 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:71
#: lib/mv_web/live/member_live/index.html.heex:78
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
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:19
#: lib/mv_web/live/member_live/show.ex:95
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/index.html.heex:58
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/member_live/index.html.heex:65
#: lib/mv_web/live/member_live/show.ex:28
#: 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:24
#: lib/mv_web/live/user_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Email"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:16
#: lib/mv_web/live/member_live/show.ex:25
#: lib/mv_web/live/member_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/index.html.heex:64
#: lib/mv_web/live/member_live/show.ex:33
#: lib/mv_web/live/member_live/index.html.heex:71
#: lib/mv_web/live/member_live/show.ex:34
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:17
#: lib/mv_web/live/member_live/show.ex:26
#: lib/mv_web/live/member_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Last Name"
msgstr ""
@ -88,18 +88,18 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:68
#: lib/mv_web/live/member_live/index.html.heex:75
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
msgstr ""
#: lib/mv_web/components/layouts.ex:78
#: lib/mv_web/components/layouts.ex:87
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr ""
#: lib/mv_web/components/layouts.ex:66
#: lib/mv_web/components/layouts.ex:75
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr ""
@ -110,52 +110,52 @@ msgid "close"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:19
#: lib/mv_web/live/member_live/show.ex:28
#: lib/mv_web/live/member_live/show.ex:29
#, elixir-autogen, elixir-format
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:56
#, elixir-autogen, elixir-format
msgid "Custom Properties"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:23
#: lib/mv_web/live/member_live/show.ex:34
#: lib/mv_web/live/member_live/show.ex:35
#, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/index.html.heex:60
#: lib/mv_web/live/member_live/show.ex:38
#: lib/mv_web/live/member_live/index.html.heex:67
#: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:24
#: lib/mv_web/live/member_live/show.ex:35
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "Notes"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:20
#: lib/mv_web/live/member_live/show.ex:29
#: lib/mv_web/live/member_live/show.ex:30
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/index.html.heex:63
#: lib/mv_web/live/member_live/show.ex:32
#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:33
#, 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:61
#: lib/mv_web/live/member_live/show.ex:39
#: lib/mv_web/live/member_live/index.html.heex:68
#: lib/mv_web/live/member_live/show.ex:40
#, 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:59
#: lib/mv_web/live/member_live/show.ex:37
#: lib/mv_web/live/member_live/index.html.heex:66
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "Street"
msgstr ""
@ -185,17 +185,17 @@ msgstr ""
msgid "Use this form to manage member records and their properties."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:24
#: lib/mv_web/live/member_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Id"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "No"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:80
#: lib/mv_web/live/member_live/show.ex:94
#, elixir-autogen, elixir-format
msgid "Show Member"
msgstr ""
@ -205,7 +205,7 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
@ -282,17 +282,17 @@ msgstr ""
msgid "Description"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:17
#: lib/mv_web/live/user_live/show.ex:18
#, elixir-autogen, elixir-format
msgid "Edit User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Enabled"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:23
#: lib/mv_web/live/user_live/show.ex:24
#, elixir-autogen, elixir-format
msgid "ID"
msgstr ""
@ -302,7 +302,7 @@ msgstr ""
msgid "Immutable"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:73
#: lib/mv_web/components/layouts/navbar.ex:94
#, elixir-autogen, elixir-format
msgid "Logout"
msgstr ""
@ -318,14 +318,14 @@ msgstr ""
msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:14
#: lib/mv_web/live/member_live/index.ex:12
#: lib/mv_web/components/layouts/navbar.ex:19
#: lib/mv_web/live/member_live/index.ex:14
#: 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:50
#: 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"
@ -336,12 +336,12 @@ msgstr ""
msgid "New User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Not enabled"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr ""
@ -353,12 +353,12 @@ msgid "Note"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex:52
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "OIDC ID"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:26
#: lib/mv_web/live/user_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Password Authentication"
msgstr ""
@ -368,7 +368,7 @@ msgstr ""
msgid "Please select a property type first"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:69
#: lib/mv_web/components/layouts/navbar.ex:89
#, elixir-autogen, elixir-format
msgid "Profil"
msgstr ""
@ -403,17 +403,17 @@ msgstr ""
msgid "Save Property type"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:27
#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:41
#: lib/mv_web/live/member_live/index.html.heex:48
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:72
#: lib/mv_web/components/layouts/navbar.ex:92
#, elixir-autogen, elixir-format
msgid "Settings"
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:54
#, elixir-autogen, elixir-format
msgid "Show User"
msgstr ""
@ -554,7 +554,57 @@ 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:30
#, elixir-autogen, elixir-format
msgid "or"
msgid "Linked Member"
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
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
msgid "Users"
msgstr ""

View file

@ -58,6 +58,3 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
msgid "Sign in with Rauthy"
msgstr "Sign in with Vereinscloud"

View file

@ -16,69 +16,69 @@ msgstr ""
msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/member_live/index.html.heex:84
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr ""
#: lib/mv_web/components/layouts.ex:71
#: lib/mv_web/components/layouts.ex:83
#: lib/mv_web/components/layouts.ex:80
#: lib/mv_web/components/layouts.ex:92
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/index.html.heex:62
#: lib/mv_web/live/member_live/show.ex:36
#: lib/mv_web/live/member_live/index.html.heex:69
#: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:79
#: lib/mv_web/live/member_live/index.html.heex:86
#: 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:71
#: lib/mv_web/live/member_live/index.html.heex:78
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
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:19
#: lib/mv_web/live/member_live/show.ex:95
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/index.html.heex:58
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/member_live/index.html.heex:65
#: lib/mv_web/live/member_live/show.ex:28
#: 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:24
#: lib/mv_web/live/user_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Email"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:16
#: lib/mv_web/live/member_live/show.ex:25
#: lib/mv_web/live/member_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/index.html.heex:64
#: lib/mv_web/live/member_live/show.ex:33
#: lib/mv_web/live/member_live/index.html.heex:71
#: lib/mv_web/live/member_live/show.ex:34
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:17
#: lib/mv_web/live/member_live/show.ex:26
#: lib/mv_web/live/member_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Last Name"
msgstr ""
@ -88,18 +88,18 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:68
#: lib/mv_web/live/member_live/index.html.heex:75
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
msgstr ""
#: lib/mv_web/components/layouts.ex:78
#: lib/mv_web/components/layouts.ex:87
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr ""
#: lib/mv_web/components/layouts.ex:66
#: lib/mv_web/components/layouts.ex:75
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr ""
@ -110,52 +110,52 @@ msgid "close"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:19
#: lib/mv_web/live/member_live/show.ex:28
#: lib/mv_web/live/member_live/show.ex:29
#, elixir-autogen, elixir-format
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:56
#, elixir-autogen, elixir-format
msgid "Custom Properties"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:23
#: lib/mv_web/live/member_live/show.ex:34
#: lib/mv_web/live/member_live/show.ex:35
#, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/index.html.heex:60
#: lib/mv_web/live/member_live/show.ex:38
#: lib/mv_web/live/member_live/index.html.heex:67
#: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:24
#: lib/mv_web/live/member_live/show.ex:35
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "Notes"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:20
#: lib/mv_web/live/member_live/show.ex:29
#: lib/mv_web/live/member_live/show.ex:30
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/index.html.heex:63
#: lib/mv_web/live/member_live/show.ex:32
#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:33
#, 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:61
#: lib/mv_web/live/member_live/show.ex:39
#: lib/mv_web/live/member_live/index.html.heex:68
#: lib/mv_web/live/member_live/show.ex:40
#, 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:59
#: lib/mv_web/live/member_live/show.ex:37
#: lib/mv_web/live/member_live/index.html.heex:66
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "Street"
msgstr ""
@ -185,17 +185,17 @@ msgstr ""
msgid "Use this form to manage member records and their properties."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:24
#: lib/mv_web/live/member_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Id"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "No"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:80
#: lib/mv_web/live/member_live/show.ex:94
#, elixir-autogen, elixir-format, fuzzy
msgid "Show Member"
msgstr ""
@ -205,7 +205,7 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
@ -282,17 +282,17 @@ msgstr ""
msgid "Description"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:17
#: lib/mv_web/live/user_live/show.ex:18
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Enabled"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:23
#: lib/mv_web/live/user_live/show.ex:24
#, elixir-autogen, elixir-format
msgid "ID"
msgstr ""
@ -302,7 +302,7 @@ msgstr ""
msgid "Immutable"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:73
#: lib/mv_web/components/layouts/navbar.ex:94
#, elixir-autogen, elixir-format
msgid "Logout"
msgstr ""
@ -318,14 +318,14 @@ msgstr ""
msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:14
#: lib/mv_web/live/member_live/index.ex:12
#: lib/mv_web/components/layouts/navbar.ex:19
#: lib/mv_web/live/member_live/index.ex:14
#: 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:50
#: 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"
@ -336,12 +336,12 @@ msgstr ""
msgid "New User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Not enabled"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format, fuzzy
msgid "Not set"
msgstr ""
@ -353,12 +353,12 @@ msgid "Note"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex:52
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "OIDC ID"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:26
#: lib/mv_web/live/user_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Password Authentication"
msgstr ""
@ -368,7 +368,7 @@ msgstr ""
msgid "Please select a property type first"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:69
#: lib/mv_web/components/layouts/navbar.ex:89
#, elixir-autogen, elixir-format
msgid "Profil"
msgstr ""
@ -403,17 +403,17 @@ msgstr ""
msgid "Save Property type"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:27
#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:41
#: lib/mv_web/live/member_live/index.html.heex:48
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:72
#: lib/mv_web/components/layouts/navbar.ex:92
#, elixir-autogen, elixir-format
msgid "Settings"
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:54
#, elixir-autogen, elixir-format, fuzzy
msgid "Show User"
msgstr ""
@ -554,7 +554,57 @@ 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:30
#, elixir-autogen, elixir-format, fuzzy
msgid "Linked Member"
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 ""

View file

@ -110,3 +110,48 @@ msgstr ""
msgid "must be equal to %{number}"
msgstr ""
## Ash Framework - Standard constraint messages
msgid "length must be greater than or equal to %{min}"
msgstr ""
msgid "length must be less than or equal to %{max}"
msgstr ""
msgid "must be present"
msgstr ""
## Custom validation messages from Mv.Accounts.User
msgid "User already has a member. Remove existing member first."
msgstr ""
msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field"
msgstr ""
## Custom validation messages from Mv.Membership.Member
msgid "User is already linked to another member"
msgstr ""
msgid "User not found"
msgstr ""
msgid "cannot be in the future"
msgstr ""
msgid "cannot be before join date"
msgstr ""
msgid "is not a valid phone number"
msgstr ""
msgid "must consist of 5 digits"
msgstr ""
msgid "is not a valid email"
msgstr ""
msgid "must have length of at least 8"
msgstr ""
msgid "is required"
msgstr ""

View file

@ -107,3 +107,48 @@ msgstr ""
msgid "must be equal to %{number}"
msgstr ""
## Ash Framework - Standard constraint messages
msgid "length must be greater than or equal to %{min}"
msgstr ""
msgid "length must be less than or equal to %{max}"
msgstr ""
msgid "must be present"
msgstr ""
## Custom validation messages from Mv.Accounts.User
msgid "User already has a member. Remove existing member first."
msgstr ""
msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field"
msgstr ""
## Custom validation messages from Mv.Membership.Member
msgid "User is already linked to another member"
msgstr ""
msgid "User not found"
msgstr ""
msgid "cannot be in the future"
msgstr ""
msgid "cannot be before join date"
msgstr ""
msgid "is not a valid phone number"
msgstr ""
msgid "must consist of 5 digits"
msgstr ""
msgid "is not a valid email"
msgstr ""
msgid "must have length of at least 8"
msgstr ""
msgid "is required"
msgstr ""

View file

@ -0,0 +1,19 @@
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
# Ensure 1:1 relationship - one user can only be linked to one member
# This prevents multiple users from sharing the same member account
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

@ -0,0 +1,19 @@
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
# Ensure email uniqueness across all members
# This supports upsert operations and prevents duplicate member accounts
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

View file

@ -0,0 +1,46 @@
defmodule Mv.Repo.Migrations.AddConstraintsForUserMemberAndProperty 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
drop constraint(:users, "users_member_id_fkey")
alter table(:users) do
modify :member_id,
references(:members,
column: :id,
name: "users_member_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :nilify_all
)
end
create unique_index(:properties, [:member_id, :property_type_id],
name: "properties_unique_property_per_member_index"
)
end
def down do
drop_if_exists unique_index(:properties, [:member_id, :property_type_id],
name: "properties_unique_property_per_member_index"
)
drop constraint(:users, "users_member_id_fkey")
alter table(:users) do
modify :member_id,
references(:members,
column: :id,
name: "users_member_id_fkey",
type: :uuid,
prefix: "public"
)
end
end
end

View file

@ -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!")

View file

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

View file

@ -0,0 +1,214 @@
{
"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"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "search_vector",
"type": "tsvector"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "6AAC71BCCA5F112087CEF6877A5BBF74EF8965D5DA4812C44CD6E672F882CC3F",
"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"
}

View file

@ -0,0 +1,124 @@
{
"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?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value",
"type": "map"
},
{
"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": "properties_member_id_fkey",
"on_delete": "delete",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
},
{
"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": "properties_property_type_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "property_types"
},
"scale": null,
"size": null,
"source": "property_type_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "8F90B1AAD1063CF2BB0BDEBBDFBA86AF0B24D854689FB834BC20DFAB2143A451",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "properties_unique_property_per_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
},
{
"type": "atom",
"value": "property_type_id"
}
],
"name": "unique_property_per_member",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "properties"
}

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

@ -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": "nilify",
"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": "1D22936DF847949B543000F3E2E4BDA7D78682AAE6EE0CB9CBD55A4F8F4A7228",
"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_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
}
],
"name": "unique_member",
"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
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "users"
}

View file

@ -0,0 +1,88 @@
defmodule Mv.Accounts.UserMemberDeletionTest do
@moduledoc """
Tests for ON DELETE SET NULL constraint on users.member_id.
When a member is deleted, the linked user should remain but with member_id set to NULL.
"""
use Mv.DataCase, async: true
alias Mv.Accounts
alias Mv.Membership
describe "User remains when linked Member is deleted (ON DELETE SET NULL)" do
@valid_user_attrs %{
email: "test@example.com"
}
@valid_member_attrs %{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}
test "deleting a member sets the user's member_id to NULL" do
# Create a member
{:ok, member} = Membership.create_member(@valid_member_attrs)
# Create a user linked to the member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
# Verify the relationship is established
{:ok, user_before_delete} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
assert user_before_delete.member_id == member.id
assert user_before_delete.member.id == member.id
# Delete the member
:ok = Membership.destroy_member(member)
# Verify the user still exists but member_id is NULL
{:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
assert user_after_delete.id == user.id
assert user_after_delete.member_id == nil
assert user_after_delete.member == nil
end
test "user can be linked to a new member after old member is deleted" do
# Create first member
{:ok, member1} = Membership.create_member(@valid_member_attrs)
# Create user linked to first member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member1.id}))
assert user.member_id == member1.id
# Delete first member
:ok = Membership.destroy_member(member1)
# Reload user from database to get updated member_id (should be NULL)
{:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id)
assert user_after_delete.member_id == nil
# Create second member
{:ok, member2} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
})
# Link user to second member (use reloaded user)
{:ok, updated_user} = Accounts.update_user(user_after_delete, %{member: %{id: member2.id}})
# Verify new relationship
{:ok, final_user} = Ash.get(Mv.Accounts.User, updated_user.id, load: [:member])
assert final_user.member_id == member2.id
assert final_user.member.id == member2.id
end
test "member without linked user can be deleted normally" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
# Delete member (no users linked)
assert :ok = Membership.destroy_member(member)
# Verify member is deleted
assert {:error, _} = Ash.get(Mv.Membership.Member, member.id)
end
end
end

View file

@ -0,0 +1,197 @@
defmodule Mv.Accounts.UserMemberRelationshipTest do
# Using async: true for faster test execution
# This is safe because all database operations are sandboxed per test
use Mv.DataCase, async: true
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

View file

@ -255,7 +255,7 @@ defmodule MvWeb.UserLive.FormTest do
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/users/new")
assert html =~ "Neuer Benutzer"
assert html =~ "Neue*r Benutzer*in"
assert html =~ "E-Mail"
assert html =~ "Passwort setzen"
end

View file

@ -7,7 +7,7 @@ defmodule MvWeb.UserLive.IndexTest do
conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/users")
assert html =~ "Benutzer auflisten"
assert html =~ "Benutzer*innen auflisten"
end
test "shows translated title in English", %{conn: conn} do
@ -362,8 +362,8 @@ defmodule MvWeb.UserLive.IndexTest do
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/users")
assert html =~ "Alle Benutzer auswählen"
assert html =~ "Benutzer auswählen"
assert html =~ "Alle Benutzer*innen auswählen"
assert html =~ "Benutzer*in auswählen"
end
test "shows English translations for selection", %{conn: conn} do