mitgliederverwaltung/docs/groups-architecture.md

101 lines
8 KiB
Markdown

# Groups - Technical Architecture
**Feature:** Groups Management
**Status:** Implemented (authorization: see [roles-and-permissions-architecture.md](./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](./database-schema-readme.md), [roles-and-permissions-architecture.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](#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](./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.