Implements settings for member fields closes #223 #300

Merged
carla merged 26 commits from feature/223_memberfields_settings into main 2026-01-12 13:24:54 +01:00
23 changed files with 1274 additions and 176 deletions
Showing only changes of commit df8c6a1854 - Show all commits

View file

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

View file

@ -7,7 +7,6 @@ defmodule Mv.Constants do
:first_name, :first_name,
:last_name, :last_name,
:email, :email,
:phone_number,
:join_date, :join_date,
:exit_date, :exit_date,
:notes, :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 /> <.mockup_warning />
<.header> <.header>
{gettext("Contributions for %{name}", name: "#{@member.first_name} #{@member.last_name}")} {gettext("Contributions for %{name}", name: MvWeb.Helpers.MemberHelpers.display_name(@member))}
<:subtitle> <:subtitle>
{gettext("Contribution type")}: {gettext("Contribution type")}:
<span class="font-semibold">{@member.contribution_type}</span> <span class="font-semibold">{@member.contribution_type}</span>

View file

@ -289,6 +289,6 @@ defmodule MvWeb.CustomFieldValueLive.Form do
end end
defp member_options(members) do 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
end end

View file

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

View file

@ -239,24 +239,6 @@
> >
{member.city} {member.city}
</:col> </: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 <:col
:let={member} :let={member}
:if={:join_date in @member_fields_visible} :if={:join_date in @member_fields_visible}

View file

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

View file

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

View file

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

View file

@ -57,7 +57,7 @@ defmodule MvWeb.UserLive.Show do
class="text-blue-600 underline hover:text-blue-800" class="text-blue-600 underline hover:text-blue-800"
> >
<.icon name="hero-users" class="inline w-4 h-4 mr-1" /> <.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> </.link>
<% else %> <% else %>
<span class="italic text-gray-500">{gettext("No member linked")}</span> <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(:first_name), do: gettext("First Name")
def label(:last_name), do: gettext("Last Name") def label(:last_name), do: gettext("Last Name")
def label(:email), do: gettext("Email") def label(:email), do: gettext("Email")
def label(:phone_number), do: gettext("Phone")
def label(:join_date), do: gettext("Join Date") def label(:join_date), do: gettext("Join Date")
def label(:exit_date), do: gettext("Exit Date") def label(:exit_date), do: gettext("Exit Date")
def label(:notes), do: gettext("Notes") def label(:notes), do: gettext("Notes")

View file

@ -151,11 +151,6 @@ msgstr "Notizen"
msgid "Paid" msgid "Paid"
msgstr "Bezahlt" msgstr "Bezahlt"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex #: lib/mv_web/translations/member_fields.ex
@ -865,13 +860,6 @@ msgstr "Zahlungen"
msgid "Personal Data" msgid "Personal Data"
msgstr "Persönliche Daten" msgstr "Persönliche Daten"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Phone"
msgstr "Telefon"
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -2007,6 +1995,18 @@ msgstr "Nicht gesetzt"
#~ msgid "Pending" #~ msgid "Pending"
#~ msgstr "Ausstehend" #~ msgstr "Ausstehend"
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #: lib/mv_web/translations/member_fields.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Phone"
#~ msgstr "Telefon"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Phone Number"
#~ msgstr "Telefonnummer"
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Quarterly Interval - Joining Period Excluded" #~ msgid "Quarterly Interval - Joining Period Excluded"

View file

@ -152,11 +152,6 @@ msgstr ""
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex #: lib/mv_web/translations/member_fields.ex
@ -863,13 +858,6 @@ msgstr ""
msgid "Personal Data" msgid "Personal Data"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Phone"
msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format

View file

