diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index 7657f86..6e444a5 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -233,9 +233,8 @@ Settings (1) → MembershipFeeType (0..1) ## Full-Text Search ### Implementation -- **Trigger:** `members_search_vector_trigger()` on `members` (INSERT/UPDATE) -- **Trigger:** `update_member_search_vector_from_member_groups()` on `member_groups` (INSERT/UPDATE/DELETE) -- **Function:** Automatically updates `search_vector` on member and member_groups changes +- **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 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 index 7d21760..5508f16 100644 --- 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 @@ -32,7 +32,7 @@ defmodule Mv.Repo.Migrations.AddGroupNamesToMemberSearchVector do FROM custom_field_values WHERE member_id = NEW.id AND value IS NOT NULL; - -- Aggregate group names for this member (weight B, same as city/notes) + -- Aggregate group names for this member (weight B, same as notes/email) SELECT string_agg(g.name, ' ') INTO groups_text FROM member_groups mg @@ -162,7 +162,8 @@ defmodule Mv.Repo.Migrations.AddGroupNamesToMemberSearchVector do $$ LANGUAGE plpgsql; """) - # 3. Trigger on member_groups: when associations change, refresh that member's search_vector + # 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 @@ -180,66 +181,71 @@ defmodule Mv.Repo.Migrations.AddGroupNamesToMemberSearchVector do custom_values_text text; groups_text text; BEGIN - member_id_val := COALESCE(NEW.member_id, OLD.member_id); + 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 - 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( - 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; - 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; + 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 diff --git a/test/membership/member_search_groups_integration_test.exs b/test/membership/member_search_groups_integration_test.exs index fb2c4c3..f6d7aa8 100644 --- a/test/membership/member_search_groups_integration_test.exs +++ b/test/membership/member_search_groups_integration_test.exs @@ -273,7 +273,9 @@ defmodule Mv.Membership.MemberSearchGroupsIntegrationTest do end describe "edge cases" do - test "partial group name matches via FTS", %{system_actor: actor} 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"}, @@ -300,7 +302,9 @@ defmodule Mv.Membership.MemberSearchGroupsIntegrationTest do "Search for 'Board' should find member in group 'Board Members'" end - test "special characters in group name do not break search", %{system_actor: actor} do + 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"}, @@ -317,7 +321,7 @@ defmodule Mv.Membership.MemberSearchGroupsIntegrationTest do |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) |> Ash.create(actor: actor) - # Search should not crash; at least exact or word match should work + # Search for a token from the group name; proves tokenization does not crash on "A&B" results = Member |> Member.fuzzy_search(%{query: "Team"}) 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 1599451..86738da 100644 --- a/test/mv_web/member_live/index_groups_integration_test.exs +++ b/test/mv_web/member_live/index_groups_integration_test.exs @@ -15,10 +15,12 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do 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} = @@ -81,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", %{ @@ -141,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"), @@ -160,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,