mitgliederverwaltung/docs/groups-architecture.md

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

  1. Many-to-many: members can belong to multiple groups and vice versa, via the member_groups join table (a separate Ash resource).
  2. Flat structure: no hierarchy in the current schema; the design leaves a clear path to add it later (see Future Extensibility).
  3. Minimal attributes: name, description, slug. The slug is auto-generated from name, immutable, URL-friendly.
  4. Cascade on the join table only: deleting a group (or member) removes the member_groups associations but never deletes members/groups themselves. Group deletion requires explicit confirmation (typing the group name).
  5. 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) — attributes name, slug, description; has_many :member_groups, many_to_many :members; member_count aggregate (count :member_count, :member_groups); unique_slug identity for slug lookups. Slug is generated by the shared Mv.Membership.Changes.GenerateSlug change (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). Has create/destroy actions only (no update); 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 the MemberGroup join resource, not via dedicated Member actions.

Data Model

groups

  • id (UUIDv7), name (required), slug (required, immutable, auto-generated), description (optional), timestamps.
  • Uniqueness: name unique case-insensitively (UNIQUE on LOWER(name), index groups_unique_name_lower_index); slug unique 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 on member_id and group_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_groups joined to groups and added to members.search_vector at weight B.
  • The trigger update_member_search_vector_on_member_groups_change runs update_member_search_vector_from_member_groups() on INSERT/UPDATE/DELETE on member_groups and refreshes the affected member's search_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 :update permission both in the UI and server-side in the event handlers.
  • /groups/:slug/edit and /groups/new — separate form pages; slug not editable. Edit does auth in mount/3 and loads the group once in handle_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. Use aria-label (e.g. "Member of group X") instead.
  • role="status" with aria-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 an aria-label; removal icons get aria-hidden="true".
  • Filter <select> needs id, name, aria-label. Delete modal uses role="dialog" with aria-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_count aggregate, sort at the DB level.
  • Search: group names in the GIN-indexed search_vector; trigger updates on member_groups changes (see above).

Future Extensibility

Hierarchical groups (design path, not yet in schema):

  • Add nullable parent_group_id to groups (self-referential parent_group relationship).
  • Add a circular-reference guard validation.
  • Add a path calculation (e.g. "Parent > Child > Grandchild").
  • Migration: add the column with NULL default (all groups become root-level), then the FK constraint, validation, and UI.

Roles/positions in groups:

  • Add a member_group_roles table linking MemberGroup to a Role (e.g. "Leiter", "Mitglied"); extend MemberGroup with a role_id FK; 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.