@ -152,11 +152,6 @@ msgstr ""
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex #: lib/mv_web/translations/member_fields.ex
@ -864,13 +859,6 @@ msgstr ""
msgid "Personal Data" msgid "Personal Data"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Phone"
msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -1405,20 +1393,437 @@ msgstr ""
msgid "String" msgid "String"
msgstr "" msgstr ""
#~ #: lib/mv_web/live/custom_field_live/show.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "About Membership Fee Types"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Already paid cycles will remain with the old amount."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "An error occurred"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Are you sure you want to delete this cycle?"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Cannot delete - %{count} member(s) assigned"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Change Amount?"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Changing the amount will affect %{count} member(s)."
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Confirm Change"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Current Cycle"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Current amount"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cycle"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cycle amount updated"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cycle deleted"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cycle status updated"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cycles regenerated successfully"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Cycle"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Edit Cycle Amount"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit Membership Fee Type"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Failed to update cycle status: %{errors}"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Future unpaid cycles will be regenerated with the new amount."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Generate cycles from the last existing cycle to today"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Interval cannot be changed after creation."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Invalid amount format"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Last Cycle"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Manage membership fee types for membership fees."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Mark as paid"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Mark as suspended"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Mark as unpaid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Status"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Type"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Types"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fees"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee type deleted"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee type removed"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type saved successfully"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type updated. Cycles regenerated."
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Membership Fee Type"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "New amount"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "No cycle"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "No cycles"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No membership fee type assigned"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "No status"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Please confirm the amount change first"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Regenerate Cycles"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Regenerating..."
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Membership Fee Type"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Select a membership fee type for this member. Members can only switch between types with the same interval."
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Select interval"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Type"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage membership fee types in your database."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "A cycle for this period already exists"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "All cycles deleted"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Click to edit amount"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Create"
msgstr "created"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Create Cycle"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Create a new cycle manually"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Cycle Period"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Cycle created successfully"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete All"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete All Cycles"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete all cycles"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete cycle"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Invalid date format"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Payment Interval"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "The cycle period will be calculated based on this date and the interval."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "This action cannot be undone."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Type '%{confirmation}' to confirm"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Warning"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "You are about to delete all %{count} cycles for this member."
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current Cycle Payment Status"
msgstr "Current Cycle Payment Status"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last Cycle Payment Status"
msgstr "Last Cycle Payment Status"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Delete membership fee type"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit membership fee type"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Confirmation text does not match"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "No cycles to delete"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Not set"
msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)" #~ msgid "Show current cycle"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Configure global settings for membership contributions." #~ msgid "Unpaid in last cycle"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #: lib/mv_web/live/member_live/show.ex #~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "New Custom field"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Contribution" #~ msgid "Show Last/Current Cycle Payment Status"
#~ msgstr ""
#~ #: lib/mv_web/live/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "All payment statuses"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_field_live/index_component.ex #~ #: lib/mv_web/live/member_field_live/index_component.ex
@ -1426,10 +1831,26 @@ msgstr ""
#~ msgid "Field Name" #~ msgid "Field Name"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/components/layouts/navbar.ex #~ #: lib/mv_web/live/member_live/index.html.heex
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Contribution Settings" #~ msgid "Copy emails"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #: lib/mv_web/translations/member_fields.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Phone"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Pending"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Payment Cycle"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_field_live/index_component.ex #~ #: lib/mv_web/live/member_field_live/index_component.ex
@ -1444,17 +1865,13 @@ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Contribution start" #~ msgid "View Example Member"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex #~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Copy emails" #~ msgid "This data is for demonstration purposes only (mockup)."
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Default Contribution Type"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
@ -1463,6 +1880,11 @@ msgstr ""
#~ msgid "Edit amount" #~ msgid "Edit amount"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Quarterly Interval - Joining Period Excluded"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Example: Member Contribution View" #~ msgid "Example: Member Contribution View"
@ -1473,20 +1895,20 @@ msgstr ""
#~ msgid "Failed to delete some cycles: %{errors}" #~ msgid "Failed to delete some cycles: %{errors}"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Switch to current cycle"
#~ msgstr ""
#~ #: lib/mv_web/live/membership_fee_settings_live.ex #~ #: lib/mv_web/live/membership_fee_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Failed to save settings. Please check the errors below." #~ msgid "Failed to save settings. Please check the errors below."
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/user_live/index.html.heex #~ #: lib/mv_web/components/layouts/navbar.ex
#~ #: lib/mv_web/live/user_live/show.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Generated periods" #~ msgid "Contribution Settings"
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Immutable"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
@ -1509,27 +1931,19 @@ msgstr ""
#~ msgid "Not paid" #~ msgid "Not paid"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex #~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Payment Cycle"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Pending"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Quarterly Interval - Joining Period Excluded" #~ msgid "Quarterly Interval - Joining Period Excluded"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex #~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Show Last/Current Cycle Payment Status" #~ msgid "Show Last/Current Cycle Payment Status"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex #~ #: lib/mv_web/live/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Show current cycle" #~ msgid "Show current cycle"
#~ msgstr "" #~ msgstr ""
@ -1552,36 +1966,46 @@ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex #~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "This data is for demonstration purposes only (mockup)." #~ msgid "Contribution"
#~ msgstr ""
#~ #: lib/mv_web/live/user_live/index.html.heex
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Generated periods"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex #~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in current cycle" #~ msgid "Switch to last completed cycle"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in last cycle"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "View Example Member" #~ msgid "Configure global settings for membership contributions."
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Yearly Interval - Joining Period Included" #~ msgid "Default Contribution Type"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "monthly"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/member_live/form.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "yearly" #~ msgid "yearly"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Phone Number"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in current cycle"
#~ msgstr ""

