8 KiB
Groups - Technical Architecture
Feature: Groups Management
Status: Implemented (authorization: see roles-and-permissions-architecture.md)
This document records the durable design of the Groups feature: data model, key decisions, integration points, accessibility rules, and the planned extension paths. The original implementation plan (estimations, vertical slices, per-issue acceptance criteria, testing/migration strategy) has been removed now that the feature has shipped.
Related: database-schema-readme.md, roles-and-permissions-architecture.md.
Core Design Decisions
- Many-to-many: members can belong to multiple groups and vice versa, via the
member_groupsjoin table (a separate Ash resource). - Flat structure: no hierarchy in the current schema; the design leaves a clear path to add it later (see Future Extensibility).
- Minimal attributes:
name,description,slug. Theslugis auto-generated fromname, immutable, URL-friendly. - Cascade on the join table only: deleting a group (or member) removes the
member_groupsassociations but never deletes members/groups themselves. Group deletion requires explicit confirmation (typing the group name). - Search integration: group names are included in the member
search_vector(not a separate search index).
Domain & Resources
Groups live in the Mv.Membership domain alongside Members and CustomFields.
Mv.Membership.Group(lib/membership/group.ex) — attributesname,slug,description;has_many :member_groups,many_to_many :members;member_countaggregate (count :member_count, :member_groups);unique_slugidentity for slug lookups. Slug is generated by the sharedMv.Membership.Changes.GenerateSlugchange (the same change CustomFields uses), generated on create and immutable on update.Mv.Membership.MemberGroup(lib/membership/member_group.ex) — join table;belongs_to :member,belongs_to :group; unique on(member_id, group_id). Hascreate/destroyactions only (noupdate); group membership is managed by creating and destroying these join rows.Mv.Membership.Member(extended) —has_many :member_groups,many_to_many :groups. Group membership is managed through theMemberGroupjoin resource, not via dedicated Member actions.
Data Model
groups
id(UUIDv7),name(required),slug(required, immutable, auto-generated),description(optional), timestamps.- Uniqueness:
nameunique case-insensitively (UNIQUEonLOWER(name), indexgroups_unique_name_lower_index);slugunique case-sensitively (groups_unique_slug_index).
member_groups (join table)
id(UUIDv7),member_id,group_id, timestamps.- Unique
(member_id, group_id)prevents duplicates; indexes onmember_idandgroup_id. - CASCADE delete on both foreign keys — the cascade is intentionally on the join table only.
For exact columns/indexes see database_schema.dbml.
Search Integration
Group names are part of the member full-text search:
- They are aggregated from
member_groupsjoined togroupsand added tomembers.search_vectorat weight B. - The trigger
update_member_search_vector_on_member_groups_changerunsupdate_member_search_vector_from_member_groups()on INSERT/UPDATE/DELETE onmember_groupsand refreshes the affected member'ssearch_vector. - Migration
20260217120000_add_group_names_to_member_search_vector.exs(Issue #375). No Elixir search change is needed — searching a group name finds its members automatically.
UI Surface (implemented)
/groups— index table (name, description, member count, actions), sorted by name at the DB level. Create button →/groups/new./groups/:slug— detail: group info, member list, inline add-member combobox (search/autocomplete, excludes members already in the group), per-row remove (no confirmation), edit/delete. Add/remove are guarded by:updatepermission both in the UI and server-side in the event handlers./groups/:slug/editand/groups/new— separate form pages; slug not editable. Edit does auth inmount/3and loads the group once inhandle_params/3.- Delete confirmation modal — warns with member count (pluralized), requires typing the group name to enable delete (
phx-debounce="200"), stays open on mismatch, authorizes server-side. - Member overview — "Groups" column with badges; filter dropdown (persisted in URL query params); sort by group; group names searchable.
- Member detail — Groups shown as a data field in Personal Data (below Linked User), button-style links to
/groups/:slug.
Accessibility
- Do not use
role="status"on group badges or navigation links. That role is for live regions (screen-reader announcements), not for static labels or navigation. Usearia-label(e.g. "Member of group X") instead. role="status"witharia-live="polite"is appropriate only for dynamic announcements (filter changes, member-count updates).- Clickable filter badges (optional enhancement) must be real
<button type="button">with anaria-label; removal icons getaria-hidden="true". - Filter
<select>needsid,name,aria-label. Delete modal usesrole="dialog"witharia-labelledby/aria-describedby. - All interactive elements keyboard-focusable; modals trap focus, Escape closes, Enter/Space activate.
Authorization
Implemented; Group and MemberGroup policies and PermissionSets are in place. Authorization uses Mv.Authorization.Checks.HasPermission. Full permission matrix and policy patterns: roles-and-permissions-architecture.md.
Per-permission-set access to Group:
| Permission set | read | create | update | destroy |
|---|---|---|---|---|
own_data |
✓ (:all) |
— | — | — |
read_only |
✓ (:all) |
— | — | — |
normal_user |
✓ (:all) |
✓ | ✓ | ✓ |
admin |
✓ (:all) |
✓ | ✓ | ✓ |
Groups are public information: every set with member-read access can read groups, using :all scope.
MemberGroup (the join resource) has only read/create/destroy actions (no update). Its read scope differs for own_data (:linked — a member sees only their own memberships) while read_only/normal_user/admin read :all; create/destroy follow the same pattern as Group (normal_user + admin). Adding/removing a member to/from a group is a MemberGroup create/destroy, not a Group update.
Performance
- Preload groups when querying members to avoid N+1; preload only
id, name, slug. Filter by group at the DB level. - With proper preloading the overview is efficient up to ~100 members (the original scope); beyond that, paginate, lazy-load the groups column, or aggregate group counts in the DB.
- Group detail: paginate large member lists (> 50), compute member count via the
member_countaggregate, sort at the DB level. - Search: group names in the GIN-indexed
search_vector; trigger updates onmember_groupschanges (see above).
Future Extensibility
Hierarchical groups (design path, not yet in schema):
- Add nullable
parent_group_idtogroups(self-referentialparent_grouprelationship). - Add a circular-reference guard validation.
- Add a
pathcalculation (e.g. "Parent > Child > Grandchild"). - Migration: add the column with
NULLdefault (all groups become root-level), then the FK constraint, validation, and UI.
Roles/positions in groups:
- Add a
member_group_rolestable linkingMemberGroupto aRole(e.g. "Leiter", "Mitglied"); extendMemberGroupwith arole_idFK; surface the role in member/group detail.
Other planned attributes: founded/dissolved dates, status (active/inactive), badge color/icon — all as nullable additive columns. Group-specific permission sets and member-level self-assignment permissions are also future work.