- <.link navigate={~p"/members"} class="btn btn-primary">
- Members
-
+
+
+
```
@@ -1535,17 +1642,126 @@ policies do
authorize_if always()
end
- # Specific permissions
- policy action_type([:read, :update]) do
- authorize_if relates_to_actor_via(:user)
- end
-
- policy action_type(:destroy) do
- authorize_if actor_attribute_equals(:role, :admin)
+ # Use HasPermission check for role-based authorization
+ policy action_type([:read, :update, :create, :destroy]) do
+ authorize_if Mv.Authorization.Checks.HasPermission
end
end
```
+**Actor Handling in LiveViews:**
+
+Always use the `current_actor/1` helper for consistent actor access:
+
+```elixir
+# In LiveView modules
+import MvWeb.LiveHelpers, only: [current_actor: 1, ash_actor_opts: 1, submit_form: 3]
+
+def mount(_params, _session, socket) do
+ actor = current_actor(socket)
+
+ case Ash.read(Mv.Membership.Member, ash_actor_opts(actor)) do
+ {:ok, members} ->
+ {:ok, assign(socket, :members, members)}
+ {:error, error} ->
+ {:ok, put_flash(socket, :error, "Failed to load members")}
+ end
+end
+
+def handle_event("save", %{"member" => params}, socket) do
+ actor = current_actor(socket)
+ form = AshPhoenix.Form.for_create(Mv.Membership.Member, :create)
+
+ case submit_form(form, params, actor) do
+ {:ok, member} ->
+ {:noreply, push_navigate(socket, to: ~p"/members/#{member.id}")}
+ {:error, form} ->
+ {:noreply, assign(socket, :form, form)}
+ end
+end
+```
+
+**Never use bang calls (`Ash.read!`, `Ash.get!`) without error handling:**
+
+```elixir
+# Bad - will crash on authorization errors
+members = Ash.read!(Mv.Membership.Member, actor: actor)
+
+# Good - proper error handling
+case Ash.read(Mv.Membership.Member, actor: actor) do
+ {:ok, members} -> # success
+ {:error, %Ash.Error.Forbidden{}} -> # handle authorization error
+ {:error, error} -> # handle other errors
+end
+```
+
+### 5.1a Authorization in Tests
+
+**IMPORTANT:** All tests must explicitly provide an actor for Ash operations. The NoActor bypass has been removed to prevent masking authorization bugs.
+
+**Exception: AshAuthentication Bypass Tests**
+
+Tests that verify the AshAuthentication bypass mechanism are a **conscious exception**. These tests must verify that registration/login works **without an actor** via the `AshAuthenticationInteraction` check. To enable this bypass in tests, set the context explicitly:
+
+```elixir
+# ✅ GOOD - Testing AshAuthentication bypass (conscious exception)
+changeset =
+ Accounts.User
+ |> Ash.Changeset.for_create(:register_with_password, %{...})
+ |> Ash.Changeset.set_context(%{private: %{ash_authentication?: true}})
+
+{:ok, user} = Ash.create(changeset) # No actor - tests bypass mechanism
+
+# ❌ BAD - Using system_actor masks the bypass test
+system_actor = Mv.Helpers.SystemActor.get_system_actor()
+Ash.create(changeset, actor: system_actor) # Tests admin permissions, not bypass!
+```
+
+**Test Fixtures:**
+
+All test fixtures use `system_actor` for authorization:
+
+```elixir
+# test/support/fixtures.ex
+def member_fixture(attrs \\ %{}) do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ attrs
+ |> Enum.into(%{...})
+ |> Mv.Membership.create_member(actor: system_actor)
+end
+```
+
+**Why Explicit Actors in Tests:**
+
+- Prevents masking authorization bugs
+- Makes authorization requirements explicit
+- Tests fail if authorization is broken (fail-fast)
+- Consistent with production code patterns
+
+**Using system_actor in Tests:**
+
+```elixir
+# ✅ GOOD - Explicit actor in tests
+system_actor = Mv.Helpers.SystemActor.get_system_actor()
+Ash.create!(Member, attrs, actor: system_actor)
+
+# ❌ BAD - Missing actor (will fail)
+Ash.create!(Member, attrs) # Forbidden error!
+```
+
+**For Bootstrap Operations:**
+
+Use `authorize?: false` only for bootstrap scenarios (seeds, SystemActor initialization):
+
+```elixir
+# ✅ GOOD - Bootstrap only
+Accounts.create_user!(%{email: admin_email}, authorize?: false)
+
+# ❌ BAD - Never use in tests for normal operations
+Ash.create!(Member, attrs, authorize?: false) # Never do this!
+```
+
### 5.2 Password Security
**Use bcrypt for Password Hashing:**
diff --git a/README.md b/README.md
index 6255f8d..94adf08 100644
--- a/README.md
+++ b/README.md
@@ -40,14 +40,16 @@ Our philosophy: **software should help people spend less time on administration
## 🔑 Features
- ✅ Manage member data with ease
-- 🚧 Overview of membership fees & payment status
-- ✅ Full-text search
-- 🚧 Sorting & filtering
-- 🚧 Roles & permissions (e.g. board, treasurer)
+- ✅ Membership fees & payment status tracking
+- ✅ Full-text search with fuzzy matching
+- ✅ Sorting & filtering
+- ✅ Roles & permissions (RBAC system with 4 permission sets)
- ✅ Custom fields (flexible per club needs)
- ✅ SSO via OIDC (works with Authentik, Rauthy, Keycloak, etc.)
+- ✅ Sidebar navigation (standard-compliant, accessible)
+- ✅ Global settings management
- 🚧 Self-service & online application
-- 🚧 Accessibility, GDPR, usability improvements
+- ✅ Accessibility improvements (WCAG 2.1 AA compliant keyboard navigation)
- 🚧 Email sending
## 🚀 Quick Start (Development)
@@ -187,8 +189,9 @@ The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/
- **Auth:** AshAuthentication (OIDC + password)
**Code Structure:**
-- `lib/accounts/` & `lib/membership/` — Ash resources and domains
+- `lib/accounts/` & `lib/membership/` & `lib/membership_fees/` & `lib/mv/authorization/` — Ash resources and domains
- `lib/mv_web/` — Phoenix controllers, LiveViews, components
+- `lib/mv/` — Shared helpers and business logic
- `assets/` — Tailwind, JavaScript, static files
📚 **Full tech stack details:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
diff --git a/config/test.exs b/config/test.exs
index 45acaa4..fe2b855 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -12,7 +12,10 @@ config :mv, Mv.Repo,
port: System.get_env("TEST_POSTGRES_PORT", "5000"),
database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
- pool_size: System.schedulers_online() * 4
+ pool_size: System.schedulers_online() * 8,
+ queue_target: 5000,
+ queue_interval: 1000,
+ timeout: 60_000
# We don't run a server during test. If one is required,
# you can enable the server option below.
diff --git a/docs/csv-member-import-v1.md b/docs/csv-member-import-v1.md
index bc8f99f..25a4e11 100644
--- a/docs/csv-member-import-v1.md
+++ b/docs/csv-member-import-v1.md
@@ -1,11 +1,30 @@
# CSV Member Import v1 - Implementation Plan
**Version:** 1.0
-**Date:** 2025-01-XX
-**Status:** Ready for Implementation
+**Last Updated:** 2026-01-13
+**Status:** In Progress (Backend Complete, UI Pending)
**Related Documents:**
- [Feature Roadmap](./feature-roadmap.md) - Overall feature planning
+## Implementation Status
+
+**Completed Issues:**
+- ✅ Issue #1: CSV Specification & Static Template Files
+- ✅ Issue #2: Import Service Module Skeleton
+- ✅ Issue #3: CSV Parsing + Delimiter Auto-Detection + BOM Handling
+- ✅ Issue #4: Header Normalization + Per-Header Mapping
+- ✅ Issue #5: Validation (Required Fields) + Error Formatting
+- ✅ Issue #6: Persistence via Ash Create + Per-Row Error Capture (with Error-Capping)
+- ✅ Issue #11: Custom Field Import (Backend)
+
+**In Progress / Pending:**
+- ⏳ Issue #7: Admin Global Settings LiveView UI (Upload + Start Import + Results)
+- ⏳ Issue #8: Authorization + Limits
+- ⏳ Issue #9: End-to-End LiveView Tests + Fixtures
+- ⏳ Issue #10: Documentation Polish
+
+**Latest Update:** Error-Capping in `process_chunk/4` implemented (2025-01-XX)
+
---
## Table of Contents
@@ -332,19 +351,24 @@ Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string c
**Dependencies:** None
+**Status:** ✅ **COMPLETED**
+
**Goal:** Define CSV contract and add static templates.
**Tasks:**
-- [ ] Finalize header mapping variants
-- [ ] Document normalization rules
-- [ ] Document delimiter detection strategy
-- [ ] Create templates in `priv/static/templates/` (UTF-8 with BOM)
-- [ ] Document template URLs and how to link them from LiveView
-- [ ] Document line number semantics (physical CSV line numbers)
+- [x] Finalize header mapping variants
+- [x] Document normalization rules
+- [x] Document delimiter detection strategy
+- [x] Create templates in `priv/static/templates/` (UTF-8 with BOM)
+ - `member_import_en.csv` with English headers
+ - `member_import_de.csv` with German headers
+- [x] Document template URLs and how to link them from LiveView
+- [x] Document line number semantics (physical CSV line numbers)
+- [x] Templates included in `MvWeb.static_paths()` configuration
**Definition of Done:**
-- [ ] Templates open cleanly in Excel/LibreOffice
-- [ ] CSV spec section complete
+- [x] Templates open cleanly in Excel/LibreOffice
+- [x] CSV spec section complete
---
@@ -352,18 +376,20 @@ Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string c
**Dependencies:** None
+**Status:** ✅ **COMPLETED**
+
**Goal:** Create service API and error types.
**API (recommended):**
- `prepare/2` — parse + map + limit checks, returns import_state
-- `process_chunk/3` — process one chunk (pure-ish), returns per-chunk results
+- `process_chunk/4` — process one chunk (pure-ish), returns per-chunk results
**Tasks:**
-- [ ] Create `lib/mv/membership/import/member_csv.ex`
-- [ ] Define public function: `prepare/2 (file_content, opts \\ [])`
-- [ ] Define public function: `process_chunk/3 (chunk_rows_with_lines, column_map, opts \\ [])`
-- [ ] Define error struct: `%MemberCSV.Error{csv_line_number: integer, field: atom | nil, message: String.t}`
-- [ ] Document module + API
+- [x] Create `lib/mv/membership/import/member_csv.ex`
+- [x] Define public function: `prepare/2 (file_content, opts \\ [])`
+- [x] Define public function: `process_chunk/4 (chunk_rows_with_lines, column_map, custom_field_map, opts \\ [])`
+- [x] Define error struct: `%MemberCSV.Error{csv_line_number: integer, field: atom | nil, message: String.t}`
+- [x] Document module + API
---
@@ -371,24 +397,26 @@ Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string c
**Dependencies:** Issue #2
+**Status:** ✅ **COMPLETED**
+
**Goal:** Parse CSV robustly with correct delimiter detection and BOM handling.
**Tasks:**
-- [ ] Verify/add NimbleCSV dependency (`{:nimble_csv, "~> 1.0"}`)
-- [ ] Create `lib/mv/membership/import/csv_parser.ex`
-- [ ] Implement `strip_bom/1` and apply it **before** any header handling
-- [ ] Handle `\r\n` and `\n` line endings (trim `\r` on header record)
-- [ ] Detect delimiter via header recognition (try `;` and `,`)
-- [ ] Parse CSV and return:
+- [x] Verify/add NimbleCSV dependency (`{:nimble_csv, "~> 1.0"}`)
+- [x] Create `lib/mv/membership/import/csv_parser.ex`
+- [x] Implement `strip_bom/1` and apply it **before** any header handling
+- [x] Handle `\r\n` and `\n` line endings (trim `\r` on header record)
+- [x] Detect delimiter via header recognition (try `;` and `,`)
+- [x] Parse CSV and return:
- `headers :: [String.t()]`
- - `rows :: [{csv_line_number, [String.t()]}]` or directly `[{csv_line_number, row_map}]`
-- [ ] Skip completely empty records (but preserve correct physical line numbers)
-- [ ] Return `{:ok, headers, rows}` or `{:error, reason}`
+ - `rows :: [{csv_line_number, [String.t()]}]` with correct physical line numbers
+- [x] Skip completely empty records (but preserve correct physical line numbers)
+- [x] Return `{:ok, headers, rows}` or `{:error, reason}`
**Definition of Done:**
-- [ ] BOM handling works (Excel exports)
-- [ ] Delimiter detection works reliably
-- [ ] Rows carry correct `csv_line_number`
+- [x] BOM handling works (Excel exports)
+- [x] Delimiter detection works reliably
+- [x] Rows carry correct `csv_line_number`
---
@@ -396,20 +424,22 @@ Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string c
**Dependencies:** Issue #3
+**Status:** ✅ **COMPLETED**
+
**Goal:** Map each header individually to canonical fields (normalized comparison).
**Tasks:**
-- [ ] Create `lib/mv/membership/import/header_mapper.ex`
-- [ ] Implement `normalize_header/1`
-- [ ] Normalize mapping variants once and compare normalized strings
-- [ ] Build `column_map` (canonical field -> column index)
-- [ ] **Early abort if required headers missing** (`email`)
-- [ ] Ignore unknown columns (member fields only)
-- [ ] **Separate custom field column detection** (by name, with normalization)
+- [x] Create `lib/mv/membership/import/header_mapper.ex`
+- [x] Implement `normalize_header/1`
+- [x] Normalize mapping variants once and compare normalized strings
+- [x] Build `column_map` (canonical field -> column index)
+- [x] **Early abort if required headers missing** (`email`)
+- [x] Ignore unknown columns (member fields only)
+- [x] **Separate custom field column detection** (by name, with normalization)
**Definition of Done:**
-- [ ] English/German headers map correctly
-- [ ] Missing required columns fails fast
+- [x] English/German headers map correctly
+- [x] Missing required columns fails fast
---
@@ -417,14 +447,16 @@ Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string c
**Dependencies:** Issue #4
+**Status:** ✅ **COMPLETED**
+
**Goal:** Validate each row and return structured, translatable errors.
**Tasks:**
-- [ ] Implement `validate_row/3 (row_map, csv_line_number, opts)`
-- [ ] Required field presence (`email`)
-- [ ] Email format validation (EctoCommons.EmailValidator)
-- [ ] Trim values before validation
-- [ ] Gettext-backed error messages
+- [x] Implement `validate_row/3 (row_map, csv_line_number, opts)`
+- [x] Required field presence (`email`)
+- [x] Email format validation (EctoCommons.EmailValidator)
+- [x] Trim values before validation
+- [x] Gettext-backed error messages
---
@@ -432,21 +464,32 @@ Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string c
**Dependencies:** Issue #5
+**Status:** ✅ **COMPLETED**
+
**Goal:** Create members and capture errors per row with correct CSV line numbers.
**Tasks:**
-- [ ] Implement `process_chunk/3` in service:
+- [x] Implement `process_chunk/4` in service:
- Input: `[{csv_line_number, row_map}]`
- Validate + create sequentially
- Collect counts + first 50 errors (per import overall; LiveView enforces cap across chunks)
-- [ ] Implement Ash error formatter helper:
+ - **Error-Capping:** Supports `existing_error_count` and `max_errors` in opts (default: 50)
+ - **Error-Capping:** Only collects errors if under limit, but continues processing all rows
+ - **Error-Capping:** `failed` count is always accurate, even when errors are capped
+- [x] Implement Ash error formatter helper:
- Convert `Ash.Error.Invalid` into `%MemberCSV.Error{}`
- Prefer field-level errors where possible (attach `field` atom)
- Handle unique email constraint error as user-friendly message
-- [ ] Map row_map to Ash attrs (`%{first_name: ..., ...}`)
+- [x] Map row_map to Ash attrs (`%{first_name: ..., ...}`)
+- [x] Custom field value processing and creation
**Important:** **Do not recompute line numbers** in this layer—use the ones provided by the parser.
+**Implementation Notes:**
+- `process_chunk/4` accepts `opts` with `existing_error_count` and `max_errors` for error capping across chunks
+- Error capping respects the limit per import overall (not per chunk)
+- Processing continues even after error limit is reached (for accurate counts)
+
---
### Issue #7: Admin Global Settings LiveView UI (Upload + Start Import + Results + Template Links)
@@ -546,6 +589,8 @@ Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string c
**Priority:** High (Core v1 Feature)
+**Status:** ✅ **COMPLETED** (Backend Implementation)
+
**Goal:** Support importing custom field values from CSV columns. Custom fields should exist in Mila before import for best results.
**Important Requirements:**
@@ -555,27 +600,32 @@ Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string c
- Unknown custom field columns (non-existent names) will be ignored with a warning - import continues
**Tasks:**
-- [ ] Extend `header_mapper.ex` to detect custom field columns by name (using same normalization as member fields)
-- [ ] Query existing custom fields during `prepare/2` to map custom field columns
-- [ ] Collect unknown custom field columns and add warning messages (don't fail import)
-- [ ] Map custom field CSV values to `CustomFieldValue` creation in `process_chunk/3`
-- [ ] Handle custom field type validation (string, integer, boolean, date, email)
-- [ ] Create `CustomFieldValue` records linked to members during import
-- [ ] Update error messages to include custom field validation errors
-- [ ] Add UI help text explaining custom field requirements:
+- [x] Extend `header_mapper.ex` to detect custom field columns by name (using same normalization as member fields)
+- [x] Query existing custom fields during `prepare/2` to map custom field columns
+- [x] Collect unknown custom field columns and add warning messages (don't fail import)
+- [x] Map custom field CSV values to `CustomFieldValue` creation in `process_chunk/4`
+- [x] Handle custom field type validation (string, integer, boolean, date, email)
+- [x] Create `CustomFieldValue` records linked to members during import
+- [ ] Update error messages to include custom field validation errors (if needed)
+- [ ] Add UI help text explaining custom field requirements (pending Issue #7):
- "Custom fields must be created in Mila before importing"
- "Use the custom field name as the CSV column header (same normalization as member fields)"
- Link to custom fields management section
-- [ ] Update CSV templates documentation to explain custom field columns
-- [ ] Add tests for custom field import (valid, invalid name, type validation, warning for unknown)
+- [ ] Update CSV templates documentation to explain custom field columns (pending Issue #1)
+- [x] Add tests for custom field import (valid, invalid name, type validation, warning for unknown)
**Definition of Done:**
-- [ ] Custom field columns are recognized by name (with normalization)
-- [ ] Warning messages shown for unknown custom field columns (import continues)
-- [ ] Custom field values are created and linked to members
-- [ ] Type validation works for all custom field types
-- [ ] UI clearly explains custom field requirements
-- [ ] Tests cover custom field import scenarios (including warning for unknown names)
+- [x] Custom field columns are recognized by name (with normalization)
+- [x] Warning messages shown for unknown custom field columns (import continues)
+- [x] Custom field values are created and linked to members
+- [x] Type validation works for all custom field types
+- [ ] UI clearly explains custom field requirements (pending Issue #7)
+- [x] Tests cover custom field import scenarios (including warning for unknown names)
+
+**Implementation Notes:**
+- Custom field lookup is built in `prepare/2` and passed via `custom_field_lookup` in opts
+- Custom field values are formatted according to type in `format_custom_field_value/2`
+- Unknown custom field columns generate warnings in `import_state.warnings`
---
@@ -683,4 +733,4 @@ end
---
-**End of Implementation Plan**
\ No newline at end of file
+**End of Implementation Plan**
diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md
index 6457db5..15e4e33 100644
--- a/docs/database-schema-readme.md
+++ b/docs/database-schema-readme.md
@@ -15,10 +15,10 @@ This document provides a comprehensive overview of the Mila Membership Managemen
| Metric | Count |
|--------|-------|
-| **Tables** | 5 |
-| **Domains** | 2 (Accounts, Membership) |
-| **Relationships** | 3 |
-| **Indexes** | 15+ |
+| **Tables** | 9 |
+| **Domains** | 4 (Accounts, Membership, MembershipFees, Authorization) |
+| **Relationships** | 7 |
+| **Indexes** | 20+ |
| **Triggers** | 1 (Full-text search) |
## Tables Overview
@@ -68,16 +68,39 @@ This document provides a comprehensive overview of the Mila Membership Managemen
- Immutable and required flags
- Centralized custom field management
+#### `settings`
+- **Purpose:** Global application settings (singleton resource)
+- **Rows (Estimated):** 1 (singleton pattern)
+- **Key Features:**
+ - Club name configuration
+ - Member field visibility settings
+ - Membership fee default settings
+ - Environment variable support for club name
+
+### Authorization Domain
+
+#### `roles`
+- **Purpose:** Role-based access control (RBAC)
+- **Rows (Estimated):** Low (typically 3-10 roles)
+- **Key Features:**
+ - Links users to permission sets
+ - System role protection
+ - Four hardcoded permission sets: own_data, read_only, normal_user, admin
+
## Key Relationships
```
User (0..1) ←→ (0..1) Member
- ↓
- Tokens (N)
+ ↓ ↓
+ Tokens (N) CustomFieldValues (N)
+ ↓ ↓
+ Role (N:1) CustomField (1)
-Member (1) → (N) Properties
+Member (1) → (N) MembershipFeeCycles
↓
- CustomField (1)
+ MembershipFeeType (1)
+
+Settings (1) → MembershipFeeType (0..1)
```
### Relationship Details
@@ -89,16 +112,39 @@ Member (1) → (N) Properties
- Email synchronization when linked (User.email is source of truth)
- `ON DELETE SET NULL` on user side (User preserved when Member deleted)
-2. **Member → Properties (1:N)**
+2. **User → Role (N:1)**
+ - Many users can be assigned to one role
+ - `ON DELETE RESTRICT` - cannot delete role if users are assigned
+ - Role links user to permission set for authorization
+
+3. **Member → CustomFieldValues (1:N)**
- One member, many custom_field_values
- `ON DELETE CASCADE` - custom_field_values deleted with member
- Composite unique constraint (member_id, custom_field_id)
-3. **CustomFieldValue → CustomField (N:1)**
- - Properties reference type definition
+4. **CustomFieldValue → CustomField (N:1)**
+ - Custom field values reference type definition
- `ON DELETE RESTRICT` - cannot delete type if in use
- Type defines data structure
+5. **Member → MembershipFeeType (N:1, optional)**
+ - Many members can be assigned to one fee type
+ - `ON DELETE RESTRICT` - cannot delete fee type if members are assigned
+ - Optional relationship (member can have no fee type)
+
+6. **Member → MembershipFeeCycles (1:N)**
+ - One member, many billing cycles
+ - `ON DELETE CASCADE` - cycles deleted when member deleted
+ - Unique constraint (member_id, cycle_start)
+
+7. **MembershipFeeCycle → MembershipFeeType (N:1)**
+ - Many cycles reference one fee type
+ - `ON DELETE RESTRICT` - cannot delete fee type if cycles exist
+
+8. **Settings → MembershipFeeType (N:1, optional)**
+ - Settings can reference a default fee type
+ - `ON DELETE SET NULL` - if fee type is deleted, setting is cleared
+
## Important Business Rules
### Email Synchronization
@@ -141,7 +187,6 @@ Member (1) → (N) Properties
- `email` (B-tree) - Exact email lookups
- `last_name` (B-tree) - Name sorting
- `join_date` (B-tree) - Date filtering
-- `paid` (partial B-tree) - Payment status queries
**custom_field_values:**
- `member_id` - Member custom field value lookups
@@ -168,14 +213,14 @@ Member (1) → (N) Properties
### Weighted Fields
- **Weight A (highest):** first_name, last_name
- **Weight B:** email, notes
-- **Weight C:** phone_number, city, street, house_number, postal_code, custom_field_values
+- **Weight C:** city, street, house_number, postal_code, custom_field_values
- **Weight D (lowest):** join_date, exit_date
### Custom Field Values in Search
Custom field values are automatically included in the search vector:
- All custom field values (string, integer, boolean, date, email) are aggregated and added to the search vector
- Values are converted to text format for indexing
-- Custom field values receive weight 'C' (same as phone_number, city, etc.)
+- Custom field values receive weight 'C' (same as city, etc.)
- The search vector is automatically updated when custom field values are created, updated, or deleted via database triggers
### Usage Example
@@ -331,7 +376,7 @@ priv/repo/migrations/
**High Frequency:**
- Member search (uses GIN index on search_vector)
-- Member list with filters (uses indexes on join_date, paid)
+- Member list with filters (uses indexes on join_date, membership_fee_type_id)
- User authentication (uses unique index on email/oidc_id)
- CustomFieldValue lookups by member (uses index on member_id)
@@ -350,7 +395,7 @@ priv/repo/migrations/
1. **Use indexes:** All critical query paths have indexes
2. **Preload relationships:** Use Ash's `load` to avoid N+1
3. **Pagination:** Use keyset pagination (configured by default)
-4. **Partial indexes:** `members.paid` index only non-NULL values
+4. **GIN indexes:** Full-text search and fuzzy search on multiple fields
5. **Search optimization:** Full-text search via tsvector, not LIKE
## Visualization
@@ -464,7 +509,7 @@ mix run priv/repo/seeds.exs
---
-**Last Updated:** 2025-11-13
-**Schema Version:** 1.1
+**Last Updated:** 2026-01-13
+**Schema Version:** 1.4
**Database:** PostgreSQL 17.6 (dev) / 16 (prod)
diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml
index f97463e..23605bf 100644
--- a/docs/database_schema.dbml
+++ b/docs/database_schema.dbml
@@ -6,8 +6,8 @@
// - https://dbdocs.io
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
//
-// Version: 1.3
-// Last Updated: 2025-12-11
+// Version: 1.4
+// Last Updated: 2026-01-13
Project mila_membership_management {
database_type: 'PostgreSQL'
@@ -28,6 +28,7 @@ Project mila_membership_management {
- **Accounts**: User authentication and session management
- **Membership**: Club member data and custom fields
- **MembershipFees**: Membership fee types and billing cycles
+ - **Authorization**: Role-based access control (RBAC)
## Required PostgreSQL Extensions:
- uuid-ossp (UUID generation)
@@ -120,11 +121,9 @@ Table tokens {
Table members {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key (sortable by creation time)']
- first_name text [not null, note: 'Member first name (min length: 1)']
- last_name text [not null, note: 'Member last name (min length: 1)']
+ first_name text [null, note: 'Member first name (min length: 1 if present)']
+ last_name text [null, note: 'Member last name (min length: 1 if present)']
email text [not null, unique, note: 'Member email address (5-254 chars, validated)']
- paid boolean [null, note: 'Payment status flag']
- phone_number text [null, note: 'Contact phone number (format: +?[0-9\- ]{6,20})']
join_date date [null, note: 'Date when member joined club (cannot be in future)']
exit_date date [null, note: 'Date when member left club (must be after join_date)']
notes text [null, note: 'Additional notes about member']
@@ -148,7 +147,6 @@ Table members {
email [name: 'members_email_idx', note: 'B-tree index for exact lookups']
last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting']
join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters']
- (paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL']
membership_fee_type_id [name: 'members_membership_fee_type_id_index', note: 'B-tree index for fee type lookups']
}
@@ -157,8 +155,8 @@ Table members {
Core entity for membership management containing:
- Personal information (name, email)
- - Contact details (phone, address)
- - Membership status (join/exit dates, payment status)
+ - Contact details (address)
+ - Membership status (join/exit dates, membership fee cycles)
- Additional notes
**Email Synchronization:**
@@ -186,12 +184,11 @@ Table members {
- 1:N with membership_fee_cycles - billing history
**Validation Rules:**
- - first_name, last_name: min 1 character
- - email: 5-254 characters, valid email format
+ - first_name, last_name: optional, but if present min 1 character
+ - email: 5-254 characters, valid email format (required)
- join_date: cannot be in future
- exit_date: must be after join_date (if both present)
- - phone_number: matches pattern ^\+?[0-9\- ]{6,20}$
- - postal_code: exactly 5 digits
+ - postal_code: exactly 5 digits (if present)
'''
}
@@ -500,3 +497,138 @@ TableGroup membership_fees_domain {
'''
}
+// ============================================
+// AUTHORIZATION DOMAIN
+// ============================================
+
+Table roles {
+ id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
+ name text [not null, unique, note: 'Unique role name (e.g., "Vorstand", "Admin", "Mitglied")']
+ description text [null, note: 'Human-readable description of the role']
+ permission_set_name text [not null, note: 'Permission set name: "own_data", "read_only", "normal_user", or "admin"']
+ is_system_role boolean [not null, default: false, note: 'If true, role cannot be deleted (protects critical roles)']
+ inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
+ updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
+
+ indexes {
+ name [unique, name: 'roles_unique_name_index']
+ }
+
+ Note: '''
+ **Role-Based Access Control (RBAC)**
+
+ Roles link users to permission sets. Each role references one of four hardcoded
+ permission sets defined in the application code.
+
+ **Permission Sets:**
+ - `own_data`: Users can only access their own linked member data
+ - `read_only`: Users can read all data but cannot modify
+ - `normal_user`: Users can read and modify most data (standard permissions)
+ - `admin`: Full access to all features and settings
+
+ **System Roles:**
+ - System roles (is_system_role = true) cannot be deleted
+ - Protects critical roles like "Mitglied" (member) from accidental deletion
+ - Only set via seed scripts or internal actions
+
+ **Relationships:**
+ - 1:N with users - users assigned to this role
+ - ON DELETE RESTRICT: Cannot delete role if users are assigned
+
+ **Constraints:**
+ - `name` must be unique
+ - `permission_set_name` must be a valid permission set (validated in application)
+ - System roles cannot be deleted (enforced via validation)
+ '''
+}
+
+// ============================================
+// MEMBERSHIP DOMAIN (Additional Tables)
+// ============================================
+
+Table settings {
+ id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
+ club_name text [not null, note: 'The name of the association/club (min length: 1)']
+ member_field_visibility jsonb [null, note: 'Visibility configuration for member fields in overview (JSONB map)']
+ include_joining_cycle boolean [not null, default: true, note: 'Whether to include the joining cycle in membership fee generation']
+ default_membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - default fee type for new members']
+ inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
+ updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
+
+ indexes {
+ default_membership_fee_type_id [name: 'settings_default_membership_fee_type_id_index', note: 'B-tree index for fee type lookups']
+ }
+
+ Note: '''
+ **Global Application Settings (Singleton Resource)**
+
+ Stores global configuration for the association/club. There should only ever
+ be one settings record in the database (singleton pattern).
+
+ **Attributes:**
+ - `club_name`: The name of the association/club (required, can be set via ASSOCIATION_NAME env var)
+ - `member_field_visibility`: JSONB map storing visibility configuration for member fields
+ (e.g., `{"street": false, "house_number": false}`). Fields not in the map default to `true`.
+ - `include_joining_cycle`: When true, members pay from their joining cycle. When false,
+ they pay from the next full cycle after joining.
+ - `default_membership_fee_type_id`: The membership fee type automatically assigned to
+ new members. Can be nil if no default is set.
+
+ **Singleton Pattern:**
+ - Only one settings record should exist
+ - Designed to be read and updated, not created/destroyed via normal CRUD
+ - Initial settings should be seeded
+
+ **Environment Variable Support:**
+ - `club_name` can be set via `ASSOCIATION_NAME` environment variable
+ - Database values always take precedence over environment variables
+
+ **Relationships:**
+ - Optional N:1 with membership_fee_types - default fee type for new members
+ - ON DELETE SET NULL: If default fee type is deleted, setting is cleared
+ '''
+}
+
+// ============================================
+// RELATIONSHIPS (Additional)
+// ============================================
+
+// User → Role (N:1)
+// - Many users can be assigned to one role
+// - ON DELETE RESTRICT: Cannot delete role if users are assigned
+Ref: users.role_id > roles.id [delete: restrict]
+
+// Settings → MembershipFeeType (N:1, optional)
+// - Settings can reference a default membership fee type
+// - ON DELETE SET NULL: If fee type is deleted, setting is cleared
+Ref: settings.default_membership_fee_type_id > membership_fee_types.id [delete: set null]
+
+// ============================================
+// TABLE GROUPS (Updated)
+// ============================================
+
+TableGroup authorization_domain {
+ roles
+
+ Note: '''
+ **Authorization Domain**
+
+ Handles role-based access control (RBAC) with hardcoded permission sets.
+ Roles link users to permission sets for authorization.
+ '''
+}
+
+TableGroup membership_domain {
+ members
+ custom_field_values
+ custom_fields
+ settings
+
+ Note: '''
+ **Membership Domain**
+
+ Core business logic for club membership management.
+ Supports flexible, extensible member data model.
+ Includes global application settings (singleton).
+ '''
+}
diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md
index 629987e..928558e 100644
--- a/docs/development-progress-log.md
+++ b/docs/development-progress-log.md
@@ -68,7 +68,7 @@ mix phx.new mv --no-ecto --no-mailer
**Key decisions:**
- **Elixir 1.18.3 + OTP 27**: Latest stable versions for performance
- **Ash Framework 3.0**: Declarative resource layer, reduces boilerplate
-- **Phoenix LiveView 1.1**: Real-time UI without JavaScript complexity
+- **Phoenix LiveView 1.1.0-rc.3**: Real-time UI without JavaScript complexity
- **Tailwind CSS 4.0**: Utility-first styling with custom build
- **PostgreSQL 17**: Advanced features (full-text search, JSONB, citext)
- **Bandit**: Modern HTTP server, better than Cowboy for LiveView
@@ -80,14 +80,15 @@ mix phx.new mv --no-ecto --no-mailer
**Versions pinned in `.tool-versions`:**
- Elixir 1.18.3-otp-27
- Erlang 27.3.4
-- Just 1.43.0
+- Just 1.46.0
#### 4. Database Setup
**PostgreSQL Extensions:**
```sql
-CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- UUID generation
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- UUID generation (via uuid_generate_v7 function)
CREATE EXTENSION IF NOT EXISTS "citext"; -- Case-insensitive text
+CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Trigram-based fuzzy search
```
**Migration Strategy:**
@@ -468,7 +469,7 @@ end
- **Tailwind:** Utility-first, no custom CSS
- **DaisyUI:** Pre-built components, consistent design
- **Heroicons:** Icon library, inline SVG
-- **Phoenix LiveView:** Server-rendered, minimal JavaScript
+- **Phoenix LiveView 1.1.0-rc.3:** Server-rendered, minimal JavaScript
**Trade-offs:**
- Larger HTML (utility classes)
@@ -598,14 +599,33 @@ end
#### Database Migrations
-**Key migrations in chronological order:**
-1. `20250528163901_initial_migration.exs` - Core tables (members, custom_field_values, custom_fields)
-2. `20250617090641_member_fields.exs` - Member attributes expansion
-3. `20250620110850_add_accounts_domain.exs` - Users & tokens tables
-4. `20250912085235_AddSearchVectorToMembers.exs` - Full-text search (tsvector + GIN index)
-5. `20250926164519_member_relation.exs` - User-Member link (optional 1:1)
-6. `20251001141005_add_trigram_to_members.exs` - Fuzzy search (pg_trgm + 6 GIN trigram indexes)
-7. `20251016130855_add_constraints_for_user_member_and_property.exs` - Email sync constraints
+**Key migrations in chronological order (26 total):**
+1. `20250421101957_initialize_extensions_1.exs` - PostgreSQL extensions (uuid-ossp, citext, pg_trgm)
+2. `20250528163901_initial_migration.exs` - Core tables (members, custom_field_values, custom_fields - originally property_types/properties)
+3. `20250617090641_member_fields.exs` - Member attributes expansion
+4. `20250617132424_member_delete.exs` - Member deletion constraints
+5. `20250620110849_add_accounts_domain_extensions.exs` - Accounts domain extensions
+6. `20250620110850_add_accounts_domain.exs` - Users & tokens tables
+7. `20250912085235_AddSearchVectorToMembers.exs` - Full-text search (tsvector + GIN index)
+8. `20250926164519_member_relation.exs` - User-Member link (optional 1:1)
+9. `20250926180341_add_unique_email_to_members.exs` - Unique email constraint on members
+10. `20251001141005_add_trigram_to_members.exs` - Fuzzy search (pg_trgm + 6 GIN trigram indexes)
+11. `20251016130855_add_constraints_for_user_member_and_property.exs` - Email sync constraints
+12. `20251113163600_rename_properties_to_custom_fields_extensions_1.exs` - Rename properties extensions
+13. `20251113163602_rename_properties_to_custom_fields.exs` - Rename property_types → custom_fields, properties → custom_field_values
+14. `20251113180429_add_slug_to_custom_fields.exs` - Add slug to custom fields
+15. `20251113183538_change_custom_field_delete_cascade.exs` - Change delete cascade behavior
+16. `20251119160509_add_show_in_overview_to_custom_fields.exs` - Add show_in_overview flag
+17. `20251127134451_add_settings_table.exs` - Create settings table (singleton)
+18. `20251201115939_add_member_field_visibility_to_settings.exs` - Add member_field_visibility JSONB to settings
+19. `20251202145404_remove_birth_date_from_members.exs` - Remove birth_date field
+20. `20251204123714_add_custom_field_values_to_search_vector.exs` - Include custom field values in search vector
+21. `20251211151449_add_membership_fees_tables.exs` - Create membership_fee_types and membership_fee_cycles tables
+22. `20251211172549_remove_immutable_from_custom_fields.exs` - Remove immutable flag from custom fields
+23. `20251211195058_add_membership_fee_settings.exs` - Add membership fee settings to settings table
+24. `20251218113900_remove_paid_from_members.exs` - Remove paid boolean from members (replaced by cycle status)
+25. `20260102155350_remove_phone_number_and_make_fields_optional.exs` - Remove phone_number, make first_name/last_name optional
+26. `20260106161215_add_authorization_domain.exs` - Create roles table and add role_id to users
**Learning:** Ash's code generation from resources ensures schema always matches code.
@@ -775,7 +795,7 @@ end
### Test Data Management
**Seed Data:**
-- Admin user: `admin@mv.local` / `testpassword`
+- Admin user: `admin@localhost` / `testpassword` (configurable via `ADMIN_EMAIL` env var)
- Sample members: Hans Müller, Greta Schmidt, Friedrich Wagner
- Linked accounts: Maria Weber, Thomas Klein
- CustomFieldValue types: String, Date, Boolean, Email
@@ -1562,7 +1582,7 @@ Effective workflow:
This project demonstrates a modern Phoenix application built with:
- ✅ **Ash Framework** for declarative resources and policies
-- ✅ **Phoenix LiveView** for real-time, server-rendered UI
+- ✅ **Phoenix LiveView 1.1.0-rc.3** for real-time, server-rendered UI
- ✅ **Tailwind CSS + DaisyUI** for rapid UI development
- ✅ **PostgreSQL** with advanced features (full-text search, UUIDv7)
- ✅ **Multi-strategy authentication** (Password + OIDC)
@@ -1570,15 +1590,19 @@ This project demonstrates a modern Phoenix application built with:
- ✅ **Flexible data model** (EAV pattern with union types)
**Key Achievements:**
-- 🎯 8 sprints completed
-- 🚀 82 pull requests merged
-- ✅ Core features implemented (CRUD, search, auth, sync)
+- 🎯 9+ sprints completed
+- 🚀 100+ pull requests merged
+- ✅ Core features implemented (CRUD, search, auth, sync, membership fees, roles & permissions)
+- ✅ Membership fees system (types, cycles, settings)
+- ✅ Role-based access control (RBAC) with 4 permission sets
+- ✅ Member field visibility settings
+- ✅ Sidebar navigation (WCAG 2.1 AA compliant)
- 📚 Comprehensive documentation
- 🔒 Security-focused (audits, validations, policies)
- 🐳 Docker-ready for self-hosting
**Next Steps:**
-- Implement roles & permissions
+- ✅ ~~Implement roles & permissions~~ - RBAC system implemented (2026-01-08)
- Add payment tracking
- ✅ ~~Improve accessibility (WCAG 2.1 AA)~~ - Keyboard navigation implemented
- Member self-service portal
@@ -1586,8 +1610,150 @@ This project demonstrates a modern Phoenix application built with:
---
-**Document Version:** 1.3
-**Last Updated:** 2025-12-02
+## Recent Updates (2025-12-02 to 2026-01-13)
+
+### Membership Fees System Implementation (2025-12-11 to 2025-12-26)
+
+**PR #283:** *Membership Fee - Database Schema & Ash Domain Foundation* (closes #275)
+- Created `Mv.MembershipFees` domain
+- Added `MembershipFeeType` resource with intervals (monthly, quarterly, half_yearly, yearly)
+- Added `MembershipFeeCycle` resource for individual billing cycles
+- Database migrations for membership fee tables
+
+**PR #284:** *Calendar Cycle Calculation Logic* (closes #276)
+- Calendar-based cycle calculation module
+- Support for different intervals
+- Cycle start/end date calculations
+- Integration with member joining dates
+
+**PR #290:** *Cycle Generation System* (closes #277)
+- Automatic cycle generation for members
+- Cycle regeneration when fee type changes
+- Integration with member lifecycle hooks
+- Actor-based authorization for cycle operations
+
+**PR #291:** *Membership Fee Type Resource & Settings* (closes #278)
+- Membership fee type CRUD operations
+- Global membership fee settings
+- Default fee type assignment
+- `include_joining_cycle` setting
+
+**PR #294:** *Cycle Management & Member Integration* (closes #279)
+- Member-fee type relationship
+- Cycle status tracking (unpaid, paid, suspended)
+- Member detail view integration
+- Cycle regeneration on fee type change
+
+**PR #304:** *Membership Fee 6 - UI Components & LiveViews* (closes #280)
+- Membership fee type management LiveViews
+- Membership fee settings LiveView
+- Cycle display in member detail view
+- Payment status indicators
+
+### Custom Fields Enhancements (2025-12-11 to 2026-01-02)
+
+**PR #266:** *Implements search for custom fields* (closes #196)
+- Custom field search in member overview
+- Integration with full-text search
+- Custom field value filtering
+
+**PR #301:** *Implements validation for required custom fields* (closes #274)
+- Required custom field validation
+- Form-level validation
+- Error messages for missing required fields
+
+**PR #313:** *Fix hidden empty custom fields* (closes #282)
+- Fixed display of empty custom fields
+- Improved custom field visibility logic
+
+### UI/UX Improvements (2025-12-03 to 2025-12-16)
+
+**PR #240:** *Implement dropdown to show/hide columns in member overview* (closes #209)
+- Field visibility dropdown
+- User-specific field selection
+- Integration with global settings
+
+**PR #247:** *Visual hierarchy for fields in member view and edit form* (closes #231)
+- Improved field grouping
+- Visual hierarchy improvements
+- Better form layout
+
+**PR #250:** *UX - Avoid opening member by clicking the checkbox* (closes #233)
+- Checkbox click handling
+- Prevented accidental navigation
+- Improved selection UX
+
+**PR #259:** *Fix small UI issues* (closes #220)
+- Various UI bug fixes
+- Accessibility improvements
+
+**PR #293:** *Small UX fixes* (closes #281)
+- Additional UX improvements
+- Polish and refinement
+
+**PR #319:** *Reduce member fields* (closes #273)
+- Removed unnecessary member fields
+- Streamlined member data model
+- Migration for field removal
+
+### Roles and Permissions System (2026-01-06 to 2026-01-08)
+- ✅ **RBAC Implementation Complete** - Member Resource Policies (#345)
+ - Four hardcoded permission sets: `own_data`, `read_only`, `normal_user`, `admin`
+ - Role-based access control with database-backed roles
+ - Member resource policies with scope filtering (`:own`, `:linked`, `:all`)
+ - Authorization checks via `Mv.Authorization.Checks.HasPermission`
+ - System role protection (cannot delete critical roles)
+ - Comprehensive test coverage
+
+### Actor Handling Refactoring (2026-01-09)
+- ✅ **Consistent Actor Access** - `current_actor/1` helper function
+ - Standardized actor access across all LiveViews
+ - `ash_actor_opts/1` helper for consistent authorization options
+ - `submit_form/3` wrapper for form submissions with actor
+ - All Ash operations now properly pass `actor` parameter
+ - Error handling improvements (replaced bang calls with proper error handling)
+
+### Internationalization Improvements (2026-01-13)
+- ✅ **Complete German Translations** - All UI strings translated
+ - CI check for empty German translations in lint task
+ - Standardized English `msgstr` entries (all empty for consistency)
+ - Corrected language headers in `.po` files
+ - Added missing translations for error messages
+
+### Code Quality Improvements (2026-01-13)
+- ✅ **Error Handling** - Replaced `Ash.read!` with proper error handling
+- ✅ **Code Complexity** - Reduced nesting depth in `UserLive.Form`
+- ✅ **Test Infrastructure** - Role tag support in `ConnCase`
+
+### CSV Import Feature (2026-01-13)
+- ✅ **CSV Templates** - Member import templates (#329)
+ - German and English CSV templates
+ - Template files in `priv/static/templates/`
+
+### Sidebar Implementation (2026-01-12)
+- ✅ **Sidebar Navigation** - Replaced navbar with sidebar (#260)
+ - Standard-compliant sidebar with comprehensive tests
+ - DaisyUI drawer pattern implementation
+ - Desktop expanded/collapsed states
+ - Mobile overlay drawer
+ - localStorage persistence for sidebar state
+ - WCAG 2.1 Level AA compliant
+
+### Member Field Settings (2026-01-12, PR #300, closes #223)
+- ✅ **Member Field Visibility Configuration** - Global settings for field visibility
+ - JSONB-based visibility configuration in Settings resource
+ - Per-field visibility toggle (show/hide in member overview)
+ - Atomic updates for single field visibility changes
+ - Integration with member list overview
+ - User-specific field selection (takes priority over global settings)
+ - Custom field visibility support
+ - Default visibility: all fields visible except `exit_date` (hidden by default)
+ - LiveComponent for managing member field visibility in settings page
+
+---
+
+**Document Version:** 1.4
+**Last Updated:** 2026-01-13
**Maintainer:** Development Team
**Status:** Living Document (update as project evolves)
diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md
index c4ecfc9..1df3eb6 100644
--- a/docs/feature-roadmap.md
+++ b/docs/feature-roadmap.md
@@ -1,8 +1,8 @@
# Feature Roadmap & Implementation Plan
**Project:** Mila - Membership Management System
-**Last Updated:** 2025-11-10
-**Status:** Planning Phase
+**Last Updated:** 2026-01-13
+**Status:** Active Development
---
@@ -37,17 +37,24 @@
- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low)
- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low)
+**Current State:**
+- ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345)
+- ✅ **Permission system** - Four hardcoded permission sets (`own_data`, `read_only`, `normal_user`, `admin`)
+- ✅ **Database-backed roles** - Roles table with permission set references
+- ✅ **Resource policies** - Member resource policies with scope filtering
+- ✅ **Page-level authorization** - LiveView page access control
+- ✅ **System role protection** - Critical roles cannot be deleted
+
**Missing Features:**
-- ❌ Role-based access control (RBAC)
-- ❌ Permission system
- ❌ Password reset flow
- ❌ Email verification
- ❌ Two-factor authentication (future)
**Related Issues:**
-- [#191](https://git.local-it.org/local-it/mitgliederverwaltung/issues/191) - Implement Roles in Ash (M)
-- [#190](https://git.local-it.org/local-it/mitgliederverwaltung/issues/190) - Implement Permissions in Ash (M)
-- [#151](https://git.local-it.org/local-it/mitgliederverwaltung/issues/151) - Define implementation plan for roles and permissions (M) [3/7 tasks done]
+- ✅ [#345](https://git.local-it.org/local-it/mitgliederverwaltung/issues/345) - Member Resource Policies (closed 2026-01-13)
+- ✅ [#191](https://git.local-it.org/local-it/mitgliederverwaltung/issues/191) - Implement Roles in Ash (M) - Completed
+- ✅ [#190](https://git.local-it.org/local-it/mitgliederverwaltung/issues/190) - Implement Permissions in Ash (M) - Completed
+- ✅ [#151](https://git.local-it.org/local-it/mitgliederverwaltung/issues/151) - Define implementation plan for roles and permissions (M) - Completed
---
@@ -187,23 +194,27 @@
**Current State:**
- ✅ Basic "paid" boolean field on members
-- ✅ **UI Mock-ups for Membership Fee Types & Settings** (2025-12-02)
-- ⚠️ No payment tracking
+- ✅ **Membership Fee Types Management** - Full CRUD implementation
+- ✅ **Membership Fee Cycles** - Individual billing cycles per member
+- ✅ **Membership Fee Settings** - Global settings (include_joining_cycle, default_fee_type)
+- ✅ **Cycle Generation** - Automatic cycle generation for members
+- ✅ **Payment Status Tracking** - Status per cycle (unpaid, paid, suspended)
+- ✅ **Member Fee Assignment** - Members can be assigned to fee types
+- ✅ **Cycle Regeneration** - Regenerate cycles when fee type changes
+- ✅ **UI Components** - Membership fee status in member list and detail views
**Open Issues:**
- [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority)
-- [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Membership Fee Mockup Pages (Preview)
+- ✅ [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Membership Fee Mockup Pages (Preview) - Implemented
-**Mock-Up Pages (Non-Functional Preview):**
-- `/membership_fee_types` - Membership Fee Types Management
-- `/membership_fee_settings` - Global Membership Fee Settings
+**Implemented Pages:**
+- `/membership_fee_types` - Membership Fee Types Management (fully functional)
+- `/membership_fee_settings` - Global Membership Fee Settings (fully functional)
+- `/members/:id` - Member detail view with membership fee cycles
**Missing Features:**
-- ❌ Membership fee configuration
-- ❌ Payment records/transactions
-- ❌ Payment history per member
+- ❌ Payment records/transactions (external payment tracking)
- ❌ Payment reminders
-- ❌ Payment status tracking (pending, paid, overdue)
- ❌ Invoice generation
- ❌ vereinfacht.digital API integration
- ❌ SEPA direct debit support
@@ -218,17 +229,18 @@
**Current State:**
- ✅ AshAdmin integration (basic)
-- ⚠️ No user-facing admin UI
+- ✅ **Global Settings Management** - `/settings` page (singleton resource)
+- ✅ **Club/Organization profile** - Club name configuration
+- ✅ **Member Field Visibility Settings** - Configure which fields show in overview
+- ✅ **CustomFieldValue type management UI** - Full CRUD for custom fields
+- ✅ **Role Management UI** - Full CRUD for roles (`/admin/roles`)
+- ✅ **Membership Fee Settings** - Global fee settings management
**Open Issues:**
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
**Missing Features:**
-- ❌ Global settings management
-- ❌ Club/Organization profile
- ❌ Email templates configuration
-- ❌ CustomFieldValue type management UI (user-facing)
-- ❌ Role and permission management UI
- ❌ System health dashboard
- ❌ Audit log viewer
- ❌ Backup/restore functionality
@@ -273,10 +285,12 @@
**Current State:**
- ✅ Seed data script
-- ⚠️ No user-facing import/export
+- ✅ **CSV Import Templates** - German and English templates (#329, 2026-01-13)
+ - Template files in `priv/static/templates/member_import_de.csv` and `member_import_en.csv`
+ - CSV specification documented in `docs/csv-member-import-v1.md`
**Missing Features:**
-- ❌ CSV import for members
+- ❌ CSV import implementation (templates ready, import logic pending)
- ❌ Excel import for members
- ❌ Import validation and preview
- ❌ Import error handling
@@ -452,6 +466,7 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
| `GET` | `/auth/user/rauthy` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy |
| `GET` | `/auth/user/rauthy/callback` | Handle OIDC callback | 🔓 | `{code, state}` | Redirect + session cookie |
| `POST` | `/auth/user/sign_out` | Sign out user | 🔐 | - | Redirect to login |
+| `GET` | `/auth/link-oidc-account` | OIDC account linking (password verification) | 🔓 | - | LiveView form | ✅ Implemented |
| `GET` | `/auth/user/password/reset` | Show password reset form | 🔓 | - | HTML form |
| `POST` | `/auth/user/password/reset` | Request password reset | 🔓 | `{email}` | Success message + email sent |
| `GET` | `/auth/user/password/reset/:token` | Show reset password form | 🔓 | - | HTML form |
@@ -537,13 +552,18 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
### 3. Custom Fields (CustomFieldValue System) Endpoints
-#### LiveView Endpoints
+#### LiveView Endpoints (✅ Implemented)
-| Mount | Purpose | Auth | Events |
-|-------|---------|------|--------|
-| `/custom-fields` | List custom fields | 🛡️ | `new`, `edit`, `delete` |
-| `/custom-fields/new` | Create custom field | 🛡️ | `save`, `cancel` |
-| `/custom-fields/:id/edit` | Edit custom field | 🛡️ | `save`, `cancel`, `delete` |
+| Mount | Purpose | Auth | Events | Status |
+|-------|---------|------|--------|--------|
+| `/settings` | Global settings (includes custom fields management) | 🔐 | `save`, `validate` | ✅ Implemented |
+| `/custom_field_values` | List all custom field values | 🔐 | `new`, `edit`, `delete` | ✅ Implemented |
+| `/custom_field_values/new` | Create custom field value | 🔐 | `save`, `cancel` | ✅ Implemented |
+| `/custom_field_values/:id` | Custom field value detail | 🔐 | `edit` | ✅ Implemented |
+| `/custom_field_values/:id/edit` | Edit custom field value | 🔐 | `save`, `cancel` | ✅ Implemented |
+| `/custom_field_values/:id/show/edit` | Edit from show page | 🔐 | `save`, `cancel` | ✅ Implemented |
+
+**Note:** Custom fields (definitions) are managed via LiveComponent in `/settings` page, not as separate routes.
#### Ash Resource Actions
@@ -622,63 +642,81 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
### 6. Internationalization Endpoints
-#### HTTP Controller Endpoints
+#### HTTP Controller Endpoints (✅ Implemented)
-| Method | Route | Purpose | Auth | Request | Response |
-|--------|-------|---------|------|---------|----------|
-| `POST` | `/locale` | Set user locale | 🔐 | `{locale: "de"}` | Redirect with cookie |
-| `GET` | `/locales` | List available locales | 🔓 | - | `["de", "en"]` |
+| Method | Route | Purpose | Auth | Request | Response | Status |
+|--------|-------|---------|------|---------|----------|--------|
+| `POST` | `/set_locale` | Set user locale | 🔐 | `{locale: "de"}` | Redirect with cookie | ✅ Implemented |
+| `GET` | `/locales` | List available locales | 🔓 | - | `["de", "en"]` | ❌ Not implemented |
+
+**Note:** Locale is set via `/set_locale` POST endpoint and persisted in session/cookie. Supported locales: `de` (default), `en`.
---
### 7. Payment & Fees Management Endpoints
-#### LiveView Endpoints (NEW - Issue #156)
+#### LiveView Endpoints (✅ Implemented)
-| Mount | Purpose | Auth | Events |
-|-------|---------|------|--------|
-| `/payments` | Payment list | 🔐 | `new`, `record_payment`, `send_reminder` |
-| `/payments/:id` | Payment detail | 🔐 | `edit`, `delete`, `mark_paid` |
-| `/fees` | Fee configuration | 🛡️ | `create`, `edit`, `delete` |
-| `/invoices` | Invoice list | 🔐 | `generate`, `download`, `send` |
+| Mount | Purpose | Auth | Events | Status |
+|-------|---------|------|--------|--------|
+| `/membership_fee_types` | Membership fee type list | 🔐 | `new`, `edit`, `delete` | ✅ Implemented |
+| `/membership_fee_types/new` | Create membership fee type | 🔐 | `save`, `cancel` | ✅ Implemented |
+| `/membership_fee_types/:id/edit` | Edit membership fee type | 🔐 | `save`, `cancel` | ✅ Implemented |
+| `/membership_fee_settings` | Global membership fee settings | 🔐 | `save` | ✅ Implemented |
+| `/contributions/member/:id` | Member contribution periods (mock-up) | 🔐 | - | ⚠️ Mock-up only |
+| `/contribution_types` | Contribution types (mock-up) | 🔐 | - | ⚠️ Mock-up only |
-#### Ash Resource Actions (NEW)
+#### Ash Resource Actions (✅ Partially Implemented)
-| Resource | Action | Purpose | Auth | Input | Output |
-|----------|--------|---------|------|-------|--------|
-| `Fee` | `:create` | Create fee type | 🛡️ | `{name, amount, frequency}` | `{:ok, fee}` |
-| `Fee` | `:read` | List fees | 🔐 | - | `[%Fee{}]` |
-| `Payment` | `:create` | Record payment | 🔐 | `{member_id, fee_id, amount, date}` | `{:ok, payment}` |
-| `Payment` | `:list_by_member` | Member payment history | 🔐 | `{member_id}` | `[%Payment{}]` |
-| `Payment` | `:mark_paid` | Mark as paid | 🔐 | `{id}` | `{:ok, payment}` |
-| `Invoice` | `:generate` | Generate invoice | 🔐 | `{member_id, fee_id, period}` | `{:ok, invoice}` |
-| `Invoice` | `:send` | Send invoice via email | 🔐 | `{id}` | `{:ok, sent}` |
-| `Payment` | `:import_vereinfacht` | Import from vereinfacht.digital | 🛡️ | `{transactions}` | `{:ok, count}` |
+| Resource | Action | Purpose | Auth | Input | Output | Status |
+|----------|--------|---------|------|-------|--------|--------|
+| `MembershipFeeType` | `:create` | Create fee type | 🔐 | `{name, amount, interval, ...}` | `{:ok, fee_type}` | ✅ Implemented |
+| `MembershipFeeType` | `:read` | List fee types | 🔐 | - | `[%MembershipFeeType{}]` | ✅ Implemented |
+| `MembershipFeeType` | `:update` | Update fee type (name, amount, description) | 🔐 | `{id, attrs}` | `{:ok, fee_type}` | ✅ Implemented |
+| `MembershipFeeType` | `:destroy` | Delete fee type (if no cycles) | 🔐 | `{id}` | `{:ok, fee_type}` | ✅ Implemented |
+| `MembershipFeeCycle` | `:read` | List cycles for member | 🔐 | `{member_id}` | `[%MembershipFeeCycle{}]` | ✅ Implemented |
+| `MembershipFeeCycle` | `:update` | Update cycle status | 🔐 | `{id, status}` | `{:ok, cycle}` | ✅ Implemented |
+| `Payment` | `:create` | Record payment | 🔐 | `{member_id, fee_id, amount, date}` | `{:ok, payment}` | ❌ Not implemented |
+| `Payment` | `:list_by_member` | Member payment history | 🔐 | `{member_id}` | `[%Payment{}]` | ❌ Not implemented |
+| `Payment` | `:mark_paid` | Mark as paid | 🔐 | `{id}` | `{:ok, payment}` | ❌ Not implemented |
+| `Invoice` | `:generate` | Generate invoice | 🔐 | `{member_id, fee_id, period}` | `{:ok, invoice}` | ❌ Not implemented |
+| `Invoice` | `:send` | Send invoice via email | 🔐 | `{id}` | `{:ok, sent}` | ❌ Not implemented |
+| `Payment` | `:import_vereinfacht` | Import from vereinfacht.digital | 🛡️ | `{transactions}` | `{:ok, count}` | ❌ Not implemented |
---
### 8. Admin Panel & Configuration Endpoints
-#### LiveView Endpoints (NEW)
+#### LiveView Endpoints (✅ Partially Implemented)
-| Mount | Purpose | Auth | Events |
-|-------|---------|------|--------|
-| `/admin` | Admin dashboard | 🛡️ | - |
-| `/admin/settings` | Global settings | 🛡️ | `save` |
-| `/admin/organization` | Organization profile | 🛡️ | `save` |
-| `/admin/email-templates` | Email template editor | 🛡️ | `create`, `edit`, `preview` |
-| `/admin/audit-log` | System audit log | 🛡️ | `filter`, `export` |
+| Mount | Purpose | Auth | Events | Status |
+|-------|---------|------|--------|--------|
+| `/settings` | Global settings (club name, member fields, custom fields) | 🔐 | `save`, `validate` | ✅ Implemented |
+| `/admin/roles` | Role management | 🛡️ | `new`, `edit`, `delete` | ✅ Implemented |
+| `/admin/roles/new` | Create role | 🛡️ | `save`, `cancel` | ✅ Implemented |
+| `/admin/roles/:id` | Role detail view | 🛡️ | `edit` | ✅ Implemented |
+| `/admin/roles/:id/edit` | Edit role | 🛡️ | `save`, `cancel` | ✅ Implemented |
+| `/admin` | Admin dashboard | 🛡️ | - | ❌ Not implemented |
+| `/admin/organization` | Organization profile | 🛡️ | `save` | ❌ Not implemented |
+| `/admin/email-templates` | Email template editor | 🛡️ | `create`, `edit`, `preview` | ❌ Not implemented |
+| `/admin/audit-log` | System audit log | 🛡️ | `filter`, `export` | ❌ Not implemented |
-#### Ash Resource Actions (NEW)
+#### Ash Resource Actions (✅ Partially Implemented)
-| Resource | Action | Purpose | Auth | Input | Output |
-|----------|--------|---------|------|-------|--------|
-| `Setting` | `:get` | Get setting value | 🔐 | `{key}` | `value` |
-| `Setting` | `:set` | Set setting value | 🛡️ | `{key, value}` | `{:ok, setting}` |
-| `Setting` | `:list` | List all settings | 🛡️ | - | `[%Setting{}]` |
-| `Organization` | `:read` | Get organization info | 🔐 | - | `%Organization{}` |
-| `Organization` | `:update` | Update organization | 🛡️ | `{name, logo, ...}` | `{:ok, org}` |
-| `AuditLog` | `:list` | List audit entries | 🛡️ | `{filters, pagination}` | `[%AuditLog{}]` |
+| Resource | Action | Purpose | Auth | Input | Output | Status |
+|----------|--------|---------|------|-------|--------|--------|
+| `Setting` | `:read` | Get settings (singleton) | 🔐 | - | `{:ok, settings}` | ✅ Implemented |
+| `Setting` | `:update` | Update settings | 🔐 | `{club_name, member_field_visibility, ...}` | `{:ok, settings}` | ✅ Implemented |
+| `Setting` | `:update_member_field_visibility` | Update field visibility | 🔐 | `{member_field_visibility}` | `{:ok, settings}` | ✅ Implemented |
+| `Setting` | `:update_single_member_field_visibility` | Atomic field visibility update | 🔐 | `{field, show_in_overview}` | `{:ok, settings}` | ✅ Implemented |
+| `Setting` | `:update_membership_fee_settings` | Update fee settings | 🔐 | `{include_joining_cycle, default_membership_fee_type_id}` | `{:ok, settings}` | ✅ Implemented |
+| `Role` | `:read` | List roles | 🛡️ | - | `[%Role{}]` | ✅ Implemented |
+| `Role` | `:create` | Create role | 🛡️ | `{name, permission_set_name, ...}` | `{:ok, role}` | ✅ Implemented |
+| `Role` | `:update` | Update role | 🛡️ | `{id, attrs}` | `{:ok, role}` | ✅ Implemented |
+| `Role` | `:destroy` | Delete role (if not system role) | 🛡️ | `{id}` | `{:ok, role}` | ✅ Implemented |
+| `Organization` | `:read` | Get organization info | 🔐 | - | `%Organization{}` | ❌ Not implemented |
+| `Organization` | `:update` | Update organization | 🛡️ | `{name, logo, ...}` | `{:ok, org}` | ❌ Not implemented |
+| `AuditLog` | `:list` | List audit entries | 🛡️ | `{filters, pagination}` | `[%AuditLog{}]` | ❌ Not implemented |
---
diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md
new file mode 100644
index 0000000..b075c4b
--- /dev/null
+++ b/docs/groups-architecture.md
@@ -0,0 +1,1515 @@
+# 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 (LOWER(name))
+);
+
+CREATE INDEX groups_name_index ON groups(LOWER(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 (case-insensitive, using LOWER(name))
+- `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 (case-insensitive), 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
+- *(Optional)* Click badge to filter by that group (enhanced UX, can be added later)
+
+**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:**
+
+```heex
+
+ <%= group.name %>
+
+```
+
+**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
+
+```
+
+**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
+
+### 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 all permission sets that have read access)
+
+### Permission Sets Update
+
+**Admin Permission Set:**
+```elixir
+%{
+ 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:**
+```elixir
+%{
+ resources: [
+ # ... existing resources ...
+ %{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):**
+- 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 `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 (>50 members)
+- Load member count via calculation (not separate query)
+- Use `Ash.Query.load` for member details when displaying
+
+### 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
+10. ✅ Gruppen in Mitgliedersuche (automatisch via search_vector)
+
+**Nicht im MVP:**
+- ❌ Hierarchische Gruppen
+- ❌ Rollen/Positionen in Gruppen
+- ❌ Erweiterte Gruppenattribute (Datum, Status, etc.)
+- ❌ Gruppen-spezifische Berechtigungen
+
+### 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 6: Gruppen in Mitgliedersuche (automatisch via search_vector)
+- ✅ Einheit 7: Berechtigungen
+
+**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
+6. *(Optional)* Clickable group badges for filtering (enhanced UX)
+
+**Deliverables:**
+- Groups visible in member overview
+- Filter by group (via dropdown)
+- Sort by group
+- *(Optional)* Clickable badges for quick filtering
+
+**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
+- *(Optional)* Clickable group badges for filtering
+
+**Acceptance Criteria:**
+- Groups visible in member overview
+- Can filter by group (via dropdown)
+- Can sort by group
+- Filter persists in URL
+- *(Optional)* Can filter by clicking group badge
+
+### 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 to include group names
+- Extend fuzzy search to search group names
+- Test search functionality
+
+**Acceptance Criteria:**
+- Group names searchable in member search (automatic via search_vector)
+- Search finds members by group name
+- Search vector updates automatically when member-group associations change
+
+**Note:** This is part of MVP as group names are automatically included in full-text search once the search_vector trigger is updated.
+
+### 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`
+
+```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-insensitive" do
+ Group.create!(%{name: "Vorstand"})
+ attrs = %{name: "VORSTAND"}
+ # Name uniqueness should be case-insensitive
+ assert {:error, changeset} = Group.create(attrs)
+ assert %{name: ["has already been taken"]} = errors_on(changeset)
+ 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
+
+#### Member-Group Relationships
+
+**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
+
+**File:** `test/mv_web/live/group_live/index_test.exs`
+
+```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
+```
+
+---
+
+## 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, [fragment("LOWER(name)")], name: :groups_name_unique)
+create index(:groups, [fragment("LOWER(name)")], name: :groups_name_index)
+```
+
+**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.
diff --git a/docs/membership-fee-architecture.md b/docs/membership-fee-architecture.md
index 7c8da24..4a290b7 100644
--- a/docs/membership-fee-architecture.md
+++ b/docs/membership-fee-architecture.md
@@ -3,8 +3,8 @@
**Project:** Mila - Membership Management System
**Feature:** Membership Fee Management
**Version:** 1.0
-**Last Updated:** 2025-11-27
-**Status:** Architecture Design - Ready for Implementation
+**Last Updated:** 2026-01-13
+**Status:** ✅ Implemented
---
@@ -76,6 +76,13 @@ This document defines the technical architecture for the Membership Fees system.
- `MembershipFeeType` - Membership fee type definitions (admin-managed)
- `MembershipFeeCycle` - Individual membership fee cycles per member
+**Public API:**
+The domain exposes code interface functions:
+- `create_membership_fee_type/1`, `list_membership_fee_types/0`, `update_membership_fee_type/2`, `destroy_membership_fee_type/1`
+- `create_membership_fee_cycle/1`, `list_membership_fee_cycles/0`, `update_membership_fee_cycle/2`, `destroy_membership_fee_cycle/1`
+
+**Note:** In LiveViews, direct `Ash.read`, `Ash.create`, `Ash.update`, `Ash.destroy` calls are used with `domain: Mv.MembershipFees` instead of code interface functions. This is acceptable for LiveView forms that use `AshPhoenix.Form`.
+
**Extensions:**
- Member resource extended with membership fee fields
@@ -348,6 +355,9 @@ lib/
1. MembershipFeeType index/form (admin)
2. MembershipFeeCycle table component (member detail view)
+ - Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent`
+ - Displays all cycles in a table with status management
+ - Allows changing cycle status, editing amounts, and regenerating cycles
3. Settings form section (admin)
4. Member list column (membership fee status)
diff --git a/docs/membership-fee-overview.md b/docs/membership-fee-overview.md
index bd47faa..8eb48b0 100644
--- a/docs/membership-fee-overview.md
+++ b/docs/membership-fee-overview.md
@@ -3,8 +3,8 @@
**Project:** Mila - Membership Management System
**Feature:** Membership Fee Management
**Version:** 1.0
-**Last Updated:** 2025-11-27
-**Status:** Concept - Ready for Review
+**Last Updated:** 2026-01-13
+**Status:** ✅ Implemented
---
diff --git a/docs/policy-bypass-vs-haspermission.md b/docs/policy-bypass-vs-haspermission.md
new file mode 100644
index 0000000..31bb737
--- /dev/null
+++ b/docs/policy-bypass-vs-haspermission.md
@@ -0,0 +1,330 @@
+# Policy Pattern: Bypass vs. HasPermission
+
+**Date:** 2026-01-22
+**Status:** Implemented and Tested
+**Applies to:** User Resource, Member Resource
+
+---
+
+## Summary
+
+For filter-based permissions (`scope :own`, `scope :linked`), we use a **two-tier authorization pattern**:
+
+1. **Bypass with `expr()` for READ operations** - Handles list queries via auto_filter
+2. **HasPermission for UPDATE/CREATE/DESTROY** - Uses scope from PermissionSets when record is present
+
+This pattern ensures that the scope concept in PermissionSets is actually used and not redundant.
+
+---
+
+## The Problem
+
+### Initial Assumption (INCORRECT)
+
+> "No separate Own Credentials Bypass needed, as all permission sets already have User read/update :own. HasPermission with scope :own handles this correctly."
+
+This assumption was based on the idea that `HasPermission` returning `{:filter, expr(...)}` would automatically trigger Ash's `auto_filter` for list queries.
+
+### Reality
+
+**When HasPermission returns `{:filter, expr(...)}`:**
+
+1. `strict_check` is called first
+2. For list queries (no record yet), `strict_check` returns `{:ok, false}`
+3. Ash **STOPS** evaluation and does **NOT** call `auto_filter`
+4. Result: List queries fail with empty results ❌
+
+**Example:**
+```elixir
+# This FAILS for list queries:
+policy action_type([:read, :update]) do
+ authorize_if Mv.Authorization.Checks.HasPermission
+end
+
+# User tries to list all users:
+Ash.read(User, actor: user)
+# Expected: Returns [user] (filtered to own record)
+# Actual: Returns [] (empty list)
+```
+
+---
+
+## The Solution
+
+### Pattern: Bypass for READ, HasPermission for UPDATE
+
+**User Resource Example:**
+
+```elixir
+policies do
+ # Bypass for READ (handles list queries via auto_filter)
+ bypass action_type(:read) do
+ description "Users can always read their own account"
+ authorize_if expr(id == ^actor(:id))
+ end
+
+ # HasPermission for UPDATE (scope :own works with changesets)
+ policy action_type([:read, :create, :update, :destroy]) do
+ description "Check permissions from user's role and permission set"
+ authorize_if Mv.Authorization.Checks.HasPermission
+ end
+end
+```
+
+**Why This Works:**
+
+| Operation | Record Available? | Method | Result |
+|-----------|-------------------|--------|--------|
+| **READ (list)** | ❌ No | `bypass` with `expr()` | Ash applies expr as SQL WHERE → ✅ Filtered list |
+| **READ (single)** | ✅ Yes | `bypass` with `expr()` | Ash evaluates expr → ✅ true/false |
+| **UPDATE** | ✅ Yes (changeset) | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized |
+| **CREATE** | ✅ Yes (changeset) | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized |
+| **DESTROY** | ✅ Yes | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized |
+
+**Important: UPDATE Strategy**
+
+UPDATE is **NOT** a hardcoded bypass. It is controlled by **PermissionSets**:
+
+- All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) explicitly grant `User.update :own`
+- `HasPermission` evaluates `scope :own` when a changeset with record is present
+- If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials
+- This is intentional - UPDATE is controlled by PermissionSets, not hardcoded
+
+**Example:** The `read_only` permission set grants `User.update :own` even though it's "read-only" for member data. This allows password changes while keeping member data read-only.
+
+---
+
+## Why `scope :own` Is NOT Redundant
+
+### The Question
+
+> "If we use a bypass with `expr(id == ^actor(:id))` for READ, isn't `scope :own` in PermissionSets redundant?"
+
+### The Answer: NO! ✅
+
+**`scope :own` is ONLY used for operations where a record is present:**
+
+```elixir
+# PermissionSets.ex
+%{resource: "User", action: :read, scope: :own, granted: true}, # Not used (bypass handles it)
+%{resource: "User", action: :update, scope: :own, granted: true}, # USED by HasPermission ✅
+```
+
+**Test Proof:**
+
+```elixir
+# test/mv/accounts/user_policies_test.exs:82
+test "can update own email", %{user: user} do
+ new_email = "updated@example.com"
+
+ # This works via HasPermission with scope :own (NOT via bypass)
+ {:ok, updated_user} =
+ user
+ |> Ash.Changeset.for_update(:update_user, %{email: new_email})
+ |> Ash.update(actor: user)
+
+ assert updated_user.email == Ash.CiString.new(new_email)
+end
+# ✅ Test passes - proves scope :own is used!
+```
+
+---
+
+## Consistency Across Resources
+
+### User Resource
+
+```elixir
+# Bypass for READ list queries
+bypass action_type(:read) do
+ authorize_if expr(id == ^actor(:id))
+end
+
+# HasPermission for UPDATE (uses scope :own from PermissionSets)
+policy action_type([:read, :create, :update, :destroy]) do
+ authorize_if Mv.Authorization.Checks.HasPermission
+end
+```
+
+**PermissionSets:**
+- `own_data`, `read_only`, `normal_user`: `scope :own` for read/update
+- `admin`: `scope :all` for all operations
+
+### Member Resource
+
+```elixir
+# Bypass for READ list queries
+bypass action_type(:read) do
+ authorize_if expr(id == ^actor(:member_id))
+end
+
+# HasPermission for UPDATE (uses scope :linked from PermissionSets)
+policy action_type([:read, :create, :update, :destroy]) do
+ authorize_if Mv.Authorization.Checks.HasPermission
+end
+```
+
+**PermissionSets:**
+- `own_data`: `scope :linked` for read/update
+- `read_only`: `scope :all` for read (no update permission)
+- `normal_user`, `admin`: `scope :all` for all operations
+
+---
+
+## Technical Deep Dive
+
+### Why Does `expr()` in Bypass Work?
+
+**Ash treats `expr()` natively in two contexts:**
+
+1. **strict_check** (single record):
+ - Ash evaluates the expression against the record
+ - Returns true/false based on match
+
+2. **auto_filter** (list queries):
+ - Ash compiles the expression to SQL WHERE clause
+ - Applies filter directly in database query
+
+**Example:**
+```elixir
+bypass action_type(:read) do
+ authorize_if expr(id == ^actor(:id))
+end
+
+# For list query: Ash.read(User, actor: user)
+# Compiled SQL: SELECT * FROM users WHERE id = $1 (user.id)
+# Result: [user] ✅
+```
+
+### Why Doesn't HasPermission Trigger auto_filter?
+
+**HasPermission.strict_check logic:**
+
+```elixir
+def strict_check(actor, authorizer, _opts) do
+ # ...
+ case check_permission(...) do
+ {:filter, filter_expr} ->
+ if record do
+ # Evaluate filter against record
+ evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
+ else
+ # No record (list query) - return false
+ # Ash STOPS here, does NOT call auto_filter
+ {:ok, false}
+ end
+ end
+end
+```
+
+**Why return false instead of :unknown?**
+
+We tested returning `:unknown`, but Ash's policy evaluation still didn't reliably call `auto_filter`. The `bypass` with `expr()` is the only consistent solution.
+
+---
+
+## Design Principles
+
+### 1. Consistency
+
+Both User and Member follow the same pattern:
+- Bypass for READ (list queries)
+- HasPermission for UPDATE/CREATE/DESTROY (with scope)
+
+### 2. Scope Concept Is Essential
+
+PermissionSets define scopes for all operations:
+- `:own` - User can access their own records
+- `:linked` - User can access linked records (e.g., their member)
+- `:all` - User can access all records (admin)
+
+**These scopes are NOT redundant** - they are used for UPDATE/CREATE/DESTROY.
+
+### 3. Bypass Is a Technical Workaround
+
+The bypass is not a design choice but a **technical necessity** due to Ash's policy evaluation behavior:
+- Ash doesn't call `auto_filter` when `strict_check` returns `false`
+- `expr()` in bypass is handled natively by Ash for both contexts
+- This is consistent with Ash's documentation and best practices
+
+---
+
+## Test Coverage
+
+### User Resource Tests
+
+**File:** `test/mv/accounts/user_policies_test.exs`
+
+**Coverage:**
+- ✅ 31 tests: 30 passing, 1 skipped
+- ✅ All 4 permission sets: `own_data`, `read_only`, `normal_user`, `admin`
+- ✅ READ operations (list and single) via bypass
+- ✅ UPDATE operations via HasPermission with `scope :own`
+- ✅ Admin operations via HasPermission with `scope :all`
+- ✅ AshAuthentication bypass (registration/login)
+- ✅ Tests use system_actor for authorization
+
+**Key Tests Proving Pattern:**
+
+```elixir
+# Test 1: READ list uses bypass (returns filtered list)
+test "list users returns only own user", %{user: user} do
+ {:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
+ assert length(users) == 1 # Filtered to own user ✅
+ assert hd(users).id == user.id
+end
+
+# Test 2: UPDATE uses HasPermission with scope :own
+test "can update own email", %{user: user} do
+ {:ok, updated_user} =
+ user
+ |> Ash.Changeset.for_update(:update_user, %{email: "new@example.com"})
+ |> Ash.update(actor: user)
+
+ assert updated_user.email # Uses scope :own from PermissionSets ✅
+end
+
+# Test 3: Admin uses HasPermission with scope :all
+test "admin can update other users", %{admin: admin, other_user: other_user} do
+ {:ok, updated_user} =
+ other_user
+ |> Ash.Changeset.for_update(:update_user, %{email: "admin-changed@example.com"})
+ |> Ash.update(actor: admin)
+
+ assert updated_user.email # Uses scope :all from PermissionSets ✅
+end
+```
+
+---
+
+## Lessons Learned
+
+1. **Don't assume** that returning a filter from `strict_check` will trigger `auto_filter` - test it!
+2. **Bypass with `expr()` is necessary** for list queries with filter-based permissions
+3. **Scope concept is NOT redundant** - it's used for operations with records (UPDATE/CREATE/DESTROY)
+4. **Consistency matters** - following the same pattern across resources improves maintainability
+5. **Documentation is key** - explaining WHY the pattern exists prevents future confusion
+
+---
+
+## Future Considerations
+
+### If Ash Changes Policy Evaluation
+
+If a future version of Ash reliably calls `auto_filter` when `strict_check` returns `:unknown` or `{:filter, expr}`:
+
+1. We could **remove** the bypass for READ
+2. Keep only the HasPermission policy for all operations
+3. Update tests to verify the new behavior
+
+**However, for now (Ash 3.13.1), the bypass pattern is necessary and correct.**
+
+---
+
+## References
+
+- **Ash Policy Documentation**: [https://hexdocs.pm/ash/policies.html](https://hexdocs.pm/ash/policies.html)
+- **Implementation**: `lib/accounts/user.ex` (lines 271-315)
+- **Tests**: `test/mv/accounts/user_policies_test.exs`
+- **Architecture Doc**: `docs/roles-and-permissions-architecture.md`
+- **Permission Sets**: `lib/mv/authorization/permission_sets.ex`
diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md
index 8c89af7..8934688 100644
--- a/docs/roles-and-permissions-architecture.md
+++ b/docs/roles-and-permissions-architecture.md
@@ -2,7 +2,8 @@
**Version:** 2.0 (Clean Rewrite)
**Date:** 2025-01-13
-**Status:** Ready for Implementation
+**Last Updated:** 2026-01-13
+**Status:** ✅ Implemented (2026-01-08, PR #346, closes #345)
**Related Documents:**
- [Overview](./roles-and-permissions-overview.md) - High-level concepts for stakeholders
- [Implementation Plan](./roles-and-permissions-implementation-plan.md) - Step-by-step implementation guide
@@ -870,79 +871,156 @@ end
**Policy Order Matters!** Ash evaluates policies top-to-bottom, first match wins.
+---
+
+## Bypass vs. HasPermission: When to Use Which?
+
+**Key Finding:** For filter-based permissions (`scope :own`, `scope :linked`), we use a **two-tier approach**:
+
+1. **Bypass with `expr()` for READ** - Handles list queries (auto_filter)
+2. **HasPermission for UPDATE/CREATE/DESTROY** - Handles operations with records
+
+### Why This Pattern?
+
+**The Problem with HasPermission for List Queries:**
+
+When `HasPermission` returns `{:filter, expr(...)}` for `scope :own` or `scope :linked`:
+- `strict_check` returns `{:ok, false}` for queries without a record
+- Ash does **NOT** reliably call `auto_filter` when `strict_check` returns `false`
+- Result: List queries fail ❌
+
+**The Solution:**
+
+Use `bypass` with `expr()` directly for READ operations:
+- Ash handles `expr()` natively for both `strict_check` and `auto_filter`
+- List queries work correctly ✅
+- Single-record reads work correctly ✅
+
+### Pattern Summary
+
+| Operation | Has Record? | Use | Why |
+|-----------|-------------|-----|-----|
+| **READ (list)** | ❌ No | `bypass` with `expr()` | Triggers auto_filter |
+| **READ (single)** | ✅ Yes | `bypass` with `expr()` | expr() evaluates to true/false |
+| **UPDATE** | ✅ Yes (changeset) | `HasPermission` | strict_check can evaluate record |
+| **CREATE** | ✅ Yes (changeset) | `HasPermission` | strict_check can evaluate record |
+| **DESTROY** | ✅ Yes | `HasPermission` | strict_check can evaluate record |
+
+### Is scope :own/:linked Still Useful?
+
+**YES! ✅** The scope concept is essential:
+
+1. **Documentation** - Clearly expresses intent in PermissionSets
+2. **UPDATE/CREATE/DESTROY** - Works perfectly via HasPermission when record is present
+3. **Consistency** - All permissions are centralized in PermissionSets
+4. **Maintainability** - Easy to see what each role can do
+
+The bypass is a **technical workaround** for Ash's auto_filter limitation, not a replacement for the scope concept.
+
+### Consistency Across Resources
+
+Both `User` and `Member` follow this pattern:
+
+- **User**: Bypass for READ (`id == ^actor(:id)`), HasPermission for UPDATE (`scope :own`)
+- **Member**: Bypass for READ (`id == ^actor(:member_id)`), HasPermission for UPDATE (`scope :linked`)
+
+This ensures consistent behavior and predictable authorization logic throughout the application.
+
+---
+
### User Resource Policies
-**Location:** `lib/mv/accounts/user.ex`
+**Location:** `lib/accounts/user.ex`
-**Special Case:** Users can ALWAYS read/update their own credentials, regardless of role.
+**Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :own).
+
+**Key Insight:** Bypass with `expr()` is needed ONLY for READ list queries because HasPermission's strict_check cannot properly trigger auto_filter. UPDATE operations work correctly via HasPermission because a changeset with record is available.
```elixir
defmodule Mv.Accounts.User do
use Ash.Resource, ...
policies do
- # SPECIAL CASE: Users can always access their own account
- # This takes precedence over permission checks
- policy action_type([:read, :update]) do
- description "Users can always read and update their own account"
+ # 1. AshAuthentication Bypass (registration/login without actor)
+ bypass AshAuthentication.Checks.AshAuthenticationInteraction do
+ authorize_if always()
+ end
+
+ # 2. SPECIAL CASE: Users can always READ their own account
+ # Bypass needed for list queries (expr() triggers auto_filter in Ash)
+ # UPDATE is handled by HasPermission below (scope :own works with changesets)
+ bypass action_type(:read) do
+ description "Users can always read their own account"
authorize_if expr(id == ^actor(:id))
end
- # GENERAL: Other operations require permission
- # (e.g., admin reading/updating other users, admin destroying users)
+ # 3. GENERAL: Check permissions from user's role
+ # - :own_data → can UPDATE own user (scope :own via HasPermission)
+ # - :read_only → can UPDATE own user (scope :own via HasPermission)
+ # - :normal_user → can UPDATE own user (scope :own via HasPermission)
+ # - :admin → can read/create/update/destroy all users (scope :all)
policy action_type([:read, :create, :update, :destroy]) do
- description "Check permissions from user's role"
+ description "Check permissions from user's role and permission set"
authorize_if Mv.Authorization.Checks.HasPermission
end
- # DEFAULT: Forbid if no policy matched
- policy action_type([:read, :create, :update, :destroy]) do
- forbid_if always()
- end
+ # 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
end
# ...
end
```
+**Why Bypass for READ but not UPDATE?**
+
+- **READ list queries** (`Ash.read(User, actor: user)`): No record at strict_check time → HasPermission returns `{:ok, false}` → auto_filter not called → bypass with `expr()` needed ✅
+- **UPDATE operations** (`Ash.update(changeset, actor: user)`): Changeset contains record → HasPermission can evaluate `scope :own` correctly → works via HasPermission ✅
+
**Permission Matrix:**
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|--------|----------|----------|------------|-------------|-------|
-| Read own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ |
-| Update own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ |
-| Read others | ❌ | ❌ | ❌ | ❌ | ✅ |
-| Update others | ❌ | ❌ | ❌ | ❌ | ✅ |
-| Create | ❌ | ❌ | ❌ | ❌ | ✅ |
-| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ |
+| Read own | ✅ (bypass) | ✅ (bypass) | ✅ (bypass) | ✅ (bypass) | ✅ (scope :all) |
+| Update own | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :all) |
+| Read others | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
+| Update others | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
+| Create | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
+| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
+
+**Note:** This pattern is consistent with Member resource policies (bypass for READ, HasPermission for UPDATE).
### Member Resource Policies
**Location:** `lib/mv/membership/member.ex`
-**Special Case:** Users can always READ their linked member (where `id == user.member_id`).
+**Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :linked).
+
+**Key Insight:** Same pattern as User - bypass with `expr()` is needed ONLY for READ list queries. UPDATE operations work correctly via HasPermission because a changeset with record is available.
```elixir
defmodule Mv.Membership.Member do
use Ash.Resource, ...
policies do
- # SPECIAL CASE: Users can always access their linked member
- policy action_type([:read, :update]) do
- description "Users can access member linked to their account"
- authorize_if expr(user_id == ^actor(:id))
+ # 1. SPECIAL CASE: Users can always READ their linked member
+ # Bypass needed for list queries (expr() triggers auto_filter in Ash)
+ # UPDATE is handled by HasPermission below (scope :linked works with changesets)
+ bypass action_type(:read) do
+ description "Users can always read member linked to their account"
+ authorize_if expr(id == ^actor(:member_id))
end
- # GENERAL: Check permissions from role
+ # 2. GENERAL: Check permissions from role
+ # - :own_data → can UPDATE linked member (scope :linked via HasPermission)
+ # - :read_only → can READ all members (scope :all), no update permission
+ # - :normal_user → can CRUD all members (scope :all)
+ # - :admin → can CRUD all members (scope :all)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role"
authorize_if Mv.Authorization.Checks.HasPermission
end
- # DEFAULT: Forbid
- policy action_type([:read, :create, :update, :destroy]) do
- forbid_if always()
- end
+ # 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
end
# Custom validation for email editing (see Special Cases section)
@@ -956,6 +1034,11 @@ defmodule Mv.Membership.Member do
end
```
+**Why Bypass for READ but not UPDATE?**
+
+- **READ list queries**: No record at strict_check time → bypass with `expr(id == ^actor(:member_id))` needed for auto_filter ✅
+- **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :linked` correctly ✅
+
**Permission Matrix:**
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
@@ -1555,7 +1638,7 @@ end
**Navbar with conditional links:**
```heex
-
+