41 KiB
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 documentation
- roles-and-permissions-architecture.md - Authorization system
Table of Contents
- Architecture Principles
- Domain Structure
- Data Architecture
- Business Logic Architecture
- UI/UX Architecture
- Integration Points
- Authorization
- Performance Considerations
- Future Extensibility
- Implementation Phases
Architecture Principles
Core Design Decisions
-
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
-
Flat Structure (MVP):
- Groups are initially flat (no hierarchy)
- Architecture designed to allow hierarchical extension later
- No parent/child relationships in MVP
-
Minimal Attributes (MVP):
- Only
nameanddescriptionin initial version - Extensible for future attributes (dates, status, etc.)
- Only
-
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
-
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 withhas_many :groupsrelationship (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
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 keyname- Unique group name (required, max 100 chars)description- Optional description (max 500 chars)inserted_at/updated_at- Timestamps
Constraints:
namemust be uniquenamecannot be nullnamemax length: 100 charactersdescriptionmax length: 500 characters
member_groups Table (Join Table)
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 keymember_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_idfor efficient member → groups queries - Index on
group_idfor efficient group → members queries
Ash Resources
Mv.Membership.Group
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 groupread- List/search groupsupdate- Update group name/descriptiondestroy- Delete group (with confirmation)
Validations:
namerequired, unique, max 100 charsdescriptionoptional, max 500 chars
Mv.Membership.MemberGroup
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 groupread- Query member-group associationsdestroy- Remove member from group
Validations:
- Unique constraint on
(member_id, group_id)
Mv.Membership.Member (Extended)
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 groupsremove_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_groupsassociations - Group itself deleted
Member-Group Association
Add Member to Group:
- Validate member exists
- Validate group exists
- Check for duplicate association
- Create
MemberGrouprecord
Remove Member from Group:
- Find
MemberGrouprecord - 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_vectortrigger to include group names - Update trigger on
member_groupschanges - Use PostgreSQL
tsvectorfor 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)
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:
<span
class="badge badge-primary"
role="status"
aria-label={"Member of group: #{group.name}"}
title={"Member of group: #{group.name}"}
>
<%= group.name %>
</span>
Clickable Group Badge (for filtering):
<button
phx-click="filter_by_group"
phx-value-group-id={group.id}
aria-label={"Filter members by group: #{group.name}"}
class="badge badge-primary badge-clickable"
type="button"
>
<%= group.name %>
<.icon name="hero-x-mark" class="w-3 h-3 ml-1" aria-hidden="true" />
</button>
Group Filter Dropdown:
<select
id="group-filter"
name="group_filter"
phx-change="group_filter_changed"
aria-label="Filter members by group"
class="select select-bordered"
>
<option value="">All Groups</option>
<%= for group <- @groups do %>
<option value={group.id} selected={@selected_group_id == group.id}>
<%= group.name %>
</option>
<% end %>
</select>
Screen Reader Announcements:
<div role="status" aria-live="polite" class="sr-only">
<%= if @filtered_by_group do %>
Showing <%= @member_count %> members in group <%= @filtered_group.name %>
<% else %>
Showing <%= @member_count %> members
<% end %>
</div>
Delete Confirmation Modal:
<dialog
id="delete-group-modal"
class="modal"
role="dialog"
aria-labelledby="delete-modal-title"
aria-describedby="delete-modal-description"
>
<div class="modal-box">
<h3 id="delete-modal-title" class="text-lg font-bold">
Delete Group
</h3>
<div id="delete-modal-description" class="py-4">
<p class="font-bold">Group "<%= @group.name %>" will be permanently deleted.</p>
<p class="text-warning">
<%= @group.member_count %> members are in this group.
All member-group associations will be permanently deleted.
</p>
</div>
<!-- Form with name confirmation -->
</div>
</dialog>
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
Member Search Vector
Trigger Update:
- When
member_groupsrecord created/deleted - Update
members.search_vectorto include group names - Use PostgreSQL trigger for automatic updates
Search Query:
- Extend existing
fuzzy_searchto 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:
groupsresource with:allscope
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 all permission sets that have read access)
Permission Sets Update
Admin Permission Set:
%{
resources: [
# ... existing resources ...
%{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}
]
}
Read-Only Permission Set:
%{
resources: [
# ... existing resources ...
%{resource: "Group", action: :read, scope: :all, granted: true}
]
}
Normal User Permission Set:
%{
resources: [
# ... existing resources ...
%{resource: "Group", action: :read, scope: :all, granted: true}
]
}
Own Data Permission Set:
%{
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):
- 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 searchmember_groups.member_id- For member → groups queriesmember_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
Ash.Query.load - Use
Ash.Query.load(groups: [:id, :name])to minimize data transfer - Filter groups at database level when filtering by group
Example:
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.loadto preload groups relationship - Never access
member.groupswithout preloading (would trigger N+1 queries)
Performance Threshold:
- With proper
loadusage: 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):
# 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:
# 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 (>50 members)
- Load member count via calculation (not separate query)
- Use
Ash.Query.loadfor member details when displaying
Search Performance
Search Vector:
- Group names included in
search_vector(tsvector) - GIN index on
search_vectorfor fast full-text search - Trigger updates on
member_groupschanges
Filtering:
- Use database-level filtering (not in-memory)
- Leverage indexes for group filtering
Future Extensibility
Hierarchical Groups
Design for Future:
- Add
parent_group_idtogroupstable (nullable) - Add
parent_grouprelationship (self-referential) - Add validation to prevent circular references
- Add calculation for
path(e.g., "Parent > Child > Grandchild")
Migration Path:
- Add column with
NULLdefault (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 datedissolved_at- Group dissolution datestatus- Active/inactive/suspendedcolor- UI color for badgesicon- 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_rolestable - Link
MemberGrouptoRole(e.g., "Leiter", "Mitglied") - Extend
MemberGroupwithrole_idforeign 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:
- ✅ Gruppen anlegen (Name + Beschreibung)
- ✅ Gruppen bearbeiten
- ✅ Gruppen löschen (mit Bestätigung)
- ✅ Mitglieder zu Gruppen zuordnen
- ✅ Mitglieder aus Gruppen entfernen
- ✅ Gruppen in Mitgliederübersicht anzeigen
- ✅ Nach Gruppen filtern
- ✅ Nach Gruppen sortieren
- ✅ 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:
-
Phase 1: Backend Foundation (Einheit 1 + 2)
- Gruppen-Ressource
- MemberGroup Join-Tabelle
- CRUD-Operationen
- Ergebnis: Gruppen können über API verwaltet werden
-
Phase 2: Verwaltungs-UI (Einheit 3)
- Gruppen-Übersicht
- Gruppen-Formular
- Gruppen-Detail
- Ergebnis: Gruppen können über UI verwaltet werden
-
Phase 3: Mitglieder-Integration (Einheit 4 + 5)
- Gruppen in Übersicht
- Gruppen in Detail
- Ergebnis: Gruppen sichtbar in Mitglieder-Ansichten
-
Phase 4: Such-Integration (Einheit 6)
- Search Vector Update
- Ergebnis: Gruppen durchsuchbar
-
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:
- Create
Groupresource (name, description) - Create
MemberGroupjoin table resource - Extend
Memberwith groups relationship - Database migrations
- Basic CRUD actions for groups
- 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:
- Groups index page (
/groups) - Group form (create/edit)
- Group show page (list members)
- Delete confirmation modal (with name input)
- 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:
- Add "Gruppen" column to member overview table
- Display group badges
- Group filter dropdown
- Group sorting
- 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:
- Add "Gruppen" section to member show page
- Display group badges
- 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:
- Update
search_vectortrigger to include group names - Extend
fuzzy_searchto search group names - 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:
- Add groups to permission sets
- Implement authorization policies
- Test permission enforcement
- 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
Groupresource - Create
MemberGroupjoin table resource - Extend
Memberresource - 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 Tests
File: test/membership/group_test.exs
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
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
Member-Group Relationships
File: test/membership/group_integration_test.exs
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
File: test/mv_web/live/group_live/index_test.exs
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
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
Migration Strategy
Database Migrations
Migration 1: Create groups table
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
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.codegento generate migrations - Manually adjust if needed
Domain Updates:
- Add groups resources to
Mv.Membershipdomain - 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.