Merge branch 'main' into feature/223_memberfields_settings
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2026-01-07 11:42:54 +01:00
commit df8c6a1854
23 changed files with 1274 additions and 176 deletions

View file

@ -5,7 +5,7 @@ defmodule Mv.Membership.Member do
## Overview
Members are the core entity in the membership management system. Each member
can have:
- Personal information (name, email, phone, address)
- Personal information (name, email, address)
- Optional link to a User account (1:1 relationship)
- Dynamic custom field values via CustomField system
- Full-text searchable profile
@ -20,9 +20,8 @@ defmodule Mv.Membership.Member do
- `has_one :user` - Optional authentication account link
## Validations
- Required: first_name, last_name, email
- Required: email (all other fields are optional)
- Email format validation (using EctoCommons.EmailValidator)
- Phone number format: international format with 6-20 digits
- Postal code format: exactly 5 digits (German format)
- Date validations: join_date not in future, exit_date after join_date
- Email uniqueness: prevents conflicts with unlinked users
@ -31,7 +30,7 @@ defmodule Mv.Membership.Member do
Members have a `search_vector` attribute (tsvector) that is automatically
updated via database trigger. Search includes name, email, notes, contact fields,
and all custom field values. Custom field values are automatically included in
the search vector with weight 'C' (same as phone_number, city, etc.).
the search vector with weight 'C' (same as city, etc.).
"""
use Ash.Resource,
domain: Mv.Membership,
@ -343,9 +342,7 @@ defmodule Mv.Membership.Member do
validations do
# Required fields are covered by allow_nil? false
# First name and last name must not be empty
validate present(:first_name)
validate present(:last_name)
# Email is required
validate present(:email)
# Email uniqueness check for all actions that change the email attribute
@ -396,11 +393,6 @@ defmodule Mv.Membership.Member do
where: [present([:join_date, :exit_date])],
message: "cannot be before join date"
# Phone number format (only if set)
validate match(:phone_number, ~r/^\+?[0-9\- ]{6,20}$/),
where: [present(:phone_number)],
message: "is not a valid phone number"
# Postal code format (only if set)
validate match(:postal_code, ~r/^\d{5}$/),
where: [present(:postal_code)],
@ -453,12 +445,12 @@ defmodule Mv.Membership.Member do
uuid_v7_primary_key :id
attribute :first_name, :string do
allow_nil? false
allow_nil? true
constraints min_length: 1
end
attribute :last_name, :string do
allow_nil? false
allow_nil? true
constraints min_length: 1
end
@ -474,10 +466,6 @@ defmodule Mv.Membership.Member do
constraints min_length: 5, max_length: 254
end
attribute :phone_number, :string do
allow_nil? true
end
attribute :join_date, :date do
allow_nil? true
end
@ -1073,7 +1061,6 @@ defmodule Mv.Membership.Member do
expr(
contains(postal_code, ^query) or
contains(house_number, ^query) or
contains(phone_number, ^query) or
contains(email, ^query) or
contains(city, ^query)
)

View file

@ -7,7 +7,6 @@ defmodule Mv.Constants do
:first_name,
:last_name,
:email,
:phone_number,
:join_date,
:exit_date,
:notes,

View file

@ -0,0 +1,64 @@
defmodule MvWeb.Helpers.MemberHelpers do
@moduledoc """
Helper functions for member-related operations in the web layer.
Provides utilities for formatting and displaying member information.
"""
alias Mv.Membership.Member
@doc """
Returns a display name for a member.
Combines first_name and last_name if available, otherwise falls back to email.
This ensures that members without names still have a meaningful display name.
## Examples
iex> member = %Member{first_name: "John", last_name: "Doe", email: "john@example.com"}
iex> MvWeb.Helpers.MemberHelpers.display_name(member)
"John Doe"
iex> member = %Member{first_name: nil, last_name: nil, email: "john@example.com"}
iex> MvWeb.Helpers.MemberHelpers.display_name(member)
"john@example.com"
iex> member = %Member{first_name: "John", last_name: nil, email: "john@example.com"}
iex> MvWeb.Helpers.MemberHelpers.display_name(member)
"John"
"""
def display_name(%Member{} = member) do
name_parts =
[member.first_name, member.last_name]
|> Enum.reject(&blank?/1)
|> Enum.map_join(" ", &String.trim/1)
if name_parts == "" do
member.email
else
name_parts
end
end
@doc """
Checks if a value is blank (nil, empty string, or only whitespace).
## Examples
iex> MvWeb.Helpers.MemberHelpers.blank?(nil)
true
iex> MvWeb.Helpers.MemberHelpers.blank?("")
true
iex> MvWeb.Helpers.MemberHelpers.blank?(" ")
true
iex> MvWeb.Helpers.MemberHelpers.blank?("John")
false
"""
def blank?(nil), do: true
def blank?(""), do: true
def blank?(value) when is_binary(value), do: String.trim(value) == ""
def blank?(_), do: false
end

View file

@ -36,7 +36,7 @@ defmodule MvWeb.ContributionPeriodLive.Show do
<.mockup_warning />
<.header>
{gettext("Contributions for %{name}", name: "#{@member.first_name} #{@member.last_name}")}
{gettext("Contributions for %{name}", name: MvWeb.Helpers.MemberHelpers.display_name(@member))}
<:subtitle>
{gettext("Contribution type")}:
<span class="font-semibold">{@member.contribution_type}</span>

View file

@ -289,6 +289,6 @@ defmodule MvWeb.CustomFieldValueLive.Form do
end
defp member_options(members) do
Enum.map(members, &{"#{&1.first_name} #{&1.last_name}", &1.id})
Enum.map(members, &{MvWeb.Helpers.MemberHelpers.display_name(&1), &1.id})
end
end

View file

@ -43,7 +43,7 @@ defmodule MvWeb.MemberLive.Form do
<h1 class="text-2xl font-bold text-center flex-1">
<%= if @member do %>
{@member.first_name} {@member.last_name}
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
<% else %>
{gettext("New Member")}
<% end %>
@ -82,10 +82,10 @@ defmodule MvWeb.MemberLive.Form do
<%!-- Name Row --%>
<div class="flex gap-4">
<div class="w-48">
<.input field={@form[:first_name]} label={gettext("First Name")} required />
<.input field={@form[:first_name]} label={gettext("First Name")} />
</div>
<div class="w-48">
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
<.input field={@form[:last_name]} label={gettext("Last Name")} />
</div>
</div>
@ -110,11 +110,6 @@ defmodule MvWeb.MemberLive.Form do
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
</div>
<%!-- Phone --%>
<div>
<.input field={@form[:phone_number]} label={gettext("Phone")} type="tel" />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-4">
<div class="w-36">

View file

@ -239,24 +239,6 @@
>
{member.city}
</:col>
<:col
:let={member}
:if={:phone_number in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_phone_number}
field={:phone_number}
label={gettext("Phone Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.phone_number}
</:col>
<:col
:let={member}
:if={:join_date in @member_fields_visible}

View file

@ -35,7 +35,7 @@ defmodule MvWeb.MemberLive.Show do
</.button>
<h1 class="text-2xl font-bold text-center flex-1">
{@member.first_name} {@member.last_name}
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
</h1>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
@ -104,11 +104,6 @@ defmodule MvWeb.MemberLive.Show do
</.data_field>
</div>
<%!-- Phone --%>
<div>
<.data_field label={gettext("Phone")} value={@member.phone_number} />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-6">
<.data_field

View file

@ -131,7 +131,7 @@ defmodule MvWeb.UserLive.Form do
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-green-900">
{@user.member.first_name} {@user.member.last_name}
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
</p>
<p class="text-sm text-green-700">{@user.member.email}</p>
</div>
@ -210,7 +210,7 @@ defmodule MvWeb.UserLive.Form do
)
]}
>
<p class="font-medium">{member.first_name} {member.last_name}</p>
<p class="font-medium">{MvWeb.Helpers.MemberHelpers.display_name(member)}</p>
<p class="text-sm text-base-content/70">{member.email}</p>
</div>
<% end %>
@ -438,7 +438,7 @@ defmodule MvWeb.UserLive.Form do
member_name =
if selected_member,
do: "#{selected_member.first_name} #{selected_member.last_name}",
do: MvWeb.Helpers.MemberHelpers.display_name(selected_member),
else: ""
# Store the selected member ID and name in socket state and clear unlink flag

View file

@ -51,7 +51,7 @@
</:col>
<:col :let={user} label={gettext("Linked Member")}>
<%= if user.member do %>
{user.member.first_name} {user.member.last_name}
{MvWeb.Helpers.MemberHelpers.display_name(user.member)}
<% else %>
<span class="text-base-content/50">{gettext("No member linked")}</span>
<% end %>

View file

@ -57,7 +57,7 @@ defmodule MvWeb.UserLive.Show do
class="text-blue-600 underline hover:text-blue-800"
>
<.icon name="hero-users" class="inline w-4 h-4 mr-1" />
{@user.member.first_name} {@user.member.last_name}
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
</.link>
<% else %>
<span class="italic text-gray-500">{gettext("No member linked")}</span>

View file

@ -20,7 +20,6 @@ defmodule MvWeb.Translations.MemberFields do
def label(:first_name), do: gettext("First Name")
def label(:last_name), do: gettext("Last Name")
def label(:email), do: gettext("Email")
def label(:phone_number), do: gettext("Phone")
def label(:join_date), do: gettext("Join Date")
def label(:exit_date), do: gettext("Exit Date")
def label(:notes), do: gettext("Notes")