Include group names in member search closing #375 #426

Merged
simon merged 3 commits from feature/groups-search-integration into main 2026-02-18 13:28:51 +01:00
4 changed files with 83 additions and 77 deletions
Showing only changes of commit 63b8e70e62 - Show all commits

View file

@ -233,9 +233,8 @@ Settings (1) → MembershipFeeType (0..1)
## Full-Text Search ## Full-Text Search
### Implementation ### Implementation
- **Trigger:** `members_search_vector_trigger()` on `members` (INSERT/UPDATE) - **Trigger** on `members` (INSERT/UPDATE): runs function `members_search_vector_trigger()`
- **Trigger:** `update_member_search_vector_from_member_groups()` on `member_groups` (INSERT/UPDATE/DELETE) - **Trigger** on `member_groups` (INSERT/UPDATE/DELETE): `update_member_search_vector_on_member_groups_change` runs function `update_member_search_vector_from_member_groups()`
- **Function:** Automatically updates `search_vector` on member and member_groups changes
- **Index Type:** GIN (Generalized Inverted Index) - **Index Type:** GIN (Generalized Inverted Index)
### Weighted Fields ### Weighted Fields

View file

@ -32,7 +32,7 @@ defmodule Mv.Repo.Migrations.AddGroupNamesToMemberSearchVector do
FROM custom_field_values FROM custom_field_values
WHERE member_id = NEW.id AND value IS NOT NULL; 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, ' ') SELECT string_agg(g.name, ' ')
INTO groups_text INTO groups_text
FROM member_groups mg FROM member_groups mg
@ -162,7 +162,8 @@ defmodule Mv.Repo.Migrations.AddGroupNamesToMemberSearchVector do
$$ LANGUAGE plpgsql; $$ 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(""" execute("""
CREATE FUNCTION update_member_search_vector_from_member_groups() RETURNS trigger AS $$ CREATE FUNCTION update_member_search_vector_from_member_groups() RETURNS trigger AS $$
DECLARE DECLARE
@ -180,66 +181,71 @@ defmodule Mv.Repo.Migrations.AddGroupNamesToMemberSearchVector do
custom_values_text text; custom_values_text text;
groups_text text; groups_text text;
BEGIN 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 SELECT string_agg(
first_name, CASE
last_name, WHEN value ? '_union_value' THEN value->>'_union_value'
email, WHEN value ? 'value' THEN value->>'value'
join_date, ELSE ''
exit_date, END,
notes, ' '
city, )
street, INTO custom_values_text
house_number, FROM custom_field_values
postal_code WHERE member_id = member_id_val AND value IS NOT NULL;
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( SELECT string_agg(g.name, ' ')
CASE INTO groups_text
WHEN value ? '_union_value' THEN value->>'_union_value' FROM member_groups mg
WHEN value ? 'value' THEN value->>'value' JOIN groups g ON g.id = mg.group_id
ELSE '' WHERE mg.member_id = member_id_val;
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, ' ') UPDATE members
INTO groups_text SET search_vector =
FROM member_groups mg setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
JOIN groups g ON g.id = mg.group_id setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
WHERE mg.member_id = member_id_val; setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
UPDATE members setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
SET search_vector = setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') || setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') || setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') || setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') || setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') || setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') || setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B')
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') || WHERE id = member_id_val;
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') || END LOOP;
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); RETURN COALESCE(NEW, OLD);
END END

View file

@ -273,7 +273,9 @@ defmodule Mv.Membership.MemberSearchGroupsIntegrationTest do
end end
describe "edge cases" do 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} = {:ok, member} =
Mv.Membership.create_member( Mv.Membership.create_member(
%{first_name: "Partial", last_name: "Test", email: "partial@example.com"}, %{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'" "Search for 'Board' should find member in group 'Board Members'"
end 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} = {:ok, member} =
Mv.Membership.create_member( Mv.Membership.create_member(
%{first_name: "Special", last_name: "Char", email: "special@example.com"}, %{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.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|> Ash.create(actor: actor) |> 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 = results =
Member Member
|> Member.fuzzy_search(%{query: "Team"}) |> Member.fuzzy_search(%{query: "Team"})

View file

@ -15,10 +15,12 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
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} =
@ -81,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", %{
@ -141,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"),
@ -160,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,