View file

@ -0,0 +1,404 @@
defmodule Mv.Repo.Migrations.RemovePhoneNumberAndMakeFieldsOptional do
@moduledoc """
Removes phone_number field from members table and makes first_name/last_name optional.
This migration:
1. Removes phone_number column from members table
2. Makes first_name and last_name columns nullable
3. Updates members_search_vector_trigger() function to remove phone_number
4. Updates update_member_search_vector_from_custom_field_value() function to remove phone_number
5. Updates existing search_vector values for all members
"""
use Ecto.Migration
def up do
# Update the main trigger function to remove phone_number
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
DECLARE
custom_values_text text;
BEGIN
-- Aggregate all custom field values for this member
-- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy)
-- ->> operator always returns TEXT directly (no need for -> + ::text fallback)
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = NEW.id AND value IS NOT NULL;
-- Build search_vector with member fields and custom field values
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
# Update trigger function to remove phone_number
execute("""
CREATE OR REPLACE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$
DECLARE
member_id_val uuid;
member_first_name text;
member_last_name text;
member_email text;
member_join_date date;
member_exit_date date;
member_notes text;
member_city text;
member_street text;
member_house_number text;
member_postal_code text;
custom_values_text text;
old_value_text text;
new_value_text text;
BEGIN
-- Get member ID from trigger context
member_id_val := COALESCE(NEW.member_id, OLD.member_id);
-- Optimization: For UPDATE operations, check if value actually changed
-- If value hasn't changed, we can skip the expensive re-aggregation
IF TG_OP = 'UPDATE' THEN
-- Extract OLD value for comparison (handle both JSONB formats)
-- ->> operator always returns TEXT directly
old_value_text := COALESCE(
NULLIF(OLD.value->>'_union_value', ''),
NULLIF(OLD.value->>'value', ''),
''
);
-- Extract NEW value for comparison (handle both JSONB formats)
new_value_text := COALESCE(
NULLIF(NEW.value->>'_union_value', ''),
NULLIF(NEW.value->>'value', ''),
''
);
-- Check if value, member_id, or custom_field_id actually changed
-- If nothing changed, skip expensive re-aggregation
IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND
(OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND
(OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN
RETURN COALESCE(NEW, OLD);
END IF;
END IF;
-- Fetch only required fields instead of full record (performance optimization)
SELECT
first_name,
last_name,
email,
join_date,
exit_date,
notes,
city,
street,
house_number,
postal_code
INTO
member_first_name,
member_last_name,
member_email,
member_join_date,
member_exit_date,
member_notes,
member_city,
member_street,
member_house_number,
member_postal_code
FROM members
WHERE id = member_id_val;
-- Aggregate all custom field values for this member
-- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy)
-- ->> operator always returns TEXT directly
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = member_id_val AND value IS NOT NULL;
-- Update the search_vector for the affected member
UPDATE members
SET search_vector =
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C')
WHERE id = member_id_val;
RETURN COALESCE(NEW, OLD);
END
$$ LANGUAGE plpgsql;
""")
# Update existing search_vector values for all members
execute("""
UPDATE members m
SET search_vector =
setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(
(SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
FROM custom_field_values
WHERE member_id = m.id AND value IS NOT NULL),
''
)), 'C')
""")
# Make first_name and last_name nullable
execute("ALTER TABLE members ALTER COLUMN first_name DROP NOT NULL")
execute("ALTER TABLE members ALTER COLUMN last_name DROP NOT NULL")
# Remove phone_number column
alter table(:members) do
remove :phone_number
end
end
def down do
# Set default values for NULL fields before restoring NOT NULL constraint
# This prevents the migration from failing if NULL values exist
execute("UPDATE members SET first_name = '' WHERE first_name IS NULL")
execute("UPDATE members SET last_name = '' WHERE last_name IS NULL")
# Restore first_name and last_name as NOT NULL
execute("ALTER TABLE members ALTER COLUMN first_name SET NOT NULL")
execute("ALTER TABLE members ALTER COLUMN last_name SET NOT NULL")
# Add phone_number column back
alter table(:members) do
add :phone_number, :text
end
# Restore trigger functions with phone_number
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
DECLARE
custom_values_text text;
BEGIN
-- Aggregate all custom field values for this member
-- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy)
-- ->> operator always returns TEXT directly (no need for -> + ::text fallback)
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = NEW.id AND value IS NOT NULL;
-- Build search_vector with member fields and custom field values
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
execute("""
CREATE OR REPLACE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$
DECLARE
member_id_val uuid;
member_first_name text;
member_last_name text;
member_email text;
member_phone_number text;
member_join_date date;
member_exit_date date;
member_notes text;
member_city text;
member_street text;
member_house_number text;
member_postal_code text;
custom_values_text text;
old_value_text text;
new_value_text text;
BEGIN
-- Get member ID from trigger context
member_id_val := COALESCE(NEW.member_id, OLD.member_id);
-- Optimization: For UPDATE operations, check if value actually changed
-- If value hasn't changed, we can skip the expensive re-aggregation
IF TG_OP = 'UPDATE' THEN
-- Extract OLD value for comparison (handle both JSONB formats)
-- ->> operator always returns TEXT directly
old_value_text := COALESCE(
NULLIF(OLD.value->>'_union_value', ''),
NULLIF(OLD.value->>'value', ''),
''
);
-- Extract NEW value for comparison (handle both JSONB formats)
new_value_text := COALESCE(
NULLIF(NEW.value->>'_union_value', ''),
NULLIF(NEW.value->>'value', ''),
''
);
-- Check if value, member_id, or custom_field_id actually changed
-- If nothing changed, skip expensive re-aggregation
IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND
(OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND
(OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN
RETURN COALESCE(NEW, OLD);
END IF;
END IF;
-- Fetch only required fields instead of full record (performance optimization)
SELECT
first_name,
last_name,
email,
phone_number,
join_date,
exit_date,
notes,
city,
street,
house_number,
postal_code
INTO
member_first_name,
member_last_name,
member_email,
member_phone_number,
member_join_date,
member_exit_date,
member_notes,
member_city,
member_street,
member_house_number,
member_postal_code
FROM members
WHERE id = member_id_val;
-- Aggregate all custom field values for this member
-- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy)
-- ->> operator always returns TEXT directly
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = member_id_val AND value IS NOT NULL;
-- Update the search_vector for the affected member
UPDATE members
SET search_vector =
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C')
WHERE id = member_id_val;
RETURN COALESCE(NEW, OLD);
END
$$ LANGUAGE plpgsql;
""")
# Update existing search_vector values to include phone_number
execute("""
UPDATE members m
SET search_vector =
setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(
(SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
FROM custom_field_values
WHERE member_id = m.id AND value IS NOT NULL),
''
)), 'C')
""")
end
end

