101 lines
8 KiB
Markdown
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.
|