Fixes missing Rauthy error message closes #289 #427
16 changed files with 1136 additions and 88 deletions
|
|
@ -233,16 +233,22 @@ Settings (1) → MembershipFeeType (0..1)
|
|||
## Full-Text Search
|
||||
|
||||
### Implementation
|
||||
- **Trigger:** `members_search_vector_trigger()`
|
||||
- **Function:** Automatically updates `search_vector` on INSERT/UPDATE
|
||||
- **Trigger** on `members` (INSERT/UPDATE): runs function `members_search_vector_trigger()`
|
||||
- **Trigger** on `member_groups` (INSERT/UPDATE/DELETE): `update_member_search_vector_on_member_groups_change` runs function `update_member_search_vector_from_member_groups()`
|
||||
- **Index Type:** GIN (Generalized Inverted Index)
|
||||
|
||||
### Weighted Fields
|
||||
- **Weight A (highest):** first_name, last_name
|
||||
- **Weight B:** email, notes
|
||||
- **Weight B:** email, notes, group names (from member_groups → groups)
|
||||
- **Weight C:** city, street, house_number, postal_code, custom_field_values
|
||||
- **Weight D (lowest):** join_date, exit_date
|
||||
|
||||
### Group Names in Search
|
||||
Group names are included in the member search vector so that searching for a group name (e.g. "Vorstand") finds all members in that group:
|
||||
- Group names are aggregated from `member_groups` joined with `groups` and receive weight 'B'
|
||||
- The trigger `update_member_search_vector_on_member_groups_change` runs on INSERT/UPDATE/DELETE on `member_groups` and refreshes the affected member's `search_vector`
|
||||
- See migration `20260217120000_add_group_names_to_member_search_vector.exs` (Issue #375)
|
||||
|
||||
### Custom Field Values in Search
|
||||
Custom field values are automatically included in the search vector:
|
||||
- All custom field values (string, integer, boolean, date, email) are aggregated and added to the search vector
|
||||
|
|
|
|||
|
|
@ -81,12 +81,13 @@
|
|||
- ✅ User-Member linking (optional 1:1)
|
||||
- ✅ Email synchronization between User and Member
|
||||
- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230)
|
||||
- ✅ **Groups** - Organize members into groups (PR #378, #382, closes #371, #372, 2026-01-27)
|
||||
- ✅ **Groups** - Organize members into groups (PR #378, #382, #423, closes #371, #372, #374, #375, 2026-01/02)
|
||||
- Many-to-many relationship with groups
|
||||
- Groups management UI (`/groups`)
|
||||
- Filter and sort by groups in member list
|
||||
- Per-group filter in member list: one row per group with All / Yes / No (All/Alle); URL params `group_<uuid>=in|not_in`
|
||||
- Groups displayed in member overview and detail views
|
||||
- Member search includes group names (search by group name finds members in that group; search_vector + trigger on member_groups)
|
||||
- ✅ **CSV Import** - Import members from CSV files (PR #359, #394, #395, closes #335, #336, #338, 2026-01-27)
|
||||
- Member field import
|
||||
- Custom field value import
|
||||
|
|
@ -97,6 +98,7 @@
|
|||
- ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12)
|
||||
- ✅ [#371](https://git.local-it.org/local-it/mitgliederverwaltung/issues/371) - Add groups resource (closed 2026-01-27)
|
||||
- ✅ [#372](https://git.local-it.org/local-it/mitgliederverwaltung/issues/372) - Groups Admin UI (closed 2026-01-27)
|
||||
- ✅ [#375](https://git.local-it.org/local-it/mitgliederverwaltung/issues/375) - Search Integration (group names in member search) (implemented 2026-02-17)
|
||||
- ✅ [#335](https://git.local-it.org/local-it/mitgliederverwaltung/issues/335) - CSV Import UI (closed 2026-01-27)
|
||||
- ✅ [#336](https://git.local-it.org/local-it/mitgliederverwaltung/issues/336) - Config for import limits (closed 2026-01-27)
|
||||
- ✅ [#338](https://git.local-it.org/local-it/mitgliederverwaltung/issues/338) - Custom field CSV import (closed 2026-01-27)
|
||||
|
|
|
|||
|
|
@ -975,9 +975,11 @@ Each functional unit can be implemented as a **separate issue**:
|
|||
### Issue 5: Search Integration
|
||||
**Type:** Backend
|
||||
**Estimation:** 2h
|
||||
**Status:** ✅ Implemented (migration `20260217120000_add_group_names_to_member_search_vector.exs`, Issue #375)
|
||||
|
||||
**Tasks:**
|
||||
- Update search vector trigger to include group names
|
||||
- Extend fuzzy search to search group names
|
||||
- Extend fuzzy search to search group names (via search_vector; no Elixir change needed)
|
||||
- Test search functionality
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ defmodule Mv.Membership.CustomField do
|
|||
## Attributes
|
||||
- `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday")
|
||||
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
|
||||
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
|
||||
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation.
|
||||
- `description` - Optional human-readable description
|
||||
- `required` - If true, all members must have this custom field (future feature)
|
||||
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
|
||||
|
|
@ -28,6 +28,7 @@ defmodule Mv.Membership.CustomField do
|
|||
## Constraints
|
||||
- Name must be unique across all custom fields
|
||||
- Name maximum length: 100 characters
|
||||
- `value_type` cannot be changed after creation (immutable)
|
||||
- Deleting a custom field will cascade delete all associated custom field values
|
||||
|
||||
## Calculations
|
||||
|
|
@ -59,7 +60,7 @@ defmodule Mv.Membership.CustomField do
|
|||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :update]
|
||||
defaults [:read]
|
||||
default_accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||
|
||||
create :create do
|
||||
|
|
@ -68,6 +69,19 @@ defmodule Mv.Membership.CustomField do
|
|||
validate string_length(:slug, min: 1)
|
||||
end
|
||||
|
||||
update :update do
|
||||
accept [:name, :description, :required, :show_in_overview]
|
||||
require_atomic? false
|
||||
|
||||
validate fn changeset, _context ->
|
||||
if Ash.Changeset.changing_attribute?(changeset, :value_type) do
|
||||
{:error, field: :value_type, message: "cannot be changed after creation"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
destroy :destroy_with_values do
|
||||
primary? true
|
||||
end
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
|||
class="btn btn-ghost select-none"
|
||||
phx-click="sort"
|
||||
phx-value-field={@field}
|
||||
phx-target={@myself}
|
||||
data-testid={@field}
|
||||
>
|
||||
{@label}
|
||||
|
|
@ -43,12 +42,6 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
|||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sort", %{"field" => field_str}, socket) do
|
||||
send(self(), {:sort, field_str})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# -------------------------------------------------
|
||||
# Hilfsfunktionen für ARIA Attribute & Icon SVG
|
||||
# -------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
## Features
|
||||
- Create new custom field definitions
|
||||
- Edit existing custom fields
|
||||
- Select value type from supported types
|
||||
- Select value type from supported types (only on create; immutable after creation)
|
||||
- Set required flag
|
||||
- Real-time validation
|
||||
|
||||
|
|
@ -44,15 +44,50 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
>
|
||||
<.input field={@form[:name]} type="text" label={gettext("Name")} />
|
||||
|
||||
<%= if @custom_field do %>
|
||||
<%!-- Show value_type as read-only input when editing (matches Member Field pattern) --%>
|
||||
<div
|
||||
class="tooltip tooltip-right"
|
||||
data-tip={gettext("Value type cannot be changed after creation")}
|
||||
aria-label={gettext("Value type cannot be changed after creation")}
|
||||
>
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span class="mb-1 label flex items-center gap-2">
|
||||
{gettext("Value type")}
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name={@form[:value_type].name}
|
||||
id={@form[:value_type].id}
|
||||
value={MvWeb.Translations.FieldTypes.label(@custom_field.value_type)}
|
||||
disabled
|
||||
readonly
|
||||
class="w-full input"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<% else %>
|
||||
<%!-- Show value_type as select when creating --%>
|
||||
<.input
|
||||
field={@form[:value_type]}
|
||||
type="select"
|
||||
label={gettext("Value type")}
|
||||
options={
|
||||
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
|
||||
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[
|
||||
:one_of
|
||||
]
|
||||
|> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end)
|
||||
}
|
||||
/>
|
||||
<% end %>
|
||||
|
||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
||||
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||
<.input
|
||||
|
|
@ -85,8 +120,16 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
|
||||
@impl true
|
||||
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
|
||||
# Remove value_type from params when editing (it's immutable after creation)
|
||||
cleaned_params =
|
||||
if socket.assigns[:custom_field] do
|
||||
Map.delete(custom_field_params, "value_type")
|
||||
else
|
||||
custom_field_params
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
|
||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, cleaned_params))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -94,7 +137,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
# Actor must be passed from parent (IndexComponent); component socket has no current_user
|
||||
actor = socket.assigns[:actor]
|
||||
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do
|
||||
# Remove value_type from params when editing (it's immutable after creation)
|
||||
cleaned_params =
|
||||
if socket.assigns[:custom_field] do
|
||||
Map.delete(custom_field_params, "value_type")
|
||||
else
|
||||
custom_field_params
|
||||
end
|
||||
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, cleaned_params, actor) do
|
||||
{:ok, custom_field} ->
|
||||
action =
|
||||
case socket.assigns.form.source.type do
|
||||
|
|
|
|||
|
|
@ -68,18 +68,15 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# This is appropriate for initialization errors that should be visible to the user.
|
||||
actor = current_actor(socket)
|
||||
|
||||
custom_fields_visible =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.filter(expr(show_in_overview == true))
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
# Load ALL custom fields for the dropdown (to show all available fields)
|
||||
all_custom_fields =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
custom_fields_visible =
|
||||
all_custom_fields
|
||||
|> Enum.filter(& &1.show_in_overview)
|
||||
|
||||
# Load boolean custom fields (filtered and sorted from all_custom_fields)
|
||||
boolean_custom_fields =
|
||||
all_custom_fields
|
||||
|
|
@ -163,6 +160,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
- `"delete"` - Removes a member from the database
|
||||
- `"select_member"` - Toggles individual member selection
|
||||
- `"select_all"` - Toggles selection of all visible members
|
||||
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
||||
"""
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
|
|
@ -305,6 +303,46 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sort", %{"field" => field_str}, socket) do
|
||||
# Handle both atom and string field names (for custom fields)
|
||||
field =
|
||||
try do
|
||||
String.to_existing_atom(field_str)
|
||||
rescue
|
||||
ArgumentError -> field_str
|
||||
end
|
||||
|
||||
{new_field, new_order} = determine_new_sort(field, socket)
|
||||
old_field = socket.assigns.sort_field
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:sort_field, new_field)
|
||||
|> assign(:sort_order, new_order)
|
||||
|> update_sort_components(old_field, new_field, new_order)
|
||||
|> load_members()
|
||||
|> update_selection_assigns()
|
||||
|
||||
# URL sync - push_patch happens synchronously in the event handler
|
||||
query_params =
|
||||
build_query_params(
|
||||
socket.assigns.query,
|
||||
export_sort_field(socket.assigns.sort_field),
|
||||
export_sort_order(socket.assigns.sort_order),
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns[:group_filters],
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|> maybe_add_field_selection(
|
||||
socket.assigns[:user_field_selection],
|
||||
socket.assigns[:fields_in_url?] || false
|
||||
)
|
||||
|
||||
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
||||
end
|
||||
|
||||
# Helper to format errors for display
|
||||
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||||
error_messages =
|
||||
|
|
@ -329,50 +367,10 @@ defmodule MvWeb.MemberLive.Index do
|
|||
Handles messages from child components.
|
||||
|
||||
## Supported messages:
|
||||
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
||||
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
|
||||
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
|
||||
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
|
||||
"""
|
||||
@impl true
|
||||
def handle_info({:sort, field_str}, socket) do
|
||||
# Handle both atom and string field names (for custom fields)
|
||||
field =
|
||||
try do
|
||||
String.to_existing_atom(field_str)
|
||||
rescue
|
||||
ArgumentError -> field_str
|
||||
end
|
||||
|
||||
{new_field, new_order} = determine_new_sort(field, socket)
|
||||
old_field = socket.assigns.sort_field
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:sort_field, new_field)
|
||||
|> assign(:sort_order, new_order)
|
||||
|> update_sort_components(old_field, new_field, new_order)
|
||||
|> load_members()
|
||||
|> update_selection_assigns()
|
||||
|
||||
# URL sync
|
||||
query_params =
|
||||
build_query_params(
|
||||
socket.assigns.query,
|
||||
export_sort_field(socket.assigns.sort_field),
|
||||
export_sort_order(socket.assigns.sort_order),
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns[:group_filters],
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|> maybe_add_field_selection(
|
||||
socket.assigns[:user_field_selection],
|
||||
socket.assigns[:fields_in_url?] || false
|
||||
)
|
||||
|
||||
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:search_changed, q}, socket) do
|
||||
|
|
|
|||
|
|
@ -2617,6 +2617,11 @@ msgstr "PDF"
|
|||
msgid "Import"
|
||||
msgstr "Import"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Value type cannot be changed after creation"
|
||||
msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden."
|
||||
|
||||
#~ #: lib/mv_web/live/import_export_live.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Export Members (CSV)"
|
||||
|
|
|
|||
|
|
@ -2617,3 +2617,8 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Value type cannot be changed after creation"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -2618,6 +2618,11 @@ msgstr ""
|
|||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Value type cannot be changed after creation"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/import_export_live.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Export Members (CSV)"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,466 @@
|
|||
defmodule Mv.Repo.Migrations.AddGroupNamesToMemberSearchVector do
|
||||
@moduledoc """
|
||||
Includes group names in member search_vector for full-text search (Issue #375).
|
||||
|
||||
This migration:
|
||||
1. Updates members_search_vector_trigger() to include group names (weight B)
|
||||
2. Updates update_member_search_vector_from_custom_field_value() to include group names
|
||||
3. Creates trigger on member_groups to refresh member search_vector when associations change
|
||||
4. Backfills existing members' search_vector with group names
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
# 1. Main trigger on members: add group names to search_vector
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
custom_values_text text;
|
||||
groups_text text;
|
||||
BEGIN
|
||||
-- Aggregate all custom field values for this member
|
||||
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;
|
||||
|
||||
-- Aggregate group names for this member (weight B, same as notes/email)
|
||||
SELECT string_agg(g.name, ' ')
|
||||
INTO groups_text
|
||||
FROM member_groups mg
|
||||
JOIN groups g ON g.id = mg.group_id
|
||||
WHERE mg.member_id = NEW.id;
|
||||
|
||||
-- Build search_vector with member fields, custom field values, and group names
|
||||
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') ||
|
||||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B');
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
# 2. Custom field trigger: when custom_field_values change, include group names in recomputed search_vector
|
||||
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;
|
||||
groups_text text;
|
||||
old_value_text text;
|
||||
new_value_text text;
|
||||
BEGIN
|
||||
member_id_val := COALESCE(NEW.member_id, OLD.member_id);
|
||||
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
old_value_text := COALESCE(
|
||||
NULLIF(OLD.value->>'_union_value', ''),
|
||||
NULLIF(OLD.value->>'value', ''),
|
||||
''
|
||||
);
|
||||
new_value_text := COALESCE(
|
||||
NULLIF(NEW.value->>'_union_value', ''),
|
||||
NULLIF(NEW.value->>'value', ''),
|
||||
''
|
||||
);
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
SELECT string_agg(g.name, ' ')
|
||||
INTO groups_text
|
||||
FROM member_groups mg
|
||||
JOIN groups g ON g.id = mg.group_id
|
||||
WHERE mg.member_id = member_id_val;
|
||||
|
||||
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') ||
|
||||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B')
|
||||
WHERE id = member_id_val;
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
# 3. Trigger on member_groups: when associations change, refresh affected member(s) search_vector.
|
||||
# On UPDATE with different member_id, refresh both OLD and NEW member so neither keeps a stale vector.
|
||||
execute("""
|
||||
CREATE FUNCTION update_member_search_vector_from_member_groups() 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;
|
||||
groups_text text;
|
||||
BEGIN
|
||||
FOR member_id_val IN
|
||||
SELECT COALESCE(NEW.member_id, OLD.member_id)
|
||||
UNION ALL
|
||||
SELECT OLD.member_id
|
||||
WHERE TG_OP = 'UPDATE' AND OLD.member_id IS DISTINCT FROM NEW.member_id
|
||||
LOOP
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
SELECT string_agg(g.name, ' ')
|
||||
INTO groups_text
|
||||
FROM member_groups mg
|
||||
JOIN groups g ON g.id = mg.group_id
|
||||
WHERE mg.member_id = member_id_val;
|
||||
|
||||
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') ||
|
||||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B')
|
||||
WHERE id = member_id_val;
|
||||
END LOOP;
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE TRIGGER update_member_search_vector_on_member_groups_change
|
||||
AFTER INSERT OR UPDATE OR DELETE ON member_groups
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_member_search_vector_from_member_groups()
|
||||
""")
|
||||
|
||||
# 4. Backfill: update all members' search_vector to include group names
|
||||
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') ||
|
||||
setweight(to_tsvector('simple', coalesce(
|
||||
(SELECT string_agg(g.name, ' ')
|
||||
FROM member_groups mg
|
||||
JOIN groups g ON g.id = mg.group_id
|
||||
WHERE mg.member_id = m.id),
|
||||
''
|
||||
)), 'B')
|
||||
""")
|
||||
end
|
||||
|
||||
def down do
|
||||
execute(
|
||||
"DROP TRIGGER IF EXISTS update_member_search_vector_on_member_groups_change ON member_groups"
|
||||
)
|
||||
|
||||
execute("DROP FUNCTION IF EXISTS update_member_search_vector_from_member_groups()")
|
||||
|
||||
# Restore members_search_vector_trigger without group names
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
custom_values_text text;
|
||||
BEGIN
|
||||
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;
|
||||
|
||||
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;
|
||||
""")
|
||||
|
||||
# Restore update_member_search_vector_from_custom_field_value without group names
|
||||
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
|
||||
member_id_val := COALESCE(NEW.member_id, OLD.member_id);
|
||||
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
old_value_text := COALESCE(
|
||||
NULLIF(OLD.value->>'_union_value', ''),
|
||||
NULLIF(OLD.value->>'value', ''),
|
||||
''
|
||||
);
|
||||
new_value_text := COALESCE(
|
||||
NULLIF(NEW.value->>'_union_value', ''),
|
||||
NULLIF(NEW.value->>'value', ''),
|
||||
''
|
||||
);
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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 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;
|
||||
""")
|
||||
|
||||
# Backfill without group names
|
||||
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')
|
||||
""")
|
||||
end
|
||||
end
|
||||
|
|
@ -8,6 +8,7 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
|||
- Description length validation (max 500 characters)
|
||||
- Description trimming
|
||||
- Required vs optional fields
|
||||
- Value type immutability (cannot be changed after creation)
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
|
|
@ -207,4 +208,101 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
|||
assert [%{field: :value_type}] = changeset.errors
|
||||
end
|
||||
end
|
||||
|
||||
describe "value_type immutability" do
|
||||
test "rejects attempt to change value_type after creation", %{actor: actor} do
|
||||
# Create custom field with value_type :string
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test_field",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
original_value_type = custom_field.value_type
|
||||
assert original_value_type == :string
|
||||
|
||||
# Attempt to update value_type to :integer
|
||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||
custom_field
|
||||
|> Ash.Changeset.for_update(:update, %{
|
||||
value_type: :integer
|
||||
})
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
# Verify error message contains expected text
|
||||
error_message = Exception.message(error)
|
||||
assert error_message =~ "cannot be changed" or error_message =~ "value_type"
|
||||
|
||||
# Reload and verify value_type remained unchanged
|
||||
reloaded = Ash.get!(CustomField, custom_field.id, actor: actor)
|
||||
assert reloaded.value_type == original_value_type
|
||||
assert reloaded.value_type == :string
|
||||
end
|
||||
|
||||
test "allows updating other fields while value_type remains unchanged", %{actor: actor} do
|
||||
# Create custom field with value_type :string
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test_field",
|
||||
value_type: :string,
|
||||
description: "Original description"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
original_value_type = custom_field.value_type
|
||||
assert original_value_type == :string
|
||||
|
||||
# Update other fields (name, description) without touching value_type
|
||||
{:ok, updated_custom_field} =
|
||||
custom_field
|
||||
|> Ash.Changeset.for_update(:update, %{
|
||||
name: "updated_name",
|
||||
description: "Updated description"
|
||||
})
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
# Verify value_type remained unchanged
|
||||
assert updated_custom_field.value_type == original_value_type
|
||||
assert updated_custom_field.value_type == :string
|
||||
# Verify other fields were updated
|
||||
assert updated_custom_field.name == "updated_name"
|
||||
assert updated_custom_field.description == "Updated description"
|
||||
end
|
||||
|
||||
test "rejects value_type change even when other fields are updated", %{actor: actor} do
|
||||
# Create custom field with value_type :boolean
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test_field",
|
||||
value_type: :boolean
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
original_value_type = custom_field.value_type
|
||||
assert original_value_type == :boolean
|
||||
|
||||
# Attempt to update both name and value_type
|
||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||
custom_field
|
||||
|> Ash.Changeset.for_update(:update, %{
|
||||
name: "updated_name",
|
||||
value_type: :date
|
||||
})
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
# Verify error message
|
||||
error_message = Exception.message(error)
|
||||
assert error_message =~ "cannot be changed" or error_message =~ "value_type"
|
||||
|
||||
# Reload and verify value_type remained unchanged, but name was not updated either
|
||||
reloaded = Ash.get!(CustomField, custom_field.id, actor: actor)
|
||||
assert reloaded.value_type == original_value_type
|
||||
assert reloaded.value_type == :boolean
|
||||
assert reloaded.name == "test_field"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
386
test/membership/member_search_groups_integration_test.exs
Normal file
386
test/membership/member_search_groups_integration_test.exs
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
defmodule Mv.Membership.MemberSearchGroupsIntegrationTest do
|
||||
@moduledoc """
|
||||
Tests for member search integration with group names (Issue #375).
|
||||
|
||||
Verifies that:
|
||||
- Group names are included in member search (via search_vector / FTS)
|
||||
- Searching by group name returns all members in that group
|
||||
- Search vector updates when member-group associations change (trigger on member_groups)
|
||||
- Edge cases (multiple groups, no groups, special characters) and authorization
|
||||
|
||||
Implementation: search_vector trigger and trigger on member_groups
|
||||
(see migration 20260217120000_add_group_names_to_member_search_vector.exs, Issue #375).
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.{Group, Member, MemberGroup}
|
||||
|
||||
setup do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
%{system_actor: system_actor}
|
||||
end
|
||||
|
||||
describe "search by group name" do
|
||||
test "search by group name finds member in that group", %{system_actor: actor} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Anna", last_name: "Arbeiter", email: "anna@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, group} =
|
||||
Group
|
||||
|> Ash.Changeset.for_create(:create, %{name: "Vorstand"})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, _mg} =
|
||||
MemberGroup
|
||||
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
results =
|
||||
Member
|
||||
|> Member.fuzzy_search(%{query: "Vorstand"})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
assert length(results) == 1
|
||||
assert List.first(results).id == member.id
|
||||
end
|
||||
|
||||
test "search by group name finds all members in that group", %{system_actor: actor} do
|
||||
{:ok, m1} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Bob", last_name: "Brown", email: "bob1@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, m2} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Beth", last_name: "Blue", email: "beth@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, group} =
|
||||
Group
|
||||
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
for member <- [m1, m2] do
|
||||
{:ok, _} =
|
||||
MemberGroup
|
||||
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|
||||
|> Ash.create(actor: actor)
|
||||
end
|
||||
|
||||
results =
|
||||
Member
|
||||
|> Member.fuzzy_search(%{query: "Board Members"})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(results, & &1.id)
|
||||
assert m1.id in ids
|
||||
assert m2.id in ids
|
||||
assert length(results) == 2
|
||||
end
|
||||
|
||||
test "member in multiple groups is findable by any of those group names", %{
|
||||
system_actor: actor
|
||||
} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Clara", last_name: "Clark", email: "clara@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, g1} =
|
||||
Group
|
||||
|> Ash.Changeset.for_create(:create, %{name: "Alpha Team"})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, g2} =
|
||||
Group
|
||||
|> Ash.Changeset.for_create(:create, %{name: "Beta Team"})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
for {m, g} <- [{member, g1}, {member, g2}] do
|
||||
{:ok, _} =
|
||||
MemberGroup
|
||||
|> Ash.Changeset.for_create(:create, %{member_id: m.id, group_id: g.id})
|
||||
|> Ash.create(actor: actor)
|
||||
end
|
||||
|
||||
for group_name <- ["Alpha Team", "Beta Team"] do
|
||||
results =
|
||||
Member
|
||||
|> Member.fuzzy_search(%{query: group_name})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
assert Enum.any?(results, fn r -> r.id == member.id end),
|
||||
"Search for #{group_name} should find member"
|
||||
end
|
||||
end
|
||||
|
||||
test "search by group name does not return members not in that group", %{
|
||||
system_actor: actor
|
||||
} do
|
||||
{:ok, member_in_x} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Xavier", last_name: "X", email: "xavier@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, member_in_y} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Yvonne", last_name: "Y", email: "yvonne@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, group_x} =
|
||||
Group
|
||||
|> Ash.Changeset.for_create(:create, %{name: "GroupXOnly"})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, group_y} =
|
||||
Group
|
||||
|> Ash.Changeset.for_create(:create, %{name: "GroupYOnly"})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, _} =
|
||||
MemberGroup
|
||||
|> Ash.Changeset.for_create(:create, %{member_id: member_in_x.id, group_id: group_x.id})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, _} =
|
||||
MemberGroup
|
||||
|> Ash.Changeset.for_create(:create, %{member_id: member_in_y.id, group_id: group_y.id})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
results_x =
|
||||
Member
|
||||
|> Member.fuzzy_search(%{query: "GroupXOnly"})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
assert Enum.any?(results_x, fn r -> r.id == member_in_x.id end)
|
||||
refute Enum.any?(results_x, fn r -> r.id == member_in_y.id end)
|
||||
|
||||
results_y =
|
||||
Member
|
||||
|> Member.fuzzy_search(%{query: "GroupYOnly"})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
assert Enum.any?(results_y, fn r -> r.id == member_in_y.id end)
|
||||
refute Enum.any?(results_y, fn r -> r.id == member_in_x.id end)
|
||||
end
|
||||
|
||||
test "member with no groups is not found by unrelated group name", %{system_actor: actor} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Solo", last_name: "User", email: "solo@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _group} =
|
||||
Group
|
||||
|> Ash.Changeset.for_create(:create, %{name: "SomeOtherGroup"})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Member is not in any group; search for the group name should not return this member
|
||||
results =
|
||||
Member
|
||||
|> Member.fuzzy_search(%{query: "SomeOtherGroup"})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
refute Enum.any?(results, fn r -> r.id == member.id end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "search vector update on member_groups changes" do
|
||||
test "adding member to group updates search vector (INSERT on member_groups)", %{
|
||||
system_actor: actor
|
||||
} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "New", last_name: "Member", email: "new@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, group} =
|
||||
Group
|
||||
|> Ash.Changeset.for_create(:create, %{name: "NewlyAddedGroup"})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Before adding to group, search should not find by group name
|
||||
results_before =
|
||||
Member
|
||||
|> Member.fuzzy_search(%{query: "NewlyAddedGroup"})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
refute Enum.any?(results_before, fn r -> r.id == member.id end)
|
||||
|
||||
{:ok, _mg} =
|
||||
MemberGroup
|
||||
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# After adding, search should find member (trigger on member_groups INSERT)
|
||||
results_after =
|
||||
Member
|
||||
|> Member.fuzzy_search(%{query: "NewlyAddedGroup"})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
assert Enum.any?(results_after, fn r -> r.id == member.id end)
|
||||
end
|
||||
|
||||
test "removing member from group updates search vector (DELETE on member_groups)", %{
|
||||
system_actor: actor
|
||||
} do
|
||||
# Use a member name that does not overlap with the group name so that the only
|
||||
# way to find them is via search_vector (group name). Otherwise trigram fuzzy
|
||||
# match on first_name would still find "Remove" when searching "RemovedGroup".
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Zara", last_name: "None", email: "zara.remove@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, group} =
|
||||
Group
|
||||
|> Ash.Changeset.for_create(:create, %{name: "RemovedGroup"})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, mg} =
|
||||
MemberGroup
|
||||
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
results_before =
|
||||
Member
|
||||
|> Member.fuzzy_search(%{query: "RemovedGroup"})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
assert Enum.any?(results_before, fn r -> r.id == member.id end)
|
||||
|
||||
:ok = Mv.Membership.destroy_member_group(mg, actor: actor)
|
||||
|
||||
results_after =
|
||||
Member
|
||||
|> Member.fuzzy_search(%{query: "RemovedGroup"})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
refute Enum.any?(results_after, fn r -> r.id == member.id end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "token match: single word in group name matches (e.g. Board in Board Members)", %{
|
||||
system_actor: actor
|
||||
} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Partial", last_name: "Test", email: "partial@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, group} =
|
||||
Group
|
||||
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, _mg} =
|
||||
MemberGroup
|
||||
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# FTS with 'simple' config: full word "Board" or "Members" should match
|
||||
results =
|
||||
Member
|
||||
|> Member.fuzzy_search(%{query: "Board"})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
assert Enum.any?(results, fn r -> r.id == member.id end),
|
||||
"Search for 'Board' should find member in group 'Board Members'"
|
||||
end
|
||||
|
||||
test "search with token from group name containing special characters does not crash", %{
|
||||
system_actor: actor
|
||||
} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Special", last_name: "Char", email: "special@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, group} =
|
||||
Group
|
||||
|> Ash.Changeset.for_create(:create, %{name: "Team A&B"})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, _mg} =
|
||||
MemberGroup
|
||||
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Search for a token from the group name; proves tokenization does not crash on "A&B"
|
||||
results =
|
||||
Member
|
||||
|> Member.fuzzy_search(%{query: "Team"})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
assert Enum.any?(results, fn r -> r.id == member.id end),
|
||||
"Search for 'Team' should find member in group 'Team A&B'"
|
||||
end
|
||||
end
|
||||
|
||||
describe "authorization" do
|
||||
test "search respects authorization (actor sees only allowed members)", %{
|
||||
system_actor: system_actor
|
||||
} do
|
||||
# own_data user linked to member1 can only read member1; member2 is in same group
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
user_own_data = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
|
||||
member1 =
|
||||
Mv.Fixtures.member_fixture(%{
|
||||
first_name: "Linked",
|
||||
last_name: "User",
|
||||
email: "linked@example.com"
|
||||
})
|
||||
|
||||
member2 =
|
||||
Mv.Fixtures.member_fixture(%{
|
||||
first_name: "Other",
|
||||
last_name: "User",
|
||||
email: "other@example.com"
|
||||
})
|
||||
|
||||
{:ok, user_own_data} =
|
||||
user_own_data
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.force_change_attribute(:member_id, member1.id)
|
||||
|> Ash.update(actor: admin)
|
||||
|
||||
{:ok, group} =
|
||||
Group
|
||||
|> Ash.Changeset.for_create(:create, %{name: "SharedGroupName"})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
for member <- [member1, member2] do
|
||||
{:ok, _} =
|
||||
MemberGroup
|
||||
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|
||||
|> Ash.create(actor: admin)
|
||||
end
|
||||
|
||||
# Search as own_data user: should only return member1 (linked), not member2
|
||||
results =
|
||||
Member
|
||||
|> Member.fuzzy_search(%{query: "SharedGroupName"})
|
||||
|> Ash.read!(actor: user_own_data)
|
||||
|
||||
ids = Enum.map(results, & &1.id)
|
||||
assert member1.id in ids
|
||||
refute member2.id in ids
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -223,7 +223,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
describe "component behavior" do
|
||||
test "clicking sends sort message to parent", %{conn: conn} do
|
||||
test "clicking triggers sort event on parent LiveView", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
|
|
@ -232,7 +232,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
|> element("button[phx-value-field='first_name']")
|
||||
|> render_click()
|
||||
|
||||
# The component should send a message to the parent LiveView
|
||||
# The component triggers a "sort" event on the parent LiveView
|
||||
# This is tested indirectly through the URL change in integration tests
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -8,16 +8,19 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
|
|||
- Groups sorting works with other sortings
|
||||
- Groups work with Membership Fee Status filter
|
||||
- Groups work with existing search (but not testing search integration itself)
|
||||
- Member index search by group name returns members in that group (Issue #375)
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and groups
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{Group, MemberGroup, CustomField, CustomFieldValue}
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Group, MemberGroup}
|
||||
alias Mv.MembershipFees.{MembershipFeeCycle, MembershipFeeType}
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
# Create test members
|
||||
{:ok, member1} =
|
||||
|
|
@ -80,15 +83,10 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Verify groups column is visible by default
|
||||
# Verify groups column is visible by default (header and content)
|
||||
assert html =~ group1.name
|
||||
assert html =~ member1.first_name
|
||||
|
||||
# Hide groups column via field visibility dropdown
|
||||
# (This tests integration with field visibility feature)
|
||||
# Note: Actual implementation depends on how field visibility works
|
||||
# For now, we verify the column exists and can be toggled
|
||||
assert html
|
||||
assert html =~ "Groups"
|
||||
end
|
||||
|
||||
test "groups filter works with custom field filters", %{
|
||||
|
|
@ -140,11 +138,11 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
|
|||
member1: member1,
|
||||
group1: group1
|
||||
} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
# Create a membership fee type and cycle for member1
|
||||
{:ok, fee_type} =
|
||||
Mv.MembershipFees.MembershipFeeType
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Fee",
|
||||
amount: Decimal.new("50.00"),
|
||||
|
|
@ -159,7 +157,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
|
|||
)
|
||||
|
||||
{:ok, _cycle} =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member1.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
|
|
@ -212,6 +210,25 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
|
|||
# (that's part of Issue #5 - Search Integration)
|
||||
end
|
||||
|
||||
test "member index search by group name returns members in that group", %{
|
||||
conn: conn,
|
||||
member1: member1,
|
||||
member2: member2,
|
||||
group1: group1
|
||||
} do
|
||||
# member1 is in group1 "Board Members", member2 is not
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
view
|
||||
|> element("form[phx-submit='search']")
|
||||
|> render_submit(%{"query" => group1.name})
|
||||
|
||||
html = render(view)
|
||||
assert html =~ member1.first_name
|
||||
refute html =~ member2.first_name
|
||||
end
|
||||
|
||||
test "all filters and sortings work together", %{
|
||||
conn: conn,
|
||||
member1: member1,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule MvWeb.MemberLive.IndexTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue