From af62dbac03b8dd9ad22bd77f035aba5b87eaeef5 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 16 Jan 2026 14:55:30 +0100 Subject: [PATCH 001/117] docs: add concept for groups --- docs/groups-architecture.md | 1033 +++++++++++++++++++++++++++++++++++ 1 file changed, 1033 insertions(+) create mode 100644 docs/groups-architecture.md diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md new file mode 100644 index 0000000..1c04703 --- /dev/null +++ b/docs/groups-architecture.md @@ -0,0 +1,1033 @@ +# Groups - Technical Architecture + +**Project:** Mila - Membership Management System +**Feature:** Groups Management +**Version:** 1.0 +**Last Updated:** 2025-01-XX +**Status:** Architecture Design - Ready for Implementation + +--- + +## Purpose + +This document defines the technical architecture for the Groups feature. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details. + +**Related Documents:** + +- [database-schema-readme.md](./database-schema-readme.md) - Database documentation +- [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) - Authorization system + +--- + +## Table of Contents + +1. [Architecture Principles](#architecture-principles) +2. [Domain Structure](#domain-structure) +3. [Data Architecture](#data-architecture) +4. [Business Logic Architecture](#business-logic-architecture) +5. [UI/UX Architecture](#uiux-architecture) +6. [Integration Points](#integration-points) +7. [Authorization](#authorization) +8. [Performance Considerations](#performance-considerations) +9. [Future Extensibility](#future-extensibility) +10. [Implementation Phases](#implementation-phases) + +--- + +## Architecture Principles + +### Core Design Decisions + +1. **Many-to-Many Relationship:** + - Members can belong to multiple groups + - Groups can contain multiple members + - Implemented via join table (`member_groups`) as separate Ash resource + +2. **Flat Structure (MVP):** + - Groups are initially flat (no hierarchy) + - Architecture designed to allow hierarchical extension later + - No parent/child relationships in MVP + +3. **Minimal Attributes (MVP):** + - Only `name` and `description` in initial version + - Extensible for future attributes (dates, status, etc.) + +4. **Cascade Deletion:** + - Deleting a group removes all member-group associations + - Members themselves are not deleted (CASCADE on join table only) + - Requires explicit confirmation with group name input + +5. **Search Integration:** + - Groups searchable within member search (not separate search) + - Group names included in member search vector for full-text search + +--- + +## Domain Structure + +### Ash Domain: `Mv.Membership` + +**Purpose:** Groups are part of the Membership domain, alongside Members and CustomFields + +**New Resources:** + +- `Group` - Group definitions (name, description) +- `MemberGroup` - Join table for many-to-many relationship between Members and Groups + +**Extended Resources:** + +- `Member` - Extended with `has_many :groups` relationship (through MemberGroup) + +### Module Organization + +``` +lib/ +├── membership/ +│ ├── membership.ex # Domain definition (extended) +│ ├── group.ex # Group resource +│ ├── member_group.ex # MemberGroup join table resource +│ └── member.ex # Extended with groups relationship +├── mv_web/ +│ └── live/ +│ ├── group_live/ +│ │ ├── index.ex # Groups management page +│ │ ├── form.ex # Create/edit group form +│ │ └── show.ex # Group detail view +│ └── member_live/ +│ ├── index.ex # Extended with group filtering/sorting +│ └── show.ex # Extended with group display +└── mv/ + └── membership/ + └── group/ # Future: Group-specific business logic + └── helpers.ex # Group-related helper functions +``` + +--- + +## Data Architecture + +### Database Schema + +#### `groups` Table + +```sql +CREATE TABLE groups ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + name TEXT NOT NULL, + description TEXT, + inserted_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT groups_name_unique UNIQUE (name) +); + +CREATE INDEX groups_name_index ON groups(name); +``` + +**Attributes:** +- `id` - UUID v7 primary key +- `name` - Unique group name (required, max 100 chars) +- `description` - Optional description (max 500 chars) +- `inserted_at` / `updated_at` - Timestamps + +**Constraints:** +- `name` must be unique +- `name` cannot be null +- `name` max length: 100 characters +- `description` max length: 500 characters + +#### `member_groups` Table (Join Table) + +```sql +CREATE TABLE member_groups ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + member_id UUID NOT NULL REFERENCES members(id) ON DELETE CASCADE, + group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + inserted_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT member_groups_unique_member_group UNIQUE (member_id, group_id) +); + +CREATE INDEX member_groups_member_id_index ON member_groups(member_id); +CREATE INDEX member_groups_group_id_index ON member_groups(group_id); +``` + +**Attributes:** +- `id` - UUID v7 primary key +- `member_id` - Foreign key to members (CASCADE delete) +- `group_id` - Foreign key to groups (CASCADE delete) +- `inserted_at` / `updated_at` - Timestamps + +**Constraints:** +- Unique constraint on `(member_id, group_id)` - prevents duplicate memberships +- CASCADE delete: Removing member removes all group associations +- CASCADE delete: Removing group removes all member associations + +**Indexes:** +- Index on `member_id` for efficient member → groups queries +- Index on `group_id` for efficient group → members queries + +### Ash Resources + +#### `Mv.Membership.Group` + +```elixir +use Ash.Resource, + domain: Mv.Membership, + data_layer: AshPostgres.DataLayer + +relationships do + has_many :member_groups, Mv.Membership.MemberGroup + many_to_many :members, Mv.Membership.Member, + through: Mv.Membership.MemberGroup +end + +calculations do + calculate :member_count, :integer, + expr(count(member_groups, :id)) +end +``` + +**Actions:** +- `create` - Create new group +- `read` - List/search groups +- `update` - Update group name/description +- `destroy` - Delete group (with confirmation) + +**Validations:** +- `name` required, unique, max 100 chars +- `description` optional, max 500 chars + +#### `Mv.Membership.MemberGroup` + +```elixir +use Ash.Resource, + domain: Mv.Membership, + data_layer: AshPostgres.DataLayer + +relationships do + belongs_to :member, Mv.Membership.Member + belongs_to :group, Mv.Membership.Group +end +``` + +**Actions:** +- `create` - Add member to group +- `read` - Query member-group associations +- `destroy` - Remove member from group + +**Validations:** +- Unique constraint on `(member_id, group_id)` + +#### `Mv.Membership.Member` (Extended) + +```elixir +relationships do + # ... existing relationships ... + + has_many :member_groups, Mv.Membership.MemberGroup + many_to_many :groups, Mv.Membership.Group, + through: Mv.Membership.MemberGroup +end +``` + +**New Actions:** +- `add_to_groups` - Add member to one or more groups +- `remove_from_groups` - Remove member from one or more groups + +--- + +## Business Logic Architecture + +### Group Management + +**Create Group:** +- Validate name uniqueness +- Generate slug (if needed for future URL-friendly identifiers) +- Return created group + +**Update Group:** +- Validate name uniqueness (if name changed) +- Update description +- Return updated group + +**Delete Group:** +- Check if group has members (for warning display) +- Require explicit confirmation (group name input) +- Cascade delete all `member_groups` associations +- Group itself deleted + +### Member-Group Association + +**Add Member to Group:** +- Validate member exists +- Validate group exists +- Check for duplicate association +- Create `MemberGroup` record + +**Remove Member from Group:** +- Find `MemberGroup` record +- Delete association +- Member and group remain intact + +**Bulk Operations:** +- Add member to multiple groups in single transaction +- Remove member from multiple groups in single transaction + +### Search Integration + +**Member Search Enhancement:** +- Include group names in member search vector +- When searching for member, also search in associated group names +- Example: Searching "Arbeitsgruppe" finds all members in groups with "Arbeitsgruppe" in name + +**Implementation:** +- Extend `member.search_vector` trigger to include group names +- Update trigger on `member_groups` changes +- Use PostgreSQL `tsvector` for full-text search + +--- + +## UI/UX Architecture + +### Groups Management Page (`/groups`) + +**Route:** `live "/groups", GroupLive.Index, :index` + +**Features:** +- List all groups in table +- Create new group button +- Edit group (inline or modal) +- Delete group with confirmation modal +- Show member count per group + +**Table Columns:** +- Name (sortable, searchable) +- Description +- Member Count +- Actions (Edit, Delete) + +**Delete Confirmation Modal:** +- Warning: "X members are in this group" +- Confirmation: "All member-group associations will be permanently deleted" +- Input field: Enter group name to confirm +- Delete button disabled until name matches +- Cancel button + +### Member Overview Integration + +**New Column: "Gruppen" (Groups)** +- Display group badges for each member +- Badge shows group name +- Multiple badges if member in multiple groups +- Click badge to filter by that group + +**Filtering:** +- Dropdown/select to filter by group +- "All groups" option (default) +- Filter persists in URL query params +- Works with existing search/sort + +**Sorting:** +- Sort by group name (members with groups first, then alphabetically) +- Sort by number of groups (members with most groups first) + +**Search:** +- Group names included in member search +- Searching group name shows all members in that group + +### Member Detail View Integration + +**New Section: "Gruppen" (Groups)** +- List all groups member belongs to +- Display as badges or list +- Add/remove groups inline +- Link to group detail page + +### Group Detail View (`/groups/:id`) + +**Route:** `live "/groups/:id", GroupLive.Show, :show` + +**Features:** +- Display group name and description +- List all members in group +- Link to member detail pages +- Edit group button +- Delete group button (with confirmation) + +--- + +## Integration Points + +### Member Search Vector + +**Trigger Update:** +- When `member_groups` record created/deleted +- Update `members.search_vector` to include group names +- Use PostgreSQL trigger for automatic updates + +**Search Query:** +- Extend existing `fuzzy_search` to include group names +- Group names added with weight 'B' (same as city, etc.) + +### Member Form + +**Future Enhancement:** +- Add groups selection in member form +- Multi-select dropdown for groups +- Add/remove groups during member creation/edit + +### Authorization Integration + +**Current (MVP):** +- Only admins can manage groups +- Uses existing `Mv.Authorization.Checks.HasPermission` +- Permission: `groups` resource with `:all` scope + +**Future:** +- Group-specific permissions +- Role-based group management +- Member-level group assignment permissions + +--- + +## Authorization + +### Permission Model (MVP) + +**Resource:** `groups` + +**Actions:** +- `read` - View groups (all users with member read permission) +- `create` - Create groups (admin only) +- `update` - Edit groups (admin only) +- `destroy` - Delete groups (admin only) + +**Scopes:** +- `:all` - All groups (for admins) +- `:all` - All groups (for read-only users, read permission only) + +### Permission Sets Update + +**Admin Permission Set:** +```elixir +%{ + resources: [ + # ... existing resources ... + %{resource: "groups", action: :read, scope: :all, granted: true}, + %{resource: "groups", action: :create, scope: :all, granted: true}, + %{resource: "groups", action: :update, scope: :all, granted: true}, + %{resource: "groups", action: :destroy, scope: :all, granted: true} + ] +} +``` + +**Read-Only Permission Set:** +```elixir +%{ + resources: [ + # ... existing resources ... + %{resource: "groups", action: :read, scope: :all, granted: true} + ] +} +``` + +### Member-Group Association Permissions + +**Current (MVP):** +- Adding/removing members from groups requires group update permission +- Managed through group edit interface + +**Future:** +- Separate permission for member-group management +- Member-level permissions for self-assignment + +--- + +## Performance Considerations + +### Database Indexes + +**Critical Indexes:** +- `groups.name` - For uniqueness and search +- `member_groups.member_id` - For member → groups queries +- `member_groups.group_id` - For group → members queries +- Composite index on `(member_id, group_id)` - For uniqueness check + +### Query Optimization + +**Member Overview:** +- Load groups with members in single query (using `load`) +- Use `Ash.Query.load(groups: [:id, :name])` to minimize data transfer +- Filter groups at database level when filtering by group + +**Group Detail:** +- Paginate member list for large groups +- Load member count via calculation (not separate query) + +### Search Performance + +**Search Vector:** +- Group names included in `search_vector` (tsvector) +- GIN index on `search_vector` for fast full-text search +- Trigger updates on `member_groups` changes + +**Filtering:** +- Use database-level filtering (not in-memory) +- Leverage indexes for group filtering + +--- + +## Future Extensibility + +### Hierarchical Groups + +**Design for Future:** +- Add `parent_group_id` to `groups` table (nullable) +- Add `parent_group` relationship (self-referential) +- Add validation to prevent circular references +- Add calculation for `path` (e.g., "Parent > Child > Grandchild") + +**Migration Path:** +- Add column with `NULL` default (all groups initially root-level) +- Add foreign key constraint +- Add validation logic +- Update UI to show hierarchy + +### Group Attributes + +**Future Attributes:** +- `created_at` / `founded_date` - Group creation date +- `dissolved_at` - Group dissolution date +- `status` - Active/inactive/suspended +- `color` - UI color for badges +- `icon` - Icon identifier + +**Migration Path:** +- Add nullable columns +- Set defaults for existing groups +- Update UI to display new attributes + +### Roles/Positions in Groups + +**Future Feature:** +- Add `member_group_roles` table +- Link `MemberGroup` to `Role` (e.g., "Leiter", "Mitglied") +- Extend `MemberGroup` with `role_id` foreign key +- Display role in member detail and group detail views + +### Group Permissions + +**Future Feature:** +- Group-specific permission sets +- Role-based group access +- Member-level group management permissions + +--- + +## Feature Breakdown: Fachliche Einheiten und MVP + +### Strategie: Vertikaler Schnitt + +Das Groups-Feature wird in **fachlich abgeschlossene, vertikale Einheiten** aufgeteilt. Jede Einheit liefert einen vollständigen, nutzbaren Funktionsbereich, der unabhängig getestet und ausgeliefert werden kann. + +### MVP Definition + +**Minimal Viable Product (MVP):** +Das MVP umfasst die **grundlegenden Funktionen**, die notwendig sind, um Gruppen zu verwalten und Mitgliedern zuzuordnen: + +1. ✅ Gruppen anlegen (Name + Beschreibung) +2. ✅ Gruppen bearbeiten +3. ✅ Gruppen löschen (mit Bestätigung) +4. ✅ Mitglieder zu Gruppen zuordnen +5. ✅ Mitglieder aus Gruppen entfernen +6. ✅ Gruppen in Mitgliederübersicht anzeigen +7. ✅ Nach Gruppen filtern +8. ✅ Nach Gruppen sortieren +9. ✅ Gruppen in Mitgliederdetail anzeigen + +**Nicht im MVP:** +- ❌ Hierarchische Gruppen +- ❌ Rollen/Positionen in Gruppen +- ❌ Erweiterte Gruppenattribute (Datum, Status, etc.) +- ❌ Gruppen-spezifische Berechtigungen +- ❌ Gruppen in Mitgliedersuche (kann später kommen) + +### Fachliche Einheiten (Vertikale Slices) + +#### Einheit 1: Gruppen-Verwaltung (Backend) +**Fachlicher Scope:** Administratoren können Gruppen im System verwalten + +**Umfang:** +- Gruppen-Ressource (Name, Beschreibung) +- CRUD-Operationen für Gruppen +- Validierungen (Name eindeutig, Längenlimits) +- Lösch-Logik mit Cascade-Verhalten + +**Deliverable:** Gruppen können über Ash API erstellt, bearbeitet und gelöscht werden + +**Abhängigkeiten:** Keine + +**Estimation:** 4-5h + +--- + +#### Einheit 2: Mitglieder-Gruppen-Zuordnung (Backend) +**Fachlicher Scope:** Mitglieder können Gruppen zugeordnet werden + +**Umfang:** +- MemberGroup Join-Tabelle +- Many-to-Many Relationship +- Add/Remove Member-Group Assoziationen +- Cascade Delete Verhalten + +**Deliverable:** Mitglieder können Gruppen zugeordnet und entfernt werden + +**Abhängigkeiten:** Einheit 1 (Gruppen müssen existieren) + +**Estimation:** 2-3h (kann mit Einheit 1 kombiniert werden) + +--- + +#### Einheit 3: Gruppen-Verwaltungs-UI +**Fachlicher Scope:** Administratoren können Gruppen über die Weboberfläche verwalten + +**Umfang:** +- Gruppen-Übersichtsseite (`/groups`) +- Gruppen-Formular (Anlegen/Bearbeiten) +- Gruppen-Detailseite (Mitgliederliste) +- Lösch-Bestätigungs-Modal (mit Name-Eingabe) + +**Deliverable:** Vollständige Gruppen-Verwaltung über UI möglich + +**Abhängigkeiten:** Einheit 1 + 2 (Backend muss funktionieren) + +**Estimation:** 3-4h + +--- + +#### Einheit 4: Gruppen in Mitgliederübersicht +**Fachlicher Scope:** Gruppen werden in der Mitgliederübersicht angezeigt und können gefiltert/sortiert werden + +**Umfang:** +- "Gruppen"-Spalte mit Badges +- Filter-Dropdown für Gruppen +- Sortierung nach Gruppen +- URL-Parameter-Persistenz + +**Deliverable:** Gruppen sichtbar, filterbar und sortierbar in Mitgliederübersicht + +**Abhängigkeiten:** Einheit 1 + 2 (Gruppen und Zuordnungen müssen existieren) + +**Estimation:** 2-3h + +--- + +#### Einheit 5: Gruppen in Mitgliederdetail +**Fachlicher Scope:** Gruppen werden in der Mitgliederdetail-Ansicht angezeigt + +**Umfang:** +- "Gruppen"-Sektion in Member Show +- Badge-Anzeige +- Links zu Gruppendetail-Seiten + +**Deliverable:** Gruppen sichtbar in Mitgliederdetail + +**Abhängigkeiten:** Einheit 3 (Gruppendetail-Seite muss existieren) + +**Estimation:** 1-2h + +--- + +#### Einheit 6: Gruppen in Mitgliedersuche +**Fachlicher Scope:** Gruppen-Namen sind in der Mitgliedersuche durchsuchbar + +**Umfang:** +- Search Vector Update (Trigger) +- Fuzzy Search Erweiterung +- Test der Suchfunktionalität + +**Deliverable:** Suche nach Gruppennamen findet zugehörige Mitglieder + +**Abhängigkeiten:** Einheit 1 + 2 (Gruppen und Zuordnungen müssen existieren) + +**Estimation:** 2h + +--- + +#### Einheit 7: Berechtigungen +**Fachlicher Scope:** Nur Administratoren können Gruppen verwalten + +**Umfang:** +- Gruppen zu Permission Sets hinzufügen +- Authorization Policies implementieren +- UI-Berechtigungsprüfungen + +**Deliverable:** Berechtigungen korrekt implementiert + +**Abhängigkeiten:** Alle vorherigen Einheiten (Feature muss funktionieren) + +**Estimation:** 1-2h + +--- + +### MVP-Zusammensetzung + +**MVP besteht aus:** +- ✅ Einheit 1: Gruppen-Verwaltung (Backend) +- ✅ Einheit 2: Mitglieder-Gruppen-Zuordnung (Backend) +- ✅ Einheit 3: Gruppen-Verwaltungs-UI +- ✅ Einheit 4: Gruppen in Mitgliederübersicht +- ✅ Einheit 5: Gruppen in Mitgliederdetail +- ✅ Einheit 7: Berechtigungen + +**Optional für MVP (kann später kommen):** +- ⏸️ Einheit 6: Gruppen in Mitgliedersuche (kann in Phase 2 kommen) + +**Total MVP Estimation:** 13-15h + +### Implementierungsreihenfolge + +**Empfohlene Reihenfolge:** + +1. **Phase 1: Backend Foundation** (Einheit 1 + 2) + - Gruppen-Ressource + - MemberGroup Join-Tabelle + - CRUD-Operationen + - **Ergebnis:** Gruppen können über API verwaltet werden + +2. **Phase 2: Verwaltungs-UI** (Einheit 3) + - Gruppen-Übersicht + - Gruppen-Formular + - Gruppen-Detail + - **Ergebnis:** Gruppen können über UI verwaltet werden + +3. **Phase 3: Mitglieder-Integration** (Einheit 4 + 5) + - Gruppen in Übersicht + - Gruppen in Detail + - **Ergebnis:** Gruppen sichtbar in Mitglieder-Ansichten + +4. **Phase 4: Such-Integration** (Einheit 6) + - Search Vector Update + - **Ergebnis:** Gruppen durchsuchbar + +5. **Phase 5: Berechtigungen** (Einheit 7) + - Permission Sets + - Policies + - **Ergebnis:** Berechtigungen korrekt + +### Issue-Struktur + +Jede fachliche Einheit kann als **separates Issue** umgesetzt werden: + +- **Issue 1:** Gruppen-Ressource & Datenbank-Schema (Einheit 1 + 2) +- **Issue 2:** Gruppen-Verwaltungs-UI (Einheit 3) +- **Issue 3:** Gruppen in Mitgliederübersicht (Einheit 4) +- **Issue 4:** Gruppen in Mitgliederdetail (Einheit 5) +- **Issue 5:** Gruppen in Mitgliedersuche (Einheit 6) +- **Issue 6:** Berechtigungen (Einheit 7) + +**Alternative:** Issue 3 und 4 können kombiniert werden, da sie beide die Anzeige von Gruppen betreffen. + +--- + +## Implementation Phases + +### Phase 1: MVP Core (Foundation) + +**Goal:** Basic group management and member assignment + +**Tasks:** +1. Create `Group` resource (name, description) +2. Create `MemberGroup` join table resource +3. Extend `Member` with groups relationship +4. Database migrations +5. Basic CRUD actions for groups +6. Add/remove members from groups (via group management) + +**Deliverables:** +- Groups can be created, edited, deleted +- Members can be added/removed from groups +- Basic validation and constraints + +**Estimation:** 4-5h + +### Phase 2: UI - Groups Management + +**Goal:** Complete groups management interface + +**Tasks:** +1. Groups index page (`/groups`) +2. Group form (create/edit) +3. Group show page (list members) +4. Delete confirmation modal (with name input) +5. Member count display + +**Deliverables:** +- Full groups management UI +- Delete confirmation workflow +- Group detail view + +**Estimation:** 3-4h + +### Phase 3: Member Overview Integration + +**Goal:** Display and filter groups in member overview + +**Tasks:** +1. Add "Gruppen" column to member overview table +2. Display group badges +3. Group filter dropdown +4. Group sorting +5. URL query param persistence + +**Deliverables:** +- Groups visible in member overview +- Filter by group +- Sort by group + +**Estimation:** 2-3h + +### Phase 4: Member Detail Integration + +**Goal:** Display groups in member detail view + +**Tasks:** +1. Add "Gruppen" section to member show page +2. Display group badges +3. Link to group detail pages + +**Deliverables:** +- Groups visible in member detail +- Navigation to group pages + +**Estimation:** 1-2h + +### Phase 5: Search Integration + +**Goal:** Include groups in member search + +**Tasks:** +1. Update `search_vector` trigger to include group names +2. Extend `fuzzy_search` to search group names +3. Test search functionality + +**Deliverables:** +- Group names searchable in member search +- Search finds members by group name + +**Estimation:** 2h + +### Phase 6: Authorization + +**Goal:** Implement permission-based access control + +**Tasks:** +1. Add groups to permission sets +2. Implement authorization policies +3. Test permission enforcement +4. Update UI to respect permissions + +**Deliverables:** +- Only admins can manage groups +- All users can view groups (if they can view members) + +**Estimation:** 1-2h + +### Total Estimation: 13-18h + +**Note:** This aligns with the issue estimation of 15h. + +--- + +## Issue Breakdown + +### Issue 1: Groups Resource & Database Schema +**Type:** Backend +**Estimation:** 4-5h +**Tasks:** +- Create `Group` resource +- Create `MemberGroup` join table resource +- Extend `Member` resource +- Database migrations +- Basic validations + +**Acceptance Criteria:** +- Groups can be created via Ash API +- Members can be associated with groups +- Database constraints enforced + +### Issue 2: Groups Management UI +**Type:** Frontend +**Estimation:** 3-4h +**Tasks:** +- Groups index page +- Group form (create/edit) +- Group show page +- Delete confirmation modal + +**Acceptance Criteria:** +- Groups can be created/edited/deleted via UI +- Delete requires name confirmation +- Member count displayed + +### Issue 3: Member Overview - Groups Integration +**Type:** Frontend +**Estimation:** 2-3h +**Tasks:** +- Add groups column with badges +- Group filter dropdown +- Group sorting +- URL persistence + +**Acceptance Criteria:** +- Groups visible in member overview +- Can filter by group +- Can sort by group +- Filter persists in URL + +### Issue 4: Member Detail - Groups Display +**Type:** Frontend +**Estimation:** 1-2h +**Tasks:** +- Add groups section to member show +- Display group badges +- Link to group pages + +**Acceptance Criteria:** +- Groups visible in member detail +- Links to group pages work + +### Issue 5: Search Integration +**Type:** Backend +**Estimation:** 2h +**Tasks:** +- Update search vector trigger +- Extend fuzzy search +- Test search + +**Acceptance Criteria:** +- Group names searchable in member search +- Search finds members by group name + +### Issue 6: Authorization +**Type:** Backend/Frontend +**Estimation:** 1-2h +**Tasks:** +- Add groups to permission sets +- Implement policies +- Test permissions + +**Acceptance Criteria:** +- Only admins can manage groups +- All users can view groups (if they can view members) + +--- + +## Testing Strategy + +### Unit Tests + +**Group Resource:** +- Name uniqueness validation +- Name/description length constraints +- Member count calculation + +**MemberGroup Resource:** +- Unique constraint on (member_id, group_id) +- Cascade delete behavior + +### Integration Tests + +**Group Management:** +- Create group +- Update group +- Delete group with confirmation +- Add member to group +- Remove member from group + +**Member-Group Relationships:** +- Member can belong to multiple groups +- Group can contain multiple members +- Cascade delete when member deleted +- Cascade delete when group deleted + +### UI Tests + +**Groups Management:** +- Groups index page loads +- Create group form works +- Edit group form works +- Delete confirmation modal works +- Name confirmation required for delete + +**Member Overview:** +- Groups column displays correctly +- Group filter works +- Group sorting works +- URL params persist + +**Member Detail:** +- Groups section displays +- Links to group pages work + +--- + +## Migration Strategy + +### Database Migrations + +**Migration 1: Create groups table** +```elixir +create table(:groups, primary_key: false) do + add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true + add :name, :text, null: false + add :description, :text + add :inserted_at, :utc_datetime_usec, null: false + add :updated_at, :utc_datetime_usec, null: false +end + +create unique_index(:groups, [:name]) +create index(:groups, [:name]) +``` + +**Migration 2: Create member_groups join table** +```elixir +create table(:member_groups, primary_key: false) do + add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true + add :member_id, :uuid, null: false + add :group_id, :uuid, null: false + add :inserted_at, :utc_datetime_usec, null: false + add :updated_at, :utc_datetime_usec, null: false +end + +create unique_index(:member_groups, [:member_id, :group_id]) +create index(:member_groups, [:member_id]) +create index(:member_groups, [:group_id]) + +alter table(:member_groups) do + modify :member_id, references(:members, on_delete: :delete_all, type: :uuid) + modify :group_id, references(:groups, on_delete: :delete_all, type: :uuid) +end +``` + +**Migration 3: Update search_vector trigger (if needed)** +- Extend trigger to include group names +- Update trigger function + +### Code Migration + +**Ash Resources:** +- Use `mix ash.codegen` to generate migrations +- Manually adjust if needed + +**Domain Updates:** +- Add groups resources to `Mv.Membership` domain +- Define domain actions + +--- + +## Summary + +This architecture provides a solid foundation for the Groups feature while maintaining flexibility for future enhancements. The many-to-many relationship is implemented via a join table, following existing patterns in the codebase. The MVP focuses on core functionality (create, edit, delete groups, assign members) with clear extension points for hierarchical groups, roles, and advanced permissions. + +The implementation is split into 6 manageable issues, totaling approximately 15 hours of work, aligning with the original estimation. Each phase builds on the previous one, allowing for incremental development and testing. -- 2.47.2 From 1d1f3b16b1fe0d1fd561dbdd4180f103223a1476 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 16 Jan 2026 18:10:48 +0100 Subject: [PATCH 002/117] docs: update group concept --- docs/groups-architecture.md | 554 +++++++++++++++++++++++++++++++++--- 1 file changed, 513 insertions(+), 41 deletions(-) diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md index 1c04703..88af7de 100644 --- a/docs/groups-architecture.md +++ b/docs/groups-architecture.md @@ -354,6 +354,106 @@ end - Edit group button - Delete group button (with confirmation) +### Accessibility (A11y) Considerations + +**Requirements:** +- All UI elements must be keyboard accessible +- Screen readers must be able to navigate and understand the interface +- ARIA labels and roles must be properly set + +**Group Badges in Member Overview:** + +```heex + + <%= group.name %> + +``` + +**Clickable Group Badge (for filtering):** + +```heex + +``` + +**Group Filter Dropdown:** + +```heex + +``` + +**Screen Reader Announcements:** + +```heex +
+ <%= if @filtered_by_group do %> + Showing <%= @member_count %> members in group <%= @filtered_group.name %> + <% else %> + Showing <%= @member_count %> members + <% end %> +
+``` + +**Delete Confirmation Modal:** + +```heex + + + +``` + +**Keyboard Navigation:** +- All interactive elements (buttons, links, form inputs) must be focusable via Tab key +- Modal dialogs must trap focus (Tab key cycles within modal) +- Escape key closes modals +- Enter/Space activates buttons when focused + --- ## Integration Points @@ -403,8 +503,7 @@ end - `destroy` - Delete groups (admin only) **Scopes:** -- `:all` - All groups (for admins) -- `:all` - All groups (for read-only users, read permission only) +- `:all` - All groups (for all permission sets that have read access) ### Permission Sets Update @@ -413,10 +512,10 @@ end %{ resources: [ # ... existing resources ... - %{resource: "groups", action: :read, scope: :all, granted: true}, - %{resource: "groups", action: :create, scope: :all, granted: true}, - %{resource: "groups", action: :update, scope: :all, granted: true}, - %{resource: "groups", action: :destroy, scope: :all, granted: true} + %{resource: "Group", action: :read, scope: :all, granted: true}, + %{resource: "Group", action: :create, scope: :all, granted: true}, + %{resource: "Group", action: :update, scope: :all, granted: true}, + %{resource: "Group", action: :destroy, scope: :all, granted: true} ] } ``` @@ -426,11 +525,33 @@ end %{ resources: [ # ... existing resources ... - %{resource: "groups", action: :read, scope: :all, granted: true} + %{resource: "Group", action: :read, scope: :all, granted: true} ] } ``` +**Normal User Permission Set:** +```elixir +%{ + resources: [ + # ... existing resources ... + %{resource: "Group", action: :read, scope: :all, granted: true} + ] +} +``` + +**Own Data Permission Set:** +```elixir +%{ + resources: [ + # ... existing resources ... + %{resource: "Group", action: :read, scope: :all, granted: true} + ] +} +``` + +**Note:** All permission sets use `:all` scope for groups. Groups are considered public information that all users with member read permission can view. Only admins can manage (create/update/destroy) groups. + ### Member-Group Association Permissions **Current (MVP):** @@ -456,13 +577,60 @@ end ### Query Optimization **Member Overview:** -- Load groups with members in single query (using `load`) +- Load groups with members in single query using `Ash.Query.load` - Use `Ash.Query.load(groups: [:id, :name])` to minimize data transfer - Filter groups at database level when filtering by group +**Example:** +```elixir +query = + Mv.Membership.Member + |> Ash.Query.new() + |> Ash.Query.load(groups: [:id, :name]) + |> Ash.Query.filter(expr(groups.id == ^selected_group_id)) + +members = Ash.read!(query, actor: actor) +``` + +**N+1 Query Prevention:** +- Always use `Ash.Query.load` to preload groups relationship +- Never access `member.groups` without preloading (would trigger N+1 queries) + +**Performance Threshold:** +- With proper `load` usage: Works efficiently up to **100 members** (MVP scope) +- For larger datasets (>100 members), consider: + - Pagination (limit number of members loaded) + - Lazy loading of groups (only load when groups column is visible) + - Database-level aggregation for group counts + +**Example of N+1 Problem (DO NOT DO THIS):** +```elixir +# BAD: This causes N+1 queries +members = Ash.read!(Mv.Membership.Member) +Enum.each(members, fn member -> + # Each iteration triggers a separate query! + groups = member.groups # N+1 query! +end) +``` + +**Correct Approach:** +```elixir +# GOOD: Preload in single query +members = + Mv.Membership.Member + |> Ash.Query.load(groups: [:id, :name]) + |> Ash.read!() + +# No additional queries needed +Enum.each(members, fn member -> + groups = member.groups # Already loaded! +end) +``` + **Group Detail:** -- Paginate member list for large groups +- Paginate member list for large groups (>50 members) - Load member count via calculation (not separate query) +- Use `Ash.Query.load` for member details when displaying ### Search Performance @@ -927,48 +1095,352 @@ Jede fachliche Einheit kann als **separates Issue** umgesetzt werden: ### Unit Tests -**Group Resource:** -- Name uniqueness validation -- Name/description length constraints -- Member count calculation +#### Group Resource Tests -**MemberGroup Resource:** -- Unique constraint on (member_id, group_id) -- Cascade delete behavior +**File:** `test/membership/group_test.exs` + +```elixir +defmodule Mv.Membership.GroupTest do + use Mv.DataCase + alias Mv.Membership.Group + + describe "create_group/1" do + test "creates group with valid attributes" do + attrs = %{name: "Vorstand", description: "Board of directors"} + assert {:ok, group} = Group.create(attrs) + assert group.name == "Vorstand" + assert group.description == "Board of directors" + end + + test "returns error when name is missing" do + attrs = %{description: "Some description"} + assert {:error, changeset} = Group.create(attrs) + assert %{name: ["is required"]} = errors_on(changeset) + end + + test "returns error when name exceeds 100 characters" do + long_name = String.duplicate("a", 101) + attrs = %{name: long_name} + assert {:error, changeset} = Group.create(attrs) + assert %{name: ["must be at most 100 character(s)"]} = errors_on(changeset) + end + + test "returns error when name is not unique" do + Group.create!(%{name: "Vorstand"}) + attrs = %{name: "Vorstand"} + assert {:error, changeset} = Group.create(attrs) + assert %{name: ["has already been taken"]} = errors_on(changeset) + end + + test "name uniqueness is case-sensitive" do + Group.create!(%{name: "Vorstand"}) + attrs = %{name: "VORSTAND"} + # For MVP: case-sensitive uniqueness + assert {:ok, _} = Group.create(attrs) + end + + test "allows description to be nil" do + attrs = %{name: "Test Group"} + assert {:ok, group} = Group.create(attrs) + assert is_nil(group.description) + end + + test "trims whitespace from name" do + attrs = %{name: " Vorstand "} + assert {:ok, group} = Group.create(attrs) + assert group.name == "Vorstand" + end + + test "description max length is 500 characters" do + long_desc = String.duplicate("a", 501) + attrs = %{name: "Test", description: long_desc} + assert {:error, changeset} = Group.create(attrs) + assert %{description: ["must be at most 500 character(s)"]} = errors_on(changeset) + end + end + + describe "update_group/2" do + test "updates group name and description" do + group = Group.create!(%{name: "Old Name", description: "Old Desc"}) + attrs = %{name: "New Name", description: "New Desc"} + assert {:ok, updated} = Group.update(group, attrs) + assert updated.name == "New Name" + assert updated.description == "New Desc" + end + + test "prevents duplicate name on update" do + Group.create!(%{name: "Existing"}) + group = Group.create!(%{name: "Other"}) + attrs = %{name: "Existing"} + assert {:error, changeset} = Group.update(group, attrs) + assert %{name: ["has already been taken"]} = errors_on(changeset) + end + end + + describe "delete_group/1" do + test "deletes group and all member associations" do + group = Group.create!(%{name: "Test Group"}) + member = Member.create!(%{email: "test@example.com"}) + MemberGroup.create!(%{member_id: member.id, group_id: group.id}) + + assert :ok = Group.destroy(group) + + # Group should be deleted + assert {:error, _} = Group.get(group.id) + + # MemberGroup association should be deleted (CASCADE) + assert [] = MemberGroup.read!(filter: [group_id: group.id]) + + # Member should still exist + assert {:ok, _} = Member.get(member.id) + end + + test "does not delete members themselves" do + group = Group.create!(%{name: "Test Group"}) + member = Member.create!(%{email: "test@example.com"}) + MemberGroup.create!(%{member_id: member.id, group_id: group.id}) + + Group.destroy!(group) + + # Member should still exist + assert {:ok, _} = Member.get(member.id) + end + end + + describe "member_count calculation" do + test "returns 0 for empty group" do + group = Group.create!(%{name: "Empty Group"}) + assert group.member_count == 0 + end + + test "returns correct count when members added" do + group = Group.create!(%{name: "Test Group"}) + member1 = Member.create!(%{email: "test1@example.com"}) + member2 = Member.create!(%{email: "test2@example.com"}) + + MemberGroup.create!(%{member_id: member1.id, group_id: group.id}) + MemberGroup.create!(%{member_id: member2.id, group_id: group.id}) + + # Reload group to get updated count + group = Group.get!(group.id, load: [:member_count]) + assert group.member_count == 2 + end + + test "updates correctly when members removed" do + group = Group.create!(%{name: "Test Group"}) + member = Member.create!(%{email: "test@example.com"}) + mg = MemberGroup.create!(%{member_id: member.id, group_id: group.id}) + + # Remove member + MemberGroup.destroy!(mg) + + # Reload group + group = Group.get!(group.id, load: [:member_count]) + assert group.member_count == 0 + end + end +end +``` + +#### MemberGroup Resource Tests + +**File:** `test/membership/member_group_test.exs` + +```elixir +defmodule Mv.Membership.MemberGroupTest do + use Mv.DataCase + alias Mv.Membership.{MemberGroup, Member, Group} + + describe "create_member_group/1" do + test "creates association between member and group" do + member = Member.create!(%{email: "test@example.com"}) + group = Group.create!(%{name: "Test Group"}) + + attrs = %{member_id: member.id, group_id: group.id} + assert {:ok, mg} = MemberGroup.create(attrs) + assert mg.member_id == member.id + assert mg.group_id == group.id + end + + test "prevents duplicate associations" do + member = Member.create!(%{email: "test@example.com"}) + group = Group.create!(%{name: "Test Group"}) + MemberGroup.create!(%{member_id: member.id, group_id: group.id}) + + attrs = %{member_id: member.id, group_id: group.id} + assert {:error, changeset} = MemberGroup.create(attrs) + assert %{member_id: ["has already been taken"]} = errors_on(changeset) + end + + test "cascade deletes when member deleted" do + member = Member.create!(%{email: "test@example.com"}) + group = Group.create!(%{name: "Test Group"}) + mg = MemberGroup.create!(%{member_id: member.id, group_id: group.id}) + + Member.destroy!(member) + + # Association should be deleted + assert {:error, _} = MemberGroup.get(mg.id) + end + + test "cascade deletes when group deleted" do + member = Member.create!(%{email: "test@example.com"}) + group = Group.create!(%{name: "Test Group"}) + mg = MemberGroup.create!(%{member_id: member.id, group_id: group.id}) + + Group.destroy!(group) + + # Association should be deleted + assert {:error, _} = MemberGroup.get(mg.id) + end + end +end +``` ### Integration Tests -**Group Management:** -- Create group -- Update group -- Delete group with confirmation -- Add member to group -- Remove member from group +#### Member-Group Relationships -**Member-Group Relationships:** -- Member can belong to multiple groups -- Group can contain multiple members -- Cascade delete when member deleted -- Cascade delete when group deleted +**File:** `test/membership/group_integration_test.exs` + +```elixir +defmodule Mv.Membership.GroupIntegrationTest do + use Mv.DataCase + alias Mv.Membership.{Group, Member, MemberGroup} + + describe "member-group relationships" do + test "member can belong to multiple groups" do + member = Member.create!(%{email: "test@example.com"}) + group1 = Group.create!(%{name: "Group 1"}) + group2 = Group.create!(%{name: "Group 2"}) + + MemberGroup.create!(%{member_id: member.id, group_id: group1.id}) + MemberGroup.create!(%{member_id: member.id, group_id: group2.id}) + + member = Member.get!(member.id, load: [:groups]) + assert length(member.groups) == 2 + assert Enum.any?(member.groups, &(&1.id == group1.id)) + assert Enum.any?(member.groups, &(&1.id == group2.id)) + end + + test "group can contain multiple members" do + group = Group.create!(%{name: "Test Group"}) + member1 = Member.create!(%{email: "test1@example.com"}) + member2 = Member.create!(%{email: "test2@example.com"}) + + MemberGroup.create!(%{member_id: member1.id, group_id: group.id}) + MemberGroup.create!(%{member_id: member2.id, group_id: group.id}) + + group = Group.get!(group.id, load: [:members]) + assert length(group.members) == 2 + end + end +end +``` ### UI Tests -**Groups Management:** -- Groups index page loads -- Create group form works -- Edit group form works -- Delete confirmation modal works -- Name confirmation required for delete +#### Groups Management -**Member Overview:** -- Groups column displays correctly -- Group filter works -- Group sorting works -- URL params persist +**File:** `test/mv_web/live/group_live/index_test.exs` -**Member Detail:** -- Groups section displays -- Links to group pages work +```elixir +defmodule MvWeb.GroupLive.IndexTest do + use MvWeb.ConnCase + alias Mv.Membership.Group + + describe "groups index page" do + test "lists all groups", %{conn: conn} do + group1 = Group.create!(%{name: "Group 1", description: "First group"}) + group2 = Group.create!(%{name: "Group 2", description: "Second group"}) + + {:ok, view, _html} = live(conn, ~p"/groups") + + assert render(view) =~ "Group 1" + assert render(view) =~ "Group 2" + end + + test "creates new group", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/groups") + + view + |> element("button", "New Group") + |> render_click() + + view + |> form("#group-form", group: %{name: "New Group", description: "Description"}) + |> render_submit() + + assert render(view) =~ "New Group" + end + + test "deletes group with confirmation", %{conn: conn} do + group = Group.create!(%{name: "To Delete"}) + + {:ok, view, _html} = live(conn, ~p"/groups") + + # Click delete + view + |> element("button[phx-click='delete']", "Delete") + |> render_click() + + # Enter group name to confirm + view + |> form("#delete-group-modal form", name: "To Delete") + |> render_change() + + # Confirm deletion + view + |> element("#delete-group-modal button", "Delete Group") + |> render_click() + + assert render(view) =~ "Group deleted successfully" + assert {:error, _} = Group.get(group.id) + end + end +end +``` + +#### Member Overview Integration + +**File:** `test/mv_web/live/member_live/index_groups_test.exs` + +```elixir +defmodule MvWeb.MemberLive.IndexGroupsTest do + use MvWeb.ConnCase + alias Mv.Membership.{Member, Group, MemberGroup} + + describe "groups in member overview" do + test "displays group badges", %{conn: conn} do + member = Member.create!(%{email: "test@example.com"}) + group = Group.create!(%{name: "Test Group"}) + MemberGroup.create!(%{member_id: member.id, group_id: group.id}) + + {:ok, view, _html} = live(conn, ~p"/members") + + assert render(view) =~ "Test Group" + end + + test "filters members by group", %{conn: conn} do + member1 = Member.create!(%{email: "test1@example.com"}) + member2 = Member.create!(%{email: "test2@example.com"}) + group = Group.create!(%{name: "Test Group"}) + MemberGroup.create!(%{member_id: member1.id, group_id: group.id}) + + {:ok, view, _html} = live(conn, ~p"/members") + + # Select group filter + view + |> element("#group-filter") + |> render_change(%{"group_filter" => group.id}) + + html = render(view) + assert html =~ "test1@example.com" + refute html =~ "test2@example.com" + end + end +end +``` --- -- 2.47.2 From 1c7c56130db7928e086bf38abc4d3b92024620b4 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 19 Jan 2026 11:53:14 +0100 Subject: [PATCH 003/117] docs: update group concept --- docs/groups-architecture.md | 52 ++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md index 88af7de..b075c4b 100644 --- a/docs/groups-architecture.md +++ b/docs/groups-architecture.md @@ -117,10 +117,10 @@ CREATE TABLE groups ( description TEXT, inserted_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, - CONSTRAINT groups_name_unique UNIQUE (name) + CONSTRAINT groups_name_unique UNIQUE (LOWER(name)) ); -CREATE INDEX groups_name_index ON groups(name); +CREATE INDEX groups_name_index ON groups(LOWER(name)); ``` **Attributes:** @@ -130,7 +130,7 @@ CREATE INDEX groups_name_index ON groups(name); - `inserted_at` / `updated_at` - Timestamps **Constraints:** -- `name` must be unique +- `name` must be unique (case-insensitive, using LOWER(name)) - `name` cannot be null - `name` max length: 100 characters - `description` max length: 500 characters @@ -194,7 +194,7 @@ end - `destroy` - Delete group (with confirmation) **Validations:** -- `name` required, unique, max 100 chars +- `name` required, unique (case-insensitive), max 100 chars - `description` optional, max 500 chars #### `Mv.Membership.MemberGroup` @@ -319,7 +319,7 @@ end - Display group badges for each member - Badge shows group name - Multiple badges if member in multiple groups -- Click badge to filter by that group +- *(Optional)* Click badge to filter by that group (enhanced UX, can be added later) **Filtering:** - Dropdown/select to filter by group @@ -374,7 +374,11 @@ end ``` -**Clickable Group Badge (for filtering):** +**Clickable Group Badge (for filtering) - Optional:** + +**Note:** This is an optional enhancement. The dropdown filter provides the same functionality. The clickable badge improves UX by showing the active filter visually and allowing quick removal. + +**Estimated effort:** 1.5-2.5 hours ```heex - - - -``` - -**Bewertung:** ✅ Korrekte DaisyUI Drawer-Struktur - -### 2.2 Sidebar-Komponente (`sidebar.ex`) - -**Struktur:** -```elixir -defmodule MvWeb.Layouts.Sidebar do - attr :current_user, :map - attr :club_name, :string - - def sidebar(assigns) do - # Rendert Sidebar mit Navigation, Locale-Selector, Theme-Toggle, User-Menu - end -end -``` - -**Hauptelemente:** -1. **Drawer Overlay** - Button zum Schließen (Mobile) -2. **Navigation Container** (`