Compare commits

..

5 commits

Author SHA1 Message Date
Renovate Bot
cde1aee44b chore(deps): update renovate/renovate docker tag to v43
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-02-19 00:09:38 +00:00
adea380d86 Merge pull request 'Include group names in member search closing #375' (#426) from feature/groups-search-integration into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #426
2026-02-18 13:28:49 +01:00
84f97c12f8 Merge branch 'main' into feature/groups-search-integration
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/promote/production Build is failing
2026-02-18 13:06:26 +01:00
63b8e70e62
fix: adress review comments
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-18 13:05:31 +01:00
f6575319f7
feat: add groups to search vector
Some checks reported errors
continuous-integration/drone/push Build was killed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 12:47:23 +01:00
7 changed files with 897 additions and 18 deletions

View file

@ -273,7 +273,7 @@ environment:
steps: steps:
- name: renovate - name: renovate
image: renovate/renovate:43.24 image: renovate/renovate:43.25
environment: environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN: RENOVATE_TOKEN:

View file

@ -233,16 +233,22 @@ Settings (1) → MembershipFeeType (0..1)
## Full-Text Search ## Full-Text Search
### Implementation ### Implementation
- **Trigger:** `members_search_vector_trigger()` - **Trigger** on `members` (INSERT/UPDATE): runs function `members_search_vector_trigger()`
- **Function:** Automatically updates `search_vector` on INSERT/UPDATE - **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) - **Index Type:** GIN (Generalized Inverted Index)
### Weighted Fields ### Weighted Fields
- **Weight A (highest):** first_name, last_name - **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 C:** city, street, house_number, postal_code, custom_field_values
- **Weight D (lowest):** join_date, exit_date - **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 in Search
Custom field values are automatically included in the search vector: 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 - All custom field values (string, integer, boolean, date, email) are aggregated and added to the search vector

View file

@ -81,12 +81,13 @@
- ✅ User-Member linking (optional 1:1) - ✅ User-Member linking (optional 1:1)
- ✅ Email synchronization between User and Member - ✅ Email synchronization between User and Member
- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230) - ✅ **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 - Many-to-many relationship with groups
- Groups management UI (`/groups`) - Groups management UI (`/groups`)
- Filter and sort by groups in member list - 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` - 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 - 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) - ✅ **CSV Import** - Import members from CSV files (PR #359, #394, #395, closes #335, #336, #338, 2026-01-27)
- Member field import - Member field import
- Custom field value 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) - ✅ [#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) - ✅ [#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) - ✅ [#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) - ✅ [#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) - ✅ [#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) - ✅ [#338](https://git.local-it.org/local-it/mitgliederverwaltung/issues/338) - Custom field CSV import (closed 2026-01-27)

View file

@ -975,9 +975,11 @@ Each functional unit can be implemented as a **separate issue**:
### Issue 5: Search Integration ### Issue 5: Search Integration
**Type:** Backend **Type:** Backend
**Estimation:** 2h **Estimation:** 2h
**Status:** ✅ Implemented (migration `20260217120000_add_group_names_to_member_search_vector.exs`, Issue #375)
**Tasks:** **Tasks:**
- Update search vector trigger to include group names - 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 - Test search functionality
**Acceptance Criteria:** **Acceptance Criteria:**

View file

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

View 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

View file

@ -8,16 +8,19 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
- Groups sorting works with other sortings - Groups sorting works with other sortings
- Groups work with Membership Fee Status filter - Groups work with Membership Fee Status filter
- Groups work with existing search (but not testing search integration itself) - 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 # async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query 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 setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
# Create test members # Create test members
{:ok, member1} = {:ok, member1} =
@ -80,15 +83,10 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members") {: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 =~ group1.name
assert html =~ member1.first_name assert html =~ member1.first_name
assert html =~ "Groups"
# 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
end end
test "groups filter works with custom field filters", %{ test "groups filter works with custom field filters", %{
@ -140,11 +138,11 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
member1: member1, member1: member1,
group1: group1 group1: group1
} do } do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
# Create a membership fee type and cycle for member1 # Create a membership fee type and cycle for member1
{:ok, fee_type} = {:ok, fee_type} =
Mv.MembershipFees.MembershipFeeType MembershipFeeType
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
name: "Test Fee", name: "Test Fee",
amount: Decimal.new("50.00"), amount: Decimal.new("50.00"),
@ -159,7 +157,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
) )
{:ok, _cycle} = {:ok, _cycle} =
Mv.MembershipFees.MembershipFeeCycle MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
member_id: member1.id, member_id: member1.id,
membership_fee_type_id: fee_type.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) # (that's part of Issue #5 - Search Integration)
end 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", %{ test "all filters and sortings work together", %{
conn: conn, conn: conn,
member1: member1, member1: member1,