diff --git a/.drone.yml b/.drone.yml index 81d046f..8bcd48e 100644 --- a/.drone.yml +++ b/.drone.yml @@ -273,7 +273,7 @@ environment: steps: - name: renovate - image: renovate/renovate:43.25 + image: renovate/renovate:43.24 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index 6e444a5..6bf11de 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -233,22 +233,16 @@ Settings (1) → MembershipFeeType (0..1) ## Full-Text Search ### Implementation -- **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()` +- **Trigger:** `members_search_vector_trigger()` +- **Function:** Automatically updates `search_vector` on INSERT/UPDATE - **Index Type:** GIN (Generalized Inverted Index) ### Weighted Fields - **Weight A (highest):** first_name, last_name -- **Weight B:** email, notes, group names (from member_groups → groups) +- **Weight B:** email, notes - **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 diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 41b3d83..67f01c8 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -81,13 +81,12 @@ - ✅ 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, #423, closes #371, #372, #374, #375, 2026-01/02) +- ✅ **Groups** - Organize members into groups (PR #378, #382, closes #371, #372, 2026-01-27) - 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_=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 @@ -98,7 +97,6 @@ - ✅ [#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) diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md index ca1f07b..0e59409 100644 --- a/docs/groups-architecture.md +++ b/docs/groups-architecture.md @@ -975,11 +975,9 @@ 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 (via search_vector; no Elixir change needed) +- Extend fuzzy search to search group names - Test search functionality **Acceptance Criteria:** diff --git a/priv/repo/migrations/20260217120000_add_group_names_to_member_search_vector.exs b/priv/repo/migrations/20260217120000_add_group_names_to_member_search_vector.exs deleted file mode 100644 index 5508f16..0000000 --- a/priv/repo/migrations/20260217120000_add_group_names_to_member_search_vector.exs +++ /dev/null @@ -1,466 +0,0 @@ -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 diff --git a/test/membership/member_search_groups_integration_test.exs b/test/membership/member_search_groups_integration_test.exs deleted file mode 100644 index f6d7aa8..0000000 --- a/test/membership/member_search_groups_integration_test.exs +++ /dev/null @@ -1,386 +0,0 @@ -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 diff --git a/test/mv_web/member_live/index_groups_integration_test.exs b/test/mv_web/member_live/index_groups_integration_test.exs index 86738da..3075d54 100644 --- a/test/mv_web/member_live/index_groups_integration_test.exs +++ b/test/mv_web/member_live/index_groups_integration_test.exs @@ -8,19 +8,16 @@ 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.Helpers.SystemActor - alias Mv.Membership.{CustomField, CustomFieldValue, Group, MemberGroup} - alias Mv.MembershipFees.{MembershipFeeCycle, MembershipFeeType} + alias Mv.Membership.{Group, MemberGroup, CustomField, CustomFieldValue} setup do - system_actor = SystemActor.get_system_actor() + system_actor = Mv.Helpers.SystemActor.get_system_actor() # Create test members {:ok, member1} = @@ -83,10 +80,15 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members") - # Verify groups column is visible by default (header and content) + # Verify groups column is visible by default assert html =~ group1.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 test "groups filter works with custom field filters", %{ @@ -138,11 +140,11 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do member1: member1, group1: group1 } do - system_actor = SystemActor.get_system_actor() + system_actor = Mv.Helpers.SystemActor.get_system_actor() # Create a membership fee type and cycle for member1 {:ok, fee_type} = - MembershipFeeType + Mv.MembershipFees.MembershipFeeType |> Ash.Changeset.for_create(:create, %{ name: "Test Fee", amount: Decimal.new("50.00"), @@ -157,7 +159,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do ) {:ok, _cycle} = - MembershipFeeCycle + Mv.MembershipFees.MembershipFeeCycle |> Ash.Changeset.for_create(:create, %{ member_id: member1.id, membership_fee_type_id: fee_type.id, @@ -210,25 +212,6 @@ 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,