View file

@ -147,7 +147,6 @@ member_attrs_list = [
last_name: "Müller", last_name: "Müller",
email: "hans.mueller@example.de", email: "hans.mueller@example.de",
join_date: ~D[2023-01-15], join_date: ~D[2023-01-15],
phone_number: "+49301234567",
city: "München", city: "München",
street: "Hauptstraße", street: "Hauptstraße",
house_number: "42", house_number: "42",
@ -160,7 +159,6 @@ member_attrs_list = [
last_name: "Schmidt", last_name: "Schmidt",
email: "greta.schmidt@example.de", email: "greta.schmidt@example.de",
join_date: ~D[2023-02-01], join_date: ~D[2023-02-01],
phone_number: "+49309876543",
city: "Hamburg", city: "Hamburg",
street: "Lindenstraße", street: "Lindenstraße",
house_number: "17", house_number: "17",
@ -174,7 +172,6 @@ member_attrs_list = [
last_name: "Wagner", last_name: "Wagner",
email: "friedrich.wagner@example.de", email: "friedrich.wagner@example.de",
join_date: ~D[2022-11-10], join_date: ~D[2022-11-10],
phone_number: "+49301122334",
city: "Berlin", city: "Berlin",
street: "Kastanienallee", street: "Kastanienallee",
house_number: "8", house_number: "8",
@ -186,7 +183,6 @@ member_attrs_list = [
last_name: "Wagner", last_name: "Wagner",
email: "marianne.wagner@example.de", email: "marianne.wagner@example.de",
join_date: ~D[2022-11-10], join_date: ~D[2022-11-10],
phone_number: "+49301122334",
city: "Berlin", city: "Berlin",
street: "Kastanienallee", street: "Kastanienallee",
house_number: "8" house_number: "8"
@ -299,7 +295,6 @@ linked_members = [
last_name: "Weber", last_name: "Weber",
email: "maria.weber@example.de", email: "maria.weber@example.de",
join_date: ~D[2023-03-15], join_date: ~D[2023-03-15],
phone_number: "+49301357924",
city: "Frankfurt", city: "Frankfurt",
street: "Goetheplatz", street: "Goetheplatz",
house_number: "5", house_number: "5",
@ -313,7 +308,6 @@ linked_members = [
last_name: "Klein", last_name: "Klein",
email: "thomas.klein@example.de", email: "thomas.klein@example.de",
join_date: ~D[2023-04-01], join_date: ~D[2023-04-01],
phone_number: "+49302468135",
city: "Köln", city: "Köln",
street: "Rheinstraße", street: "Rheinstraße",
house_number: "23", house_number: "23",

View file

@ -7,7 +7,6 @@ defmodule Mv.Membership.MemberTest do
first_name: "John", first_name: "John",
last_name: "Doe", last_name: "Doe",
email: "john@example.com", email: "john@example.com",
phone_number: "+49123456789",
join_date: ~D[2020-01-01], join_date: ~D[2020-01-01],
exit_date: nil, exit_date: nil,
notes: "Test note", notes: "Test note",
@ -17,16 +16,14 @@ defmodule Mv.Membership.MemberTest do
postal_code: "12345" postal_code: "12345"
} }
test "First name is required and must not be empty" do test "First name is optional" do
attrs = Map.put(@valid_attrs, :first_name, "") attrs = Map.delete(@valid_attrs, :first_name)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) assert {:ok, _member} = Membership.create_member(attrs)
assert error_message(errors, :first_name) =~ "must be present"
end end
test "Last name is required and must not be empty" do test "Last name is optional" do
attrs = Map.put(@valid_attrs, :last_name, "") attrs = Map.delete(@valid_attrs, :last_name)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) assert {:ok, _member} = Membership.create_member(attrs)
assert error_message(errors, :last_name) =~ "must be present"
end end
test "Email is required" do test "Email is required" do
@ -41,14 +38,6 @@ defmodule Mv.Membership.MemberTest do
assert error_message(errors, :email) =~ "is not a valid email" assert error_message(errors, :email) =~ "is not a valid email"
end end
test "Phone number is optional but must have a valid format if specified" do
attrs = Map.put(@valid_attrs, :phone_number, "abc")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :phone_number) =~ "is not a valid phone number"
attrs2 = Map.delete(@valid_attrs, :phone_number)
assert {:ok, _member} = Membership.create_member(attrs2)
end
test "Join date cannot be in the future" do test "Join date cannot be in the future" do
attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1)) attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1))

View file

@ -24,7 +24,6 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
:house_number, :house_number,
:postal_code, :postal_code,
:city, :city,
:phone_number,
:join_date :join_date
] ]
@ -101,7 +100,6 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
assert has_element?(view, "[data-testid='street'] .opacity-40") assert has_element?(view, "[data-testid='street'] .opacity-40")
assert has_element?(view, "[data-testid='house_number'] .opacity-40") assert has_element?(view, "[data-testid='house_number'] .opacity-40")
assert has_element?(view, "[data-testid='postal_code'] .opacity-40") assert has_element?(view, "[data-testid='postal_code'] .opacity-40")
assert has_element?(view, "[data-testid='phone_number'] .opacity-40")
assert has_element?(view, "[data-testid='join_date'] .opacity-40") assert has_element?(view, "[data-testid='join_date'] .opacity-40")
end end

View file

@ -0,0 +1,141 @@
defmodule MvWeb.Helpers.MemberHelpersTest do
@moduledoc """
Tests for the display_name/1 helper function in MemberHelpers.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias MvWeb.Helpers.MemberHelpers
describe "display_name/1" do
test "returns full name when both first_name and last_name are present" do
member = %Member{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John Doe"
end
test "returns email when both first_name and last_name are nil" do
member = %Member{
first_name: nil,
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "returns first_name only when last_name is nil" do
member = %Member{
first_name: "John",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "returns last_name only when first_name is nil" do
member = %Member{
first_name: nil,
last_name: "Doe",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "Doe"
end
test "returns email when first_name and last_name are empty strings" do
member = %Member{
first_name: "",
last_name: "",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "returns email when first_name and last_name are whitespace only" do
member = %Member{
first_name: " ",
last_name: " \t ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "trims whitespace from name parts" do
member = %Member{
first_name: " John ",
last_name: " Doe ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John Doe"
end
test "handles one empty string and one nil" do
member = %Member{
first_name: "",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one nil and one empty string" do
member = %Member{
first_name: nil,
last_name: "",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one whitespace and one nil" do
member = %Member{
first_name: " ",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one valid name and one whitespace" do
member = %Member{
first_name: "John",
last_name: " ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "handles member with only first_name containing whitespace" do
member = %Member{
first_name: " John ",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "handles member with only last_name containing whitespace" do
member = %Member{
first_name: nil,
last_name: " Doe ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "Doe"
end
end
end

View file

@ -0,0 +1,141 @@
defmodule MvWeb.Helpers.MemberHelpersTest do
@moduledoc """
Tests for the display_name/1 helper function in MemberHelpers.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias MvWeb.Helpers.MemberHelpers
describe "display_name/1" do
test "returns full name when both first_name and last_name are present" do
member = %Member{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John Doe"
end
test "returns email when both first_name and last_name are nil" do
member = %Member{
first_name: nil,
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "returns first_name only when last_name is nil" do
member = %Member{
first_name: "John",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "returns last_name only when first_name is nil" do
member = %Member{
first_name: nil,
last_name: "Doe",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "Doe"
end
test "returns email when first_name and last_name are empty strings" do
member = %Member{
first_name: "",
last_name: "",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "returns email when first_name and last_name are whitespace only" do
member = %Member{
first_name: " ",
last_name: " \t ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "trims whitespace from name parts" do
member = %Member{
first_name: " John ",
last_name: " Doe ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John Doe"
end
test "handles one empty string and one nil" do
member = %Member{
first_name: "",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one nil and one empty string" do
member = %Member{
first_name: nil,
last_name: "",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one whitespace and one nil" do
member = %Member{
first_name: " ",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one valid name and one whitespace" do
member = %Member{
first_name: "John",
last_name: " ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "handles member with only first_name containing whitespace" do
member = %Member{
first_name: " John ",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "handles member with only last_name containing whitespace" do
member = %Member{
first_name: nil,
last_name: " Doe ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "Doe"
end
end
end

View file

@ -16,7 +16,6 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
house_number: "123", house_number: "123",
postal_code: "12345", postal_code: "12345",
city: "Berlin", city: "Berlin",
phone_number: "+49123456789",
join_date: ~D[2020-01-15] join_date: ~D[2020-01-15]
}) })
|> Ash.create() |> Ash.create()

View file

@ -121,7 +121,6 @@ defmodule MvWeb.MemberLive.IndexTest do
:house_number, :house_number,
:postal_code, :postal_code,
:city, :city,
:phone_number,
:join_date :join_date
] ]