Concept for Groups #354

Closed
simon wants to merge 118 commits from feature/concept-groups into main
166 changed files with 16396 additions and 10033 deletions

View file

@ -8,6 +8,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08)
- Four hardcoded permission sets: `own_data`, `read_only`, `normal_user`, `admin`
- Database-backed roles with permission set references
- Member resource policies with scope filtering (`:own`, `:linked`, `:all`)
- Authorization checks via `Mv.Authorization.Checks.HasPermission`
- System role protection (critical roles cannot be deleted)
- Role management UI at `/admin/roles`
- **Membership Fees System** - Full implementation
- Membership fee types with intervals (monthly, quarterly, half_yearly, yearly)
- Individual billing cycles per member with payment status tracking
- Cycle generation and regeneration
- Global membership fee settings
- UI components for fee management
- **Global Settings Management** - Singleton settings resource
- Club name configuration (with environment variable support)
- Member field visibility settings
- Membership fee default settings
- **Sidebar Navigation** - Replaced navbar with standard-compliant sidebar (#260, 2026-01-12)
- **CSV Import Templates** - German and English templates (#329, 2026-01-13)
- Template files in `priv/static/templates/`
- CSV specification documented
- User-Member linking with fuzzy search autocomplete (#168)
- PostgreSQL trigram-based member search with typo tolerance
- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support
@ -19,8 +40,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- German/English translations
- Docker secrets support via `_FILE` environment variables for all sensitive configuration (SECRET_KEY_BASE, TOKEN_SIGNING_SECRET, OIDC_CLIENT_SECRET, DATABASE_URL, DATABASE_PASSWORD)
### Changed
- **Actor Handling Refactoring** (2026-01-09)
- Standardized actor access with `current_actor/1` helper function
- `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** (2026-01-13)
- Replaced `Ash.read!` with proper error handling in LiveViews
- Consistent flash message handling for authorization errors
- Early return patterns for unauthenticated users
### Fixed
- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
- Relationship data extraction from Ash manage_relationship during validation
- Copy button count now shows only visible selected members when filtering
- Language headers in German `.po` files (corrected from "en" to "de")
- Critical deny-filter bug in authorization system (2026-01-08)
- HasPermission auto_filter and strict_check implementation (2026-01-08)

View file

@ -83,7 +83,18 @@ lib/
│ ├── member.ex # Member resource
│ ├── custom_field_value.ex # Custom field value resource
│ ├── custom_field.ex # CustomFieldValue type resource
│ ├── setting.ex # Global settings (singleton resource)
│ └── email.ex # Email custom type
├── membership_fees/ # MembershipFees domain
│ ├── membership_fees.ex # Domain definition
│ ├── membership_fee_type.ex # Membership fee type resource
│ ├── membership_fee_cycle.ex # Membership fee cycle resource
│ └── changes/ # Ash changes for membership fees
├── mv/authorization/ # Authorization domain
│ ├── authorization.ex # Domain definition
│ ├── role.ex # Role resource
│ ├── permission_sets.ex # Hardcoded permission sets
│ └── checks/ # Authorization checks
├── mv/ # Core application modules
│ ├── accounts/ # Domain-specific logic
│ │ └── user/
@ -96,6 +107,11 @@ lib/
│ ├── membership/ # Domain-specific logic
│ │ └── member/
│ │ └── validations/
│ ├── membership_fees/ # Membership fee business logic
│ │ ├── cycle_generator.ex # Cycle generation algorithm
│ │ └── calendar_cycles.ex # Calendar cycle calculations
│ ├── helpers.ex # Shared helper functions (ash_actor_opts)
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix)
│ ├── application.ex # OTP application
│ ├── mailer.ex # Email mailer
│ ├── release.ex # Release tasks
@ -107,7 +123,7 @@ lib/
│ │ ├── table_components.ex
│ │ ├── layouts.ex
│ │ └── layouts/ # Layout templates
│ │ ├── navbar.ex
│ │ ├── sidebar.ex
│ │ └── root.html.heex
│ ├── controllers/ # HTTP controllers
│ │ ├── auth_controller.ex
@ -116,6 +132,11 @@ lib/
│ │ ├── error_html.ex
│ │ ├── error_json.ex
│ │ └── page_html/
│ ├── helpers/ # Web layer helper modules
│ │ ├── member_helpers.ex # Member display utilities
│ │ ├── membership_fee_helpers.ex # Membership fee formatting
│ │ ├── date_formatter.ex # Date formatting utilities
│ │ └── field_type_formatter.ex # Field type display formatting
│ ├── live/ # LiveView modules
│ │ ├── components/ # LiveView-specific components
│ │ │ ├── search_bar_component.ex
@ -123,11 +144,16 @@ lib/
│ │ ├── member_live/ # Member CRUD LiveViews
│ │ ├── custom_field_value_live/ # CustomFieldValue CRUD LiveViews
│ │ ├── custom_field_live/
│ │ └── user_live/ # User management LiveViews
│ │ ├── user_live/ # User management LiveViews
│ │ ├── role_live/ # Role management LiveViews
│ │ ├── membership_fee_type_live/ # Membership fee type LiveViews
│ │ ├── membership_fee_settings_live.ex # Membership fee settings
│ │ ├── global_settings_live.ex # Global settings
│ │ └── contribution_type_live/ # Contribution types (mock-up)
│ ├── auth_overrides.ex # AshAuthentication overrides
│ ├── endpoint.ex # Phoenix endpoint
│ ├── gettext.ex # I18n configuration
│ ├── live_helpers.ex # LiveView helpers
│ ├── live_helpers.ex # LiveView lifecycle hooks and helpers
│ ├── live_user_auth.ex # LiveView authentication
│ ├── router.ex # Application router
│ └── telemetry.ex # Telemetry configuration
@ -176,7 +202,7 @@ test/
**Module Naming:**
- **Modules:** Use `PascalCase` with full namespace (e.g., `Mv.Accounts.User`)
- **Domains:** Top-level domains are `Mv.Accounts` and `Mv.Membership`
- **Domains:** Top-level domains are `Mv.Accounts`, `Mv.Membership`, `Mv.MembershipFees`, and `Mv.Authorization`
- **Resources:** Resource modules should be singular nouns (e.g., `Member`, not `Members`)
- **Context functions:** Use `snake_case` and verb-first naming (e.g., `create_user`, `list_members`)
@ -615,7 +641,85 @@ def card(assigns) do
end
```
### 3.3 Ash Framework
### 3.3 System Actor Pattern
**When to Use System Actor:**
Some operations must always run regardless of user permissions. These are **systemic operations** that are mandatory side effects:
- **Email synchronization** (Member ↔ User)
- **Email uniqueness validation** (data integrity requirement)
- **Cycle generation** (if defined as mandatory side effect)
- **Background jobs**
- **Seeds**
**Implementation:**
Use `Mv.Helpers.SystemActor.get_system_actor/0` for all systemic operations:
```elixir
# Good - Email sync uses system actor
def get_linked_member(user) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.get(Mv.Membership.Member, id, opts) do
{:ok, member} -> member
{:error, _} -> nil
end
end
# Bad - Using user actor for systemic operation
def get_linked_member(user, actor) do
opts = Helpers.ash_actor_opts(actor) # May fail if user lacks permissions!
# ...
end
```
**System Actor Details:**
- System actor is a user with admin role (email: "system@mila.local")
- Cached in Agent for performance
- Falls back to admin user from seeds if system user doesn't exist
- Should NEVER be used for user-initiated actions (only systemic operations)
**User Mode vs System Mode:**
- **User Mode**: User-initiated actions use the actual user actor, policies are enforced
- **System Mode**: Systemic operations use system actor, bypass user permissions
**Authorization Bootstrap Patterns:**
Two mechanisms exist for bypassing standard authorization:
1. **system_actor** (systemic operations) - Admin user for operations that must always succeed
```elixir
# Good: Systemic operation
system_actor = SystemActor.get_system_actor()
Ash.read(Member, actor: system_actor)
# Bad: User-initiated action
# Never use system_actor for user-initiated actions!
```
2. **authorize?: false** (bootstrap only) - Skips policies for circular dependencies
```elixir
# Good: Bootstrap (seeds, SystemActor loading)
Accounts.create_user!(%{email: admin_email}, authorize?: false)
# Bad: User-initiated action
Ash.destroy(member, authorize?: false) # Never do this!
```
**Decision Guide:**
- Use **system_actor** for email sync, cycle generation, validations, and test fixtures
- Use **authorize?: false** only for bootstrap (seeds, circular dependencies)
- Always document why `authorize?: false` is necessary
- **Note:** NoActor bypass was removed to prevent masking authorization bugs in tests
**See also:** `docs/roles-and-permissions-architecture.md` (Authorization Bootstrap Patterns section)
### 3.4 Ash Framework
**Resource Definition Best Practices:**
@ -818,14 +922,17 @@ end
```heex
<!-- Leverage DaisyUI component classes -->
<div class="navbar bg-base-100">
<div class="navbar-start">
<a class="btn btn-ghost text-xl">Mila</a>
<!-- Note: Navbar has been replaced with Sidebar (see lib/mv_web/components/layouts/sidebar.ex) -->
<div class="drawer lg:drawer-open">
<input id="drawer-toggle" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<!-- Page content -->
</div>
<div class="navbar-end">
<.link navigate={~p"/members"} class="btn btn-primary">
Members
</.link>
<div class="drawer-side">
<label for="drawer-toggle" class="drawer-overlay"></label>
<aside class="w-64 min-h-full bg-base-200">
<!-- Sidebar content -->
</aside>
</div>
</div>
```
@ -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:**

View file

@ -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)

View file

@ -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.

View file

@ -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**
**End of Implementation Plan**

View file

@ -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)

View file

@ -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).
'''
}

View file

@ -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)

View file

@ -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 |
---

1515
docs/groups-architecture.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -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
---

View file

@ -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`

View file

@ -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
<!-- lib/mv_web/components/layouts/navbar.html.heex -->
<!-- Note: Navbar has been replaced with Sidebar (lib/mv_web/components/layouts/sidebar.ex) -->
<nav class="navbar">
<!-- Always visible -->
<.link navigate="/">Home</.link>
@ -1651,17 +1734,21 @@ end
**Implementation:**
Policy in `User` resource places this check BEFORE the general `HasPermission` check:
Policy in `User` resource uses a two-tier approach:
- **READ**: Bypass with `expr()` for list queries (auto_filter)
- **UPDATE**: HasPermission with `scope :own` (evaluates PermissionSets)
```elixir
policies do
# SPECIAL CASE: Takes precedence over role permissions
policy action_type([:read, :update]) do
description "Users can always read and update their own account"
# SPECIAL CASE: Users can always READ their own account
# Bypass needed for list queries (expr() triggers auto_filter in Ash)
bypass action_type(:read) do
description "Users can always read their own account"
authorize_if expr(id == ^actor(:id))
end
# GENERAL: For other operations (e.g., admin reading other users)
# GENERAL: Check permissions from user's role
# UPDATE uses scope :own from PermissionSets (all sets grant User.update :own)
policy action_type([:read, :create, :update, :destroy]) do
authorize_if Mv.Authorization.Checks.HasPermission
end
@ -1669,10 +1756,53 @@ end
```
**Why this works:**
- Ash evaluates policies top-to-bottom
- First matching policy wins
- Special case catches own-account access before checking permissions
- Even a user with `own_data` (no admin permissions) can update their credentials
- READ bypass handles list queries correctly (auto_filter)
- UPDATE is handled by HasPermission with `scope :own` from PermissionSets
- All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) grant `User.update :own`
- Even a user with `read_only` (read-only for member data) can update their own credentials
**Important:** UPDATE is NOT an unverrückbarer Spezialfall (hardcoded bypass). It is controlled by PermissionSets. If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials. See "User Credentials: Why read_only Can Still Update" below for details.
### 1a. User Credentials: Why read_only Can Still Update
**Question:** If `read_only` means "read-only", why can users with this permission set still update their own credentials?
**Answer:** The `read_only` permission set refers to **member data**, NOT user credentials. All permission sets grant `User.update :own` to allow password changes and profile updates.
**Implementation Details:**
1. **UPDATE is controlled by PermissionSets**, not a hardcoded bypass
2. **All 4 permission sets** (`:own_data`, `:read_only`, `:normal_user`, `:admin`) explicitly grant:
```elixir
%{resource: "User", action: :update, scope: :own, granted: true}
```
3. **HasPermission** evaluates `scope :own` for UPDATE operations (when a changeset with record is present)
4. **No special bypass** is needed for UPDATE - it works correctly via HasPermission
**Why This Design?**
- **Flexibility:** Permission sets can be modified to change UPDATE behavior
- **Consistency:** All permissions are centralized in PermissionSets
- **Clarity:** The name "read_only" refers to member data, not user credentials
- **Maintainability:** Easy to see what each role can do in PermissionSets module
**Warning:** 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:**
```elixir
# In PermissionSets.get_permissions(:read_only)
resources: [
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
# Member: Can read all members, no modifications
%{resource: "Member", action: :read, scope: :all, granted: true},
# Note: No Member.update permission - this is the "read_only" part
]
```
### 2. Linked Member Email Editing
@ -2483,8 +2613,209 @@ iex> MvWeb.Authorization.can_access_page?(user, "/members/new")
---
## Authorization Bootstrap Patterns
This section clarifies three different mechanisms for bypassing standard authorization, their purposes, and when to use each.
### Overview
The codebase uses two authorization bypass mechanisms:
1. **system_actor** - Admin user for systemic operations
2. **authorize?: false** - Bootstrap bypass for circular dependencies
**Both are necessary and serve different purposes.**
**Note:** The NoActor bypass has been removed to prevent masking authorization bugs in tests. All tests now explicitly use `system_actor` for authorization.
### 1. System Actor
**Purpose:** Admin user for systemic operations that must always succeed regardless of user permissions.
**Implementation:**
```elixir
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# => %User{email: "system@mila.local", role: %{permission_set_name: "admin"}}
```
**Security:**
- No password (hashed_password = nil) → cannot login
- No OIDC ID (oidc_id = nil) → cannot authenticate
- Cached in Agent for performance
- Created automatically in test environment if missing
**Use Cases:**
- **Email synchronization** (User ↔ Member email sync)
- **Email uniqueness validation** (cross-resource checks)
- **Cycle generation** (mandatory side effect)
- **OIDC account linking** (user not yet logged in)
- **Cross-resource validations** (must work regardless of actor)
**Example:**
```elixir
def get_linked_member(%{member_id: id}) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
# Email sync must work regardless of user permissions
Ash.get(Mv.Membership.Member, id, opts)
end
```
**Why not `authorize?: false`?**
- System actor is explicit (clear intent: "systemic operation")
- Policies are evaluated (with admin rights)
- Audit trail (actor.email = "system@mila.local")
- Consistent authorization flow
- Testable
### 2. authorize?: false
**Purpose:** Skip policies for bootstrap scenarios with circular dependencies.
**Use Cases:**
**1. Seeds** - No admin exists yet to use as actor:
```elixir
# priv/repo/seeds.exs
Accounts.create_user!(%{email: admin_email},
authorize?: false # Bootstrap: no admin exists yet
)
```
**2. SystemActor Bootstrap** - Chicken-and-egg problem:
```elixir
# lib/mv/helpers/system_actor.ex
defp find_user_by_email(email) do
# Need to find system actor, but loading requires system actor!
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> Ash.read_one(authorize?: false) # Bootstrap only
end
```
**3. Actor.ensure_loaded** - Circular dependency:
```elixir
# lib/mv/authorization/actor.ex
defp load_role(actor) do
# Actor needs role for authorization,
# but loading role requires authorization!
Ash.load(actor, :role, authorize?: false) # Bootstrap only
end
```
**4. assign_default_role** - User creation:
```elixir
# User doesn't have actor during creation
Mv.Authorization.Role
|> Ash.Query.filter(name == "Mitglied")
|> Ash.read_one(authorize?: false) # Bootstrap only
```
**Security:**
- Very powerful - skips ALL policies
- Use sparingly and document every usage
- Only for bootstrap scenarios
- All current usages are legitimate
### Comparison
| Aspect | system_actor | authorize?: false |
|--------|--------------|-------------------|
| **Environment** | All | All |
| **Actor** | Admin user | nil |
| **Policies** | Evaluated | Skipped |
| **Audit Trail** | Yes (system@mila.local) | No |
| **Use Case** | Systemic operations, test fixtures | Bootstrap |
| **Explicit?** | Function call | Query option |
### Decision Guide
**Use system_actor when:**
- ✅ Systemic operation must always succeed
- ✅ Email synchronization
- ✅ Cycle generation
- ✅ Cross-resource validations
- ✅ OIDC flows (user not logged in)
**Use authorize?: false when:**
- ✅ Bootstrap scenario (seeds)
- ✅ Circular dependency (SystemActor bootstrap, Actor.ensure_loaded)
- ⚠️ Document with comment explaining why
**DON'T:**
- ❌ Use `authorize?: false` for user-initiated actions
- ❌ Use `authorize?: false` when `system_actor` would work
- ❌ Skip actor in tests (always use system_actor)
### The Circular Dependency Problem
**SystemActor Bootstrap:**
```
SystemActor.get_system_actor()
↓ calls find_user_by_email()
↓ needs to query User
↓ User policies require actor
↓ but we're loading the actor!
Solution: authorize?: false for bootstrap query
```
**Actor.ensure_loaded:**
```
Authorization check (HasPermission)
↓ needs actor.role.permission_set_name
↓ but role is %Ash.NotLoaded{}
↓ load role with Ash.load(actor, :role)
↓ but loading requires authorization
↓ which needs actor.role!
Solution: authorize?: false for role load
```
**Why this is safe:**
- Actor is loading their OWN data (role relationship)
- Actor already passed authentication boundary
- Role contains no sensitive data (just permission_set reference)
- Alternative (denormalize permission_set_name) adds complexity
### Examples
**Good - system_actor for systemic operation:**
```elixir
defp check_if_email_used(email) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
# Validation must work regardless of current actor
Ash.read(User, opts)
end
```
**Good - authorize?: false for bootstrap:**
```elixir
# Seeds - no admin exists yet
Accounts.create_user!(%{email: admin_email}, authorize?: false)
```
**Bad - authorize?: false for user action:**
```elixir
# WRONG: Bypasses all policies for user-initiated action
def delete_member(member) do
Ash.destroy(member, authorize?: false) # ❌ Don't do this!
end
# CORRECT: Use actor
def delete_member(member, actor) do
Ash.destroy(member, actor: actor) # ✅ Policies enforced
end
```
---
**Document Version:** 2.0 (Clean Rewrite)
**Last Updated:** 2025-01-13
**Last Updated:** 2026-01-23
**Implementation Status:** ✅ Complete (2026-01-08)
**Status:** Ready for Implementation
**Changes from V1:**
@ -2498,6 +2829,10 @@ iex> MvWeb.Authorization.can_access_page?(user, "/members/new")
- Added comprehensive security section
- Enhanced edge case documentation
**Changes from V2.0:**
- Added "Authorization Bootstrap Patterns" section explaining system_actor and authorize?: false
- Removed NoActor bypass (all tests now use system_actor for explicit authorization)
---
**End of Architecture Document**

View file

@ -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
- [Architecture](./roles-and-permissions-architecture.md) - Technical specification
@ -523,61 +524,69 @@ Add authorization policies to the Member resource using the new `HasPermission`
**Size:** M (2 days)
**Dependencies:** #6 (HasPermission check)
**Can work in parallel:** Yes (parallel with #7, #9, #10)
**Assignable to:** Backend Developer
**Assignable to:** Backend Developer
**Status:** ✅ **COMPLETED**
**Description:**
Add authorization policies to the User resource. Special case: Users can always read/update their own credentials.
Add authorization policies to the User resource. Users can always read their own credentials (via bypass), and update their own credentials (via HasPermission with scope :own).
**Implementation Pattern:**
Following the same pattern as Member resource:
- **Bypass for READ** - Handles list queries (auto_filter)
- **HasPermission for UPDATE** - Handles updates with scope :own
**Tasks:**
1. Open `lib/mv/accounts/user.ex`
2. Add `policies` block
3. Add special policy: Allow user to always access their own account (before general policy)
1. ✅ Open `lib/accounts/user.ex`
2. ✅ Add `policies` block
3. ✅ Add AshAuthentication bypass (registration/login without actor)
4. ✅ ~~Add NoActor bypass (test environment only)~~ **REMOVED** - NoActor bypass was removed to prevent masking authorization bugs. All tests now use `system_actor`.
5. ✅ Add bypass for READ: Allow user to always read their own account
```elixir
policy action_type([:read, :update]) do
bypass action_type(:read) do
description "Users can always read their own account"
authorize_if expr(id == ^actor(:id))
end
```
4. Add general policy: Check HasPermission for all actions
5. Ensure :destroy is admin-only (via HasPermission)
6. Preload :role relationship for actor
6. ✅ Add general policy: Check HasPermission for all actions (including UPDATE with scope :own)
7. ✅ Ensure :destroy is admin-only (via HasPermission)
8. ✅ Preload :role relationship for actor in tests
**Policy Order:**
1. Allow user to read/update own account (id == actor.id)
2. Check HasPermission (for admin operations)
3. Default: Forbid
1. ✅ AshAuthentication bypass (registration/login)
2. ✅ Bypass: User can READ own account (id == actor.id)
3. ✅ HasPermission: General permission check (UPDATE uses scope :own, admin uses scope :all)
4. ✅ Default: Ash implicitly forbids (fail-closed)
**Note:** NoActor bypass was removed. All tests now use `system_actor` for authorization.
**Why Bypass for READ but not UPDATE?**
- **READ list queries**: No record at strict_check time → bypass with `expr()` needed for auto_filter ✅
- **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :own` correctly ✅
This ensures `scope :own` in PermissionSets is actually used (not redundant).
**Acceptance Criteria:**
- [ ] User can always read/update own credentials
- [ ] Only admin can read/update other users
- [ ] Only admin can destroy users
- [ ] Policy order is correct
- [ ] Actor preloads :role relationship
- ✅ User can always read own credentials (via bypass)
- ✅ User can always update own credentials (via HasPermission with scope :own)
- ✅ Only admin can read/update other users (scope :all)
- ✅ Only admin can destroy users (scope :all)
- ✅ Policy order is correct (AshAuth → Bypass READ → HasPermission)
- ✅ Actor preloads :role relationship
- ✅ All tests pass (30/31 pass, 1 skipped)
**Test Strategy (TDD):**
**Own Data Tests (All Roles):**
- User with :own_data can read own user record
- User with :own_data can update own email/password
- User with :own_data cannot read other users
- User with :read_only can read own data
- User with :normal_user can read own data
- Verify special policy takes precedence
**Admin Tests:**
- Admin can read all users
- Admin can update any user's credentials
- Admin can destroy users
- Admin has unrestricted access
**Forbidden Tests:**
- Non-admin cannot read other users
- Non-admin cannot update other users
- Non-admin cannot destroy users
**Test Results:**
**Test File:** `test/mv/accounts/user_policies_test.exs`
- ✅ 31 tests total: 30 passing, 1 skipped (AshAuthentication edge case)
- ✅ Tests for all 4 permission sets: own_data, read_only, normal_user, admin
- ✅ Tests for AshAuthentication bypass (registration/login)
- ✅ Tests use system_actor for authorization (NoActor bypass removed)
- ✅ Tests verify scope :own is used for UPDATE (not redundant)
---

View file

@ -3,8 +3,8 @@
**Project:** Mila - Membership Management System
**Feature:** Role-Based Access Control (RBAC) with Hardcoded Permission Sets
**Version:** 2.0
**Last Updated:** 2025-11-13
**Status:** Architecture Design - MVP Approach
**Last Updated:** 2026-01-13
**Status:** ✅ Implemented (2026-01-08, PR #346, closes #345)
---

View file

@ -1,747 +0,0 @@
# Sidebar Analysis - Current State
**Erstellt:** 2025-12-16
**Status:** Analyse für Neuimplementierung
**Autor:** Cursor AI Assistant
---
## Executive Summary
Die aktuelle Sidebar-Implementierung verwendet **nicht existierende Custom-CSS-Variants** (`is-drawer-close:` und `is-drawer-open:`), was zu einer defekten Implementierung führt. Die Sidebar ist strukturell basierend auf DaisyUI's Drawer-Komponente, aber die responsive und state-basierte Funktionalität ist nicht funktionsfähig.
**Kritisches Problem:** Die im Code verwendeten Variants `is-drawer-close:*` und `is-drawer-open:*` sind **nicht in Tailwind konfiguriert**, was bedeutet, dass diese Klassen beim Build ignoriert werden.
---
## 1. Dateien-Übersicht
### 1.1 Hauptdateien
| Datei | Zweck | Zeilen | Status |
|-------|-------|--------|--------|
| `lib/mv_web/components/layouts/sidebar.ex` | Sidebar-Komponente (Elixir) | 198 | ⚠️ Verwendet nicht existierende Variants |
| `lib/mv_web/components/layouts/navbar.ex` | Navbar mit Sidebar-Toggle | 48 | ✅ Funktional |
| `lib/mv_web/components/layouts.ex` | Layout-Wrapper mit Drawer | 121 | ✅ Funktional |
| `assets/js/app.js` | JavaScript für Sidebar-Interaktivität | 272 | ✅ Umfangreiche Accessibility-Logik |
| `assets/css/app.css` | CSS-Konfiguration | 103 | ⚠️ Keine Drawer-Variants definiert |
| `assets/tailwind.config.js` | Tailwind-Konfiguration | 75 | ⚠️ Keine Drawer-Variants definiert |
### 1.2 Verwandte Dateien
- `lib/mv_web/components/layouts/root.html.heex` - Root-Layout (minimal, keine Sidebar-Logik)
- `priv/static/images/logo.svg` - Logo (wird vermutlich für Sidebar benötigt)
---
## 2. Aktuelle Struktur
### 2.1 HTML-Struktur (DaisyUI Drawer Pattern)
```html
<!-- In layouts.ex -->
<div class="drawer">
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<!-- Navbar mit Toggle-Button -->
<navbar with sidebar-toggle button />
<!-- Hauptinhalt -->
<main>...</main>
</div>
<!-- Sidebar -->
<div class="drawer-side">
<button class="drawer-overlay" onclick="close drawer"></button>
<nav id="main-sidebar">
<!-- Navigation Items -->
</nav>
</div>
</div>
```
**Bewertung:** ✅ Korrekte DaisyUI Drawer-Struktur
### 2.2 Sidebar-Komponente (`sidebar.ex`)
**Struktur:**
```elixir
defmodule MvWeb.Layouts.Sidebar do
attr :current_user, :map
attr :club_name, :string
def sidebar(assigns) do
# Rendert Sidebar mit Navigation, Locale-Selector, Theme-Toggle, User-Menu
end
end
```
**Hauptelemente:**
1. **Drawer Overlay** - Button zum Schließen (Mobile)
2. **Navigation Container** (`<nav id="main-sidebar">`)
3. **Menü-Items** - Members, Users, Contributions (nested), Settings
4. **Footer-Bereich** - Locale-Selector, Theme-Toggle, User-Menu
---
## 3. Custom CSS Variants - KRITISCHES PROBLEM
### 3.1 Verwendete Variants im Code
Die Sidebar verwendet folgende Custom-Variants **extensiv**:
```elixir
# Beispiele aus sidebar.ex
"is-drawer-close:overflow-visible"
"is-drawer-close:w-14 is-drawer-open:w-64"
"is-drawer-close:hidden"
"is-drawer-close:tooltip is-drawer-close:tooltip-right"
"is-drawer-close:w-auto"
"is-drawer-close:justify-center"
"is-drawer-close:dropdown-end"
```
**Gefundene Verwendungen:**
- `is-drawer-close:` - 13 Instanzen in sidebar.ex
- `is-drawer-open:` - 1 Instanz in sidebar.ex
### 3.2 Definition der Variants
**❌ NICHT GEFUNDEN in:**
- `assets/css/app.css` - Enthält nur `phx-*-loading` Variants
- `assets/tailwind.config.js` - Enthält nur `phx-*-loading` Variants
**Fazit:** Diese Variants existieren **nicht** und werden beim Tailwind-Build **ignoriert**!
### 3.3 Vorhandene Variants
Nur folgende Custom-Variants sind tatsächlich definiert:
```css
/* In app.css (Tailwind CSS 4.x Syntax) */
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
```
```javascript
/* In tailwind.config.js (Tailwind 3.x Kompatibilität) */
plugin(({addVariant}) => addVariant("phx-click-loading", [...])),
plugin(({addVariant}) => addVariant("phx-submit-loading", [...])),
plugin(({addVariant}) => addVariant("phx-change-loading", [...])),
```
---
## 4. JavaScript-Implementierung
### 4.1 Übersicht
Die JavaScript-Implementierung ist **sehr umfangreich** und fokussiert auf Accessibility:
**Datei:** `assets/js/app.js` (Zeilen 106-270)
**Hauptfunktionalitäten:**
1. ✅ Tabindex-Management für fokussierbare Elemente
2. ✅ ARIA-Attribut-Management (`aria-expanded`)
3. ✅ Keyboard-Navigation (Enter, Space, Escape)
4. ✅ Focus-Management beim Öffnen/Schließen
5. ✅ Dropdown-Integration
### 4.2 Wichtige JavaScript-Funktionen
#### 4.2.1 Tabindex-Management
```javascript
const updateSidebarTabIndex = (isOpen) => {
const allFocusableElements = sidebar.querySelectorAll(
'a[href], button, select, input:not([type="hidden"]), [tabindex]'
)
allFocusableElements.forEach(el => {
if (isOpen) {
// Make focusable when open
el.removeAttribute('tabindex')
} else {
// Remove from tab order when closed
el.setAttribute('tabindex', '-1')
}
})
}
```
**Zweck:** Verhindert, dass Nutzer mit Tab zu unsichtbaren Sidebar-Elementen springen können.
#### 4.2.2 ARIA-Expanded Management
```javascript
const updateAriaExpanded = () => {
const isOpen = drawerToggle.checked
sidebarToggle.setAttribute("aria-expanded", isOpen.toString())
}
```
**Zweck:** Informiert Screen-Reader über den Sidebar-Status.
#### 4.2.3 Focus-Management
```javascript
const getFirstFocusableElement = () => {
// Priority: navigation link > other links > other focusable
const firstNavLink = sidebar.querySelector('a[href][role="menuitem"]')
// ... fallback logic
}
// On open: focus first element
// On close: focus toggle button
```
**Zweck:** Logische Fokus-Reihenfolge für Keyboard-Navigation.
#### 4.2.4 Keyboard-Shortcuts
```javascript
// ESC to close
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && drawerToggle.checked) {
drawerToggle.checked = false
sidebarToggle.focus()
}
})
// Enter/Space on toggle button
sidebarToggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
// Toggle drawer and manage focus
}
})
```
### 4.3 LiveView Hooks
**Definierte Hooks:**
```javascript
Hooks.CopyToClipboard = { ... } // Clipboard-Funktionalität
Hooks.ComboBox = { ... } // Dropdown-Prävention bei Enter
```
**Sidebar-spezifisch:** Keine Hooks, nur native DOM-Events.
---
## 5. DaisyUI Dependencies
### 5.1 Verwendete DaisyUI-Komponenten
| Komponente | Verwendung | Klassen |
|------------|-----------|---------|
| **Drawer** | Basis-Layout | `drawer`, `drawer-toggle`, `drawer-side`, `drawer-content`, `drawer-overlay` |
| **Menu** | Navigation | `menu`, `menu-title`, `w-64` |
| **Button** | Toggle, User-Menu | `btn`, `btn-ghost`, `btn-square`, `btn-circle` |
| **Avatar** | User-Menu | `avatar`, `avatar-placeholder` |
| **Dropdown** | User-Menu | `dropdown`, `dropdown-top`, `dropdown-end`, `dropdown-content` |
| **Tooltip** | Icon-Tooltips | `tooltip`, `tooltip-right` (via `data-tip`) |
| **Select** | Locale-Selector | `select`, `select-sm` |
| **Toggle** | Theme-Switch | `toggle`, `theme-controller` |
### 5.2 Standard Tailwind-Klassen
**Layout:**
- `flex`, `flex-col`, `items-start`, `justify-center`
- `gap-2`, `gap-4`, `p-4`, `mt-auto`, `w-full`, `w-64`, `min-h-full`
**Sizing:**
- `size-4`, `size-5`, `w-12`, `w-52`
**Colors:**
- `bg-base-100`, `bg-base-200`, `text-neutral-content`
**Typography:**
- `text-lg`, `text-sm`, `font-bold`
**Accessibility:**
- `sr-only`, `focus:outline-none`, `focus:ring-2`, `focus:ring-primary`
---
## 6. Toggle-Button (Navbar)
### 6.1 Implementierung
**Datei:** `lib/mv_web/components/layouts/navbar.ex`
```elixir
<button
type="button"
onclick="document.getElementById('main-drawer').checked = !document.getElementById('main-drawer').checked"
aria-label={gettext("Toggle navigation menu")}
aria-expanded="false"
aria-controls="main-sidebar"
id="sidebar-toggle"
class="mr-2 btn btn-square btn-ghost"
>
<svg><!-- Layout-Panel-Left Icon --></svg>
</button>
```
**Funktionalität:**
- ✅ Togglet Drawer-Checkbox
- ✅ ARIA-Labels vorhanden
- ✅ Keyboard-accessible
- ⚠️ `aria-expanded` wird durch JavaScript aktualisiert
**Icon:** Custom SVG (Layout-Panel-Left mit Chevron-Right)
---
## 7. Responsive Verhalten
### 7.1 Aktuelles Konzept (nicht funktional)
**Versuchte Implementierung:**
- **Desktop (collapsed):** Sidebar mit 14px Breite (`is-drawer-close:w-14`)
- **Desktop (expanded):** Sidebar mit 64px Breite (`is-drawer-open:w-64`)
- **Mobile:** Overlay-Drawer (DaisyUI Standard)
### 7.2 Problem
Da die `is-drawer-*` Variants nicht existieren, gibt es **kein responsives Verhalten**:
- Die Sidebar hat immer eine feste Breite von `w-64`
- Die conditional hiding (`:hidden`, etc.) funktioniert nicht
- Tooltips werden nicht conditional angezeigt
---
## 8. Accessibility-Features
### 8.1 Implementierte Features
| Feature | Status | Implementierung |
|---------|--------|-----------------|
| **ARIA Labels** | ✅ | Alle interaktiven Elemente haben Labels |
| **ARIA Roles** | ✅ | `menubar`, `menuitem`, `menu`, `button` |
| **ARIA Expanded** | ✅ | Wird durch JS dynamisch gesetzt |
| **ARIA Controls** | ✅ | Toggle → Sidebar verknüpft |
| **Keyboard Navigation** | ✅ | Enter, Space, Escape, Tab |
| **Focus Management** | ✅ | Logische Focus-Reihenfolge |
| **Tabindex Management** | ✅ | Verhindert Focus auf hidden Elements |
| **Screen Reader Only** | ✅ | `.sr-only` für visuelle Labels |
| **Focus Indicators** | ✅ | `focus:ring-2 focus:ring-primary` |
| **Skip Links** | ❌ | Nicht vorhanden |
### 8.2 Accessibility-Score
**Geschätzt:** 90/100 (WCAG 2.1 Level AA konform)
**Verbesserungspotenzial:**
- Skip-Link zur Hauptnavigation hinzufügen
- High-Contrast-Mode testen
---
## 9. Menü-Struktur
### 9.1 Navigation Items
```
📋 Main Menu
├── 👥 Members (/members)
├── 👤 Users (/users)
├── 💰 Contributions (collapsed submenu)
│ ├── Plans (/contribution_types)
│ └── Settings (/contribution_settings)
└── ⚙️ Settings (/settings)
🔽 Footer Area (logged in only)
├── 🌐 Locale Selector (DE/EN)
├── 🌓 Theme Toggle (Light/Dark)
└── 👤 User Menu (Dropdown)
├── Profile (/users/:id)
└── Logout (/sign-out)
```
### 9.2 Conditional Rendering
**Nicht eingeloggt:**
- Sidebar ist leer (nur Struktur)
- Keine Menü-Items
**Eingeloggt:**
- Vollständige Navigation
- Footer-Bereich mit User-Menu
### 9.3 Nested Menu (Contributions)
**Problem:** Das Contributions-Submenu ist **immer versteckt** im collapsed State:
```elixir
<li class="is-drawer-close:hidden" role="none">
<h2 class="flex items-center gap-2 menu-title">
<.icon name="hero-currency-dollar" />
{gettext("Contributions")}
</h2>
<ul role="menu">
<li class="is-drawer-close:hidden">...</li>
<li class="is-drawer-close:hidden">...</li>
</ul>
</li>
```
Da `:hidden` nicht funktioniert, wird das Submenu immer angezeigt.
---
## 10. Theme-Funktionalität
### 10.1 Theme-Toggle
```elixir
<input
type="checkbox"
value="dark"
class="toggle theme-controller"
aria-label={gettext("Toggle dark mode")}
/>
```
**Funktionalität:**
- ✅ DaisyUI `theme-controller` - automatische Theme-Umschaltung
- ✅ Persistence durch `localStorage` (siehe root.html.heex Script)
- ✅ Icon-Wechsel (Sun ↔ Moon)
### 10.2 Definierte Themes
**Datei:** `assets/css/app.css`
1. **Light Theme** (default)
- Base: `oklch(98% 0 0)`
- Primary: `oklch(70% 0.213 47.604)` (Orange/Phoenix-inspiriert)
2. **Dark Theme**
- Base: `oklch(30.33% 0.016 252.42)`
- Primary: `oklch(58% 0.233 277.117)` (Purple/Elixir-inspiriert)
---
## 11. Locale-Funktionalität
### 11.1 Locale-Selector
```elixir
<form method="post" action="/set_locale">
<select
id="locale-select-sidebar"
name="locale"
onchange="this.form.submit()"
class="select select-sm w-full is-drawer-close:w-auto"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</form>
```
**Funktionalität:**
- ✅ POST zu `/set_locale` Endpoint
- ✅ CSRF-Token included
- ✅ Auto-Submit on change
- ✅ Accessible Label (`.sr-only`)
---
## 12. Probleme und Defekte
### 12.1 Kritische Probleme
| Problem | Schweregrad | Details |
|---------|-------------|---------|
| **Nicht existierende CSS-Variants** | 🔴 Kritisch | `is-drawer-close:*` und `is-drawer-open:*` sind nicht definiert |
| **Keine responsive Funktionalität** | 🔴 Kritisch | Sidebar verhält sich nicht wie geplant |
| **Conditional Styles funktionieren nicht** | 🔴 Kritisch | Hidden/Tooltip/Width-Changes werden ignoriert |
### 12.2 Mittlere Probleme
| Problem | Schweregrad | Details |
|---------|-------------|---------|
| **Kein Logo** | 🟡 Mittel | Logo-Element fehlt komplett in der Sidebar |
| **Submenu immer sichtbar** | 🟡 Mittel | Contributions-Submenu sollte in collapsed State versteckt sein |
| **Toggle-Icon statisch** | 🟡 Mittel | Icon ändert sich nicht zwischen expanded/collapsed |
### 12.3 Kleinere Probleme
| Problem | Schweregrad | Details |
|---------|-------------|---------|
| **Code-Redundanz** | 🟢 Klein | Variants in beiden Tailwind-Configs (3.x und 4.x) |
| **Inline-onclick Handler** | 🟢 Klein | Sollten durch JS-Events ersetzt werden |
| **Keine Skip-Links** | 🟢 Klein | Accessibility-Verbesserung |
---
## 13. Abhängigkeiten
### 13.1 Externe Abhängigkeiten
| Dependency | Version | Verwendung |
|------------|---------|------------|
| **DaisyUI** | Latest (vendor) | Drawer, Menu, Button, etc. |
| **Tailwind CSS** | 4.0.9 | Utility-Klassen |
| **Heroicons** | v2.2.0 | Icons in Navigation |
| **Phoenix LiveView** | ~> 1.1.0 | Backend-Integration |
### 13.2 Interne Abhängigkeiten
| Modul | Verwendung |
|-------|-----------|
| `MvWeb.Gettext` | Internationalisierung |
| `Mv.Membership.get_settings()` | Club-Name abrufen |
| `MvWeb.CoreComponents` | Icons, Links |
---
## 14. Code-Qualität
### 14.1 Positives
- ✅ **Sehr gute Accessibility-Implementierung**
- ✅ **Saubere Modulstruktur** (Separation of Concerns)
- ✅ **Gute Dokumentation** (Moduledocs, Attribute docs)
- ✅ **Internationalisierung** vollständig implementiert
- ✅ **ARIA-Best-Practices** befolgt
- ✅ **Keyboard-Navigation** umfassend
### 14.2 Verbesserungsbedarf
- ❌ **Broken CSS-Variants** (Hauptproblem)
- ❌ **Fehlende Tests** (keine Component-Tests gefunden)
- ⚠️ **Inline-JavaScript** in onclick-Attributen
- ⚠️ **Magic-IDs** (`main-drawer`, `sidebar-toggle`) hardcoded
- ⚠️ **Komplexe JavaScript-Logik** ohne Dokumentation
---
## 15. Empfehlungen für Neuimplementierung
### 15.1 Sofort-Maßnahmen
1. **CSS-Variants entfernen**
- Alle `is-drawer-close:*` und `is-drawer-open:*` entfernen
- Durch Standard-Tailwind oder DaisyUI-Mechanismen ersetzen
2. **Logo hinzufügen**
- Logo-Element als erstes Element in Sidebar
- Konsistente Größe (32px / size-8)
3. **Toggle-Icon implementieren**
- Icon-Swap zwischen Chevron-Left und Chevron-Right
- Nur auf Desktop sichtbar
### 15.2 Architektur-Entscheidungen
1. **Responsive Strategie:**
- **Mobile:** Standard DaisyUI Drawer (Overlay)
- **Desktop:** Persistent Sidebar mit fester Breite
- **Kein collapsing auf Desktop** (einfacher, wartbarer)
2. **State-Management:**
- Drawer-Checkbox für Mobile
- Keine zusätzlichen Custom-Variants
- Standard DaisyUI-Mechanismen verwenden
3. **JavaScript-Refactoring:**
- Hooks statt inline-onclick
- Dokumentierte Funktionen
- Unit-Tests für kritische Logik
### 15.3 Prioritäten
**High Priority:**
1. CSS-Variants-Problem lösen
2. Logo implementieren
3. Basic responsive Funktionalität
**Medium Priority:**
4. Toggle-Icon implementieren
5. Tests schreiben
6. JavaScript refactoren
**Low Priority:**
7. Skip-Links hinzufügen
8. Code-Optimierung
9. Performance-Tuning
---
## 16. Checkliste für Neuimplementierung
### 16.1 Vorbereitung
- [ ] Alle `is-drawer-*` Klassen aus Code entfernen
- [ ] Keine Custom-Variants in CSS/Tailwind definieren
- [ ] DaisyUI-Dokumentation für Drawer studieren
### 16.2 Implementation
- [ ] Logo-Element hinzufügen (size-8, persistent)
- [ ] Toggle-Button mit Icon-Swap (nur Desktop)
- [ ] Mobile: Overlay-Drawer (DaisyUI Standard)
- [ ] Desktop: Persistent Sidebar (w-64)
- [ ] Menü-Items mit korrekten Klassen
- [ ] Submenu-Handling (nested `<ul>`)
### 16.3 Funktionalität
- [ ] Toggle-Funktionalität auf Mobile
- [ ] Accessibility: ARIA, Focus, Keyboard
- [ ] Theme-Toggle funktional
- [ ] Locale-Selector funktional
- [ ] User-Menu-Dropdown funktional
### 16.4 Testing
- [ ] Component-Tests schreiben
- [ ] Accessibility-Tests (axe-core)
- [ ] Keyboard-Navigation testen
- [ ] Screen-Reader testen
- [ ] Responsive Breakpoints testen
### 16.5 Dokumentation
- [ ] Code-Kommentare aktualisieren
- [ ] Component-Docs schreiben
- [ ] README aktualisieren
---
## 17. Technische Details
### 17.1 CSS-Selektoren
**Verwendete IDs:**
- `#main-drawer` - Drawer-Toggle-Checkbox
- `#main-sidebar` - Sidebar-Navigation-Container
- `#sidebar-toggle` - Toggle-Button in Navbar
- `#locale-select-sidebar` - Locale-Dropdown
**Verwendete Klassen:**
- `.drawer-side` - DaisyUI Sidebar-Container
- `.drawer-overlay` - DaisyUI Overlay-Button
- `.drawer-content` - DaisyUI Content-Container
- `.menu` - DaisyUI Menu-Container
- `.is-drawer-close:*` - ❌ NICHT DEFINIERT
- `.is-drawer-open:*` - ❌ NICHT DEFINIERT
### 17.2 Event-Handler
**JavaScript:**
```javascript
drawerToggle.addEventListener("change", ...)
sidebarToggle.addEventListener("click", ...)
sidebarToggle.addEventListener("keydown", ...)
document.addEventListener("keydown", ...) // ESC handler
```
**Inline (zu migrieren):**
```elixir
onclick="document.getElementById('main-drawer').checked = false"
onclick="document.getElementById('main-drawer').checked = !..."
onchange="this.form.submit()"
```
---
## 18. Metriken
### 18.1 Code-Metriken
| Metrik | Wert |
|--------|------|
| **Zeilen Code (Sidebar)** | 198 |
| **Zeilen JavaScript** | 165 (Sidebar-spezifisch) |
| **Zeilen CSS** | 0 (nur Tailwind-Klassen) |
| **Anzahl Komponenten** | 1 (Sidebar) + 1 (Navbar) |
| **Anzahl Menü-Items** | 6 (inkl. Submenu) |
| **Anzahl Footer-Controls** | 3 (Locale, Theme, User) |
### 18.2 Abhängigkeits-Metriken
| Kategorie | Anzahl |
|-----------|--------|
| **DaisyUI-Komponenten** | 7 |
| **Tailwind-Utility-Klassen** | ~50 |
| **Custom-Variants (broken)** | 2 (`is-drawer-close`, `is-drawer-open`) |
| **JavaScript-Event-Listener** | 6 |
| **ARIA-Attribute** | 12 |
---
## 19. Zusammenfassung
### 19.1 Was funktioniert
✅ **Sehr gute Grundlage:**
- DaisyUI Drawer-Pattern korrekt implementiert
- Exzellente Accessibility (ARIA, Keyboard, Focus)
- Saubere Modulstruktur
- Internationalisierung
- Theme-Switching
- JavaScript-Logik ist robust
### 19.2 Was nicht funktioniert
❌ **Kritische Defekte:**
- CSS-Variants existieren nicht → keine responsive Funktionalität
- Kein Logo
- Kein Toggle-Icon-Swap
- Submenu-Handling defekt
### 19.3 Nächste Schritte
1. **CSS-Variants entfernen** (alle `is-drawer-*` Klassen)
2. **Standard DaisyUI-Pattern verwenden** (ohne Custom-Variants)
3. **Logo hinzufügen** (persistent, size-8)
4. **Simplify:** Mobile = Overlay, Desktop = Persistent (keine collapsed State)
5. **Tests schreiben** (Component + Accessibility)
---
## 20. Anhang
### 20.1 Verwendete CSS-Klassen (alphabetisch)
```
avatar, avatar-placeholder, bg-base-100, bg-base-200, bg-neutral,
btn, btn-circle, btn-ghost, btn-square, cursor-pointer, drawer,
drawer-content, drawer-overlay, drawer-side, drawer-toggle, dropdown,
dropdown-content, dropdown-end, dropdown-top, flex, flex-col,
focus:outline-none, focus:ring-2, focus:ring-primary,
focus-within:outline-none, focus-within:ring-2, gap-2, gap-4,
is-drawer-close:*, is-drawer-open:*, items-center, items-start,
mb-2, menu, menu-sm, menu-title, min-h-full, mr-2, mt-3, mt-auto,
p-2, p-4, rounded-box, rounded-full, select, select-sm, shadow,
shadow-sm, size-4, size-5, sr-only, text-lg, text-neutral-content,
text-sm, theme-controller, toggle, tooltip, tooltip-right, w-12,
w-52, w-64, w-full, z-1
```
### 20.2 Verwendete ARIA-Attribute
```
aria-busy, aria-controls, aria-describedby, aria-expanded,
aria-haspopup, aria-hidden, aria-label, aria-labelledby,
aria-live, role="alert", role="button", role="menu",
role="menubar", role="menuitem", role="none", role="status"
```
### 20.3 Relevante Links
- [DaisyUI Drawer Docs](https://daisyui.com/components/drawer/)
- [Tailwind CSS Custom Variants](https://tailwindcss.com/docs/adding-custom-styles#adding-custom-variants)
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [Phoenix LiveView Docs](https://hexdocs.pm/phoenix_live_view/)
---
**Ende des Berichts**

File diff suppressed because it is too large Load diff

View file

@ -1,233 +0,0 @@
# Analyse der fehlschlagenden Tests
## Übersicht
**Gesamtanzahl fehlschlagender Tests:** 5
- **show_test.exs:** 1 Fehler
- **sidebar_test.exs:** 4 Fehler
---
## Kategorisierung
### Kategorie 1: Test-Assertions passen nicht zur Implementierung (4 Tests)
Diese Tests erwarten bestimmte Werte/Attribute, die in der aktuellen Implementierung anders sind oder fehlen.
### Kategorie 2: Datenbank-Isolation Problem (1 Test)
Ein Test schlägt fehl, weil die Datenbank nicht korrekt isoliert ist.
---
## Detaillierte Analyse
### 1. `show_test.exs` - Custom Fields Sichtbarkeit
**Test:** `does not display Custom Fields section when no custom fields exist` (Zeile 112)
**Problem:**
- Der Test erwartet, dass die "Custom Fields" Sektion NICHT angezeigt wird, wenn keine Custom Fields existieren
- Die Sektion wird aber angezeigt, weil in der Datenbank noch Custom Fields von anderen Tests vorhanden sind
**Ursache:**
- Die LiveView lädt alle Custom Fields aus der Datenbank (Zeile 238-242 in `show.ex`)
- Die Test-Datenbank wird nicht zwischen Tests geleert
- Da `async: false` verwendet wird, sollten die Tests sequenziell laufen, aber Custom Fields bleiben in der Datenbank
**Kategorie:** Datenbank-Isolation Problem
---
### 2. `sidebar_test.exs` - Settings Link
**Test:** `T3.1: renders flat menu items with icons and labels` (Zeile 174)
**Problem:**
- Test erwartet `href="#"` für Settings
- Tatsächlicher Wert: `href="/settings"`
**Ursache:**
- Die Implementierung verwendet einen echten Link `~p"/settings"` (Zeile 100 in `sidebar.ex`)
- Der Test erwartet einen Placeholder-Link `href="#"`
**Kategorie:** Test-Assertion passt nicht zur Implementierung
---
### 3. `sidebar_test.exs` - Drawer Overlay CSS-Klasse
**Test:** `drawer overlay is present` (Zeile 747)
**Problem:**
- Test sucht nach exakt `class="drawer-overlay"`
- Tatsächlicher Wert: `class="drawer-overlay lg:hidden focus:outline-none focus:ring-2 focus:ring-primary"`
**Ursache:**
- Der Test verwendet eine exakte String-Suche (`~s(class="drawer-overlay")`)
- Die Implementierung hat mehrere CSS-Klassen
**Kategorie:** Test-Assertion passt nicht zur Implementierung
---
### 4. `sidebar_test.exs` - Toggle Button ARIA-Attribut
**Test:** `T5.2: toggle button has correct ARIA attributes` (Zeile 324)
**Problem:**
- Test erwartet `aria-controls="main-sidebar"` am Toggle-Button
- Das Attribut fehlt in der Implementierung (Zeile 45-65 in `sidebar.ex`)
**Ursache:**
- Das `aria-controls` Attribut wurde nicht in der Implementierung hinzugefügt
- Der Test erwartet es für bessere Accessibility
**Kategorie:** Test-Assertion passt nicht zur Implementierung (Accessibility-Feature fehlt)
---
### 5. `sidebar_test.exs` - Contribution Settings Link
**Test:** `sidebar structure is complete with all sections` (Zeile 501)
**Problem:**
- Test erwartet Link `/contribution_settings`
- Tatsächlicher Link: `/membership_fee_settings`
**Ursache:**
- Der Test hat eine veraltete/inkorrekte Erwartung
- Die Implementierung verwendet `/membership_fee_settings` (Zeile 96 in `sidebar.ex`)
**Kategorie:** Test-Assertion passt nicht zur Implementierung (veralteter Test)
---
## Lösungsvorschläge
### Lösung 1: `show_test.exs` - Custom Fields Sichtbarkeit
**Option A: Test-Datenbank bereinigen (Empfohlen)**
- Im `setup` Block alle Custom Fields löschen, bevor der Test läuft
- Oder: Explizit prüfen, dass keine Custom Fields existieren
**Option B: Test anpassen**
- Den Test so anpassen, dass er explizit alle Custom Fields löscht
- Oder: Die LiveView-Logik ändern, um nur Custom Fields zu laden, die tatsächlich existieren
**Empfehlung:** Option A - Im Test-Setup alle Custom Fields löschen
```elixir
setup do
# Clean up any existing custom fields
Mv.Membership.CustomField
|> Ash.read!()
|> Enum.each(&Ash.destroy!/1)
# Create test member
{:ok, member} = ...
%{member: member}
end
```
---
### Lösung 2: `sidebar_test.exs` - Settings Link
**Option A: Test anpassen (Empfohlen)**
- Test ändern, um `href="/settings"` zu erwarten statt `href="#"`
**Option B: Implementierung ändern**
- Settings-Link zu `href="#"` ändern (nicht empfohlen, da es ein echter Link sein sollte)
**Empfehlung:** Option A - Test anpassen
```elixir
# Zeile 190 ändern von:
assert html =~ ~s(href="#")
# zu:
assert html =~ ~s(href="/settings")
```
---
### Lösung 3: `sidebar_test.exs` - Drawer Overlay CSS-Klasse
**Option A: Test anpassen (Empfohlen)**
- Test ändern, um nach der Klasse in der Klasse-Liste zu suchen (mit `has_class?` Helper)
**Option B: Regex verwenden**
- Regex verwenden, um die Klasse zu finden
**Empfehlung:** Option A - Test anpassen
```elixir
# Zeile 752 ändern von:
assert html =~ ~s(class="drawer-overlay")
# zu:
assert has_class?(html, "drawer-overlay")
```
---
### Lösung 4: `sidebar_test.exs` - Toggle Button ARIA-Attribut
**Option A: Implementierung anpassen (Empfohlen)**
- `aria-controls="main-sidebar"` zum Toggle-Button hinzufügen
**Option B: Test anpassen**
- Test entfernen oder als optional markieren (nicht empfohlen für Accessibility)
**Empfehlung:** Option A - Implementierung anpassen
```elixir
# In sidebar.ex Zeile 45-52, aria-controls hinzufügen:
<button
type="button"
id="sidebar-toggle"
class="hidden lg:flex ml-auto btn btn-ghost btn-sm btn-square"
aria-label={gettext("Toggle sidebar")}
aria-controls="main-sidebar"
aria-expanded="true"
onclick="toggleSidebar()"
>
```
---
### Lösung 5: `sidebar_test.exs` - Contribution Settings Link
**Option A: Test anpassen (Empfohlen)**
- Test ändern, um `/membership_fee_settings` statt `/contribution_settings` zu erwarten
**Option B: Link hinzufügen**
- Einen neuen Link `/contribution_settings` hinzufügen (nicht empfohlen, da redundant)
**Empfehlung:** Option A - Test anpassen
```elixir
# Zeile 519 ändern von:
"/contribution_settings",
# zu:
# Entfernen oder durch "/membership_fee_settings" ersetzen
# (da "/membership_fee_settings" bereits in Zeile 518 vorhanden ist)
```
---
## Zusammenfassung der empfohlenen Änderungen
1. **show_test.exs:** Custom Fields im Setup löschen
2. **sidebar_test.exs (T3.1):** Settings-Link Assertion anpassen
3. **sidebar_test.exs (drawer overlay):** CSS-Klasse-Suche mit Helper-Funktion
4. **sidebar_test.exs (T5.2):** `aria-controls` Attribut zur Implementierung hinzufügen
5. **sidebar_test.exs (edge cases):** Falschen Link aus erwarteter Liste entfernen
---
## Priorisierung
1. **Hoch:** Lösung 1 (show_test.exs) - Datenbank-Isolation ist wichtig
2. **Mittel:** Lösung 4 (ARIA-Attribut) - Accessibility-Verbesserung
3. **Niedrig:** Lösungen 2, 3, 5 - Einfache Test-Anpassungen

View file

@ -1,137 +0,0 @@
# Test Status: Membership Fee UI Components
**Date:** 2025-01-XX
**Status:** Tests Written - Implementation Complete
## Übersicht
Alle Tests für die Membership Fee UI-Komponenten wurden geschrieben. Die Tests sind TDD-konform geschrieben und sollten erfolgreich laufen, da die Implementation bereits vorhanden ist.
## Test-Dateien
### Helper Module Tests
**Datei:** `test/mv_web/helpers/membership_fee_helpers_test.exs`
- ✅ format_currency/1 formats correctly
- ✅ format_interval/1 formats all interval types
- ✅ format_cycle_range/2 formats date ranges correctly
- ✅ get_last_completed_cycle/2 returns correct cycle
- ✅ get_current_cycle/2 returns correct cycle
- ✅ status_color/1 returns correct color classes
- ✅ status_icon/1 returns correct icon names
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
**Datei:** `test/mv_web/member_live/index/membership_fee_status_test.exs`
- ✅ load_cycles_for_members/2 efficiently loads cycles
- ✅ get_cycle_status_for_member/2 returns correct status
- ✅ format_cycle_status_badge/1 returns correct badge
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Member List View Tests
**Datei:** `test/mv_web/member_live/index_membership_fee_status_test.exs`
- ✅ Status column displays correctly
- ✅ Shows last completed cycle status by default
- ✅ Toggle switches to current cycle view
- ✅ Color coding for paid/unpaid/suspended
- ✅ Filter "Unpaid in last cycle" works
- ✅ Filter "Unpaid in current cycle" works
- ✅ Handles members without cycles gracefully
- ✅ Loads cycles efficiently without N+1 queries
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Member Detail View Tests
**Datei:** `test/mv_web/member_live/show_membership_fees_test.exs`
- ✅ Cycles table displays all cycles
- ✅ Table columns show correct data
- ✅ Membership fee type dropdown shows only same-interval types
- ✅ Warning displayed if different interval selected
- ✅ Status change actions work (mark as paid/suspended/unpaid)
- ✅ Cycle regeneration works
- ✅ Handles members without membership fee type gracefully
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Membership Fee Types Admin Tests
**Datei:** `test/mv_web/live/membership_fee_type_live/index_test.exs`
- ✅ List displays all types with correct data
- ✅ Member count column shows correct count
- ✅ Create button navigates to form
- ✅ Edit button per row navigates to edit form
- ✅ Delete button disabled if type is in use
- ✅ Delete button works if type is not in use
- ✅ Only admin can access
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
**Datei:** `test/mv_web/live/membership_fee_type_live/form_test.exs`
- ✅ Create form works
- ✅ Edit form loads existing type data
- ✅ Interval field editable on create
- ✅ Interval field grayed out on edit
- ✅ Amount change warning displays on edit
- ✅ Amount change warning shows correct affected member count
- ✅ Amount change can be confirmed
- ✅ Amount change can be cancelled
- ✅ Validation errors display correctly
- ✅ Only admin can access
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Member Form Tests
**Datei:** `test/mv_web/member_live/form_membership_fee_type_test.exs`
- ✅ Membership fee type dropdown displays in form
- ✅ Shows available types
- ✅ Filters to same interval types if member has type
- ✅ Warning displayed if different interval selected
- ✅ Warning cleared if same interval selected
- ✅ Form saves with selected membership fee type
- ✅ New members get default membership fee type
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Integration Tests
**Datei:** `test/mv_web/member_live/membership_fee_integration_test.exs`
- ✅ End-to-end: Create type → Assign to member → View cycles → Change status
- ✅ End-to-end: Change member type → Cycles regenerate
- ✅ End-to-end: Update settings → New members get default type
- ✅ End-to-end: Delete cycle → Confirmation → Cycle deleted
- ✅ End-to-end: Edit cycle amount → Modal → Amount updated
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
## Test-Ausführung
Alle Tests können mit folgenden Befehlen ausgeführt werden:
```bash
# Alle Tests
mix test
# Nur Membership Fee Tests
mix test test/mv_web/helpers/membership_fee_helpers_test.exs
mix test test/mv_web/member_live/
mix test test/mv_web/live/membership_fee_type_live/
# Mit Coverage
mix test --cover
```
## Bekannte Probleme
Keine bekannten Probleme. Alle Tests sollten erfolgreich laufen, da die Implementation bereits vorhanden ist.
## Nächste Schritte
1. ✅ Tests geschrieben
2. ⏳ Tests ausführen und verifizieren
3. ⏳ Eventuelle Anpassungen basierend auf Test-Ergebnissen
4. ⏳ Code-Review durchführen

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,269 @@
# User Resource Authorization Policies - Implementation Summary
**Date:** 2026-01-22
**Status:** ✅ COMPLETED
---
## Overview
Successfully implemented authorization policies for the User resource following the Bypass + HasPermission pattern, ensuring consistency with Member resource policies and proper use of the scope concept from PermissionSets.
---
## What Was Implemented
### 1. Policy Structure in `lib/accounts/user.ex`
```elixir
policies do
# 1. AshAuthentication Bypass
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
# 2. Bypass for READ (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
# 3. HasPermission for all operations (uses scope from PermissionSets)
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
```
### 2. Test Suite in `test/mv/accounts/user_policies_test.exs`
**Coverage:**
- ✅ 31 tests total: 30 passing, 1 skipped
- ✅ All 4 permission sets tested: `own_data`, `read_only`, `normal_user`, `admin`
- ✅ READ operations (list and single record)
- ✅ UPDATE operations (own and other users)
- ✅ CREATE operations (admin only)
- ✅ DESTROY operations (admin only)
- ✅ AshAuthentication bypass (registration/login)
- ✅ Tests use system_actor for authorization
---
## Key Design Decisions
### Decision 1: Bypass for READ, HasPermission for UPDATE
**Rationale:**
- READ list queries have no record at `strict_check` time
- `HasPermission` returns `{:ok, false}` for queries without record
- Ash doesn't call `auto_filter` when `strict_check` returns `false`
- `expr()` in bypass is handled natively by Ash for `auto_filter`
**Result:**
- Bypass handles READ list queries ✅
- HasPermission handles UPDATE with `scope :own`
- No redundancy - both are necessary ✅
### Decision 2: No Explicit `forbid_if always()`
**Rationale:**
- Ash implicitly forbids if no policy authorizes (fail-closed by default)
- Explicit `forbid_if always()` at the end breaks tests
- It would forbid valid operations that should be authorized by previous policies
**Result:**
- Policies rely on Ash's implicit forbid ✅
- Tests pass with this approach ✅
### Decision 3: Consistency with Member Resource
**Rationale:**
- Member resource uses same pattern: Bypass for READ, HasPermission for UPDATE
- Consistent patterns improve maintainability and predictability
- Developers can understand authorization logic across resources
**Result:**
- User and Member follow identical pattern ✅
- Authorization logic is consistent throughout the app ✅
---
## The Scope Concept Is NOT Redundant
### Initial Concern
> "If we use a bypass with `expr(id == ^actor(:id))` for READ, isn't `scope :own` in PermissionSets redundant?"
### Resolution
**NO! The scope concept is essential:**
1. **Documentation** - `scope :own` clearly expresses intent in PermissionSets
2. **UPDATE operations** - `scope :own` is USED by HasPermission when changeset contains record
3. **Admin operations** - `scope :all` allows admins full access
4. **Maintainability** - All permissions centralized in one place
**Test Proof:**
```elixir
test "can update own email", %{user: user} do
# This works via HasPermission with scope :own (NOT bypass)
{:ok, updated_user} =
user
|> Ash.Changeset.for_update(:update_user, %{email: "new@example.com"})
|> Ash.update(actor: user)
assert updated_user.email # ✅ Proves scope :own is used
end
```
---
## Documentation Updates
### 1. Created `docs/policy-bypass-vs-haspermission.md`
Comprehensive documentation explaining:
- Why bypass is needed for READ
- Why HasPermission works for UPDATE
- Technical deep dive into Ash policy evaluation
- Test coverage proving the pattern
- Lessons learned
### 2. Updated `docs/roles-and-permissions-architecture.md`
- Added "Bypass vs. HasPermission: When to Use Which?" section
- Updated User Resource Policies section with correct implementation
- Updated Member Resource Policies section for consistency
- Added pattern comparison table
### 3. Updated `docs/roles-and-permissions-implementation-plan.md`
- Marked Issue #8 as COMPLETED ✅
- Added implementation details
- Documented why bypass is needed
- Added test results
---
## Test Results
### All Relevant Tests Pass
```bash
mix test test/mv/accounts/user_policies_test.exs \
test/mv/authorization/checks/has_permission_test.exs \
test/mv/membership/member_policies_test.exs
# Results:
# 75 tests: 74 passing, 1 skipped
# ✅ User policies: 30/31 (1 skipped)
# ✅ HasPermission check: 21/21
# ✅ Member policies: 23/23
```
### Specific Test Coverage
**Own Data Access (All Roles):**
- ✅ Can read own user record (via bypass)
- ✅ Can update own email (via HasPermission with scope :own)
- ✅ Cannot read other users (filtered by bypass)
- ✅ Cannot update other users (forbidden by HasPermission)
- ✅ List returns only own user (auto_filter via bypass)
**Admin Access:**
- ✅ Can read all users (HasPermission with scope :all)
- ✅ Can update other users (HasPermission with scope :all)
- ✅ Can create users (HasPermission with scope :all)
- ✅ Can destroy users (HasPermission with scope :all)
**AshAuthentication:**
- ✅ Registration works without actor
- ✅ OIDC registration works
- ✅ OIDC sign-in works
**Test Environment:**
- ✅ Operations without actor work in test environment
- ✅ All tests explicitly use system_actor for authorization
---
## Files Changed
### Implementation
1. ✅ `lib/accounts/user.ex` - Added policies block (lines 271-315)
2. ✅ `lib/mv/authorization/checks/has_permission.ex` - Added User resource support in `evaluate_filter_for_strict_check`
### Tests
3. ✅ `test/mv/accounts/user_policies_test.exs` - Created comprehensive test suite (435 lines)
4. ✅ `test/mv/authorization/checks/has_permission_test.exs` - Updated to expect `false` instead of `:unknown`
### Documentation
5. ✅ `docs/policy-bypass-vs-haspermission.md` - New comprehensive guide (created)
6. ✅ `docs/roles-and-permissions-architecture.md` - Updated User and Member sections
7. ✅ `docs/roles-and-permissions-implementation-plan.md` - Marked Issue #8 as completed
8. ✅ `docs/user-resource-policies-implementation-summary.md` - This file (created)
---
## Lessons Learned
### 1. Test Before Assuming
The initial plan assumed HasPermission with `scope :own` would be sufficient. Testing revealed that Ash's policy evaluation doesn't reliably call `auto_filter` when `strict_check` returns `false` or `:unknown`.
### 2. Bypass Is Not a Workaround, It's a Pattern
The bypass with `expr()` is not a hack or workaround - it's the **correct pattern** for filter-based authorization in Ash when dealing with list queries.
### 3. Scope Concept Remains Essential
Even with bypass for READ, the scope concept in PermissionSets is essential for:
- UPDATE/CREATE/DESTROY operations
- Documentation and maintainability
- Centralized permission management
### 4. Consistency Across Resources
Following the same pattern (Bypass for READ, HasPermission for UPDATE) across User and Member resources makes the codebase more maintainable and predictable.
### 5. Documentation Is Key
Thorough documentation explaining **WHY** the pattern exists prevents future confusion and ensures the pattern is applied correctly in future resources.
---
## Future Considerations
### If Adding New Resources with Filter-Based Permissions
Follow the same pattern:
1. Bypass with `expr()` for READ (list queries)
2. HasPermission for UPDATE/CREATE/DESTROY (uses scope from PermissionSets)
3. Define appropriate scopes in PermissionSets (`:own`, `:linked`, `:all`)
### If Ash Framework Changes
If a future version of Ash reliably calls `auto_filter` when `strict_check` returns `:unknown`:
1. Consider removing bypass for READ
2. Keep only HasPermission policy
3. Update tests to verify new behavior
4. Update documentation
**For now (Ash 3.13.1), the current pattern is correct and necessary.**
---
## Conclusion
✅ **User Resource Authorization Policies are fully implemented, tested, and documented.**
The implementation:
- Follows best practices for Ash policies
- Is consistent with Member resource pattern
- Uses the scope concept from PermissionSets effectively
- Has comprehensive test coverage
- Is thoroughly documented for future developers
**Status: PRODUCTION READY** 🎉

View file

@ -1,6 +1,15 @@
defmodule Mv.Accounts do
@moduledoc """
AshAuthentication specific domain to handle Authentication for users.
## Resources
- `User` - User accounts with authentication methods (password, OIDC)
- `Token` - Session tokens for authentication
## Public API
The domain exposes these main actions:
- User CRUD: `create_user/1`, `list_users/0`, `update_user/2`, `destroy_user/1`
- Authentication: `create_register_with_rauthy/1`, `read_sign_in_with_rauthy/1`
"""
use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix]

View file

@ -1,6 +1,10 @@
defmodule Mv.Accounts.Token do
@moduledoc """
AshAuthentication specific ressource
AshAuthentication Token Resource for session management.
This resource is used by AshAuthentication to manage authentication tokens
for user sessions. Tokens are automatically created and managed by the
authentication system.
"""
use Ash.Resource,
data_layer: AshPostgres.DataLayer,

View file

@ -5,9 +5,8 @@ defmodule Mv.Accounts.User do
use Ash.Resource,
domain: Mv.Accounts,
data_layer: AshPostgres.DataLayer,
extensions: [AshAuthentication]
# authorizers: [Ash.Policy.Authorizer]
extensions: [AshAuthentication],
authorizers: [Ash.Policy.Authorizer]
postgres do
table "users"
@ -68,6 +67,10 @@ defmodule Mv.Accounts.User do
identity_field :email
hash_provider AshAuthentication.BcryptProvider
confirmation_required? false
resettable do
sender Mv.Accounts.User.Senders.SendPasswordResetEmail
end
end
end
end
@ -116,6 +119,8 @@ defmodule Mv.Accounts.User do
argument :member, :map, allow_nil?: true
upsert? true
# Note: Default role is automatically assigned via attribute default (see attributes block)
# Manage the member relationship during user creation
change manage_relationship(:member, :member,
# Look up existing member and relate to it
@ -240,6 +245,8 @@ defmodule Mv.Accounts.User do
upsert? true
# Upsert based on oidc_id (primary match for existing OIDC users)
upsert_identity :unique_oidc_id
# On upsert, only update email - preserve existing role_id
upsert_fields [:email]
validate &__MODULE__.validate_oidc_id_present/2
@ -262,11 +269,38 @@ defmodule Mv.Accounts.User do
# - The LinkOidcAccountLive will auto-link passwordless users without password prompt
validate Mv.Accounts.User.Validations.OidcEmailCollision
# Note: Default role is automatically assigned via attribute default (see attributes block)
# upsert_fields [:email] ensures existing users' roles are preserved during upserts
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
end
end
# Authorization Policies
# Order matters: Most specific policies first, then general permission check
policies do
# AshAuthentication bypass (registration/login without actor)
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
description "Allow AshAuthentication internal operations (registration, login)"
authorize_if always()
end
# READ bypass for list queries (scope :own via expr)
bypass action_type(:read) do
description "Users can always read their own account"
authorize_if expr(id == ^actor(:id))
end
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
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
# Default: Ash implicitly forbids if no policy authorizes (fail-closed)
end
# Global validations - applied to all relevant actions
validations do
# Password strength policy: minimum 8 characters for all password-related actions
@ -356,6 +390,15 @@ defmodule Mv.Accounts.User do
attribute :hashed_password, :string, sensitive?: true, allow_nil?: true
attribute :oidc_id, :string, allow_nil?: true
# Role assignment: Explicitly defined to enforce default value
# This ensures every user has a role, regardless of creation path
# (register_with_password, create_user, seeds, etc.)
attribute :role_id, :uuid do
allow_nil? false
default &__MODULE__.default_role_id/0
public? false
end
end
relationships do
@ -365,10 +408,13 @@ defmodule Mv.Accounts.User do
belongs_to :member, Mv.Membership.Member
# 1:1 relationship - User belongs to a Role
# This automatically creates a `role_id` attribute in the User table
# The relationship is optional (allow_nil? true by default)
# We define role_id ourselves (above in attributes) to control default value
# Foreign key constraint: on_delete: :restrict (prevents deleting roles assigned to users)
belongs_to :role, Mv.Authorization.Role
belongs_to :role, Mv.Authorization.Role do
define_attribute? false
source_attribute :role_id
allow_nil? false
end
end
identities do
@ -388,4 +434,60 @@ defmodule Mv.Accounts.User do
# forbid_if(always())
# end
# end
@doc """
Returns the default role ID for new users.
This function is called automatically when creating a user without an explicit role_id.
It fetches the "Mitglied" role from the database without authorization checks
(safe during user creation bootstrap phase).
The result is cached in the process dictionary to avoid repeated database queries
during high-volume user creation. The cache is invalidated on application restart.
## Bootstrap Safety
Only non-nil values are cached. If the role doesn't exist yet (e.g., before seeds run),
`nil` is not cached, allowing subsequent calls to retry after the role is created.
This prevents bootstrap issues where a process would be permanently stuck with `nil`
if the first call happens before the role exists.
## Performance Note
This function makes one database query per process (cached in process dictionary).
For very high-volume scenarios, consider using a fixed UUID from Application config
instead of querying the database.
## Returns
- UUID of the "Mitglied" role if it exists
- `nil` if the role doesn't exist (will cause validation error due to `allow_nil? false`)
## Examples
iex> Mv.Accounts.User.default_role_id()
"019bf2e2-873a-7712-a7ce-a5a1f90c5f4f"
"""
@spec default_role_id() :: Ecto.UUID.t() | nil
def default_role_id do
# Cache in process dictionary to avoid repeated queries
# IMPORTANT: Only cache non-nil values to avoid bootstrap issues.
# If the role doesn't exist yet (e.g., before seeds run), we don't cache nil
# so that subsequent calls can retry after the role is created.
case Process.get({__MODULE__, :default_role_id}) do
nil ->
role_id =
case Mv.Authorization.Role.get_mitglied_role() do
{:ok, %Mv.Authorization.Role{id: id}} -> id
_ -> nil
end
# Only cache non-nil values to allow retry if role is created later
if role_id, do: Process.put({__MODULE__, :default_role_id}, role_id)
role_id
cached_role_id ->
cached_role_id
end
end
end

View file

@ -42,25 +42,29 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
if email && oidc_id && user_info do
# Check if a user with this oidc_id already exists
# If yes, this will be an upsert (email update), not a new registration
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
existing_oidc_user =
case Mv.Accounts.User
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|> Ash.read_one() do
|> Ash.read_one(actor: system_actor) do
{:ok, user} -> user
_ -> nil
end
check_email_collision(email, oidc_id, user_info, existing_oidc_user)
check_email_collision(email, oidc_id, user_info, existing_oidc_user, system_actor)
else
:ok
end
end
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user) do
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user, system_actor) do
# Find existing user with this email
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
case Mv.Accounts.User
|> Ash.Query.filter(email == ^to_string(email))
|> Ash.read_one() do
|> Ash.read_one(actor: system_actor) do
{:ok, nil} ->
# No user exists with this email - OK to create new user
:ok

View file

@ -124,8 +124,9 @@ defmodule Mv.Membership.Member do
case result do
{:ok, member} ->
if member.membership_fee_type_id && member.join_date do
actor = Map.get(changeset.context, :actor)
handle_cycle_generation(member, actor: actor)
# Capture initiator for audit trail (if available)
initiator = Map.get(changeset.context, :actor)
handle_cycle_generation(member, initiator: initiator)
end
{:error, _} ->
@ -196,16 +197,12 @@ defmodule Mv.Membership.Member do
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
if fee_type_changed && member.membership_fee_type_id && member.join_date do
actor = Map.get(changeset.context, :actor)
case regenerate_cycles_on_type_change(member, actor: actor) do
case regenerate_cycles_on_type_change(member) do
{:ok, notifications} ->
# Return notifications to Ash - they will be sent automatically after commit
{:ok, member, notifications}
{:error, reason} ->
require Logger
Logger.warning(
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
)
@ -230,8 +227,9 @@ defmodule Mv.Membership.Member do
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
actor = Map.get(changeset.context, :actor)
handle_cycle_generation(member, actor: actor)
# Capture initiator for audit trail (if available)
initiator = Map.get(changeset.context, :actor)
handle_cycle_generation(member, initiator: initiator)
end
{:error, _} ->
@ -305,15 +303,6 @@ defmodule Mv.Membership.Member do
# Authorization Policies
# Order matters: Most specific policies first, then general permission check
policies do
# SYSTEM OPERATIONS: Allow CRUD operations without actor (TEST ENVIRONMENT ONLY)
# In test: All operations allowed (for test fixtures)
# In production/dev: ALL operations denied without actor (fail-closed for security)
# NoActor.check uses compile-time environment detection to prevent security issues
bypass action_type([:create, :read, :update, :destroy]) do
description "Allow system operations without actor (test environment only)"
authorize_if Mv.Authorization.Checks.NoActor
end
# SPECIAL CASE: Users can always READ their linked member
# This allows users with ANY permission set to read their own linked member
# Check using the inverse relationship: User.member_id → Member.id
@ -404,13 +393,28 @@ defmodule Mv.Membership.Member do
user_id = user_arg[:id]
current_member_id = changeset.data.id
# Get actor from changeset context for authorization
# If no actor is present, this will fail in production (fail-closed)
# Get actor from changeset context (may be nil)
actor = Map.get(changeset.context || %{}, :actor)
# Check the current state of the user in the database
# Pass actor to ensure proper authorization (User might have policies in future)
case Ash.get(Mv.Accounts.User, user_id, actor: actor) do
# Check if authorization is disabled in the parent operation's context
# Access private context where authorize? flag is stored
authorize? =
case get_in(changeset.context, [:private, :authorize?]) do
false -> false
_ -> true
end
# Use actor for authorization when available and authorize? is true
# Fall back to authorize?: false only for bootstrap/system operations
# This ensures normal operations respect authorization while system operations work
query_opts =
if actor && authorize? do
[actor: actor]
else
[authorize?: false]
end
case Ash.get(Mv.Accounts.User, user_id, query_opts) do
# User is free to be linked
{:ok, %{member_id: nil}} ->
:ok
@ -423,6 +427,9 @@ defmodule Mv.Membership.Member do
# User is linked to a different member - prevent "stealing"
{:error, field: :user, message: "User is already linked to another member"}
{:error, %Ash.Error.Query.NotFound{}} ->
{:error, field: :user, message: "User not found"}
{:error, _} ->
{:error, field: :user, message: "User not found"}
end
@ -790,37 +797,37 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
# to be sent after transaction commits
@doc false
def regenerate_cycles_on_type_change(member, opts \\ []) do
# Uses system actor for cycle regeneration (mandatory side effect)
def regenerate_cycles_on_type_change(member, _opts \\ []) do
alias Mv.Helpers
alias Mv.Helpers.SystemActor
today = Date.utc_today()
lock_key = :erlang.phash2(member.id)
actor = Keyword.get(opts, :actor)
# Use advisory lock to prevent concurrent deletion and regeneration
# This ensures atomicity when multiple updates happen simultaneously
if Mv.Repo.in_transaction?() do
regenerate_cycles_in_transaction(member, today, lock_key, actor: actor)
regenerate_cycles_in_transaction(member, today, lock_key)
else
regenerate_cycles_new_transaction(member, today, lock_key, actor: actor)
regenerate_cycles_new_transaction(member, today, lock_key)
end
end
# Already in transaction: use advisory lock directly
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles_in_transaction(member, today, lock_key, opts) do
actor = Keyword.get(opts, :actor)
defp regenerate_cycles_in_transaction(member, today, lock_key) do
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor)
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
end
# Not in transaction: start new transaction with advisory lock
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
defp regenerate_cycles_new_transaction(member, today, lock_key, opts) do
actor = Keyword.get(opts, :actor)
defp regenerate_cycles_new_transaction(member, today, lock_key) do
Mv.Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor) do
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
{:ok, notifications} ->
# Return notifications - they will be sent by the caller
notifications
@ -838,11 +845,16 @@ defmodule Mv.Membership.Member do
# Performs the actual cycle deletion and regeneration
# Returns {:ok, notifications} or {:error, reason}
# notifications are collected to be sent after transaction commits
# Uses system actor for all operations
defp do_regenerate_cycles_on_type_change(member, today, opts) do
alias Mv.Helpers
alias Mv.Helpers.SystemActor
require Ash.Query
skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
system_actor = SystemActor.get_system_actor()
actor_opts = Helpers.ash_actor_opts(system_actor)
# Find all unpaid cycles for this member
# We need to check cycle_end for each cycle using its own interval
@ -852,20 +864,16 @@ defmodule Mv.Membership.Member do
|> Ash.Query.filter(status == :unpaid)
|> Ash.Query.load([:membership_fee_type])
result =
if actor do
Ash.read(all_unpaid_cycles_query, actor: actor)
else
Ash.read(all_unpaid_cycles_query)
end
case result do
case Ash.read(all_unpaid_cycles_query, actor_opts) do
{:ok, all_unpaid_cycles} ->
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
delete_and_regenerate_cycles(cycles_to_delete, member.id, today,
skip_lock?: skip_lock?,
actor: actor
delete_and_regenerate_cycles(
cycles_to_delete,
member.id,
today,
actor_opts,
skip_lock?: skip_lock?
)
{:error, reason} ->
@ -893,26 +901,27 @@ defmodule Mv.Membership.Member do
# Deletes future cycles and regenerates them with the new type/amount
# Passes today to ensure consistent date across deletion and regeneration
# Returns {:ok, notifications} or {:error, reason}
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
# Uses system actor for cycle generation and deletion
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, actor_opts, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
if Enum.empty?(cycles_to_delete) do
# No cycles to delete, just regenerate
regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
else
case delete_cycles(cycles_to_delete) do
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
case delete_cycles(cycles_to_delete, actor_opts) do
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
{:error, reason} -> {:error, reason}
end
end
end
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
defp delete_cycles(cycles_to_delete) do
# Uses system actor for authorization to ensure deletion always works
defp delete_cycles(cycles_to_delete, actor_opts) do
delete_results =
Enum.map(cycles_to_delete, fn cycle ->
Ash.destroy(cycle)
Ash.destroy(cycle, actor_opts)
end)
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
@ -928,13 +937,11 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles(member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member_id,
today: today,
skip_lock?: skip_lock?,
actor: actor
skip_lock?: skip_lock?
) do
{:ok, _cycles, notifications} when is_list(notifications) ->
{:ok, notifications}
@ -948,49 +955,57 @@ defmodule Mv.Membership.Member do
# based on environment (test vs production)
# This function encapsulates the common logic for cycle generation
# to avoid code duplication across different hooks
# Uses system actor for cycle generation (mandatory side effect)
# Captures initiator for audit trail (if available in opts)
defp handle_cycle_generation(member, opts) do
actor = Keyword.get(opts, :actor)
initiator = Keyword.get(opts, :initiator)
if Mv.Config.sql_sandbox?() do
handle_cycle_generation_sync(member, actor: actor)
handle_cycle_generation_sync(member, initiator)
else
handle_cycle_generation_async(member, actor: actor)
handle_cycle_generation_async(member, initiator)
end
end
# Runs cycle generation synchronously (for test environment)
defp handle_cycle_generation_sync(member, opts) do
require Logger
actor = Keyword.get(opts, :actor)
defp handle_cycle_generation_sync(member, initiator) do
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member.id,
today: Date.utc_today(),
actor: actor
initiator: initiator
) do
{:ok, cycles, notifications} ->
send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications, sync: true)
log_cycle_generation_success(member, cycles, notifications,
sync: true,
initiator: initiator
)
{:error, reason} ->
log_cycle_generation_error(member, reason, sync: true)
log_cycle_generation_error(member, reason, sync: true, initiator: initiator)
end
end
# Runs cycle generation asynchronously (for production environment)
defp handle_cycle_generation_async(member, opts) do
actor = Keyword.get(opts, :actor)
defp handle_cycle_generation_async(member, initiator) do
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id,
initiator: initiator
) do
{:ok, cycles, notifications} ->
send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications, sync: false)
log_cycle_generation_success(member, cycles, notifications,
sync: false,
initiator: initiator
)
{:error, reason} ->
log_cycle_generation_error(member, reason, sync: false)
log_cycle_generation_error(member, reason, sync: false, initiator: initiator)
end
end)
|> Task.await(:infinity)
end
# Sends notifications if any are present
@ -1001,13 +1016,15 @@ defmodule Mv.Membership.Member do
end
# Logs successful cycle generation
defp log_cycle_generation_success(member, cycles, notifications, sync: sync?) do
require Logger
defp log_cycle_generation_success(member, cycles, notifications,
sync: sync?,
initiator: initiator
) do
sync_label = if sync?, do: "", else: " (async)"
initiator_info = get_initiator_info(initiator)
Logger.debug(
"Successfully generated cycles for member#{sync_label}",
"Successfully generated cycles for member#{sync_label} (initiator: #{initiator_info})",
member_id: member.id,
cycles_count: length(cycles),
notifications_count: length(notifications)
@ -1015,13 +1032,12 @@ defmodule Mv.Membership.Member do
end
# Logs cycle generation errors
defp log_cycle_generation_error(member, reason, sync: sync?) do
require Logger
defp log_cycle_generation_error(member, reason, sync: sync?, initiator: initiator) do
sync_label = if sync?, do: "", else: " (async)"
initiator_info = get_initiator_info(initiator)
Logger.error(
"Failed to generate cycles for member#{sync_label}",
"Failed to generate cycles for member#{sync_label} (initiator: #{initiator_info})",
member_id: member.id,
member_email: member.email,
error: inspect(reason),
@ -1029,6 +1045,11 @@ defmodule Mv.Membership.Member do
)
end
# Extracts initiator information for audit trail
defp get_initiator_info(nil), do: "system"
defp get_initiator_info(%{email: email}), do: email
defp get_initiator_info(_), do: "unknown"
# Helper to extract error type for structured logging
defp error_type(%{__struct__: struct_name}), do: struct_name
defp error_type(error) when is_atom(error), do: error

View file

@ -12,8 +12,8 @@ defmodule Mv.Membership do
The domain exposes these main actions:
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, etc.
- Settings management: `get_settings/0`, `update_settings/2`
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/0`, etc.
- Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3`
## Admin Interface
The domain is configured with AshAdmin for management UI.

View file

@ -85,10 +85,11 @@ defmodule Mv.MembershipFees.MembershipFeeType do
if changeset.action_type == :destroy do
require Ash.Query
# Integrity check: count members without authorization (systemic operation)
member_count =
Mv.Membership.Member
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|> Ash.count!()
|> Ash.count!(authorize?: false)
if member_count > 0 do
{:error,
@ -108,10 +109,11 @@ defmodule Mv.MembershipFees.MembershipFeeType do
if changeset.action_type == :destroy do
require Ash.Query
# Integrity check: count cycles without authorization (systemic operation)
cycle_count =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|> Ash.count!()
|> Ash.count!(authorize?: false)
if cycle_count > 0 do
{:error,
@ -131,10 +133,11 @@ defmodule Mv.MembershipFees.MembershipFeeType do
if changeset.action_type == :destroy do
require Ash.Query
# Integrity check: count settings without authorization (systemic operation)
setting_count =
Mv.Membership.Setting
|> Ash.Query.filter(default_membership_fee_type_id == ^changeset.data.id)
|> Ash.count!()
|> Ash.count!(authorize?: false)
if setting_count > 0 do
{:error,

View file

@ -6,6 +6,13 @@ defmodule Mv.MembershipFees do
- `MembershipFeeType` - Defines membership fee types with intervals and amounts
- `MembershipFeeCycle` - Individual membership fee cycles per member
## Public API
The domain exposes these main actions:
- MembershipFeeType CRUD: `create_membership_fee_type/1`, `list_membership_fee_types/0`, `update_membership_fee_type/2`, `destroy_membership_fee_type/1`
- MembershipFeeCycle CRUD: `create_membership_fee_cycle/1`, `list_membership_fee_cycles/0`, `update_membership_fee_cycle/2`, `destroy_membership_fee_cycle/1`
Note: LiveViews may use direct Ash calls instead of these domain functions for performance or flexibility.
## Overview
This domain handles the complete membership fee lifecycle including:
- Fee type definitions (monthly, quarterly, half-yearly, yearly)

View file

@ -9,6 +9,8 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
"""
use Ash.Resource.Validation
require Logger
@doc """
Validates email uniqueness across linked User-Member pairs.
@ -73,19 +75,29 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
end
defp check_email_uniqueness(email, exclude_member_id) do
alias Mv.Helpers
alias Mv.Helpers.SystemActor
query =
Mv.Membership.Member
|> Ash.Query.filter(email == ^to_string(email))
|> maybe_exclude_id(exclude_member_id)
case Ash.read(query) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.read(query, opts) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :email, message: "is already used by another member", value: email}
{:error, _} ->
{:error, reason} ->
Logger.warning(
"Email uniqueness validation query failed for user email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
)
:ok
end
end

View file

@ -14,6 +14,7 @@ defmodule Mv.Application do
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Mv.PubSub},
{AshAuthentication.Supervisor, otp_app: :my},
Mv.Helpers.SystemActor,
# Start a worker by calling: Mv.Worker.start_link(arg)
# {Mv.Worker, arg},
# Start to serve requests, typically the last entry

View file

@ -0,0 +1,99 @@
defmodule Mv.Authorization.Actor do
@moduledoc """
Helper functions for ensuring User actors have required data loaded.
## Actor Invariant
Authorization policies (especially HasPermission) require that the User actor
has their `:role` relationship loaded. This module provides helpers to
ensure this invariant is maintained across all entry points:
- LiveView on_mount hooks
- Plug pipelines
- Background jobs
- Tests
## Scope
This module ONLY handles `Mv.Accounts.User` resources. Other resources with
a `:role` field are ignored (returned as-is). This prevents accidental
authorization bypasses and keeps the logic focused.
## Usage
# In LiveView on_mount
def ensure_user_role_loaded(_name, socket) do
user = Actor.ensure_loaded(socket.assigns[:current_user])
assign(socket, :current_user, user)
end
# In tests
user = Actor.ensure_loaded(user)
## Security Note
`ensure_loaded/1` loads the role with `authorize?: false` to avoid circular
dependency (actor needs role loaded to be authorized, but loading role requires
authorization). This is safe because:
- The actor (User) is loading their OWN role (user.role relationship)
- This load is needed FOR authorization checks to work
- The role itself contains no sensitive data (just permission_set reference)
- The actor is already authenticated (passed auth boundary)
Alternative would be to denormalize permission_set_name on User, but that
adds complexity and potential for inconsistency.
"""
require Logger
@doc """
Ensures the actor (User) has their `:role` relationship loaded.
- If actor is nil, returns nil
- If role is already loaded, returns actor as-is
- If role is %Ash.NotLoaded{}, loads it and returns updated actor
- If actor is not a User, returns as-is (no-op)
## Examples
iex> Actor.ensure_loaded(nil)
nil
iex> Actor.ensure_loaded(%User{role: %Role{}})
%User{role: %Role{}}
iex> Actor.ensure_loaded(%User{role: %Ash.NotLoaded{}})
%User{role: %Role{}} # role loaded
"""
def ensure_loaded(nil), do: nil
# Only handle Mv.Accounts.User - clear intention, no accidental other resources
def ensure_loaded(%Mv.Accounts.User{role: %Ash.NotLoaded{}} = user) do
load_role(user)
end
def ensure_loaded(actor), do: actor
defp load_role(actor) do
# SECURITY: We skip authorization here because this is a bootstrap scenario:
# - The actor is loading their OWN role (actor.role relationship)
# - This load is needed FOR authorization checks to work (circular dependency)
# - The role itself contains no sensitive data (just permission_set reference)
# - The actor is already authenticated (passed auth boundary)
# Alternative would be to denormalize permission_set_name on User.
case Ash.load(actor, :role, domain: Mv.Accounts, authorize?: false) do
{:ok, loaded_actor} ->
loaded_actor
{:error, error} ->
# Log error but don't crash - fail-closed for authorization
Logger.warning(
"Failed to load actor role: #{inspect(error)}. " <>
"Authorization may fail if role is required."
)
actor
end
end
end

View file

@ -7,7 +7,7 @@ defmodule Mv.Authorization do
## Public API
The domain exposes these main actions:
- Role CRUD: `create_role/1`, `list_roles/0`, `update_role/2`, `destroy_role/1`
- Role CRUD: `create_role/1`, `list_roles/0`, `get_role/1`, `update_role/2`, `destroy_role/1`
## Admin Interface
The domain is configured with AshAdmin for management UI.

View file

@ -8,10 +8,37 @@ defmodule Mv.Authorization.Checks.HasPermission do
3. Finds matching permission for current resource + action
4. Applies scope filter (:own, :linked, :all)
## Important: strict_check Behavior
For filter-based scopes (`:own`, `:linked`):
- **WITH record**: Evaluates filter against record (returns `true`/`false`)
- **WITHOUT record** (queries/lists): Returns `false`
**Why `false` instead of `:unknown`?**
Ash's policy evaluation doesn't reliably call `auto_filter` when `strict_check`
returns `:unknown`. To ensure list queries work correctly, resources **MUST** use
bypass policies with `expr()` for READ operations (see `docs/policy-bypass-vs-haspermission.md`).
This means `HasPermission` is **NOT** generically reusable for query authorization
with filter scopes - it requires companion bypass policies.
## Usage Pattern
See `docs/policy-bypass-vs-haspermission.md` for the two-tier pattern:
- **READ**: `bypass` with `expr()` (handles auto_filter)
- **UPDATE/CREATE/DESTROY**: `HasPermission` (handles scope evaluation)
## Usage in Ash Resource
policies do
policy action_type(:read) do
# READ: Bypass for list queries
bypass action_type(:read) do
authorize_if expr(id == ^actor(:id))
end
# UPDATE: HasPermission for scope evaluation
policy action_type([:update, :create, :destroy]) do
authorize_if Mv.Authorization.Checks.HasPermission
end
end
@ -34,6 +61,12 @@ defmodule Mv.Authorization.Checks.HasPermission do
All errors result in Forbidden (policy fails).
## Role Loading Fallback
If the actor's `:role` relationship is `%Ash.NotLoaded{}`, this check will
attempt to load it automatically. This provides a fallback if `on_mount` hooks
didn't run (e.g., in non-LiveView contexts).
## Examples
# In a resource policy
@ -83,6 +116,9 @@ defmodule Mv.Authorization.Checks.HasPermission do
# Helper function to reduce nesting depth
defp strict_check_with_permissions(actor, resource, action, record) do
# Ensure role is loaded (fallback if on_mount didn't run)
actor = ensure_role_loaded(actor)
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom),
@ -95,11 +131,25 @@ defmodule Mv.Authorization.Checks.HasPermission do
resource_name
) do
:authorized ->
# For :all scope, authorize directly
{:ok, true}
{:filter, filter_expr} ->
# For strict_check on single records, evaluate the filter against the record
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
# For :own/:linked scope:
# - With a record, evaluate filter against record for strict authorization
# - Without a record (queries/lists), return false
#
# NOTE: Returning false here forces the use of expr-based bypass policies.
# This is necessary because Ash's policy evaluation doesn't reliably call auto_filter
# when strict_check returns :unknown. Instead, resources should use bypass policies
# with expr() directly for filter-based authorization (see User resource).
if record do
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
else
# No record yet (e.g., read/list queries) - deny at strict_check level
# Resources must use expr-based bypass policies for list filtering
{:ok, false}
end
false ->
{:ok, false}
@ -224,9 +274,18 @@ defmodule Mv.Authorization.Checks.HasPermission do
end
# Evaluate filter expression for strict_check on single records
# For :own scope with User resource: id == actor.id
# For :linked scope with Member resource: id == actor.member_id
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
case {resource_name, record} do
{"User", %{id: user_id}} when not is_nil(user_id) ->
# Check if this user's ID matches the actor's ID (scope :own)
if user_id == actor.id do
{:ok, true}
else
{:ok, false}
end
{"Member", %{id: member_id}} when not is_nil(member_id) ->
# Check if this member's ID matches the actor's member_id
if member_id == actor.member_id do
@ -289,12 +348,22 @@ defmodule Mv.Authorization.Checks.HasPermission do
"Member" ->
# User.member_id → Member.id (inverse relationship)
# Filter: member.id == actor.member_id
{:filter, expr(id == ^actor.member_id)}
# If actor has no member_id, return no results (use false or impossible condition)
if is_nil(actor.member_id) do
{:filter, expr(false)}
else
{:filter, expr(id == ^actor.member_id)}
end
"CustomFieldValue" ->
# CustomFieldValue.member_id → Member.id → User.member_id
# Filter: custom_field_value.member_id == actor.member_id
{:filter, expr(member_id == ^actor.member_id)}
# If actor has no member_id, return no results
if is_nil(actor.member_id) do
{:filter, expr(false)}
else
{:filter, expr(member_id == ^actor.member_id)}
end
_ ->
# Fallback for other resources
@ -330,4 +399,10 @@ defmodule Mv.Authorization.Checks.HasPermission do
defp get_resource_name_for_logging(_resource) do
"unknown"
end
# Fallback: Load role if not loaded (in case on_mount didn't run)
# Delegates to centralized Actor helper
defp ensure_role_loaded(actor) do
Mv.Authorization.Actor.ensure_loaded(actor)
end
end

View file

@ -1,74 +0,0 @@
defmodule Mv.Authorization.Checks.NoActor do
@moduledoc """
Custom Ash Policy Check that allows actions when no actor is present.
**IMPORTANT:** This check ONLY works in test environment for security reasons.
In production/dev, ALL operations without an actor are denied.
## Security Note
This check uses compile-time environment detection to prevent accidental
security issues in production. In production, ALL operations (including :create
and :read) will be denied if no actor is present.
For seeds and system operations in production, use an admin actor instead:
admin_user = get_admin_user()
Ash.create!(resource, attrs, actor: admin_user)
## Usage in Policies
policies do
# Allow system operations without actor (TEST ENVIRONMENT ONLY)
# In test: All operations allowed
# In production: ALL operations denied (fail-closed)
bypass action_type([:create, :read, :update, :destroy]) do
authorize_if NoActor
end
# Check permissions when actor is present
policy action_type([:read, :create, :update, :destroy]) do
authorize_if HasPermission
end
end
## Behavior
- In test environment: Returns `true` when actor is nil (allows all operations)
- In production/dev: Returns `false` when actor is nil (denies all operations - fail-closed)
- Returns `false` when actor is present (delegates to other policies)
"""
use Ash.Policy.SimpleCheck
# Compile-time check: Only allow no-actor bypass in test environment
@allow_no_actor_bypass Mix.env() == :test
# Alternative (if you want to control via config):
# @allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
@impl true
def describe(_opts) do
if @allow_no_actor_bypass do
"allows actions when no actor is present (test environment only)"
else
"denies all actions when no actor is present (production/dev - fail-closed)"
end
end
@impl true
def match?(nil, _context, _opts) do
# Actor is nil
if @allow_no_actor_bypass do
# Test environment: Allow all operations
true
else
# Production/dev: Deny all operations (fail-closed for security)
false
end
end
def match?(_actor, _context, _opts) do
# Actor is present - don't match (let other policies decide)
false
end
end

View file

@ -95,7 +95,9 @@ defmodule Mv.Authorization.PermissionSets do
def get_permissions(:own_data) do
%{
resources: [
# User: Can always read/update own credentials
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
@ -125,6 +127,8 @@ defmodule Mv.Authorization.PermissionSets do
%{
resources: [
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
@ -157,6 +161,8 @@ defmodule Mv.Authorization.PermissionSets do
%{
resources: [
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},

View file

@ -67,6 +67,11 @@ defmodule Mv.Authorization.Role do
# Custom validations will still work
end
create :create_role_with_system_flag do
description "Internal action to create roles, allowing `is_system_role` to be set. Used by seeds and migrations."
accept [:name, :description, :permission_set_name, :is_system_role]
end
update :update_role do
primary? true
# is_system_role is intentionally excluded - should only be set via seeds/internal actions
@ -139,4 +144,33 @@ defmodule Mv.Authorization.Role do
identities do
identity :unique_name, [:name]
end
@doc """
Loads the "Mitglied" role without authorization (for bootstrap operations).
This is a helper function to avoid code duplication when loading the default
role in changes, migrations, and test setup.
## Returns
- `{:ok, %Mv.Authorization.Role{}}` - The "Mitglied" role
- `{:ok, nil}` - Role doesn't exist
- `{:error, term()}` - Error during lookup
## Examples
{:ok, mitglied_role} = Mv.Authorization.Role.get_mitglied_role()
# => {:ok, %Mv.Authorization.Role{name: "Mitglied", ...}}
{:ok, nil} = Mv.Authorization.Role.get_mitglied_role()
# => Role doesn't exist (e.g., in test environment before seeds run)
"""
@spec get_mitglied_role() :: {:ok, t() | nil} | {:error, term()}
def get_mitglied_role do
require Ash.Query
__MODULE__
|> Ash.Query.filter(name == "Mitglied")
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
end
end

View file

@ -19,6 +19,12 @@ defmodule Mv.Constants do
@custom_field_prefix "custom_field_"
@boolean_filter_prefix "bf_"
@max_boolean_filters 50
@max_uuid_length 36
@email_validator_checks [:html_input, :pow]
def member_fields, do: @member_fields
@ -33,6 +39,42 @@ defmodule Mv.Constants do
"""
def custom_field_prefix, do: @custom_field_prefix
@doc """
Returns the prefix used for boolean custom field filter URL parameters.
## Examples
iex> Mv.Constants.boolean_filter_prefix()
"bf_"
"""
def boolean_filter_prefix, do: @boolean_filter_prefix
@doc """
Returns the maximum number of boolean custom field filters allowed per request.
This limit prevents DoS attacks by restricting the number of filter parameters
that can be processed in a single request.
## Examples
iex> Mv.Constants.max_boolean_filters()
50
"""
def max_boolean_filters, do: @max_boolean_filters
@doc """
Returns the maximum length of a UUID string (36 characters including hyphens).
UUIDs in standard format (e.g., "550e8400-e29b-41d4-a716-446655440000") are
exactly 36 characters long. This constant is used for input validation.
## Examples
iex> Mv.Constants.max_uuid_length()
36
"""
def max_uuid_length, do: @max_uuid_length
@doc """
Returns the email validator checks used for EctoCommons.EmailValidator.

View file

@ -41,10 +41,8 @@ defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs)
actor = Map.get(changeset.context, :actor)
with {:ok, member} <- Helpers.extract_record(result),
linked_user <- Loader.get_linked_user(member, actor) do
linked_user <- Loader.get_linked_user(member) do
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
else
_ -> result

View file

@ -33,17 +33,7 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
if Map.get(context, :syncing_email, false) do
changeset
else
# Ensure actor is in changeset context - get it from context if available
actor = Map.get(changeset.context, :actor) || Map.get(context, :actor)
changeset_with_actor =
if actor && !Map.has_key?(changeset.context, :actor) do
Ash.Changeset.put_context(changeset, :actor, actor)
else
changeset
end
sync_email(changeset_with_actor)
sync_email(changeset)
end
end
@ -52,7 +42,7 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
result = callback.(cs)
with {:ok, record} <- Helpers.extract_record(result),
{:ok, user, member} <- get_user_and_member(record, cs) do
{:ok, user, member} <- get_user_and_member(record) do
# When called from Member-side, we need to update the member in the result
# When called from User-side, we update the linked member in DB only
case record do
@ -71,19 +61,16 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
end
# Retrieves user and member - works for both resource types
defp get_user_and_member(%Mv.Accounts.User{} = user, changeset) do
actor = Map.get(changeset.context, :actor)
case Loader.get_linked_member(user, actor) do
# Uses system actor via Loader functions
defp get_user_and_member(%Mv.Accounts.User{} = user) do
case Loader.get_linked_member(user) do
nil -> {:error, :no_member}
member -> {:ok, user, member}
end
end
defp get_user_and_member(%Mv.Membership.Member{} = member, changeset) do
actor = Map.get(changeset.context, :actor)
case Loader.load_linked_user!(member, actor) do
defp get_user_and_member(%Mv.Membership.Member{} = member) do
case Loader.load_linked_user!(member) do
{:ok, user} -> {:ok, user, member}
error -> error
end

View file

@ -5,25 +5,26 @@ defmodule Mv.EmailSync.Loader do
## Authorization
This module runs systemically and accepts optional actor parameters.
When called from hooks/changes, actor is extracted from changeset context.
When called directly, actor should be provided for proper authorization.
This module runs systemically and uses the system actor for all operations.
This ensures that email synchronization always works, regardless of user permissions.
All functions accept an optional `actor` parameter that is passed to Ash operations
to ensure proper authorization checks are performed.
All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass
user permission checks, as email sync is a mandatory side effect.
"""
alias Mv.Helpers
alias Mv.Helpers.SystemActor
@doc """
Loads the member linked to a user, returns nil if not linked or on error.
Accepts optional actor for authorization.
Uses system actor for authorization to ensure email sync always works.
"""
def get_linked_member(user, actor \\ nil)
def get_linked_member(%{member_id: nil}, _actor), do: nil
def get_linked_member(user)
def get_linked_member(%{member_id: nil}), do: nil
def get_linked_member(%{member_id: id}, actor) do
opts = Helpers.ash_actor_opts(actor)
def get_linked_member(%{member_id: id}) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.get(Mv.Membership.Member, id, opts) do
{:ok, member} -> member
@ -34,10 +35,11 @@ defmodule Mv.EmailSync.Loader do
@doc """
Loads the user linked to a member, returns nil if not linked or on error.
Accepts optional actor for authorization.
Uses system actor for authorization to ensure email sync always works.
"""
def get_linked_user(member, actor \\ nil) do
opts = Helpers.ash_actor_opts(actor)
def get_linked_user(member) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.load(member, :user, opts) do
{:ok, %{user: user}} -> user
@ -49,10 +51,11 @@ defmodule Mv.EmailSync.Loader do
Loads the user linked to a member, returning an error tuple if not linked.
Useful when a link is required for the operation.
Accepts optional actor for authorization.
Uses system actor for authorization to ensure email sync always works.
"""
def load_linked_user!(member, actor \\ nil) do
opts = Helpers.ash_actor_opts(actor)
def load_linked_user!(member) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.load(member, :user, opts) do
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}

View file

@ -0,0 +1,453 @@
defmodule Mv.Helpers.SystemActor do
@moduledoc """
Provides access to the system actor for systemic operations.
The system actor is a user with admin permissions that is used
for operations that must always run regardless of user permissions:
- Email synchronization
- Email uniqueness validation
- Cycle generation (if mandatory)
- Background jobs
- Seeds
## Usage
# Get system actor for systemic operations
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Ash.read(query, actor: system_actor)
## Implementation
The system actor is cached in an Agent for performance. On first access,
it attempts to load a user with email "system@mila.local" and admin role.
If that user doesn't exist, it falls back to the admin user from seeds
(identified by ADMIN_EMAIL environment variable or "admin@localhost").
## Caching
The system actor is cached in an Agent to avoid repeated database queries.
The cache is invalidated on application restart. For long-running applications,
consider implementing cache invalidation on role changes.
## Race Conditions
The system actor creation uses `upsert?: true` with `upsert_identity: :unique_email`
to prevent race conditions when multiple processes try to create the system user
simultaneously. This ensures idempotent creation and prevents database constraint errors.
## Security
The system actor should NEVER be used for user-initiated actions. It is
only for systemic operations that must bypass user permissions.
The system user is created without a password (`hashed_password = nil`) and
without an OIDC ID (`oidc_id = nil`) to prevent login. This ensures the
system user cannot be used for authentication, even if credentials are
somehow obtained.
"""
use Agent
require Ash.Query
alias Mv.Config
@doc """
Starts the SystemActor Agent.
This is called automatically by the application supervisor.
The agent starts with nil state and loads the system actor lazily on first access.
"""
def start_link(_opts) do
# Start with nil - lazy initialization on first get_system_actor call
# This prevents database access during application startup (important for tests)
Agent.start_link(fn -> nil end, name: __MODULE__)
end
@doc """
Returns the system actor (user with admin role).
The system actor is cached in an Agent for performance. On first access,
it loads the system user from the database or falls back to the admin user.
## Returns
- `%Mv.Accounts.User{}` - User with admin role loaded
- Raises if system actor cannot be found or loaded
## Examples
iex> system_actor = Mv.Helpers.SystemActor.get_system_actor()
iex> system_actor.role.permission_set_name
"admin"
"""
@spec get_system_actor() :: Mv.Accounts.User.t()
def get_system_actor do
case get_system_actor_result() do
{:ok, actor} -> actor
{:error, reason} -> raise "Failed to load system actor: #{inspect(reason)}"
end
end
@doc """
Returns the system actor as a result tuple.
This variant returns `{:ok, actor}` or `{:error, reason}` instead of raising,
which is useful for error handling in pipes or when you want to handle errors explicitly.
## Returns
- `{:ok, %Mv.Accounts.User{}}` - Successfully loaded system actor
- `{:error, term()}` - Error loading system actor
## Examples
case SystemActor.get_system_actor_result() do
{:ok, actor} -> use_actor(actor)
{:error, reason} -> handle_error(reason)
end
"""
@spec get_system_actor_result() :: {:ok, Mv.Accounts.User.t()} | {:error, term()}
def get_system_actor_result do
# In test environment (SQL sandbox), always load directly to avoid Agent/Sandbox issues
if Config.sql_sandbox?() do
try do
{:ok, load_system_actor()}
rescue
e -> {:error, e}
end
else
try do
result =
Agent.get_and_update(__MODULE__, fn
nil ->
# Cache miss - load system actor
try do
actor = load_system_actor()
{actor, actor}
rescue
e -> {{:error, e}, nil}
end
cached_actor ->
# Cache hit - return cached actor
{cached_actor, cached_actor}
end)
case result do
{:error, reason} -> {:error, reason}
actor -> {:ok, actor}
end
catch
:exit, {:noproc, _} ->
# Agent not started - load directly without caching
try do
{:ok, load_system_actor()}
rescue
e -> {:error, e}
end
end
end
end
@doc """
Invalidates the system actor cache.
This forces a reload of the system actor on the next call to `get_system_actor/0`.
Useful when the system user's role might have changed.
## Examples
iex> Mv.Helpers.SystemActor.invalidate_cache()
:ok
"""
@spec invalidate_cache() :: :ok
def invalidate_cache do
case Process.whereis(__MODULE__) do
nil -> :ok
_pid -> Agent.update(__MODULE__, fn _state -> nil end)
end
end
@doc """
Returns the email address of the system user.
This is useful for other modules that need to reference the system user
without loading the full user record.
## Returns
- `String.t()` - The system user email address ("system@mila.local")
## Examples
iex> Mv.Helpers.SystemActor.system_user_email()
"system@mila.local"
"""
@spec system_user_email() :: String.t()
def system_user_email, do: system_user_email_config()
# Returns the system user email from environment variable or default
# This allows configuration via SYSTEM_ACTOR_EMAIL env var
@spec system_user_email_config() :: String.t()
defp system_user_email_config do
System.get_env("SYSTEM_ACTOR_EMAIL") || "system@mila.local"
end
# Loads the system actor from the database
# First tries to find system@mila.local, then falls back to admin user
@spec load_system_actor() :: Mv.Accounts.User.t() | no_return()
defp load_system_actor do
case find_user_by_email(system_user_email_config()) do
{:ok, user} when not is_nil(user) ->
load_user_with_role(user)
{:ok, nil} ->
handle_system_user_not_found("no system user or admin user found")
{:error, _reason} = error ->
handle_system_user_error(error)
end
end
# Handles case when system user doesn't exist
@spec handle_system_user_not_found(String.t()) :: Mv.Accounts.User.t() | no_return()
defp handle_system_user_not_found(message) do
case load_admin_user_fallback() do
{:ok, admin_user} ->
admin_user
{:error, _} ->
handle_fallback_error(message)
end
end
# Handles database error when loading system user
@spec handle_system_user_error(term()) :: Mv.Accounts.User.t() | no_return()
defp handle_system_user_error(error) do
case load_admin_user_fallback() do
{:ok, admin_user} ->
admin_user
{:error, _} ->
handle_fallback_error("Failed to load system actor: #{inspect(error)}")
end
end
# Handles fallback error - creates test actor or raises
@spec handle_fallback_error(String.t()) :: Mv.Accounts.User.t() | no_return()
defp handle_fallback_error(message) do
if Config.sql_sandbox?() do
create_test_system_actor()
else
raise "Failed to load system actor: #{message}"
end
end
# Creates a temporary admin user for tests when no system/admin user exists
@spec create_test_system_actor() :: Mv.Accounts.User.t() | no_return()
defp create_test_system_actor do
alias Mv.Accounts
alias Mv.Authorization
admin_role = ensure_admin_role_exists()
create_system_user_with_role(admin_role)
end
# Ensures admin role exists - finds or creates it
@spec ensure_admin_role_exists() :: Mv.Authorization.Role.t() | no_return()
defp ensure_admin_role_exists do
case find_admin_role() do
{:ok, role} ->
role
{:error, :not_found} ->
create_admin_role_with_retry()
end
end
# Finds admin role in existing roles
# SECURITY: Uses authorize?: false for bootstrap role lookup.
@spec find_admin_role() :: {:ok, Mv.Authorization.Role.t()} | {:error, :not_found}
defp find_admin_role do
alias Mv.Authorization
case Authorization.list_roles(authorize?: false) do
{:ok, roles} ->
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
nil -> {:error, :not_found}
role -> {:ok, role}
end
_ ->
{:error, :not_found}
end
end
# Creates admin role, handling race conditions
@spec create_admin_role_with_retry() :: Mv.Authorization.Role.t() | no_return()
defp create_admin_role_with_retry do
alias Mv.Authorization
case create_admin_role() do
{:ok, role} ->
role
{:error, :already_exists} ->
find_existing_admin_role()
{:error, error} ->
raise "Failed to create admin role: #{inspect(error)}"
end
end
# Attempts to create admin role
# SECURITY: Uses authorize?: false for bootstrap role creation.
@spec create_admin_role() ::
{:ok, Mv.Authorization.Role.t()} | {:error, :already_exists | term()}
defp create_admin_role do
alias Mv.Authorization
case Authorization.create_role(
%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
},
authorize?: false
) do
{:ok, role} ->
{:ok, role}
{:error, %Ash.Error.Invalid{errors: [%{field: :name, message: "has already been taken"}]}} ->
{:error, :already_exists}
{:error, error} ->
{:error, error}
end
end
# Finds existing admin role after creation attempt failed due to race condition
# SECURITY: Uses authorize?: false for bootstrap role lookup.
@spec find_existing_admin_role() :: Mv.Authorization.Role.t() | no_return()
defp find_existing_admin_role do
alias Mv.Authorization
case Authorization.list_roles(authorize?: false) do
{:ok, roles} ->
Enum.find(roles, &(&1.permission_set_name == "admin")) ||
raise "Admin role should exist but was not found"
_ ->
raise "Failed to find admin role after creation attempt"
end
end
# Creates system user with admin role assigned
# SECURITY: System user is created without password (hashed_password = nil) and
# without OIDC ID (oidc_id = nil) to prevent login. This user is ONLY for
# internal system operations via SystemActor and should never be used for authentication.
@spec create_system_user_with_role(Mv.Authorization.Role.t()) ::
Mv.Accounts.User.t() | no_return()
defp create_system_user_with_role(admin_role) do
alias Mv.Accounts
# SECURITY: Uses authorize?: false for bootstrap user creation.
# This is necessary because we're creating the system actor itself,
# which would otherwise be needed for authorization (chicken-and-egg).
# This is safe because:
# 1. Only creates system user with known email
# 2. Only called during system actor initialization (bootstrap)
# 3. Once created, all subsequent operations use proper authorization
Accounts.create_user!(%{email: system_user_email_config()},
upsert?: true,
upsert_identity: :unique_email,
authorize?: false
)
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
end
# Finds a user by email address
# SECURITY: Uses authorize?: false for bootstrap lookup only.
# This is necessary because we need to find the system/admin user before
# we can load the system actor. If User policies require an actor, this
# would create a chicken-and-egg problem. This is safe because:
# 1. We only query by email (no sensitive data exposed)
# 2. This is only used during system actor initialization (bootstrap phase)
# 3. Once system actor is loaded, all subsequent operations use proper authorization
@spec find_user_by_email(String.t()) :: {:ok, Mv.Accounts.User.t() | nil} | {:error, term()}
defp find_user_by_email(email) do
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> Ash.read_one(domain: Mv.Accounts, authorize?: false)
end
# Loads a user with their role preloaded (required for authorization)
# SECURITY: Uses authorize?: false for bootstrap role loading.
# This is necessary because loading the role is part of system actor initialization,
# which would otherwise require an actor (chicken-and-egg).
@spec load_user_with_role(Mv.Accounts.User.t()) :: Mv.Accounts.User.t() | no_return()
defp load_user_with_role(user) do
case Ash.load(user, :role, domain: Mv.Accounts, authorize?: false) do
{:ok, user_with_role} ->
validate_admin_role(user_with_role)
{:error, reason} ->
raise "Failed to load role for system actor: #{inspect(reason)}"
end
end
# Validates that the user has an admin role
@spec validate_admin_role(Mv.Accounts.User.t()) :: Mv.Accounts.User.t() | no_return()
defp validate_admin_role(%{role: %{permission_set_name: "admin"}} = user) do
user
end
@spec validate_admin_role(Mv.Accounts.User.t()) :: no_return()
defp validate_admin_role(%{role: %{permission_set_name: permission_set}}) do
raise """
System actor must have admin role, but has permission_set_name: #{permission_set}
Please assign the "Admin" role to the system user.
"""
end
@spec validate_admin_role(Mv.Accounts.User.t()) :: no_return()
defp validate_admin_role(%{role: nil}) do
raise """
System actor must have a role assigned, but role is nil.
Please assign the "Admin" role to the system user.
"""
end
@spec validate_admin_role(term()) :: no_return()
defp validate_admin_role(_user) do
raise """
System actor must have a role with admin permissions.
Please assign the "Admin" role to the system user.
"""
end
# Fallback: Loads admin user from seeds (ADMIN_EMAIL env var or default)
@spec load_admin_user_fallback() :: {:ok, Mv.Accounts.User.t()} | {:error, term()}
defp load_admin_user_fallback do
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
case find_user_by_email(admin_email) do
{:ok, user} when not is_nil(user) ->
{:ok, load_user_with_role(user)}
{:ok, nil} ->
{:error, :admin_user_not_found}
{:error, _reason} = error ->
error
end
end
end

View file

@ -97,31 +97,48 @@ defmodule Mv.Membership.Import.HeaderMapper do
}
# Build reverse map: normalized_variant -> canonical_field
# Cached on first access for performance
# Computed on each access - the map is small enough that recomputing is fast
# This avoids Module.get_attribute issues while maintaining simplicity
defp normalized_to_canonical do
cached = Process.get({__MODULE__, :normalized_to_canonical})
if cached do
cached
else
map = build_normalized_to_canonical_map()
Process.put({__MODULE__, :normalized_to_canonical}, map)
map
end
end
# Builds the normalized variant -> canonical field map
defp build_normalized_to_canonical_map do
@member_field_variants_raw
|> Enum.flat_map(&map_variants_to_normalized/1)
|> Enum.flat_map(fn {canonical, variants} ->
Enum.map(variants, fn variant ->
{normalize_header(variant), canonical}
end)
end)
|> Map.new()
end
# Maps a canonical field and its variants to normalized tuples
defp map_variants_to_normalized({canonical, variants}) do
Enum.map(variants, fn variant ->
{normalize_header(variant), canonical}
end)
@doc """
Returns a MapSet of normalized member field names.
This is the single source of truth for known member fields.
Used to distinguish between member fields and custom fields.
## Returns
- `MapSet.t(String.t())` - Set of normalized member field names
## Examples
iex> HeaderMapper.known_member_fields()
#MapSet<["email", "firstname", "lastname", "street", "postalcode", "city"]>
"""
# Known member fields computed at compile-time for performance and determinism
@known_member_fields @member_field_variants_raw
|> Map.keys()
|> Enum.map(fn canonical ->
# Normalize the canonical field name (e.g., :first_name -> "firstname")
canonical
|> Atom.to_string()
|> String.replace("_", "")
|> String.downcase()
end)
|> MapSet.new()
@spec known_member_fields() :: MapSet.t(String.t())
def known_member_fields do
@known_member_fields
end
@doc """

View file

@ -70,7 +70,8 @@ defmodule Mv.Membership.Import.MemberCSV do
@type chunk_result :: %{
inserted: non_neg_integer(),
failed: non_neg_integer(),
errors: list(Error.t())
errors: list(Error.t()),
errors_truncated?: boolean()
}
alias Mv.Membership.Import.CsvParser
@ -78,6 +79,11 @@ defmodule Mv.Membership.Import.MemberCSV do
use Gettext, backend: MvWeb.Gettext
# Configuration constants
@default_max_errors 50
@default_chunk_size 200
@default_max_rows 1000
@doc """
Prepares CSV content for import by parsing, mapping headers, and validating limits.
@ -112,8 +118,8 @@ defmodule Mv.Membership.Import.MemberCSV do
"""
@spec prepare(String.t(), keyword()) :: {:ok, import_state()} | {:error, String.t()}
def prepare(file_content, opts \\ []) do
max_rows = Keyword.get(opts, :max_rows, 1000)
chunk_size = Keyword.get(opts, :chunk_size, 200)
max_rows = Keyword.get(opts, :max_rows, @default_max_rows)
chunk_size = Keyword.get(opts, :chunk_size, @default_chunk_size)
with {:ok, headers, rows} <- CsvParser.parse(file_content),
{:ok, custom_fields} <- load_custom_fields(),
@ -188,19 +194,13 @@ defmodule Mv.Membership.Import.MemberCSV do
end
# Checks if a normalized header matches a member field
# Uses HeaderMapper's internal logic to check if header would map to a member field
defp member_field?(normalized) do
# Try to build maps with just this header - if it maps to a member field, it's a member field
case HeaderMapper.build_maps([normalized], []) do
{:ok, %{member: member_map}} ->
# If member_map is not empty, it's a member field
map_size(member_map) > 0
_ ->
false
end
# Uses HeaderMapper.known_member_fields/0 as single source of truth
defp member_field?(normalized) when is_binary(normalized) do
MapSet.member?(HeaderMapper.known_member_fields(), normalized)
end
defp member_field?(_), do: false
# Validates that row count doesn't exceed limit
defp validate_row_count(rows, max_rows) do
if length(rows) > max_rows do
@ -258,7 +258,18 @@ defmodule Mv.Membership.Import.MemberCSV do
- `row_map` - Map with `:member` and `:custom` keys containing field values
- `column_map` - Map of canonical field names (atoms) to column indices (for reference)
- `custom_field_map` - Map of custom field IDs (strings) to column indices (for reference)
- `opts` - Optional keyword list for processing options
- `opts` - Optional keyword list for processing options:
- `:custom_field_lookup` - Map of custom field IDs to metadata (default: `%{}`)
- `:existing_error_count` - Number of errors already collected in previous chunks (default: `0`)
- `:max_errors` - Maximum number of errors to collect per import overall (default: `50`)
## Error Capping
Errors are capped at `max_errors` per import overall. When the limit is reached:
- No additional errors are collected in the `errors` list
- Processing continues for all rows
- The `failed` count continues to increment correctly for all failed rows
- The `errors_truncated?` flag is set to `true` to indicate that additional errors were suppressed
## Returns
@ -272,6 +283,11 @@ defmodule Mv.Membership.Import.MemberCSV do
iex> custom_field_map = %{}
iex> MemberCSV.process_chunk(chunk, column_map, custom_field_map)
{:ok, %{inserted: 1, failed: 0, errors: []}}
iex> chunk = [{2, %{member: %{email: "invalid"}, custom: %{}}}]
iex> opts = [existing_error_count: 25, max_errors: 50]
iex> MemberCSV.process_chunk(chunk, %{}, %{}, opts)
{:ok, %{inserted: 0, failed: 1, errors: [%Error{}], errors_truncated?: false}}
"""
@spec process_chunk(
list({pos_integer(), map()}),
@ -281,20 +297,40 @@ defmodule Mv.Membership.Import.MemberCSV do
) :: {:ok, chunk_result()} | {:error, String.t()}
def process_chunk(chunk_rows_with_lines, _column_map, _custom_field_map, opts \\ []) do
custom_field_lookup = Keyword.get(opts, :custom_field_lookup, %{})
existing_error_count = Keyword.get(opts, :existing_error_count, 0)
max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
actor = Keyword.fetch!(opts, :actor)
{inserted, failed, errors} =
Enum.reduce(chunk_rows_with_lines, {0, 0, []}, fn {line_number, row_map},
{acc_inserted, acc_failed, acc_errors} ->
case process_row(row_map, line_number, custom_field_lookup) do
{inserted, failed, errors, _collected_error_count, truncated?} =
Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false}, fn {line_number, row_map},
{acc_inserted, acc_failed,
acc_errors, acc_error_count,
acc_truncated?} ->
current_error_count = existing_error_count + acc_error_count
case process_row(row_map, line_number, custom_field_lookup, actor) do
{:ok, _member} ->
{acc_inserted + 1, acc_failed, acc_errors}
update_inserted(
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?}
)
{:error, error} ->
{acc_inserted, acc_failed + 1, [error | acc_errors]}
handle_row_error(
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?},
error,
current_error_count,
max_errors
)
end
end)
{:ok, %{inserted: inserted, failed: failed, errors: Enum.reverse(errors)}}
{:ok,
%{
inserted: inserted,
failed: failed,
errors: Enum.reverse(errors),
errors_truncated?: truncated?
}}
end
@doc """
@ -358,11 +394,9 @@ defmodule Mv.Membership.Import.MemberCSV do
# Extracts the first error from a changeset and converts it to a MemberCSV.Error struct
defp extract_changeset_error(changeset, csv_line_number) do
case Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end) do
errors = Ecto.Changeset.traverse_errors(changeset, &format_error_message/1)
case errors do
%{email: [message | _]} ->
# Email-specific error
%Error{
@ -391,6 +425,56 @@ defmodule Mv.Membership.Import.MemberCSV do
end
end
# Helper function to update accumulator when row is successfully inserted
defp update_inserted({acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?}) do
{acc_inserted + 1, acc_failed, acc_errors, acc_error_count, acc_truncated?}
end
# Helper function to handle row error with error count limit checking
defp handle_row_error(
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?},
error,
current_error_count,
max_errors
) do
new_acc_failed = acc_failed + 1
{new_acc_errors, new_error_count, new_truncated?} =
collect_error_if_under_limit(
error,
acc_errors,
acc_error_count,
acc_truncated?,
current_error_count,
max_errors
)
{acc_inserted, new_acc_failed, new_acc_errors, new_error_count, new_truncated?}
end
# Helper function to collect error only if under limit
defp collect_error_if_under_limit(
error,
acc_errors,
acc_error_count,
acc_truncated?,
current_error_count,
max_errors
) do
if current_error_count < max_errors do
{[error | acc_errors], acc_error_count + 1, acc_truncated?}
else
{acc_errors, acc_error_count, true}
end
end
# Formats error message by replacing placeholders
defp format_error_message({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end
# Maps changeset error messages to appropriate Gettext messages
defp gettext_error_message(message) when is_binary(message) do
cond do
@ -413,7 +497,8 @@ defmodule Mv.Membership.Import.MemberCSV do
defp process_row(
row_map,
line_number,
custom_field_lookup
custom_field_lookup,
actor
) do
# Validate row before database insertion
case validate_row(row_map, line_number, []) do
@ -438,12 +523,14 @@ defmodule Mv.Membership.Import.MemberCSV do
member_attrs_with_cf
end
case Mv.Membership.create_member(final_attrs) do
case Mv.Membership.create_member(final_attrs, actor: actor) do
{:ok, member} ->
{:ok, member}
{:error, %Ash.Error.Invalid{} = error} ->
{:error, format_ash_error(error, line_number)}
# Extract email from final_attrs for better error messages
email = Map.get(final_attrs, :email) || Map.get(trimmed_member_attrs, :email)
{:error, format_ash_error(error, line_number, email)}
{:error, error} ->
{:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}}
@ -536,7 +623,7 @@ defmodule Mv.Membership.Import.MemberCSV do
end
# Formats Ash errors into MemberCSV.Error structs
defp format_ash_error(%Ash.Error.Invalid{errors: errors}, line_number) do
defp format_ash_error(%Ash.Error.Invalid{errors: errors}, line_number, email) do
# Try to find email-related errors first (for better error messages)
email_error =
Enum.find(errors, fn error ->
@ -551,35 +638,37 @@ defmodule Mv.Membership.Import.MemberCSV do
%Error{
csv_line_number: line_number,
field: field,
message: format_error_message(message, field)
message: format_error_message(message, field, email)
}
%{message: message} ->
%Error{
csv_line_number: line_number,
field: nil,
message: format_error_message(message, nil)
message: format_error_message(message, nil, email)
}
_ ->
%Error{
csv_line_number: line_number,
field: nil,
message: "Validation failed"
message: gettext("Validation failed")
}
end
end
# Formats error messages, handling common cases like email uniqueness
defp format_error_message(message, field) when is_binary(message) do
defp format_error_message(message, field, email) when is_binary(message) do
if email_uniqueness_error?(message, field) do
"email has already been taken"
# Include email in error message for better user feedback
email_str = if email, do: to_string(email), else: gettext("email")
gettext("email %{email} has already been taken", email: email_str)
else
message
end
end
defp format_error_message(message, _field), do: to_string(message)
defp format_error_message(message, _field, _email), do: to_string(message)
# Checks if error message indicates email uniqueness constraint violation
defp email_uniqueness_error?(message, :email) do

View file

@ -10,6 +10,8 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
use Ash.Resource.Validation
alias Mv.Helpers
require Logger
@doc """
Validates email uniqueness across linked Member-User pairs.
@ -30,8 +32,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
actor = Map.get(changeset.context || %{}, :actor)
linked_user_id = get_linked_user_id(changeset.data, actor)
linked_user_id = get_linked_user_id(changeset.data)
is_linked? = not is_nil(linked_user_id)
# Only validate if member is already linked AND email is changing
@ -40,19 +41,22 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
if should_validate? do
new_email = Ash.Changeset.get_attribute(changeset, :email)
check_email_uniqueness(new_email, linked_user_id, actor)
check_email_uniqueness(new_email, linked_user_id)
else
:ok
end
end
defp check_email_uniqueness(email, exclude_user_id, actor) do
defp check_email_uniqueness(email, exclude_user_id) do
alias Mv.Helpers.SystemActor
query =
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> maybe_exclude_id(exclude_user_id)
opts = Helpers.ash_actor_opts(actor)
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.read(query, opts) do
{:ok, []} ->
@ -61,7 +65,11 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
{:ok, _} ->
{:error, field: :email, message: "is already used by another user", value: email}
{:error, _} ->
{:error, reason} ->
Logger.warning(
"Email uniqueness validation query failed for member email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
)
:ok
end
end
@ -69,8 +77,11 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
defp get_linked_user_id(member_data, actor) do
opts = Helpers.ash_actor_opts(actor)
defp get_linked_user_id(member_data) do
alias Mv.Helpers.SystemActor
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.load(member_data, :user, opts) do
{:ok, %{user: %{id: id}}} -> id

View file

@ -30,12 +30,11 @@ defmodule Mv.MembershipFees.CycleGenerator do
## Authorization
This module runs systemically and accepts optional actor parameters.
When called from hooks/changes, actor is extracted from changeset context.
When called directly, actor should be provided for proper authorization.
This module runs systemically and uses the system actor for all operations.
This ensures that cycle generation always works, regardless of user permissions.
All functions accept an optional `actor` parameter in the `opts` keyword list
that is passed to Ash operations to ensure proper authorization checks are performed.
All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass
user permission checks, as cycle generation is a mandatory side effect.
## Examples
@ -47,6 +46,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
"""
alias Mv.Helpers
alias Mv.Helpers.SystemActor
alias Mv.Membership.Member
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
@ -86,9 +87,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
def generate_cycles_for_member(member_or_id, opts \\ [])
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
actor = Keyword.get(opts, :actor)
case load_member(member_id, actor: actor) do
case load_member(member_id) do
{:ok, member} -> generate_cycles_for_member(member, opts)
{:error, reason} -> {:error, reason}
end
@ -98,27 +97,25 @@ defmodule Mv.MembershipFees.CycleGenerator do
today = Keyword.get(opts, :today, Date.utc_today())
skip_lock? = Keyword.get(opts, :skip_lock?, false)
do_generate_cycles_with_lock(member, today, skip_lock?, opts)
do_generate_cycles_with_lock(member, today, skip_lock?)
end
# Generate cycles with lock handling
# Returns {:ok, cycles, notifications} - notifications are never sent here,
# they should be returned to the caller (e.g., via after_action hook)
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
# Just generate cycles without additional locking
actor = Keyword.get(opts, :actor)
do_generate_cycles(member, today, actor: actor)
do_generate_cycles(member, today)
end
defp do_generate_cycles_with_lock(member, today, false, opts) do
defp do_generate_cycles_with_lock(member, today, false) do
lock_key = :erlang.phash2(member.id)
actor = Keyword.get(opts, :actor)
Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today, actor: actor) do
case do_generate_cycles(member, today) do
{:ok, cycles, notifications} ->
# Return cycles and notifications - do NOT send notifications here
# They will be sent by the caller (e.g., via after_action hook)
@ -168,12 +165,15 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Query ALL members with fee type assigned (including inactive/left members)
# The exit_date boundary is applied during cycle generation, not here.
# This allows catch-up generation for members who left but are missing cycles.
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
query =
Member
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|> Ash.Query.filter(not is_nil(join_date))
case Ash.read(query) do
case Ash.read(query, opts) do
{:ok, members} ->
results = process_members_in_batches(members, batch_size, today)
{:ok, build_results_summary(results)}
@ -235,33 +235,25 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Private functions
defp load_member(member_id, opts) do
actor = Keyword.get(opts, :actor)
defp load_member(member_id) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
query =
Member
|> Ash.Query.filter(id == ^member_id)
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
result =
if actor do
Ash.read_one(query, actor: actor)
else
Ash.read_one(query)
end
case result do
case Ash.read_one(query, opts) do
{:ok, nil} -> {:error, :member_not_found}
{:ok, member} -> {:ok, member}
{:error, reason} -> {:error, reason}
end
end
defp do_generate_cycles(member, today, opts) do
actor = Keyword.get(opts, :actor)
defp do_generate_cycles(member, today) do
# Reload member with relationships to ensure fresh data
case load_member(member.id, actor: actor) do
case load_member(member.id) do
{:ok, member} ->
cond do
is_nil(member.membership_fee_type_id) ->
@ -271,7 +263,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
{:error, :no_join_date}
true ->
generate_missing_cycles(member, today, actor: actor)
generate_missing_cycles(member, today)
end
{:error, reason} ->
@ -279,8 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp generate_missing_cycles(member, today, opts) do
actor = Keyword.get(opts, :actor)
defp generate_missing_cycles(member, today) do
fee_type = member.membership_fee_type
interval = fee_type.interval
amount = fee_type.amount
@ -296,7 +287,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Only generate if start_date <= end_date
if start_date && Date.compare(start_date, end_date) != :gt do
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
create_cycles(cycle_starts, member.id, fee_type.id, amount, actor: actor)
create_cycles(cycle_starts, member.id, fee_type.id, amount)
else
{:ok, [], []}
end
@ -391,8 +382,10 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
actor = Keyword.get(opts, :actor)
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
# Always use return_notifications?: true to collect notifications
# Notifications will be returned to the caller, who is responsible for
# sending them (e.g., via after_action hook returning {:ok, result, notifications})
@ -407,7 +400,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
}
handle_cycle_creation_result(
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true, actor: actor),
Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ opts),
cycle_start
)
end)

View file

@ -52,7 +52,8 @@ defmodule MvWeb do
quote do
use Phoenix.LiveView
on_mount MvWeb.LiveHelpers
on_mount {MvWeb.LiveHelpers, :default}
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
unquote(html_helpers())
end

View file

@ -81,7 +81,7 @@ defmodule MvWeb.Layouts.Sidebar do
icon="hero-currency-euro"
label={gettext("Fee Types")}
/>
<!-- Nested Admin Menu -->
<.menu_group icon="hero-cog-6-tooth" label={gettext("Administration")}>
<.menu_subitem href={~p"/users"} label={gettext("Users")} />

View file

@ -1,4 +1,10 @@
defmodule MvWeb.PageController do
@moduledoc """
Controller for rendering the homepage.
This controller handles the root route and renders the application's
homepage view.
"""
use MvWeb, :controller
def home(conn, _params) do

View file

@ -20,10 +20,13 @@ defmodule MvWeb.LinkOidcAccountLive do
@impl true
def mount(_params, session, socket) do
# Use SystemActor for authorization during OIDC linking (user is not yet logged in)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"),
oidc_user_info when not is_nil(oidc_user_info) <-
Map.get(session, "oidc_linking_user_info"),
{:ok, user} <- Ash.get(Mv.Accounts.User, user_id) do
{:ok, user} <- Ash.get(Mv.Accounts.User, user_id, actor: system_actor) do
# Check if user is passwordless
if passwordless?(user) do
# Auto-link passwordless user immediately
@ -46,9 +49,12 @@ defmodule MvWeb.LinkOidcAccountLive do
end
defp reload_user!(user_id) do
# Use SystemActor for authorization during OIDC linking (user is not yet logged in)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Mv.Accounts.User
|> Ash.Query.filter(id == ^user_id)
|> Ash.read_one!()
|> Ash.read_one!(actor: system_actor)
end
defp reset_password_form(socket) do
@ -58,13 +64,16 @@ defmodule MvWeb.LinkOidcAccountLive do
defp auto_link_passwordless_user(socket, user, oidc_user_info) do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
# Use SystemActor for authorization (passwordless user auto-linking)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
case user.id
|> reload_user!()
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: oidc_id,
oidc_user_info: oidc_user_info
})
|> Ash.update() do
|> Ash.update(actor: system_actor) do
{:ok, updated_user} ->
Logger.info(
"Passwordless account auto-linked to OIDC: user_id=#{updated_user.id}, oidc_id=#{oidc_id}"
@ -187,6 +196,9 @@ defmodule MvWeb.LinkOidcAccountLive do
defp link_oidc_account(socket, user, oidc_user_info) do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
# Use SystemActor for authorization (user just verified password but is not yet logged in)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Update the user with the OIDC ID
case user.id
|> reload_user!()
@ -194,7 +206,7 @@ defmodule MvWeb.LinkOidcAccountLive do
oidc_id: oidc_id,
oidc_user_info: oidc_user_info
})
|> Ash.update() do
|> Ash.update(actor: system_actor) do
{:ok, updated_user} ->
# After successful linking, redirect to OIDC login
# Since the user now has an oidc_id, the next OIDC login will succeed

View file

@ -0,0 +1,444 @@
defmodule MvWeb.Components.MemberFilterComponent do
@moduledoc """
Provides the MemberFilter Live-Component.
A DaisyUI dropdown filter for filtering members by payment status and boolean custom fields.
Uses radio inputs in a segmented control pattern (join + btn) for tri-state boolean filters.
## Design Decisions
- Uses `div` panel instead of `ul.menu/li` structure to avoid DaisyUI menu styles
(padding, display, hover, font sizes) that would interfere with form controls.
- Filter controls are form elements (fieldset with legend, radio inputs), not menu items.
Uses semantic `<fieldset>` and `<legend>` for proper accessibility and form structure.
- Dropdown stays open when clicking filter segments to allow multiple filter changes.
- Uses `phx-change` on form for radio inputs instead of individual `phx-click` events.
## Props
- `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid`
- `:boolean_custom_fields` - List of boolean custom fields to display
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
## Events
- Sends `{:payment_filter_changed, filter}` to parent when payment filter changes
- Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
"""
use MvWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div
class="relative"
id={@id}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
>
<button
type="button"
tabindex="0"
class={[
"btn gap-2",
(@cycle_status_filter || active_boolean_filters_count(@boolean_filters) > 0) && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
aria-haspopup="true"
aria-expanded={to_string(@open)}
aria-label={gettext("Filter members")}
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">
{button_label(@cycle_status_filter, @boolean_custom_fields, @boolean_filters)}
</span>
<span
:if={active_boolean_filters_count(@boolean_filters) > 0}
class="badge badge-primary badge-sm"
>
{active_boolean_filters_count(@boolean_filters)}
</span>
<span
:if={@cycle_status_filter && active_boolean_filters_count(@boolean_filters) == 0}
class="badge badge-primary badge-sm"
>
{@member_count}
</span>
</button>
<!--
NOTE: We use a div panel instead of ul.menu/li structure to avoid DaisyUI menu styles
(padding, display, hover, font sizes) that would interfere with our form controls.
Filter controls are form elements (fieldset with legend, radio inputs), not menu items.
We use semantic fieldset/legend structure for proper accessibility.
We use relative/absolute positioning instead of DaisyUI dropdown classes to have
full control over the open/close state via LiveView.
-->
<div
:if={@open}
tabindex="0"
class="absolute left-0 mt-2 w-[28rem] rounded-box border border-base-300 bg-base-100 p-4 shadow-xl z-[100]"
phx-click-away="close_dropdown"
phx-target={@myself}
role="dialog"
aria-label={gettext("Member filter")}
>
<form phx-change="update_filters" phx-target={@myself}>
<!-- Payment Filter Group -->
<div class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Payments")}
</div>
<fieldset class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-0 p-0 m-0 min-w-0">
<legend class="text-sm font-medium col-start-1 float-left w-auto">
{gettext("Payment Status")}
</legend>
<div class="join col-start-2">
<label
class={"#{payment_filter_label_class(@cycle_status_filter, nil)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="payment-filter-all"
>
<input
type="radio"
id="payment-filter-all"
name="payment_filter"
value="all"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={@cycle_status_filter == nil}
/>
<span class="text-xs">{gettext("All")}</span>
</label>
<label
class={"#{payment_filter_label_class(@cycle_status_filter, :paid)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="payment-filter-paid"
>
<input
type="radio"
id="payment-filter-paid"
name="payment_filter"
value="paid"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={@cycle_status_filter == :paid}
/>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("Paid")}</span>
</label>
<label
class={"#{payment_filter_label_class(@cycle_status_filter, :unpaid)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="payment-filter-unpaid"
>
<input
type="radio"
id="payment-filter-unpaid"
name="payment_filter"
value="unpaid"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={@cycle_status_filter == :unpaid}
/>
<.icon name="hero-x-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("Unpaid")}</span>
</label>
</div>
</fieldset>
</div>
<!-- Custom Fields Group -->
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Custom Fields")}
</div>
<div class="max-h-60 overflow-y-auto pr-2">
<fieldset
:for={custom_field <- @boolean_custom_fields}
class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-b border-base-200 last:border-0 border-0 p-0 m-0 min-w-0"
>
<legend class="text-sm font-medium col-start-1 float-left w-auto">
{custom_field.name}
</legend>
<div class="join col-start-2">
<label
class={"#{boolean_filter_label_class(@boolean_filters, custom_field.id, nil)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"custom-boolean-filter-#{custom_field.id}-all"}
>
<input
type="radio"
id={"custom-boolean-filter-#{custom_field.id}-all"}
name={"custom_boolean[#{custom_field.id}]"}
value="all"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@boolean_filters, to_string(custom_field.id)) == nil}
/>
<span class="text-xs">{gettext("All")}</span>
</label>
<label
class={"#{boolean_filter_label_class(@boolean_filters, custom_field.id, true)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"custom-boolean-filter-#{custom_field.id}-true"}
aria-label={gettext("Yes")}
title={gettext("Yes")}
>
<input
type="radio"
id={"custom-boolean-filter-#{custom_field.id}-true"}
name={"custom_boolean[#{custom_field.id}]"}
value="true"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@boolean_filters, to_string(custom_field.id)) == true}
/>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("Yes")}</span>
</label>
<label
class={"#{boolean_filter_label_class(@boolean_filters, custom_field.id, false)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"custom-boolean-filter-#{custom_field.id}-false"}
aria-label={gettext("No")}
title={gettext("No")}
>
<input
type="radio"
id={"custom-boolean-filter-#{custom_field.id}-false"}
name={"custom_boolean[#{custom_field.id}]"}
value="false"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@boolean_filters, to_string(custom_field.id)) == false}
/>
<.icon name="hero-x-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("No")}</span>
</label>
</div>
</fieldset>
</div>
</div>
<!-- Footer -->
<div class="mt-4 flex justify-between pt-3 border-t border-base-200">
<button
type="button"
phx-click="reset_filters"
phx-target={@myself}
class="btn btn-sm"
>
{gettext("Reset")}
</button>
<button
type="button"
phx-click="close_dropdown"
phx-target={@myself}
class="btn btn-primary btn-sm"
>
{gettext("Close")}
</button>
</div>
</form>
</div>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
@impl true
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
@impl true
def handle_event("update_filters", params, socket) do
# Parse payment filter
payment_filter =
case Map.get(params, "payment_filter") do
"paid" -> :paid
"unpaid" -> :unpaid
_ -> nil
end
# Parse boolean custom field filters (including nil values for "all")
custom_boolean_filters_parsed =
params
|> Map.get("custom_boolean", %{})
|> Enum.reduce(%{}, fn {custom_field_id_str, value_str}, acc ->
filter_value = parse_tri_state(value_str)
Map.put(acc, custom_field_id_str, filter_value)
end)
# Update payment filter if changed
if payment_filter != socket.assigns.cycle_status_filter do
send(self(), {:payment_filter_changed, payment_filter})
end
# Update boolean filters - send events for each changed filter
current_filters = socket.assigns.boolean_filters
# Process all custom field filters from form (including those set to "all"/nil)
# Radio buttons in a group always send a value, so all active filters are in the form
Enum.each(custom_boolean_filters_parsed, fn {custom_field_id_str, new_value} ->
current_value = Map.get(current_filters, custom_field_id_str)
# Only send event if value actually changed
if current_value != new_value do
send(self(), {:boolean_filter_changed, custom_field_id_str, new_value})
end
end)
# Don't close dropdown - allow multiple filter changes
{:noreply, socket}
end
@impl true
def handle_event("reset_filters", _params, socket) do
# Send single message to reset all filters at once (performance optimization)
# This avoids N×2 load_members() calls when resetting multiple filters
send(self(), {:reset_all_filters, nil, %{}})
# Close dropdown after reset
{:noreply, assign(socket, :open, false)}
end
# Parse tri-state filter value: "all" | "true" | "false" -> nil | true | false
defp parse_tri_state("true"), do: true
defp parse_tri_state("false"), do: false
defp parse_tri_state("all"), do: nil
defp parse_tri_state(_), do: nil
# Get display label for button
defp button_label(cycle_status_filter, boolean_custom_fields, boolean_filters) do
# If payment filter is active, show payment filter label
if cycle_status_filter do
payment_filter_label(cycle_status_filter)
else
# Otherwise show boolean filter labels
boolean_filter_label(boolean_custom_fields, boolean_filters)
end
end
# Get payment filter label
defp payment_filter_label(nil), do: gettext("All")
defp payment_filter_label(:paid), do: gettext("Paid")
defp payment_filter_label(:unpaid), do: gettext("Unpaid")
# Get boolean filter label (comma-separated list of active filter names)
defp boolean_filter_label(_boolean_custom_fields, boolean_filters)
when map_size(boolean_filters) == 0 do
gettext("All")
end
defp boolean_filter_label(boolean_custom_fields, boolean_filters) do
# Get names of active boolean filters
active_filter_names =
boolean_filters
|> Enum.map(fn {custom_field_id_str, _value} ->
Enum.find(boolean_custom_fields, fn cf -> to_string(cf.id) == custom_field_id_str end)
end)
|> Enum.filter(&(&1 != nil))
|> Enum.map(& &1.name)
# Join with comma and truncate if too long
label = Enum.join(active_filter_names, ", ")
truncate_label(label, 30)
end
# Truncate label if longer than max_length
defp truncate_label(label, max_length) when byte_size(label) <= max_length, do: label
defp truncate_label(label, max_length) do
String.slice(label, 0, max_length) <> "..."
end
# Count active boolean filters
defp active_boolean_filters_count(boolean_filters) do
map_size(boolean_filters)
end
# Get CSS classes for payment filter label based on current state
defp payment_filter_label_class(current_filter, expected_value) do
base_classes = "join-item btn btn-sm"
is_active = current_filter == expected_value
cond do
# All button (nil expected)
expected_value == nil ->
if is_active do
"#{base_classes} btn-active"
else
"#{base_classes} btn"
end
# Paid button
expected_value == :paid ->
if is_active do
"#{base_classes} btn-success btn-active"
else
"#{base_classes} btn"
end
# Unpaid button
expected_value == :unpaid ->
if is_active do
"#{base_classes} btn-error btn-active"
else
"#{base_classes} btn"
end
true ->
"#{base_classes} btn-outline"
end
end
# Get CSS classes for boolean filter label based on current state
defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do
base_classes = "join-item btn btn-sm"
current_value = Map.get(boolean_filters, to_string(custom_field_id))
is_active = current_value == expected_value
cond do
# All button (nil expected)
expected_value == nil ->
if is_active do
"#{base_classes} btn-active"
else
"#{base_classes} btn"
end
# True button
expected_value == true ->
if is_active do
"#{base_classes} btn-success btn-active"
else
"#{base_classes} btn"
end
# False button
expected_value == false ->
if is_active do
"#{base_classes} btn-error btn-active"
else
"#{base_classes} btn"
end
true ->
"#{base_classes} btn-outline"
end
end
end

View file

@ -1,147 +0,0 @@
defmodule MvWeb.Components.PaymentFilterComponent do
@moduledoc """
Provides the PaymentFilter Live-Component.
A dropdown filter for filtering members by cycle payment status (paid/unpaid/all).
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
Filter is based on cycle status (last or current cycle, depending on cycle view toggle).
## Props
- `:cycle_status_filter` - Current filter state: `nil` (all), `:paid`, or `:unpaid`
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
## Events
- Sends `{:payment_filter_changed, filter}` to parent when filter changes
"""
use MvWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div
class="relative"
id={@id}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
>
<button
type="button"
class={[
"btn gap-2",
@cycle_status_filter && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
aria-haspopup="true"
aria-expanded={to_string(@open)}
aria-label={gettext("Filter by payment status")}
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">{filter_label(@cycle_status_filter)}</span>
<span :if={@cycle_status_filter} class="badge badge-primary badge-sm">{@member_count}</span>
</button>
<ul
:if={@open}
class="menu dropdown-content bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg absolute right-0 mt-2"
role="menu"
aria-label={gettext("Payment filter")}
phx-click-away="close_dropdown"
phx-target={@myself}
>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@cycle_status_filter == nil)}
class={@cycle_status_filter == nil && "active"}
phx-click="select_filter"
phx-value-filter=""
phx-target={@myself}
>
<.icon name="hero-users" class="h-4 w-4" />
{gettext("All")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@cycle_status_filter == :paid)}
class={@cycle_status_filter == :paid && "active"}
phx-click="select_filter"
phx-value-filter="paid"
phx-target={@myself}
>
<.icon name="hero-check-circle" class="h-4 w-4 text-success" />
{gettext("Paid")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@cycle_status_filter == :unpaid)}
class={@cycle_status_filter == :unpaid && "active"}
phx-click="select_filter"
phx-value-filter="unpaid"
phx-target={@myself}
>
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
{gettext("Unpaid")}
</button>
</li>
</ul>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
@impl true
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
@impl true
def handle_event("select_filter", %{"filter" => filter_str}, socket) do
filter = parse_filter(filter_str)
# Close dropdown and notify parent
socket = assign(socket, :open, false)
send(self(), {:payment_filter_changed, filter})
{:noreply, socket}
end
# Parse filter string to atom
defp parse_filter("paid"), do: :paid
defp parse_filter("unpaid"), do: :unpaid
defp parse_filter(_), do: nil
# Get display label for current filter
defp filter_label(nil), do: gettext("All")
defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:unpaid), do: gettext("Unpaid")
end

View file

@ -1,345 +0,0 @@
defmodule MvWeb.ContributionPeriodLive.Show do
@moduledoc """
Mock-up LiveView for Member Contribution Periods (Admin/Treasurer View).
This is a preview-only page that displays the planned UI for viewing
and managing contribution periods for a specific member.
It shows static mock data and is not functional.
## Planned Features (Future Implementation)
- Display all contribution periods for a member
- Show period dates, interval, amount, and status
- Quick status change (paid/unpaid/suspended)
- Bulk marking of multiple periods
- Notes per period
## Note
This page is intentionally non-functional and serves as a UI mockup
for the upcoming Membership Contributions feature.
"""
use MvWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, gettext("Member Contributions"))
|> assign(:member, mock_member())
|> assign(:periods, mock_periods())
|> assign(:selected_periods, MapSet.new())}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.mockup_warning />
<.header>
{gettext("Contributions for %{name}", name: MvWeb.Helpers.MemberHelpers.display_name(@member))}
<:subtitle>
{gettext("Contribution type")}:
<span class="font-semibold">{@member.contribution_type}</span>
· {gettext("Member since")}: <span class="font-mono">{@member.joined_at}</span>
</:subtitle>
<:actions>
<.link navigate={~p"/membership_fee_settings"} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back to Settings")}
</.link>
</:actions>
</.header>
<%!-- Member Info Card --%>
<div class="mb-6 shadow card bg-base-100">
<div class="card-body">
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div>
<span class="text-sm text-base-content/60">{gettext("Email")}</span>
<p class="font-medium">{@member.email}</p>
</div>
<div>
<span class="text-sm text-base-content/60">{gettext("Contribution Start")}</span>
<p class="font-mono">{@member.contribution_start}</p>
</div>
<div>
<span class="text-sm text-base-content/60">{gettext("Total Contributions")}</span>
<p class="font-semibold">{length(@periods)}</p>
</div>
<div>
<span class="text-sm text-base-content/60">{gettext("Open Contributions")}</span>
<p class="font-semibold text-error">
{Enum.count(@periods, &(&1.status == :unpaid))}
</p>
</div>
</div>
</div>
</div>
<%!-- Contribution Type Change --%>
<div class="mb-6 card bg-base-200">
<div class="py-4 card-body">
<div class="flex flex-wrap items-center gap-4">
<span class="font-semibold">{gettext("Change Contribution Type")}:</span>
<select class="w-64 select select-bordered select-sm" disabled>
<option selected>{@member.contribution_type} (60,00 , {gettext("Yearly")})</option>
<option>{gettext("Reduced")} (30,00 , {gettext("Yearly")})</option>
<option>{gettext("Honorary")} (0,00 , {gettext("Yearly")})</option>
</select>
<span
class="text-sm text-base-content/60 cursor-help tooltip tooltip-bottom"
data-tip={
gettext(
"Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
)
}
>
<.icon name="hero-question-mark-circle" class="inline size-4" />
{gettext("Why are not all contribution types shown?")}
</span>
</div>
</div>
</div>
<%!-- Bulk Actions --%>
<div class="flex flex-wrap items-center gap-4 mb-4">
<span class="text-sm text-base-content/60">
{ngettext(
"%{count} period selected",
"%{count} periods selected",
MapSet.size(@selected_periods),
count: MapSet.size(@selected_periods)
)}
</span>
<button class="btn btn-sm btn-success" disabled>
<.icon name="hero-check" class="size-4" />
{gettext("Mark as Paid")}
</button>
<button class="btn btn-sm btn-ghost" disabled>
<.icon name="hero-minus-circle" class="size-4" />
{gettext("Mark as Suspended")}
</button>
<button class="btn btn-sm btn-ghost" disabled>
<.icon name="hero-x-circle" class="size-4" />
{gettext("Mark as Unpaid")}
</button>
</div>
<%!-- Periods Table --%>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>
<input type="checkbox" class="checkbox checkbox-sm" disabled />
</th>
<th>{gettext("Time Period")}</th>
<th>{gettext("Interval")}</th>
<th>{gettext("Amount")}</th>
<th>{gettext("Status")}</th>
<th>{gettext("Notes")}</th>
<th>{gettext("Actions")}</th>
</tr>
</thead>
<tbody>
<tr :for={period <- @periods} class={period_row_class(period.status)}>
<td>
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={MapSet.member?(@selected_periods, period.id)}
disabled
/>
</td>
<td>
<div class="font-mono">
{period.period_start} {period.period_end}
</div>
<div :if={period.is_current} class="mt-1 badge badge-info badge-sm">
{gettext("Current")}
</div>
</td>
<td>
<span class="badge badge-outline badge-sm">{format_interval(period.interval)}</span>
</td>
<td>
<span class="font-mono">{format_currency(period.amount)}</span>
</td>
<td>
<.status_badge status={period.status} />
</td>
<td>
<span :if={period.notes} class="text-sm italic text-base-content/60">
{period.notes}
</span>
<span :if={!period.notes} class="text-base-content/30"></span>
</td>
<td class="w-0 font-semibold whitespace-nowrap">
<div class="flex gap-4">
<.link
href="#"
class={[
"cursor-not-allowed",
if(period.status == :paid, do: "invisible", else: "opacity-50")
]}
>
{gettext("Paid")}
</.link>
<.link
href="#"
class={[
"cursor-not-allowed",
if(period.status == :suspended, do: "invisible", else: "opacity-50")
]}
>
{gettext("Suspend")}
</.link>
<.link
href="#"
class={[
"cursor-not-allowed",
if(period.status != :paid, do: "invisible", else: "opacity-50")
]}
>
{gettext("Reopen")}
</.link>
<.link href="#" class="opacity-50 cursor-not-allowed">
{gettext("Note")}
</.link>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</Layouts.app>
"""
end
# Mock-up warning banner component - subtle orange style
defp mockup_warning(assigns) do
~H"""
<div class="flex items-center gap-3 px-4 py-3 mb-6 border rounded-lg border-warning text-warning bg-base-100">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<div>
<span class="font-semibold">{gettext("Preview Mockup")}</span>
<span class="ml-2 text-sm text-base-content/70">
{gettext("This page is not functional and only displays the planned features.")}
</span>
</div>
</div>
"""
end
# Status badge component
attr :status, :atom, required: true
defp status_badge(%{status: :paid} = assigns) do
~H"""
<span class="gap-1 badge badge-success">
<.icon name="hero-check-circle-mini" class="size-3" />
{gettext("Paid")}
</span>
"""
end
defp status_badge(%{status: :unpaid} = assigns) do
~H"""
<span class="gap-1 badge badge-error">
<.icon name="hero-x-circle-mini" class="size-3" />
{gettext("Unpaid")}
</span>
"""
end
defp status_badge(%{status: :suspended} = assigns) do
~H"""
<span class="gap-1 badge badge-neutral">
<.icon name="hero-pause-circle-mini" class="size-3" />
{gettext("Suspended")}
</span>
"""
end
defp period_row_class(:unpaid), do: "bg-error/5"
defp period_row_class(:suspended), do: "bg-base-200/50"
defp period_row_class(_), do: ""
# Mock member data
defp mock_member do
%{
id: "123",
first_name: "Maria",
last_name: "Weber",
email: "maria.weber@example.de",
contribution_type: gettext("Regular"),
joined_at: "15.03.2021",
contribution_start: "01.01.2021"
}
end
# Mock periods data
defp mock_periods do
[
%{
id: "p1",
period_start: "01.01.2025",
period_end: "31.12.2025",
interval: :yearly,
amount: Decimal.new("60.00"),
status: :unpaid,
notes: nil,
is_current: true
},
%{
id: "p2",
period_start: "01.01.2024",
period_end: "31.12.2024",
interval: :yearly,
amount: Decimal.new("60.00"),
status: :paid,
notes: gettext("Paid via bank transfer"),
is_current: false
},
%{
id: "p3",
period_start: "01.01.2023",
period_end: "31.12.2023",
interval: :yearly,
amount: Decimal.new("50.00"),
status: :paid,
notes: nil,
is_current: false
},
%{
id: "p4",
period_start: "01.01.2022",
period_end: "31.12.2022",
interval: :yearly,
amount: Decimal.new("50.00"),
status: :paid,
notes: nil,
is_current: false
},
%{
id: "p5",
period_start: "01.01.2021",
period_end: "31.12.2021",
interval: :yearly,
amount: Decimal.new("50.00"),
status: :suspended,
notes: gettext("Joining year - reduced to 0"),
is_current: false
}
]
end
defp format_currency(%Decimal{} = amount) do
"#{Decimal.to_string(amount)}"
end
defp format_interval(:monthly), do: gettext("Monthly")
defp format_interval(:quarterly), do: gettext("Quarterly")
defp format_interval(:half_yearly), do: gettext("Half-yearly")
defp format_interval(:yearly), do: gettext("Yearly")
end

View file

@ -1,205 +0,0 @@
defmodule MvWeb.ContributionTypeLive.Index do
@moduledoc """
Mock-up LiveView for Contribution Types Management (Admin).
This is a preview-only page that displays the planned UI for managing
contribution types. It shows static mock data and is not functional.
## Planned Features (Future Implementation)
- List all contribution types
- Display: Name, Amount, Interval, Member count
- Create new contribution types
- Edit existing contribution types (name, amount, description - NOT interval)
- Delete contribution types (if no members assigned)
## Note
This page is intentionally non-functional and serves as a UI mockup
for the upcoming Membership Contributions feature.
"""
use MvWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, gettext("Contribution Types"))
|> assign(:contribution_types, mock_contribution_types())}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.mockup_warning />
<.header>
{gettext("Contribution Types")}
<:subtitle>
{gettext("Manage contribution types for membership fees.")}
</:subtitle>
<:actions>
<button class="btn btn-primary" disabled>
<.icon name="hero-plus" /> {gettext("New Contribution Type")}
</button>
</:actions>
</.header>
<.table id="contribution_types" rows={@contribution_types} row_id={fn ct -> "ct-#{ct.id}" end}>
<:col :let={ct} label={gettext("Name")}>
<span class="font-medium">{ct.name}</span>
<p :if={ct.description} class="text-sm text-base-content/60">{ct.description}</p>
</:col>
<:col :let={ct} label={gettext("Amount")}>
<span class="font-mono">{format_currency(ct.amount)}</span>
</:col>
<:col :let={ct} label={gettext("Interval")}>
<span class="badge badge-outline">{format_interval(ct.interval)}</span>
</:col>
<:col :let={ct} label={gettext("Members")}>
<span class="badge badge-ghost">{ct.member_count}</span>
</:col>
<:action :let={_ct}>
<button class="btn btn-ghost btn-xs" disabled title={gettext("Edit")}>
<.icon name="hero-pencil" class="size-4" />
</button>
</:action>
<:action :let={ct}>
<button
class="btn btn-ghost btn-xs text-error"
disabled
title={
if ct.member_count > 0,
do: gettext("Cannot delete - members assigned"),
else: gettext("Delete")
}
>
<.icon name="hero-trash" class="size-4" />
</button>
</:action>
</.table>
<.info_card />
</Layouts.app>
"""
end
# Mock-up warning banner component - subtle orange style
defp mockup_warning(assigns) do
~H"""
<div class="border border-warning text-warning bg-base-100 rounded-lg px-4 py-3 mb-6 flex items-center gap-3">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<div>
<span class="font-semibold">{gettext("Preview Mockup")}</span>
<span class="text-sm text-base-content/70 ml-2">
{gettext("This page is not functional and only displays the planned features.")}
</span>
</div>
</div>
"""
end
# Info card explaining the contribution type concept
defp info_card(assigns) do
~H"""
<div class="card bg-base-200 mt-6">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-information-circle" class="size-5" />
{gettext("About Contribution Types")}
</h2>
<div class="prose prose-sm max-w-none">
<p>
{gettext(
"Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
)}
</p>
<ul>
<li>
<strong>{gettext("Name & Amount")}</strong>
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
</li>
<li>
<strong>{gettext("Interval")}</strong>
- {gettext(
"Fixed after creation. Members can only switch between types with the same interval."
)}
</li>
<li>
<strong>{gettext("Deletion")}</strong>
- {gettext("Only possible if no members are assigned to this type.")}
</li>
</ul>
</div>
</div>
</div>
"""
end
# Mock data for demonstration
defp mock_contribution_types do
[
%{
id: "1",
name: gettext("Regular"),
description: gettext("Standard membership fee for regular members"),
amount: Decimal.new("60.00"),
interval: :yearly,
member_count: 45
},
%{
id: "2",
name: gettext("Reduced"),
description: gettext("Reduced fee for unemployed, pensioners, or low income"),
amount: Decimal.new("30.00"),
interval: :yearly,
member_count: 12
},
%{
id: "3",
name: gettext("Student"),
description: gettext("Monthly fee for students and trainees"),
amount: Decimal.new("5.00"),
interval: :monthly,
member_count: 8
},
%{
id: "4",
name: gettext("Family"),
description: gettext("Quarterly fee for family memberships"),
amount: Decimal.new("25.00"),
interval: :quarterly,
member_count: 15
},
%{
id: "5",
name: gettext("Supporting Member"),
description: gettext("Half-yearly contribution for supporting members"),
amount: Decimal.new("100.00"),
interval: :half_yearly,
member_count: 3
},
%{
id: "6",
name: gettext("Honorary"),
description: gettext("No fee for honorary members"),
amount: Decimal.new("0.00"),
interval: :yearly,
member_count: 2
}
]
end
defp format_currency(%Decimal{} = amount) do
"#{Decimal.to_string(amount)}"
end
defp format_interval(:monthly), do: gettext("Monthly")
defp format_interval(:quarterly), do: gettext("Quarterly")
defp format_interval(:half_yearly), do: gettext("Half-yearly")
defp format_interval(:yearly), do: gettext("Yearly")
end

View file

@ -1,300 +0,0 @@
defmodule MvWeb.CustomFieldValueLive.Form do
@moduledoc """
LiveView form for creating and editing custom field values.
## Features
- Create new custom field values with member and type selection
- Edit existing custom field values
- Value input adapts to custom field type (string, integer, boolean, date, email)
- Real-time validation
## Form Fields
**Required:**
- member - Select which member owns this custom field value
- custom_field - Select the type (defines value type)
- value - The actual value (input type depends on custom field type)
## Value Types
The form dynamically renders appropriate inputs based on custom field type:
- String: text input
- Integer: number input
- Boolean: checkbox
- Date: date picker
- Email: email input with validation
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update custom field value)
## Note
Custom field values are typically managed through the member edit form,
not through this standalone form.
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>
{gettext("Use this form to manage Custom Field Value records in your database.")}
</:subtitle>
</.header>
<.form for={@form} id="custom_field_value-form" phx-change="validate" phx-submit="save">
<!-- Custom Field Selection -->
<.input
field={@form[:custom_field_id]}
type="select"
label={gettext("Custom field")}
options={custom_field_options(@custom_fields)}
prompt={gettext("Choose a custom field")}
/>
<!-- Member Selection -->
<.input
field={@form[:member_id]}
type="select"
label={gettext("Member")}
options={member_options(@members)}
prompt={gettext("Choose a member")}
/>
<!-- Value Input - handles Union type -->
<%= if @selected_custom_field do %>
<.union_value_input form={@form} custom_field={@selected_custom_field} />
<% else %>
<div class="text-sm text-gray-600">
{gettext("Please select a custom field first")}
</div>
<% end %>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom Field Value")}
</.button>
<.button navigate={return_path(@return_to, @custom_field_value)}>{gettext("Cancel")}</.button>
</.form>
</Layouts.app>
"""
end
# Helper function for Union-Value Input
defp union_value_input(assigns) do
# Extract the current value from the CustomFieldValue
current_value = extract_current_value(assigns.form.data, assigns.custom_field.value_type)
assigns = assign(assigns, :current_value, current_value)
~H"""
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">
{gettext("Value")}
</label>
<%= case @custom_field.value_type do %>
<% :string -> %>
<.inputs_for :let={value_form} field={@form[:value]}>
<.input field={value_form[:value]} type="text" label="" value={@current_value} />
<input type="hidden" name={value_form[:_union_type].name} value="string" />
</.inputs_for>
<% :integer -> %>
<.inputs_for :let={value_form} field={@form[:value]}>
<.input field={value_form[:value]} type="number" label="" value={@current_value} />
<input type="hidden" name={value_form[:_union_type].name} value="integer" />
</.inputs_for>
<% :boolean -> %>
<.inputs_for :let={value_form} field={@form[:value]}>
<.input field={value_form[:value]} type="checkbox" label="" checked={@current_value} />
<input type="hidden" name={value_form[:_union_type].name} value="boolean" />
</.inputs_for>
<% :date -> %>
<.inputs_for :let={value_form} field={@form[:value]}>
<.input
field={value_form[:value]}
type="date"
label=""
value={format_date_value(@current_value)}
/>
<input type="hidden" name={value_form[:_union_type].name} value="date" />
</.inputs_for>
<% :email -> %>
<.inputs_for :let={value_form} field={@form[:value]}>
<.input field={value_form[:value]} type="email" label="" value={@current_value} />
<input type="hidden" name={value_form[:_union_type].name} value="email" />
</.inputs_for>
<% _ -> %>
<div class="text-sm text-red-600">
{gettext("Unsupported value type: %{type}", type: @custom_field.value_type)}
</div>
<% end %>
</div>
"""
end
# Helper function to extract the current value from the CustomFieldValue
defp extract_current_value(
%Mv.Membership.CustomFieldValue{value: %Ash.Union{value: value}},
_value_type
) do
value
end
defp extract_current_value(_data, _value_type) do
nil
end
# Helper function to format Date values for HTML input
defp format_date_value(%Date{} = date) do
Date.to_iso8601(date)
end
defp format_date_value(nil), do: ""
defp format_date_value(date) when is_binary(date) do
case Date.from_iso8601(date) do
{:ok, parsed_date} -> Date.to_iso8601(parsed_date)
_ -> ""
end
end
defp format_date_value(_), do: ""
@impl true
def mount(params, _session, socket) do
custom_field_value =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.CustomFieldValue, id) |> Ash.load!([:custom_field])
end
action = if is_nil(custom_field_value), do: "New", else: "Edit"
page_title = action <> " " <> "Custom field value"
# Load all CustomFields and Members for the selection fields
actor = current_actor(socket)
custom_fields = Ash.read!(Mv.Membership.CustomField, actor: actor)
members = Ash.read!(Mv.Membership.Member, actor: actor)
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(custom_field_value: custom_field_value)
|> assign(:page_title, page_title)
|> assign(:custom_fields, custom_fields)
|> assign(:members, members)
|> assign(:selected_custom_field, custom_field_value && custom_field_value.custom_field)
|> assign_form()}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"custom_field_value" => custom_field_value_params}, socket) do
# Find the selected CustomField
selected_custom_field =
case custom_field_value_params["custom_field_id"] do
"" -> nil
nil -> nil
id -> Enum.find(socket.assigns.custom_fields, &(&1.id == id))
end
# Set the Union type based on the selected CustomField
updated_params =
if selected_custom_field do
union_type = to_string(selected_custom_field.value_type)
put_in(custom_field_value_params, ["value", "_union_type"], union_type)
else
custom_field_value_params
end
{:noreply,
socket
|> assign(:selected_custom_field, selected_custom_field)
|> assign(form: AshPhoenix.Form.validate(socket.assigns.form, updated_params))}
end
def handle_event("save", %{"custom_field_value" => custom_field_value_params}, socket) do
# Set the Union type based on the selected CustomField
updated_params =
if socket.assigns.selected_custom_field do
union_type = to_string(socket.assigns.selected_custom_field.value_type)
put_in(custom_field_value_params, ["value", "_union_type"], union_type)
else
custom_field_value_params
end
actor = current_actor(socket)
case submit_form(socket.assigns.form, updated_params, actor) do
{:ok, custom_field_value} ->
notify_parent({:saved, custom_field_value})
action =
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
socket =
socket
|> put_flash(
:info,
gettext("Custom field value %{action} successfully", action: action)
)
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field_value))
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{custom_field_value: custom_field_value}} = socket) do
form =
if custom_field_value do
# Determine the Union type based on the custom_field
union_type = custom_field_value.custom_field && custom_field_value.custom_field.value_type
params =
if union_type do
%{"value" => %{"_union_type" => to_string(union_type)}}
else
%{}
end
AshPhoenix.Form.for_update(custom_field_value, :update,
as: "custom_field_value",
params: params
)
else
AshPhoenix.Form.for_create(Mv.Membership.CustomFieldValue, :create,
as: "custom_field_value"
)
end
assign(socket, form: to_form(form))
end
defp return_path("index", _custom_field_value), do: ~p"/custom_field_values"
defp return_path("show", custom_field_value),
do: ~p"/custom_field_values/#{custom_field_value.id}"
# Helper functions for selection options
defp custom_field_options(custom_fields) do
Enum.map(custom_fields, &{&1.name, &1.id})
end
defp member_options(members) do
Enum.map(members, &{MvWeb.Helpers.MemberHelpers.display_name(&1), &1.id})
end
end

View file

@ -1,157 +0,0 @@
defmodule MvWeb.CustomFieldValueLive.Index do
@moduledoc """
LiveView for displaying and managing custom field values.
## Features
- List all custom field values with their values and types
- Show which member each custom field value belongs to
- Display custom field information
- Navigate to custom field value details and edit forms
- Delete custom field values
## Relationships
Each custom field value is linked to:
- A member (the custom field value owner)
- A custom field (defining value type and behavior)
## Events
- `delete` - Remove a custom field value from the database
## Note
Custom field values are typically managed through the member edit form.
This view provides a global overview of all custom field values.
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Custom field values
<:actions>
<.button variant="primary" navigate={~p"/custom_field_values/new"}>
<.icon name="hero-plus" /> New Custom field value
</.button>
</:actions>
</.header>
<.table
id="custom_field_values"
rows={@streams.custom_field_values}
row_click={
fn {_id, custom_field_value} ->
JS.navigate(~p"/custom_field_values/#{custom_field_value}")
end
}
>
<:col :let={{_id, custom_field_value}} label="Id">{custom_field_value.id}</:col>
<:action :let={{_id, custom_field_value}}>
<div class="sr-only">
<.link navigate={~p"/custom_field_values/#{custom_field_value}"}>Show</.link>
</div>
<.link navigate={~p"/custom_field_values/#{custom_field_value}/edit"}>Edit</.link>
</:action>
<:action :let={{id, custom_field_value}}>
<.link
phx-click={JS.push("delete", value: %{id: custom_field_value.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
actor = current_actor(socket)
# Early return if no actor (prevents exceptions in unauthenticated tests)
if is_nil(actor) do
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, [])}
else
case Ash.read(Mv.Membership.CustomFieldValue, actor: actor) do
{:ok, custom_field_values} ->
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, custom_field_values)}
{:error, %Ash.Error.Forbidden{}} ->
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, [])
|> put_flash(:error, gettext("You do not have permission to view custom field values"))}
{:error, error} ->
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, [])
|> put_flash(:error, format_error(error))}
end
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
actor = MvWeb.LiveHelpers.current_actor(socket)
case Ash.get(Mv.Membership.CustomFieldValue, id, actor: actor) do
{:ok, custom_field_value} ->
case Ash.destroy(custom_field_value, actor: actor) do
:ok ->
{:noreply,
socket
|> stream_delete(:custom_field_values, custom_field_value)
|> put_flash(:info, gettext("Custom field value deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this custom field value")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Custom field value not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to access this custom field value")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
defp format_error(%Ash.Error.Invalid{errors: errors}) do
Enum.map_join(errors, ", ", fn %{message: message} -> message end)
end
defp format_error(error) do
inspect(error)
end
end

View file

@ -1,67 +0,0 @@
defmodule MvWeb.CustomFieldValueLive.Show do
@moduledoc """
LiveView for displaying a single custom field value's details.
## Features
- Display custom field value and type
- Show linked member
- Show custom field definition
- Navigate to edit form
- Return to custom field value list
## Displayed Information
- Custom field value (formatted based on type)
- Custom field name and description
- Member information (who owns this custom field value)
- Custom field value metadata (ID, timestamps if added)
## Navigation
- Back to custom field value list
- Edit custom field value
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Data field value {@custom_field_value.id}
<:subtitle>This is a custom_field_value record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/custom_field_values"}>
<.icon name="hero-arrow-left" />
</.button>
<.button
variant="primary"
navigate={~p"/custom_field_values/#{@custom_field_value}/edit?return_to=show"}
>
<.icon name="hero-pencil-square" /> Edit Custom field value
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@custom_field_value.id}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:custom_field_value, Ash.get!(Mv.Membership.CustomFieldValue, id))}
end
defp page_title(:show), do: "Show data field value"
defp page_title(:edit), do: "Edit data field value"
end

View file

@ -7,6 +7,7 @@ defmodule MvWeb.GlobalSettingsLive do
- Manage custom fields
- Real-time form validation
- Success/error feedback
- CSV member import (admin only)
## Settings
- `club_name` - The name of the association/club (required)
@ -14,6 +15,29 @@ defmodule MvWeb.GlobalSettingsLive do
## Events
- `validate` - Real-time form validation
- `save` - Save settings changes
- `start_import` - Start CSV member import (admin only)
## CSV Import
The CSV import feature allows administrators to upload CSV files and import members.
### File Upload
Files are uploaded automatically when selected (`auto_upload: true`). No manual
upload trigger is required.
### Rate Limiting
Currently, there is no rate limiting for CSV imports. Administrators can start
multiple imports in quick succession. This is intentional for bulk data migration
scenarios, but should be monitored in production.
### Limits
- Maximum file size: 10 MB
- Maximum rows: 1,000 rows (excluding header)
- Processing: chunks of 200 rows
- Errors: capped at 50 per import
## Note
Settings is a singleton resource - there is only one settings record.
@ -21,18 +45,48 @@ defmodule MvWeb.GlobalSettingsLive do
"""
use MvWeb, :live_view
alias Mv.Authorization.Actor
alias Mv.Config
alias Mv.Membership
alias Mv.Membership.Import.MemberCSV
alias MvWeb.Authorization
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
# CSV Import configuration constants
# 10 MB
@max_file_size_bytes 10_485_760
@max_errors 50
@impl true
def mount(_params, _session, socket) do
def mount(_params, session, socket) do
{:ok, settings} = Membership.get_settings()
{:ok,
socket
|> assign(:page_title, gettext("Settings"))
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)
|> assign_form()}
# Get locale from session for translations
locale = session["locale"] || "de"
Gettext.put_locale(MvWeb.Gettext, locale)
socket =
socket
|> assign(:page_title, gettext("Settings"))
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)
|> assign(:import_state, nil)
|> assign(:import_progress, nil)
|> assign(:import_status, :idle)
|> assign(:locale, locale)
|> assign(:max_errors, @max_errors)
|> assign_form()
# Configure file upload with auto-upload enabled
# Files are uploaded automatically when selected, no need for manual trigger
|> allow_upload(:csv_file,
accept: ~w(.csv),
max_entries: 1,
max_file_size: @max_file_size_bytes,
auto_upload: true
)
{:ok, socket}
end
@impl true
@ -78,6 +132,206 @@ defmodule MvWeb.GlobalSettingsLive do
id="custom-fields-component"
/>
</.form_section>
<%!-- CSV Import Section (Admin only) --%>
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
<.form_section title={gettext("Import Members (CSV)")}>
<div role="note" class="alert alert-info mb-4">
<div>
<p class="font-semibold">
{gettext(
"Custom fields must be created in Mila before importing CSV files with custom field columns"
)}
</p>
<p class="text-sm mt-2">
{gettext(
"Use the custom field name as the CSV column header (same normalization as member fields applies)"
)}
</p>
</div>
</div>
<div class="mb-4">
<p class="text-sm text-base-content/70 mb-2">
{gettext("Download CSV templates:")}
</p>
<ul class="list-disc list-inside space-y-1">
<li>
<.link
href={~p"/templates/member_import_en.csv"}
download="member_import_en.csv"
class="link link-primary"
>
{gettext("English Template")}
</.link>
</li>
<li>
<.link
href={~p"/templates/member_import_de.csv"}
download="member_import_de.csv"
class="link link-primary"
>
{gettext("German Template")}
</.link>
</li>
</ul>
</div>
<.form
id="csv-upload-form"
for={%{}}
multipart={true}
phx-change="validate_csv_upload"
phx-submit="start_import"
data-testid="csv-upload-form"
>
<div class="form-control">
<label for="csv_file" class="label">
<span class="label-text">
{gettext("CSV File")}
</span>
</label>
<.live_file_input
upload={@uploads.csv_file}
id="csv_file"
class="file-input file-input-bordered w-full"
aria-describedby="csv_file_help"
/>
<label class="label" id="csv_file_help">
<span class="label-text-alt">
{gettext("CSV files only, maximum 10 MB")}
</span>
</label>
</div>
<.button
type="submit"
phx-disable-with={gettext("Starting import...")}
variant="primary"
disabled={
@import_status == :running or
Enum.empty?(@uploads.csv_file.entries) or
@uploads.csv_file.entries |> List.first() |> then(&(&1 && not &1.done?))
}
data-testid="start-import-button"
>
{gettext("Start Import")}
</.button>
</.form>
<%= if @import_status == :running or @import_status == :done do %>
<%= if @import_progress do %>
<div
role="status"
aria-live="polite"
class="mt-4"
data-testid="import-progress-container"
>
<%= if @import_progress.status == :running do %>
<p class="text-sm" data-testid="import-progress-text">
{gettext("Processing chunk %{current} of %{total}...",
current: @import_progress.current_chunk,
total: @import_progress.total_chunks
)}
</p>
<% end %>
<%= if @import_progress.status == :done do %>
<section class="space-y-4" data-testid="import-results-panel">
<h2 class="text-lg font-semibold">
{gettext("Import Results")}
</h2>
<div class="space-y-4">
<div>
<h3 class="text-sm font-semibold mb-2">
{gettext("Summary")}
</h3>
<div class="text-sm space-y-2">
<p>
<.icon
name="hero-check-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Successfully inserted: %{count} member(s)",
count: @import_progress.inserted
)}
</p>
<%= if @import_progress.failed > 0 do %>
<p>
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Failed: %{count} row(s)", count: @import_progress.failed)}
</p>
<% end %>
<%= if @import_progress.errors_truncated? do %>
<p>
<.icon
name="hero-information-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Error list truncated to %{count} entries",
count: @max_errors
)}
</p>
<% end %>
</div>
</div>
<%= if length(@import_progress.errors) > 0 do %>
<div data-testid="import-error-list">
<h3 class="text-sm font-semibold mb-2">
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Errors")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for error <- @import_progress.errors do %>
<li>
{gettext("Line %{line}: %{message}",
line: error.csv_line_number || "?",
message: error.message || gettext("Unknown error")
)}
<%= if error.field do %>
{gettext(" (Field: %{field})", field: error.field)}
<% end %>
</li>
<% end %>
</ul>
</div>
<% end %>
<%= if length(@import_progress.warnings) > 0 do %>
<div class="alert alert-warning">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<h3 class="font-semibold mb-2">
{gettext("Warnings")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for warning <- @import_progress.warnings do %>
<li>{warning}</li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
</section>
<% end %>
</div>
<% end %>
<% end %>
</.form_section>
<% end %>
</Layouts.app>
"""
end
@ -110,6 +364,112 @@ defmodule MvWeb.GlobalSettingsLive do
end
end
@impl true
def handle_event("validate_csv_upload", _params, socket) do
{:noreply, socket}
end
@impl true
def handle_event("start_import", _params, socket) do
case check_import_prerequisites(socket) do
{:error, message} ->
{:noreply, put_flash(socket, :error, message)}
:ok ->
process_csv_upload(socket)
end
end
# Checks if import can be started (admin permission, status, upload ready)
defp check_import_prerequisites(socket) do
# Ensure user role is loaded before authorization check
user = socket.assigns[:current_user]
user_with_role = Actor.ensure_loaded(user)
cond do
not Authorization.can?(user_with_role, :create, Mv.Membership.Member) ->
{:error, gettext("Only administrators can import members from CSV files.")}
socket.assigns.import_status == :running ->
{:error, gettext("Import is already running. Please wait for it to complete.")}
Enum.empty?(socket.assigns.uploads.csv_file.entries) ->
{:error, gettext("Please select a CSV file to import.")}
not List.first(socket.assigns.uploads.csv_file.entries).done? ->
{:error,
gettext("Please wait for the file upload to complete before starting the import.")}
true ->
:ok
end
end
# Processes CSV upload and starts import
defp process_csv_upload(socket) do
with {:ok, content} <- consume_and_read_csv(socket),
{:ok, import_state} <- MemberCSV.prepare(content) do
start_import(socket, import_state)
else
{:error, reason} when is_binary(reason) ->
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to prepare CSV import: %{reason}", reason: reason)
)}
{:error, error} ->
error_message = format_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to prepare CSV import: %{error}", error: error_message)
)}
end
end
# Starts the import process
defp start_import(socket, import_state) do
progress = initialize_import_progress(import_state)
socket =
socket
|> assign(:import_state, import_state)
|> assign(:import_progress, progress)
|> assign(:import_status, :running)
send(self(), {:process_chunk, 0})
{:noreply, socket}
end
# Initializes import progress structure
defp initialize_import_progress(import_state) do
%{
inserted: 0,
failed: 0,
errors: [],
warnings: import_state.warnings || [],
status: :running,
current_chunk: 0,
total_chunks: length(import_state.chunks),
errors_truncated?: false
}
end
# Formats error messages for display
defp format_error_message(error) do
case error do
%{message: msg} when is_binary(msg) -> msg
%{errors: errors} when is_list(errors) -> inspect(errors)
reason when is_binary(reason) -> reason
other -> inspect(other)
end
end
@impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent,
@ -180,6 +540,139 @@ defmodule MvWeb.GlobalSettingsLive do
{:noreply, assign(socket, :settings, updated_settings)}
end
@impl true
def handle_info({:process_chunk, idx}, socket) do
case socket.assigns do
%{import_state: import_state, import_progress: progress}
when is_map(import_state) and is_map(progress) ->
if idx >= 0 and idx < length(import_state.chunks) do
start_chunk_processing_task(socket, import_state, progress, idx)
else
handle_chunk_error(socket, :invalid_index, idx)
end
_ ->
# Missing required assigns - mark as error
handle_chunk_error(socket, :missing_state, idx)
end
end
@impl true
def handle_info({:chunk_done, idx, result}, socket) do
case socket.assigns do
%{import_state: import_state, import_progress: progress}
when is_map(import_state) and is_map(progress) ->
handle_chunk_result(socket, import_state, progress, idx, result)
_ ->
# Missing required assigns - mark as error
handle_chunk_error(socket, :missing_state, idx)
end
end
@impl true
def handle_info({:chunk_error, idx, reason}, socket) do
handle_chunk_error(socket, :processing_failed, idx, reason)
end
# Starts async task to process a chunk
# In tests (SQL sandbox mode), runs synchronously to avoid Ecto Sandbox issues
defp start_chunk_processing_task(socket, import_state, progress, idx) do
chunk = Enum.at(import_state.chunks, idx)
# Ensure user role is loaded before using as actor
user = socket.assigns[:current_user]
actor = Actor.ensure_loaded(user)
live_view_pid = self()
# Process chunk with existing error count for capping
opts = [
custom_field_lookup: import_state.custom_field_lookup,
existing_error_count: length(progress.errors),
max_errors: @max_errors,
actor: actor
]
# Get locale from socket for translations in background tasks
locale = socket.assigns[:locale] || "de"
Gettext.put_locale(MvWeb.Gettext, locale)
if Config.sql_sandbox?() do
# Run synchronously in tests to avoid Ecto Sandbox issues with async tasks
{:ok, chunk_result} =
MemberCSV.process_chunk(
chunk,
import_state.column_map,
import_state.custom_field_map,
opts
)
# In test mode, send the message - it will be processed when render() is called
# in the test. The test helper wait_for_import_completion() handles message processing
send(live_view_pid, {:chunk_done, idx, chunk_result})
else
# Start async task to process chunk in production
# Use start_child for fire-and-forget: no monitor, no Task messages
# We only use our own send/2 messages for communication
Task.Supervisor.start_child(Mv.TaskSupervisor, fn ->
# Set locale in task process for translations
Gettext.put_locale(MvWeb.Gettext, locale)
{:ok, chunk_result} =
MemberCSV.process_chunk(
chunk,
import_state.column_map,
import_state.custom_field_map,
opts
)
send(live_view_pid, {:chunk_done, idx, chunk_result})
end)
end
{:noreply, socket}
end
# Handles chunk processing result from async task
defp handle_chunk_result(socket, import_state, progress, idx, chunk_result) do
# Merge progress
new_progress = merge_progress(progress, chunk_result, idx)
socket =
socket
|> assign(:import_progress, new_progress)
|> assign(:import_status, new_progress.status)
# Schedule next chunk or mark as done
socket = schedule_next_chunk(socket, idx, length(import_state.chunks))
{:noreply, socket}
end
# Handles chunk processing errors
defp handle_chunk_error(socket, error_type, idx, reason \\ nil) do
error_message =
case error_type do
:invalid_index ->
gettext("Invalid chunk index: %{idx}", idx: idx)
:missing_state ->
gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx)
:processing_failed ->
gettext("Failed to process chunk %{idx}: %{reason}",
idx: idx,
reason: inspect(reason)
)
end
socket =
socket
|> assign(:import_status, :error)
|> put_flash(:error, error_message)
{:noreply, socket}
end
defp assign_form(%{assigns: %{settings: settings}} = socket) do
form =
AshPhoenix.Form.for_update(
@ -192,4 +685,71 @@ defmodule MvWeb.GlobalSettingsLive do
assign(socket, form: to_form(form))
end
defp consume_and_read_csv(socket) do
result =
consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry ->
case File.read(path) do
{:ok, content} -> {:ok, content}
{:error, reason} -> {:error, Exception.message(reason)}
end
end)
result
|> case do
[content] when is_binary(content) ->
{:ok, content}
[{:ok, content}] when is_binary(content) ->
{:ok, content}
[{:error, reason}] ->
{:error, gettext("Failed to read file: %{reason}", reason: reason)}
[] ->
{:error, gettext("No file was uploaded")}
_other ->
{:error, gettext("Failed to read uploaded file")}
end
end
defp merge_progress(progress, chunk_result, current_chunk_idx) do
# Merge errors with cap of @max_errors overall
all_errors = progress.errors ++ chunk_result.errors
new_errors = Enum.take(all_errors, @max_errors)
errors_truncated? = length(all_errors) > @max_errors
# Merge warnings (optional dedupe - simple append for now)
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
# Update status based on whether we're done
# current_chunk_idx is 0-based, so after processing chunk 0, we've processed 1 chunk
chunks_processed = current_chunk_idx + 1
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
%{
inserted: progress.inserted + chunk_result.inserted,
failed: progress.failed + chunk_result.failed,
errors: new_errors,
warnings: new_warnings,
status: new_status,
current_chunk: chunks_processed,
total_chunks: progress.total_chunks,
errors_truncated?: errors_truncated? || chunk_result.errors_truncated?
}
end
defp schedule_next_chunk(socket, current_idx, total_chunks) do
next_idx = current_idx + 1
if next_idx < total_chunks do
# Schedule next chunk
send(self(), {:process_chunk, next_idx})
socket
else
# All chunks processed - status already set to :done in merge_progress
socket
end
end
end

View file

@ -13,7 +13,7 @@ defmodule MvWeb.MemberLive.Form do
## Form Sections
- Personal Data: Name, address, contact information, membership dates, notes
- Custom Fields: Dynamic fields in uniform grid layout (displayed sorted by name)
- Payment Data: Mockup section (not editable)
- Membership Fee: Selection of membership fee type with interval validation
## Events
- `validate` - Real-time form validation
@ -21,8 +21,6 @@ defmodule MvWeb.MemberLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
alias Mv.MembershipFees
@ -295,11 +293,14 @@ defmodule MvWeb.MemberLive.Form do
handle_save_success(socket, member)
{:error, form} ->
{:noreply, assign(socket, form: form)}
handle_save_error(socket, form)
end
rescue
_e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] ->
handle_save_forbidden(socket)
e ->
handle_save_exception(socket, e)
end
end
@ -321,6 +322,13 @@ defmodule MvWeb.MemberLive.Form do
{:noreply, socket}
end
defp handle_save_error(socket, form) do
# Always show a flash message when save fails
# Field-level validation errors are displayed in form fields, but flash provides additional feedback
error_message = extract_error_message(form)
{:noreply, socket |> assign(form: form) |> put_flash(:error, error_message)}
end
defp handle_save_forbidden(socket) do
# Handle policy violations that aren't properly displayed in forms
# AshPhoenix.Form doesn't implement FormData.Error protocol for Forbidden errors
@ -332,6 +340,98 @@ defmodule MvWeb.MemberLive.Form do
{:noreply, put_flash(socket, :error, error_message)}
end
defp handle_save_exception(socket, exception) do
# Handle unexpected exceptions (database errors, network issues, etc.)
require Logger
Logger.error("Unexpected error saving member: #{inspect(exception)}")
action = get_action_name(socket.assigns.form.source.type)
error_message = gettext("Failed to %{action} member.", action: action)
{:noreply, put_flash(socket, :error, error_message)}
end
# Extracts a user-friendly error message from form errors
defp extract_error_message(form) do
source_errors = get_source_errors(form)
cond do
has_invalid_error?(source_errors) ->
extract_invalid_error_message(source_errors)
has_other_error?(source_errors) ->
extract_other_error_message(source_errors)
has_form_errors?(form) ->
gettext("Please correct the errors in the form and try again.")
true ->
gettext("Failed to save member. Please try again.")
end
end
# Checks if source errors contain an Ash.Error.Invalid
defp has_invalid_error?([%Ash.Error.Invalid{errors: errors} | _]) when is_list(errors), do: true
defp has_invalid_error?(_), do: false
# Extracts message from Ash.Error.Invalid
defp extract_invalid_error_message([%Ash.Error.Invalid{errors: errors} | _]) do
case List.first(errors) do
%{message: message} when is_binary(message) ->
gettext("Validation failed: %{message}", message: message)
%{field: field, message: message} when is_binary(message) ->
gettext("Validation failed: %{field} %{message}", field: field, message: message)
_ ->
gettext("Validation failed. Please check your input.")
end
end
# Checks if source errors contain other error types
defp has_other_error?([_ | _]), do: true
defp has_other_error?(_), do: false
# Extracts message from other error types
defp extract_other_error_message([error | _]) do
cond do
Map.has_key?(error, :message) and is_binary(error.message) ->
error.message
is_struct(error) ->
extract_struct_error_message(error)
true ->
gettext("Failed to save member. Please try again.")
end
end
# Extracts message from struct error using Ash.ErrorKind protocol
defp extract_struct_error_message(error) do
try do
Ash.ErrorKind.message(error)
rescue
Protocol.UndefinedError -> gettext("Failed to save member. Please try again.")
end
end
# Checks if form has any errors
defp has_form_errors?(form) do
case Map.get(form, :errors) do
errors when is_list(errors) and errors != [] -> true
_ -> false
end
end
# Extracts source-level errors from form (Ash errors, etc.)
defp get_source_errors(form) do
case form.source do
%{errors: errors} when is_list(errors) -> errors
%Ash.Changeset{errors: errors} when is_list(errors) -> errors
_ -> []
end
end
defp get_action_name(:create), do: gettext("create")
defp get_action_name(:update), do: gettext("update")
defp get_action_name(other), do: to_string(other)

View file

@ -27,9 +27,8 @@ defmodule MvWeb.MemberLive.Index do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
require Ash.Query
require Logger
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1]
@ -43,6 +42,15 @@ defmodule MvWeb.MemberLive.Index do
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix Mv.Constants.custom_field_prefix()
# Prefix used for boolean custom field filter URL parameters (e.g., "bf_<id>")
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
# Maximum number of boolean custom field filters allowed per request (DoS protection)
@max_boolean_filters Mv.Constants.max_boolean_filters()
# Maximum length of UUID string (36 characters including hyphens)
@max_uuid_length Mv.Constants.max_uuid_length()
# Member fields that are loaded for the overview
# Uses constants from Mv.Constants to ensure consistency
# Note: :id is always included for member identification
@ -74,6 +82,12 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
# Load boolean custom fields (filtered and sorted from all_custom_fields)
boolean_custom_fields =
all_custom_fields
|> Enum.filter(&(&1.value_type == :boolean))
|> Enum.sort_by(& &1.name, :asc)
# Load settings once to avoid N+1 queries
settings =
case Membership.get_settings() do
@ -103,10 +117,12 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:cycle_status_filter, nil)
|> assign(:boolean_custom_field_filters, %{})
|> assign(:selected_members, MapSet.new())
|> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields)
|> assign(:boolean_custom_fields, boolean_custom_fields)
|> assign(:all_available_fields, all_available_fields)
|> assign(:user_field_selection, initial_selection)
|> assign(
@ -220,7 +236,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
new_show_current
new_show_current,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
@ -334,7 +351,8 @@ defmodule MvWeb.MemberLive.Index do
existing_field_query,
existing_sort_query,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
# Set the new path with params
@ -363,7 +381,77 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
@impl true
def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do
# Update boolean filters map
updated_filters =
if filter_value == nil do
# Remove filter if nil (All option selected)
Map.delete(socket.assigns.boolean_custom_field_filters, custom_field_id_str)
else
# Add or update filter
Map.put(socket.assigns.boolean_custom_field_filters, custom_field_id_str, filter_value)
end
socket =
socket
|> assign(:boolean_custom_field_filters, updated_filters)
|> load_members()
|> update_selection_assigns()
# Build the URL with all params including new filter
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle,
updated_filters
)
new_path = ~p"/members?#{query_params}"
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
@impl true
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
# Reset all filters at once (performance optimization)
# This avoids N×2 load_members() calls when resetting multiple filters
socket =
socket
|> assign(:cycle_status_filter, cycle_status_filter)
|> assign(:boolean_custom_field_filters, boolean_filters)
|> load_members()
|> update_selection_assigns()
# Build the URL with all params including reset filters
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
cycle_status_filter,
socket.assigns.show_current_cycle,
boolean_filters
)
new_path = ~p"/members?#{query_params}"
@ -450,6 +538,9 @@ defmodule MvWeb.MemberLive.Index do
"""
@impl true
def handle_params(params, _url, socket) do
# Build signature BEFORE updates to detect if anything actually changed
prev_sig = build_signature(socket)
# Parse field selection from URL
url_selection = FieldSelection.parse_from_url(params)
@ -473,23 +564,68 @@ defmodule MvWeb.MemberLive.Index do
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
# Apply all updates
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_cycle_status_filter(params)
|> maybe_update_boolean_filters(params)
|> maybe_update_show_current_cycle(params)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
# Build signature AFTER updates
next_sig = build_signature(socket)
# Only load members if signature changed (optimization: avoid duplicate loads)
# OR if members haven't been loaded yet (first handle_params call after mount)
socket =
if prev_sig == next_sig && Map.has_key?(socket.assigns, :members) do
# Nothing changed AND members already loaded, skip expensive load_members() call
socket
|> prepare_dynamic_cols()
|> update_selection_assigns()
else
# Signature changed OR members not loaded yet, reload members
socket
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
end
{:noreply, socket}
end
# Builds a signature tuple representing all filter/sort parameters that affect member loading.
#
# This signature is used to detect if member data needs to be reloaded when handle_params
# is called. If the signature hasn't changed, we can skip the expensive load_members() call.
#
# Returns a tuple containing all relevant parameters:
# - query: Search query string
# - sort_field: Field to sort by
# - sort_order: Sort direction (:asc or :desc)
# - cycle_status_filter: Payment filter (:paid, :unpaid, or nil)
# - show_current_cycle: Whether to show current cycle
# - boolean_custom_field_filters: Map of active boolean filters
# - user_field_selection: Map of user's field visibility selections
# - visible_custom_field_ids: List of visible custom field IDs (affects which custom fields are loaded)
defp build_signature(socket) do
{
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters,
socket.assigns.user_field_selection,
socket.assigns[:visible_custom_field_ids] || []
}
end
# Prepares dynamic column definitions for custom fields that should be shown in the overview.
#
# Creates a list of column definitions, each containing:
@ -588,7 +724,8 @@ defmodule MvWeb.MemberLive.Index do
field_str,
Atom.to_string(order),
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
@ -618,7 +755,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
@ -636,12 +774,14 @@ defmodule MvWeb.MemberLive.Index do
# Builds URL query parameters map including all filter/sort state.
# Converts cycle_status_filter atom to string for URL.
# Adds boolean custom field filters as bf_<id>=true|false.
defp build_query_params(
query,
sort_field,
sort_order,
cycle_status_filter,
show_current_cycle
show_current_cycle,
boolean_filters
) do
field_str =
if is_atom(sort_field) do
@ -672,11 +812,19 @@ defmodule MvWeb.MemberLive.Index do
end
# Add show_current_cycle if true
if show_current_cycle do
Map.put(base_params, "show_current_cycle", "true")
else
base_params
end
base_params =
if show_current_cycle do
Map.put(base_params, "show_current_cycle", "true")
else
base_params
end
# Add boolean custom field filters
Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc ->
param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
param_value = if filter_value == true, do: "true", else: "false"
Map.put(acc, param_key, param_value)
end)
end
# Loads members from the database with custom field values and applies search/sort/payment filters.
@ -706,9 +854,32 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.new()
|> Ash.Query.select(@overview_fields)
# Load custom field values for visible custom fields (based on user selection)
# Load custom field values for visible custom fields AND active boolean filters
# This ensures boolean filters work even when the custom field is not visible in overview
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
query = load_custom_field_values(query, visible_custom_field_ids)
# Get IDs of active boolean filters (whitelisted against boolean_custom_fields)
# Convert boolean_custom_fields list to map for efficient lookup (consistent with maybe_update_boolean_filters)
boolean_custom_fields_map =
socket.assigns.boolean_custom_fields
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
active_boolean_filter_ids =
socket.assigns.boolean_custom_field_filters
|> Map.keys()
|> Enum.filter(fn id_str ->
# Validate UUID format and check against whitelist
String.length(id_str) <= @max_uuid_length &&
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
Map.has_key?(boolean_custom_fields_map, id_str)
end)
# Union of visible IDs and active filter IDs
ids_to_load =
(visible_custom_field_ids ++ active_boolean_filter_ids)
|> Enum.uniq()
query = load_custom_field_values(query, ids_to_load)
# Load membership fee cycles for status display
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
@ -728,7 +899,9 @@ defmodule MvWeb.MemberLive.Index do
# Errors in handle_params are handled by Phoenix LiveView
actor = current_actor(socket)
members = Ash.read!(query, actor: actor)
{time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end)
time_milliseconds = time_microseconds / 1000
Logger.info("Ash.read! in load_members/1 took #{time_milliseconds} ms")
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore
@ -741,6 +914,14 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.show_current_cycle
)
# Apply boolean custom field filters if set
members =
apply_boolean_custom_field_filters(
members,
socket.assigns.boolean_custom_field_filters,
socket.assigns.all_custom_fields
)
# Sort in memory if needed (for custom fields)
members =
if sort_after_load do
@ -1135,6 +1316,142 @@ defmodule MvWeb.MemberLive.Index do
defp determine_cycle_status_filter("unpaid"), do: :unpaid
defp determine_cycle_status_filter(_), do: nil
# Updates boolean custom field filters from URL parameters if present.
#
# Parses all URL parameters with prefix @boolean_filter_prefix and validates them:
# - Extracts custom field ID from parameter name (explicitly removes prefix)
# - Validates filter value using determine_boolean_filter/1
# - Whitelisting: Only custom field IDs that exist and have value_type: :boolean
# - Security: Limits to maximum @max_boolean_filters filters to prevent DoS attacks
# - Security: Validates UUID length (max @max_uuid_length characters)
#
# Returns socket with updated :boolean_custom_field_filters assign.
defp maybe_update_boolean_filters(socket, params) do
# Get all boolean custom fields for whitelisting (keyed by ID as string for consistency)
boolean_custom_fields =
socket.assigns.all_custom_fields
|> Enum.filter(&(&1.value_type == :boolean))
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
# Parse all boolean filter parameters
# Security: Use reduce_while to abort early after @max_boolean_filters to prevent DoS attacks
# This protects CPU/Parsing costs, not just memory/state
# We count processed parameters (not just valid filters) to protect against parsing DoS
prefix_length = String.length(@boolean_filter_prefix)
{filters, total_processed} =
params
|> Enum.filter(fn {key, _value} -> String.starts_with?(key, @boolean_filter_prefix) end)
|> Enum.reduce_while({%{}, 0}, fn {key, value_str}, {acc, count} ->
if count >= @max_boolean_filters do
{:halt, {acc, count}}
else
new_acc =
process_boolean_filter_param(
key,
value_str,
prefix_length,
boolean_custom_fields,
acc
)
# Increment counter for each processed parameter (DoS protection)
# Note: We count processed params, not just valid filters, to protect parsing costs
{:cont, {new_acc, count + 1}}
end
end)
# Log warning if we hit the limit
if total_processed >= @max_boolean_filters do
Logger.warning(
"Boolean filter limit reached: processed #{total_processed} parameters, accepted #{map_size(filters)} valid filters (max: #{@max_boolean_filters})"
)
end
assign(socket, :boolean_custom_field_filters, filters)
end
# Processes a single boolean filter parameter from URL params.
#
# Validates the parameter and adds it to the accumulator if valid.
# Returns the accumulator unchanged if validation fails.
defp process_boolean_filter_param(
key,
value_str,
prefix_length,
boolean_custom_fields,
acc
) do
# Extract custom field ID from parameter name (explicitly remove prefix)
# This is more secure than String.replace_prefix which only removes first occurrence
custom_field_id_str = String.slice(key, prefix_length, String.length(key) - prefix_length)
# Validate custom field ID length (UUIDs are max @max_uuid_length characters)
# This provides an additional security layer beyond UUID format validation
if String.length(custom_field_id_str) > @max_uuid_length do
acc
else
validate_and_add_boolean_filter(
custom_field_id_str,
value_str,
boolean_custom_fields,
acc
)
end
end
# Validates UUID format and custom field existence, then adds filter if valid.
defp validate_and_add_boolean_filter(
custom_field_id_str,
value_str,
boolean_custom_fields,
acc
) do
case Ecto.UUID.cast(custom_field_id_str) do
{:ok, _custom_field_id} ->
add_boolean_filter_if_valid(
custom_field_id_str,
value_str,
boolean_custom_fields,
acc
)
:error ->
acc
end
end
# Adds boolean filter to accumulator if custom field exists and value is valid.
defp add_boolean_filter_if_valid(
custom_field_id_str,
value_str,
boolean_custom_fields,
acc
) do
if Map.has_key?(boolean_custom_fields, custom_field_id_str) do
case determine_boolean_filter(value_str) do
nil -> acc
filter_value -> Map.put(acc, custom_field_id_str, filter_value)
end
else
acc
end
end
# Determines valid boolean filter value from URL parameter.
#
# SECURITY: This function whitelists allowed filter values. Only "true" and "false"
# are accepted - all other input (including malicious strings) falls back to nil.
# This ensures no raw user input is ever passed to filter functions.
#
# Returns:
# - `true` for "true" string
# - `false` for "false" string
# - `nil` for any other value
defp determine_boolean_filter("true"), do: true
defp determine_boolean_filter("false"), do: false
defp determine_boolean_filter(_), do: nil
# Updates show_current_cycle from URL parameters if present.
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do
assign(socket, :show_current_cycle, true)
@ -1168,7 +1485,166 @@ defmodule MvWeb.MemberLive.Index do
values when is_list(values) ->
Enum.find(values, fn cfv ->
cfv.custom_field_id == custom_field.id or
(cfv.custom_field && cfv.custom_field.id == custom_field.id)
(match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id)
end)
_ ->
nil
end
end
# Extracts the boolean value from a member's custom field value.
#
# Handles different value formats:
# - `%Ash.Union{value: value, type: :boolean}` - Extracts value from union
# - Map format with `"type"` and `"value"` keys - Extracts from map
# - Map format with `"_union_type"` and `"_union_value"` keys - Extracts from map
#
# Returns:
# - `true` if the custom field value is boolean true
# - `false` if the custom field value is boolean false
# - `nil` if no custom field value exists, value is nil, or value is not boolean
#
# Examples:
# get_boolean_custom_field_value(member, boolean_field) -> true
# get_boolean_custom_field_value(member, non_existent_field) -> nil
def get_boolean_custom_field_value(member, custom_field) do
case get_custom_field_value(member, custom_field) do
nil ->
nil
cfv ->
extract_boolean_value(cfv.value)
end
end
# Extracts boolean value from custom field value, handling different formats.
#
# Handles:
# - `%Ash.Union{value: value, type: :boolean}` - Union struct format
# - Map with `"type"` and `"value"` keys - JSONB map format
# - Map with `"_union_type"` and `"_union_value"` keys - Alternative map format
# - Direct boolean value - Primitive boolean
#
# Returns `true`, `false`, or `nil`.
defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}) do
extract_boolean_value(value)
end
defp extract_boolean_value(value) when is_map(value) do
# Handle map format from JSONB
type = Map.get(value, "type") || Map.get(value, "_union_type")
val = Map.get(value, "value") || Map.get(value, "_union_value")
if type == "boolean" or type == :boolean do
extract_boolean_value(val)
else
nil
end
end
defp extract_boolean_value(value) when is_boolean(value), do: value
defp extract_boolean_value(nil), do: nil
defp extract_boolean_value(_), do: nil
# Applies boolean custom field filters to a list of members.
#
# Filters members based on boolean custom field values. Only members that match
# ALL active filters (AND logic) are returned.
#
# Parameters:
# - `members` - List of Member resources with loaded custom_field_values
# - `filters` - Map of `%{custom_field_id_string => true | false}`
# - `all_custom_fields` - List of all CustomField resources (for validation)
#
# Returns:
# - Filtered list of members that match all active filters
# - All members if filters map is empty
# - Filters with non-existent custom field IDs are ignored
#
# Examples:
# apply_boolean_custom_field_filters(members, %{"uuid-123" => true}, all_custom_fields) -> [member1, ...]
# apply_boolean_custom_field_filters(members, %{}, all_custom_fields) -> members
def apply_boolean_custom_field_filters(members, filters, _all_custom_fields)
when map_size(filters) == 0 do
members
end
def apply_boolean_custom_field_filters(members, filters, all_custom_fields) do
# Build a map of valid boolean custom field IDs (as strings) for quick lookup
valid_custom_field_ids =
all_custom_fields
|> Enum.filter(&(&1.value_type == :boolean))
|> MapSet.new(fn cf -> to_string(cf.id) end)
# Filter out invalid custom field IDs from filters
valid_filters =
Enum.filter(filters, fn {custom_field_id_str, _value} ->
MapSet.member?(valid_custom_field_ids, custom_field_id_str)
end)
|> Enum.into(%{})
# If no valid filters remain, return all members
if map_size(valid_filters) == 0 do
members
else
Enum.filter(members, fn member ->
matches_all_filters?(member, valid_filters)
end)
end
end
# Checks if a member matches all active boolean filters.
#
# A member matches a filter if:
# - The filter value is `true` and the member's custom field value is `true`
# - The filter value is `false` and the member's custom field value is `false`
#
# Members without a custom field value or with `nil` value do not match any filter.
#
# Returns `true` if all filters match, `false` otherwise.
defp matches_all_filters?(member, filters) do
Enum.all?(filters, fn {custom_field_id_str, filter_value} ->
matches_filter?(member, custom_field_id_str, filter_value)
end)
end
# Checks if a member matches a specific boolean filter.
#
# Finds the custom field value by ID and checks if the member's boolean value
# matches the filter value.
#
# Returns:
# - `true` if the member's boolean value matches the filter value
# - `false` if no custom field value exists (member is filtered out)
# - `false` if value is nil or values don't match
defp matches_filter?(member, custom_field_id_str, filter_value) do
case find_custom_field_value_by_id(member, custom_field_id_str) do
nil ->
false
cfv ->
boolean_value = extract_boolean_value(cfv.value)
boolean_value == filter_value
end
end
# Finds a custom field value by custom field ID string.
#
# Searches through the member's custom_field_values to find one matching
# the given custom field ID.
#
# Returns the CustomFieldValue or nil.
defp find_custom_field_value_by_id(member, custom_field_id_str) do
case member.custom_field_values do
nil ->
nil
values when is_list(values) ->
Enum.find(values, fn cfv ->
to_string(cfv.custom_field_id) == custom_field_id_str or
(match?(%{custom_field: %{id: _}}, cfv) &&
to_string(cfv.custom_field.id) == custom_field_id_str)
end)
_ ->
@ -1223,8 +1699,11 @@ defmodule MvWeb.MemberLive.Index do
#
# Note: Mailto URLs have length limits that vary by email client.
# For large selections, consider using export functionality instead.
#
# Handles case where members haven't been loaded yet (e.g., when signature didn't change in handle_params).
defp update_selection_assigns(socket) do
members = socket.assigns.members
# Handle case where members haven't been loaded yet (e.g., when signature didn't change)
members = socket.assigns[:members] || []
selected_members = socket.assigns.selected_members
selected_count =

View file

@ -37,9 +37,11 @@
placeholder={gettext("Search...")}
/>
<.live_component
module={MvWeb.Components.PaymentFilterComponent}
id="payment-filter"
module={MvWeb.Components.MemberFilterComponent}
id="member-filter"
cycle_status_filter={@cycle_status_filter}
boolean_custom_fields={@boolean_custom_fields}
boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)}
/>
<button

View file

@ -12,7 +12,7 @@ defmodule MvWeb.MemberLive.Show do
## Sections
- Personal Data: Name, address, contact information, membership dates, notes
- Custom Fields: Dynamic fields in uniform grid layout (sorted by name)
- Payment Data: Mockup section with placeholder data
- Membership Fees: Tab showing all membership fee cycles with status management (via MembershipFeesComponent)
## Navigation
- Back to member list
@ -22,8 +22,6 @@ defmodule MvWeb.MemberLive.Show do
import Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true

View file

@ -554,46 +554,55 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end
def handle_event("regenerate_cycles", _params, socket) do
socket = assign(socket, :regenerating, true)
member = socket.assigns.member
actor = current_actor(socket)
case CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
{:ok, _new_cycles, _notifications} ->
# Reload member with cycles
actor = current_actor(socket)
# SECURITY: Only admins can manually regenerate cycles via UI
# Cycle generation itself uses system actor, but UI access should be restricted
if actor.role && actor.role.permission_set_name == "admin" do
socket = assign(socket, :regenerating, true)
member = socket.assigns.member
updated_member =
member
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
case CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _new_cycles, _notifications} ->
# Reload member with cycles
actor = current_actor(socket)
cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
updated_member =
member
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
send(self(), {:member_updated, updated_member})
cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:regenerating, false)
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
send(self(), {:member_updated, updated_member})
{:error, error} ->
{:noreply,
socket
|> assign(:regenerating, false)
|> put_flash(:error, format_error(error))}
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:regenerating, false)
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
{:error, error} ->
{:noreply,
socket
|> assign(:regenerating, false)
|> put_flash(:error, format_error(error))}
end
else
{:noreply,
socket
|> put_flash(:error, gettext("Only administrators can regenerate cycles"))}
end
end

View file

@ -13,7 +13,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
require Ash.Query

View file

@ -14,7 +14,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
require Ash.Query

View file

@ -17,8 +17,6 @@ defmodule MvWeb.RoleLive.Form do
import MvWeb.RoleLive.Helpers, only: [format_error: 1]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def render(assigns) do
~H"""

View file

@ -24,8 +24,6 @@ defmodule MvWeb.RoleLive.Index do
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def mount(_params, _session, socket) do
actor = socket.assigns[:current_user]

View file

@ -19,8 +19,6 @@ defmodule MvWeb.RoleLive.Show do
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def mount(%{"id" => id}, _session, socket) do
try do

View file

@ -33,7 +33,8 @@ defmodule MvWeb.UserLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
require Jason
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
@impl true
@ -326,6 +327,7 @@ defmodule MvWeb.UserLive.Form do
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
actor = current_actor(socket)
# First save the user without member changes
case submit_form(socket.assigns.form, user_params, actor) do
{:ok, user} ->

View file

@ -23,7 +23,6 @@ defmodule MvWeb.UserLive.Index do
use MvWeb, :live_view
import MvWeb.TableComponents
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true

View file

@ -26,7 +26,6 @@ defmodule MvWeb.UserLive.Show do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true

View file

@ -27,39 +27,17 @@ defmodule MvWeb.LiveHelpers do
end
defp ensure_user_role_loaded(socket) do
if socket.assigns[:current_user] do
user = socket.assigns.current_user
user_with_role = load_user_role(user)
user = socket.assigns[:current_user]
if user do
# Use centralized Actor helper to ensure role is loaded
user_with_role = Mv.Authorization.Actor.ensure_loaded(user)
assign(socket, :current_user, user_with_role)
else
socket
end
end
defp load_user_role(user) do
case Map.get(user, :role) do
%Ash.NotLoaded{} -> load_role_safely(user)
nil -> load_role_safely(user)
_role -> user
end
end
defp load_role_safely(user) do
# Use self as actor for loading own role relationship
opts = [domain: Mv.Accounts, actor: user]
case Ash.load(user, :role, opts) do
{:ok, loaded_user} ->
loaded_user
{:error, error} ->
# Log warning if role loading fails - this can cause authorization issues
require Logger
Logger.warning("Failed to load role for user #{user.id}: #{inspect(error)}")
user
end
end
@doc """
Helper function to get the current actor (user) from socket assigns.

View file

@ -58,12 +58,6 @@ defmodule MvWeb.Router do
live "/members/:id", MemberLive.Show, :show
live "/members/:id/show/edit", MemberLive.Show, :edit
live "/custom_field_values", CustomFieldValueLive.Index, :index
live "/custom_field_values/new", CustomFieldValueLive.Form, :new
live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit
live "/custom_field_values/:id", CustomFieldValueLive.Show, :show
live "/custom_field_values/:id/show/edit", CustomFieldValueLive.Show, :edit
live "/users", UserLive.Index, :index
live "/users/new", UserLive.Form, :new
live "/users/:id/edit", UserLive.Form, :edit
@ -80,10 +74,6 @@ defmodule MvWeb.Router do
live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new
live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit
# Contribution Management (Mock-ups)
live "/contribution_types", ContributionTypeLive.Index, :index
live "/contributions/member/:id", ContributionPeriodLive.Show, :show
# Role Management (Admin only)
live "/admin/roles", RoleLive.Index, :index
live "/admin/roles/new", RoleLive.Form, :new

View file

@ -11,7 +11,6 @@ msgstr ""
"Language: de\n"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr "Aktionen"
@ -37,7 +36,6 @@ msgstr "Verbindung wird wiederhergestellt"
msgid "City"
msgstr "Stadt"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -47,7 +45,6 @@ msgstr "Stadt"
msgid "Delete"
msgstr "Löschen"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
@ -65,7 +62,6 @@ msgstr "Bearbeiten"
msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@ -141,7 +137,6 @@ msgstr "Austrittsdatum"
msgid "House Number"
msgstr "Hausnummer"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
@ -149,8 +144,7 @@ msgstr "Hausnummer"
msgid "Notes"
msgstr "Notizen"
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -171,7 +165,6 @@ msgid "Save Member"
msgstr "Mitglied speichern"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
@ -189,6 +182,7 @@ msgstr "Speichern..."
msgid "Street"
msgstr "Straße"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
@ -203,6 +197,7 @@ msgstr "Nein"
msgid "Show Member"
msgstr "Mitglied anzeigen"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
@ -214,14 +209,12 @@ msgid "Yes"
msgstr "Ja"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "create"
msgstr "erstellt"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "update"
@ -264,7 +257,6 @@ msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -275,11 +267,6 @@ msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
msgid "Cancel"
msgstr "Abbrechen"
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Choose a member"
msgstr "Mitglied auswählen"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
@ -313,13 +300,7 @@ msgstr "Abmelden"
msgid "Listing Users"
msgstr "Benutzer*innen auflisten"
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Member"
msgstr "Mitglied"
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex
@ -327,7 +308,6 @@ msgstr "Mitglied"
msgid "Members"
msgstr "Mitglieder"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
@ -351,7 +331,6 @@ msgstr "Neue*r Benutzer*in"
msgid "Not enabled"
msgstr "Nicht aktiviert"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Note"
@ -401,11 +380,6 @@ msgstr "Benutzer*in anzeigen"
msgid "This is a user record from your database."
msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank."
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Unsupported value type: %{type}"
msgstr "Nicht unterstützter Wertetyp: %{type}"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage user records in your database."
@ -417,11 +391,6 @@ msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalte
msgid "User"
msgstr "Benutzer*in"
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Value"
msgstr "Wert"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
@ -611,37 +580,13 @@ msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden."
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Choose a custom field"
msgstr "Wähle ein Benutzerdefiniertes Feld"
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom field"
msgstr "Benutzerdefinierte Felder"
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Custom field value %{action} successfully"
msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Please select a custom field first"
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Custom Fields"
msgstr "Benutzerdefinierte Felder"
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage Custom Field Value records in your database."
msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten."
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
@ -797,21 +742,11 @@ msgid "This field cannot be empty"
msgstr "Dieses Feld darf nicht leer bleiben"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr "Alle"
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr "Nach Zahlungsstatus filtern"
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr "Zahlungsfilter"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Address"
@ -844,6 +779,7 @@ msgstr "Nr."
msgid "Payment Data"
msgstr "Beitragsdaten"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Payments"
@ -866,20 +802,6 @@ msgstr "Speichern"
msgid "Create Member"
msgstr "Mitglied erstellen"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "%{count} period selected"
msgid_plural "%{count} periods selected"
msgstr[0] "%{count} Zyklus ausgewählt"
msgstr[1] "%{count} Zyklen ausgewählt"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "About Contribution Types"
msgstr "Über Beitragsarten"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
@ -887,54 +809,16 @@ msgstr "Über Beitragsarten"
msgid "Amount"
msgstr "Betrag"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to Settings"
msgstr "Zurück zu den Einstellungen"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only."
msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur zukünftige Zyklen."
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Cannot delete - members assigned"
msgstr "Löschen nicht möglich es sind Mitglieder zugewiesen"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Change Contribution Type"
msgstr "Beitragsart ändern"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contribution Start"
msgstr "Beitragsbeginn"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contribution Types"
msgstr "Beitragsarten"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contribution type"
msgstr "Beitragsart"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contributions for %{name}"
msgstr "Beiträge für %{name}"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Current"
msgstr "Aktuell"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Deletion"
@ -945,12 +829,6 @@ msgstr "Löschen"
msgid "Examples"
msgstr "Beispiele"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Family"
msgstr "Familie"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Fixed after creation. Members can only switch between types with the same interval."
@ -962,27 +840,12 @@ msgid "Global Settings"
msgstr "Globale Einstellungen"
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Half-yearly"
msgstr "Halbjährlich"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Half-yearly contribution for supporting members"
msgstr "Halbjährlicher Beitrag für Fördermitglieder"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Honorary"
msgstr "Ehrenamtlich"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
@ -995,36 +858,6 @@ msgstr "Intervall"
msgid "Joining date"
msgstr "Beitrittsdatum"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Joining year - reduced to 0"
msgstr "Beitrittsjahr auf 0 reduziert"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Manage contribution types for membership fees."
msgstr "Beitragsarten für Mitgliedsbeiträge verwalten."
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Paid"
msgstr "Als bezahlt markieren"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Suspended"
msgstr "Als pausiert markieren"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Unpaid"
msgstr "Als unbezahlt markieren"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member Contributions"
msgstr "Mitgliedsbeiträge"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Member pays for the year they joined"
@ -1045,131 +878,35 @@ msgstr "Mitglied zahlt ab dem nächsten vollständigen Quartal"
msgid "Member pays from the next full year"
msgstr "Mitglied zahlt ab dem nächsten vollständigen Jahr"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member since"
msgstr "Mitglied seit"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
msgstr "Mitglieder können nur zwischen Beitragsarten mit demselben Zahlungszyklus wechseln (z.B. jährlich zu jährlich). Dadurch werden komplexe Überlappungen vermieden."
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Monthly"
msgstr "Monatlich"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Monthly fee for students and trainees"
msgstr "Monatlicher Beitrag für Studierende und Auszubildende"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name & Amount"
msgstr "Name & Betrag"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Contribution Type"
msgstr "Neue Beitragsart"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "No fee for honorary members"
msgstr "Kein Beitrag für ehrenamtliche Mitglieder"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type."
msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind."
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Open Contributions"
msgstr "Offene Beiträge"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Paid via bank transfer"
msgstr "Bezahlt durch Überweisung"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Preview Mockup"
msgstr "Vorschau"
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Quarterly"
msgstr "Vierteljährlich"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Quarterly fee for family memberships"
msgstr "Vierteljährlicher Beitrag für Familienmitgliedschaften"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reduced"
msgstr "Reduziert"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reduced fee for unemployed, pensioners, or low income"
msgstr "Ermäßigter Beitrag für Arbeitslose, Rentner*innen oder Geringverdienende"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Regular"
msgstr "Regulär"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Reopen"
msgstr "Wieder öffnen"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Standard membership fee for regular members"
msgstr "Regulärer Mitgliedsbeitrag für Vollmitglieder"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
msgstr "Status"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Student"
msgstr "Student"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Supporting Member"
msgstr "Fördermitglied"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Suspend"
msgstr "Pausieren"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -1177,24 +914,7 @@ msgstr "Pausieren"
msgid "Suspended"
msgstr "Pausiert"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "This page is not functional and only displays the planned features."
msgstr "Diese Seite ist nicht funktionsfähig und zeigt nur geplante Funktionen."
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Time Period"
msgstr "Zeitraum"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Total Contributions"
msgstr "Gesamtbeiträge"
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -1202,14 +922,7 @@ msgstr "Gesamtbeiträge"
msgid "Unpaid"
msgstr "Unbezahlt"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Why are not all contribution types shown?"
msgstr "Warum werden nicht alle Beitragsarten angezeigt?"
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
@ -1697,11 +1410,6 @@ msgstr "Zyklen regenerieren"
msgid "Regenerating..."
msgstr "Regeneriere..."
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Custom Field Value"
msgstr "Benutzerdefinierten Feldwert speichern"
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Field"
@ -1799,11 +1507,6 @@ msgstr "Jährliches Intervall Beitrittszeitraum einbezogen"
msgid "You are about to delete all %{count} cycles for this member."
msgstr "Du bist dabei alle %{count} Zyklen für dieses Mitglied zu löschen."
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
msgstr "Beitragsarten definieren verschiedene Beitragsmodelle. Jede Art hat einen festen Zyklus (monatlich, vierteljährlich, halbjährlich, jährlich), der nach Erstellung nicht mehr geändert werden kann."
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Membership Fee Type"
@ -2072,16 +1775,6 @@ msgstr "Zyklus löschen"
msgid "The cycle period will be calculated based on this date and the interval."
msgstr "Der Zyklus wird basierend auf diesem Datum und dem Intervall berechnet."
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "Custom field value deleted successfully"
msgstr "Benutzerdefinierter Feldwert erfolgreich gelöscht"
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "Custom field value not found"
msgstr "Benutzerdefinierter Feldwert nicht gefunden"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type not found"
@ -2102,11 +1795,6 @@ msgstr "Benutzer*in erfolgreich gelöscht"
msgid "User not found"
msgstr "Benutzer*in nicht gefunden"
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this custom field value"
msgstr "Sie haben keine Berechtigung, auf diesen benutzerdefinierten Feldwert zuzugreifen"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this membership fee type"
@ -2117,11 +1805,6 @@ msgstr "Sie haben keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen
msgid "You do not have permission to access this user"
msgstr "Sie haben keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this custom field value"
msgstr "Sie haben keine Berechtigung, diesen benutzerdefinierten Feldwert zu löschen"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type"
@ -2142,6 +1825,7 @@ msgstr "erstellt"
msgid "updated"
msgstr "aktualisiert"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Unknown error"
@ -2167,11 +1851,6 @@ msgstr "Sie haben keine Berechtigung, auf dieses Mitglied zuzugreifen"
msgid "You do not have permission to delete this member"
msgstr "Sie haben keine Berechtigung, dieses Mitglied zu löschen"
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to view custom field values"
msgstr "Sie haben keine Berechtigung, benutzerdefinierte Feldwerte anzuzeigen"
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Member created successfully"
@ -2212,7 +1891,247 @@ msgstr "Beitragstypen"
msgid "Administration"
msgstr "Administration"
#~ #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Failed to %{action} member."
msgstr "Fehler beim %{action} des Mitglieds."
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Failed to save member. Please try again."
msgstr "Fehler beim Speichern des Mitglieds. Bitte versuchen Sie es erneut."
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Please correct the errors in the form and try again."
msgstr "Bitte korrigieren Sie die Fehler im Formular und versuchen Sie es erneut."
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Validation failed. Please check your input."
msgstr "Validierung fehlgeschlagen. Bitte überprüfen Sie Ihre Eingabe."
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Validation failed: %{field} %{message}"
msgstr "Validierung fehlgeschlagen: %{field} %{message}"
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Validation failed: %{message}"
msgstr "Validierung fehlgeschlagen: %{message}"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Close"
msgstr "Schließen"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter members"
msgstr "Mitglieder filtern"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Member filter"
msgstr "Mitgliedsfilter"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Payment Status"
msgstr "Bezahlstatus"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Reset"
msgstr "Zurücksetzen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can regenerate cycles"
msgstr "Nur Administrator*innen können Zyklen regenerieren"
#~ #: lib/mv_web/live/components/member_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Filter by %{name}"
#~ msgstr "Filtern nach %{name}"
#~ #: lib/mv_web/live/components/member_filter_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Contributions"
#~ msgstr "Beiträge"
#~ msgid "Payment status filter"
#~ msgstr "Bezahlstatusfilter"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid " (Field: %{field})"
msgstr " (Datenfeld: %{field})"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "CSV File"
msgstr "CSV Datei"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "CSV files only, maximum 10 MB"
msgstr "Nur CSV Dateien, maximal 10 MB"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Custom fields must be created in Mila before importing CSV files with custom field columns"
msgstr "Individuelle Datenfelder müssen zuerst in Mila angelegt werden bevor das Importieren von diesen Feldern mit CSV Dateien mölich ist."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Download CSV templates:"
msgstr "CSV Vorlagen herunterladen:"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "English Template"
msgstr "Englische Vorlage"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Error list truncated to %{count} entries"
msgstr "Liste der Fehler auf %{count} Einträge reduziert"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Errors"
msgstr "Fehler"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to prepare CSV import: %{error}"
msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to prepare CSV import: %{reason}"
msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{reason}"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to process chunk %{idx}: %{reason}"
msgstr "Das Importieren von %{idx} ist gescheitert: %{reason}"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to read file: %{reason}"
msgstr "Fehler beim Lesen der Datei: %{reason}"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to read uploaded file"
msgstr "Fehler beim Lesen der hochgeladenen Datei"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed: %{count} row(s)"
msgstr "Fehlgeschlagen: %{count} Zeile(n)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "German Template"
msgstr "Deutsche Vorlage"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Import Members (CSV)"
msgstr "Mitglieder importieren (CSV)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Import Results"
msgstr "Import-Ergebnisse"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Import is already running. Please wait for it to complete."
msgstr "Import läuft bereits. Bitte warten Sie, bis er abgeschlossen ist."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Import state is missing. Cannot process chunk %{idx}."
msgstr "Import-Status fehlt. Chunk %{idx} kann nicht verarbeitet werden."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Invalid chunk index: %{idx}"
msgstr "Ungültiger Chunk-Index: %{idx}"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Line %{line}: %{message}"
msgstr "Zeile %{line}: %{message}"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "No file was uploaded"
msgstr "Es wurde keine Datei hochgeladen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can import members from CSV files."
msgstr "Nur Administrator*innen können Mitglieder aus CSV-Dateien importieren."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Please select a CSV file to import."
msgstr "Bitte wählen Sie eine CSV-Datei zum Importieren."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Please wait for the file upload to complete before starting the import."
msgstr "Bitte warten Sie, bis der Datei-Upload abgeschlossen ist, bevor Sie den Import starten."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Processing chunk %{current} of %{total}..."
msgstr "Verarbeite Chunk %{current} von %{total}..."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Start Import"
msgstr "Import starten"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Starting import..."
msgstr "Import wird gestartet..."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Successfully inserted: %{count} member(s)"
msgstr "Erfolgreich eingefügt: %{count} Mitglied(er)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Summary"
msgstr "Zusammenfassung"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Use the custom field name as the CSV column header (same normalization as member fields applies)"
msgstr "Verwenden Sie den Namen des benutzerdefinierten Feldes als CSV-Spaltenüberschrift (gleiche Normalisierung wie bei Mitgliedsfeldern)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Warnings"
msgstr "Warnungen"
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Validation failed"
msgstr "Validierung fehlgeschlagen: %{message}"
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "email"
msgstr "E-Mail"
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "email %{email} has already been taken"
msgstr "E-Mail %{email} wurde bereits verwendet"

View file

@ -12,7 +12,6 @@ msgid ""
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
@ -38,7 +37,6 @@ msgstr ""
msgid "City"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -48,7 +46,6 @@ msgstr ""
msgid "Delete"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
@ -66,7 +63,6 @@ msgstr ""
msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@ -142,7 +138,6 @@ msgstr ""
msgid "House Number"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
@ -150,8 +145,7 @@ msgstr ""
msgid "Notes"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -172,7 +166,6 @@ msgid "Save Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
@ -190,6 +183,7 @@ msgstr ""
msgid "Street"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
@ -204,6 +198,7 @@ msgstr ""
msgid "Show Member"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
@ -215,14 +210,12 @@ msgid "Yes"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "create"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "update"
@ -265,7 +258,6 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -276,11 +268,6 @@ msgstr ""
msgid "Cancel"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Choose a member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
@ -314,13 +301,7 @@ msgstr ""
msgid "Listing Users"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex
@ -328,7 +309,6 @@ msgstr ""
msgid "Members"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
@ -352,7 +332,6 @@ msgstr ""
msgid "Not enabled"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Note"
@ -402,11 +381,6 @@ msgstr ""
msgid "This is a user record from your database."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Unsupported value type: %{type}"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage user records in your database."
@ -418,11 +392,6 @@ msgstr ""
msgid "User"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
@ -612,37 +581,13 @@ msgstr ""
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Choose a custom field"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Custom field value %{action} successfully"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Custom Fields"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage Custom Field Value records in your database."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
@ -798,21 +743,11 @@ msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Address"
@ -845,6 +780,7 @@ msgstr ""
msgid "Payment Data"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Payments"
@ -867,20 +803,6 @@ msgstr ""
msgid "Create Member"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "%{count} period selected"
msgid_plural "%{count} periods selected"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "About Contribution Types"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
@ -888,54 +810,16 @@ msgstr ""
msgid "Amount"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to Settings"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only."
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Cannot delete - members assigned"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Change Contribution Type"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contribution Start"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Contribution Types"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contribution type"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contributions for %{name}"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Current"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Deletion"
@ -946,12 +830,6 @@ msgstr ""
msgid "Examples"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Family"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Fixed after creation. Members can only switch between types with the same interval."
@ -963,27 +841,12 @@ msgid "Global Settings"
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Half-yearly"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Half-yearly contribution for supporting members"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Honorary"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
@ -996,36 +859,6 @@ msgstr ""
msgid "Joining date"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Joining year - reduced to 0"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Manage contribution types for membership fees."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Paid"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Suspended"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Unpaid"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member Contributions"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Member pays for the year they joined"
@ -1046,131 +879,35 @@ msgstr ""
msgid "Member pays from the next full year"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member since"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Monthly"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Monthly fee for students and trainees"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name & Amount"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "New Contribution Type"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "No fee for honorary members"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Open Contributions"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Paid via bank transfer"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Preview Mockup"
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Quarterly"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Quarterly fee for family memberships"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reduced"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reduced fee for unemployed, pensioners, or low income"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Regular"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Reopen"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Standard membership fee for regular members"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Student"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Supporting Member"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Suspend"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -1178,24 +915,7 @@ msgstr ""
msgid "Suspended"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "This page is not functional and only displays the planned features."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Time Period"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Total Contributions"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -1203,14 +923,7 @@ msgstr ""
msgid "Unpaid"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Why are not all contribution types shown?"
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
@ -1698,11 +1411,6 @@ msgstr ""
msgid "Regenerating..."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Save Custom Field Value"
msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Save Field"
@ -1800,11 +1508,6 @@ msgstr ""
msgid "You are about to delete all %{count} cycles for this member."
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Delete Membership Fee Type"
@ -2073,16 +1776,6 @@ msgstr ""
msgid "The cycle period will be calculated based on this date and the interval."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "Custom field value deleted successfully"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "Custom field value not found"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type not found"
@ -2103,11 +1796,6 @@ msgstr ""
msgid "User not found"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this custom field value"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this membership fee type"
@ -2118,11 +1806,6 @@ msgstr ""
msgid "You do not have permission to access this user"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this custom field value"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type"
@ -2143,6 +1826,7 @@ msgstr ""
msgid "updated"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Unknown error"
@ -2168,11 +1852,6 @@ msgstr ""
msgid "You do not have permission to delete this member"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to view custom field values"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Member created successfully"
@ -2191,6 +1870,7 @@ msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Email is required."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
@ -2211,3 +1891,238 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Administration"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Failed to %{action} member."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Failed to save member. Please try again."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Please correct the errors in the form and try again."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Validation failed. Please check your input."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Validation failed: %{field} %{message}"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Validation failed: %{message}"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Close"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter members"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Member filter"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment Status"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Reset"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can regenerate cycles"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid " (Field: %{field})"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "CSV File"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "CSV files only, maximum 10 MB"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Custom fields must be created in Mila before importing CSV files with custom field columns"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Download CSV templates:"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "English Template"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Error list truncated to %{count} entries"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Errors"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to prepare CSV import: %{error}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to prepare CSV import: %{reason}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to process chunk %{idx}: %{reason}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to read file: %{reason}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to read uploaded file"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed: %{count} row(s)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "German Template"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Import Members (CSV)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Import Results"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Import is already running. Please wait for it to complete."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Import state is missing. Cannot process chunk %{idx}."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Invalid chunk index: %{idx}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Line %{line}: %{message}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "No file was uploaded"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can import members from CSV files."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Please select a CSV file to import."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Please wait for the file upload to complete before starting the import."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Processing chunk %{current} of %{total}..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Start Import"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Starting import..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Successfully inserted: %{count} member(s)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Summary"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Use the custom field name as the CSV column header (same normalization as member fields applies)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Warnings"
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Validation failed"
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "email"
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "email %{email} has already been taken"
msgstr ""

View file

@ -12,7 +12,6 @@ msgstr ""
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
@ -38,7 +37,6 @@ msgstr ""
msgid "City"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -48,7 +46,6 @@ msgstr ""
msgid "Delete"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
@ -66,7 +63,6 @@ msgstr ""
msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@ -142,7 +138,6 @@ msgstr ""
msgid "House Number"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
@ -150,8 +145,7 @@ msgstr ""
msgid "Notes"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -172,7 +166,6 @@ msgid "Save Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
@ -190,6 +183,7 @@ msgstr ""
msgid "Street"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
@ -204,6 +198,7 @@ msgstr ""
msgid "Show Member"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
@ -215,14 +210,12 @@ msgid "Yes"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "create"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "update"
@ -265,7 +258,6 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -276,11 +268,6 @@ msgstr ""
msgid "Cancel"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Choose a member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
@ -314,13 +301,7 @@ msgstr ""
msgid "Listing Users"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex
@ -328,7 +309,6 @@ msgstr ""
msgid "Members"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
@ -352,7 +332,6 @@ msgstr ""
msgid "Not enabled"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Note"
@ -402,11 +381,6 @@ msgstr ""
msgid "This is a user record from your database."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Unsupported value type: %{type}"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage user records in your database."
@ -418,11 +392,6 @@ msgstr ""
msgid "User"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
@ -612,37 +581,13 @@ msgstr ""
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Choose a custom field"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Custom field value %{action} successfully"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom Fields"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage Custom Field Value records in your database."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
@ -798,21 +743,11 @@ msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Address"
@ -845,6 +780,7 @@ msgstr ""
msgid "Payment Data"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Payments"
@ -867,20 +803,6 @@ msgstr ""
msgid "Create Member"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "%{count} period selected"
msgid_plural "%{count} periods selected"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "About Contribution Types"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
@ -888,54 +810,16 @@ msgstr ""
msgid "Amount"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to Settings"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only."
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Cannot delete - members assigned"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Change Contribution Type"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contribution Start"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Contribution Types"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contribution type"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contributions for %{name}"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Current"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Deletion"
@ -946,12 +830,6 @@ msgstr ""
msgid "Examples"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Family"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Fixed after creation. Members can only switch between types with the same interval."
@ -963,27 +841,12 @@ msgid "Global Settings"
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Half-yearly"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Half-yearly contribution for supporting members"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Honorary"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
@ -996,36 +859,6 @@ msgstr ""
msgid "Joining date"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Joining year - reduced to 0"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Manage contribution types for membership fees."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Paid"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Suspended"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Unpaid"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member Contributions"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Member pays for the year they joined"
@ -1046,131 +879,35 @@ msgstr ""
msgid "Member pays from the next full year"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member since"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Monthly"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Monthly fee for students and trainees"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name & Amount"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "New Contribution Type"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "No fee for honorary members"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Open Contributions"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Paid via bank transfer"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Preview Mockup"
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Quarterly"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Quarterly fee for family memberships"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reduced"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reduced fee for unemployed, pensioners, or low income"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Regular"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Reopen"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Standard membership fee for regular members"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Student"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Supporting Member"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Suspend"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -1178,24 +915,7 @@ msgstr ""
msgid "Suspended"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "This page is not functional and only displays the planned features."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Time Period"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Total Contributions"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -1203,14 +923,7 @@ msgstr ""
msgid "Unpaid"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Why are not all contribution types shown?"
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
@ -1698,11 +1411,6 @@ msgstr ""
msgid "Regenerating..."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Custom Field Value"
msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Field"
@ -1800,11 +1508,6 @@ msgstr ""
msgid "You are about to delete all %{count} cycles for this member."
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Membership Fee Type"
@ -2073,16 +1776,6 @@ msgstr ""
msgid "The cycle period will be calculated based on this date and the interval."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom field value deleted successfully"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom field value not found"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee type not found"
@ -2103,11 +1796,6 @@ msgstr ""
msgid "User not found"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to access this custom field value"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to access this membership fee type"
@ -2118,11 +1806,6 @@ msgstr ""
msgid "You do not have permission to access this user"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to delete this custom field value"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to delete this membership fee type"
@ -2143,6 +1826,7 @@ msgstr ""
msgid "updated"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Unknown error"
@ -2168,11 +1852,6 @@ msgstr ""
msgid "You do not have permission to delete this member"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to view custom field values"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Member created successfully"
@ -2213,12 +1892,243 @@ msgstr ""
msgid "Administration"
msgstr ""
#~ #: lib/mv_web/components/layouts/sidebar.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Admin"
#~ msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Failed to %{action} member."
msgstr ""
#~ #: lib/mv_web/components/layouts/sidebar.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Contributions"
#~ msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Failed to save member. Please try again."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Please correct the errors in the form and try again."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Validation failed. Please check your input."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Validation failed: %{field} %{message}"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Validation failed: %{message}"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Close"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter members"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member filter"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Payment Status"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Reset"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can regenerate cycles"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid " (Field: %{field})"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "CSV File"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "CSV files only, maximum 10 MB"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Custom fields must be created in Mila before importing CSV files with custom field columns"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Download CSV templates:"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "English Template"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Error list truncated to %{count} entries"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Errors"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to prepare CSV import: %{error}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to prepare CSV import: %{reason}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to process chunk %{idx}: %{reason}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to read file: %{reason}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to read uploaded file"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed: %{count} row(s)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "German Template"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Import Members (CSV)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Import Results"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Import is already running. Please wait for it to complete."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Import state is missing. Cannot process chunk %{idx}."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Invalid chunk index: %{idx}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Line %{line}: %{message}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "No file was uploaded"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can import members from CSV files."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Please select a CSV file to import."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Please wait for the file upload to complete before starting the import."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Processing chunk %{current} of %{total}..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Start Import"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Starting import..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Successfully inserted: %{count} member(s)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Summary"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Use the custom field name as the CSV column header (same normalization as member fields applies)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Warnings"
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Validation failed"
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "email"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can regenerate cycles"
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "email %{email} has already been taken"
msgstr ""

View file

@ -0,0 +1,60 @@
defmodule Mv.Repo.Migrations.AssignMitgliedRoleToExistingUsers do
@moduledoc """
Assigns the "Mitglied" role to all existing users without a role.
This migration runs once during deployment to ensure all users have a role assigned.
New users will automatically get the "Mitglied" role via the role_id attribute's default function.
"""
use Ecto.Migration
import Ecto.Query
def up do
# Find or create the "Mitglied" role
# This ensures the migration works even if seeds haven't run yet
mitglied_role_id =
case repo().one(
from(r in "roles",
where: r.name == "Mitglied",
select: r.id
)
) do
nil ->
# Role doesn't exist - create it
# This is idempotent and safe because the role name is unique
# Use execute with SQL string to properly use uuid_generate_v7() function
execute("""
INSERT INTO roles (id, name, description, permission_set_name, is_system_role, inserted_at, updated_at)
VALUES (uuid_generate_v7(), 'Mitglied', 'Default member role with access to own data only', 'own_data', true, (now() AT TIME ZONE 'utc'), (now() AT TIME ZONE 'utc'))
""")
# Get the created role ID
role_id =
repo().one(
from(r in "roles",
where: r.name == "Mitglied",
select: r.id
)
)
IO.puts("✅ Created 'Mitglied' role (was missing)")
role_id
role_id ->
role_id
end
# Assign Mitglied role to all users without a role
{count, _} =
repo().update_all(
from(u in "users", where: is_nil(u.role_id)),
set: [role_id: mitglied_role_id]
)
IO.puts("✅ Assigned 'Mitglied' role to #{count} existing user(s)")
end
def down do
# Not reversible - we can't know which users had no role before
:ok
end
end

View file

@ -0,0 +1,36 @@
defmodule Mv.Repo.Migrations.AddNotNullConstraintToUsersRoleId do
@moduledoc """
Adds NOT NULL constraint to users.role_id column.
This ensures that role_id can never be NULL at the database level,
providing an additional safety layer beyond Ash's allow_nil? false.
Before running this migration, ensure all existing users have a role_id
(the previous migration AssignMitgliedRoleToExistingUsers handles this).
"""
use Ecto.Migration
def up do
# First ensure all users have a role_id (safety check)
# This should already be done by the previous migration, but we check anyway
execute("""
UPDATE users
SET role_id = (
SELECT id FROM roles WHERE name = 'Mitglied' LIMIT 1
)
WHERE role_id IS NULL
""")
# Now add NOT NULL constraint
alter table(:users) do
modify :role_id, :uuid, null: false
end
end
def down do
# Remove NOT NULL constraint (allow NULL again)
alter table(:users) do
modify :role_id, :uuid, null: true
end
end
end

View file

@ -5,10 +5,11 @@
alias Mv.Membership
alias Mv.Accounts
alias Mv.Authorization
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.CycleGenerator
require Ash.Query
# Create example membership fee types
for fee_type_attrs <- [
%{
@ -124,55 +125,178 @@ for attrs <- [
)
end
# Create admin user for testing
admin_user =
Accounts.create_user!(%{email: "admin@mv.local"}, upsert?: true, upsert_identity: :unique_email)
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!()
# Get admin email from environment variable or use default
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
# Create admin role and assign it to admin user
admin_role =
case Authorization.list_roles() do
{:ok, roles} ->
case Enum.find(roles, &(&1.name == "Admin" && &1.permission_set_name == "admin")) do
nil ->
# Create admin role if it doesn't exist
case Authorization.create_role(%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
}) do
{:ok, role} -> role
{:error, _error} -> nil
end
# Create all authorization roles (idempotent - creates only if they don't exist)
# Roles are created using create_role_with_system_flag to allow setting is_system_role
role_configs = [
%{
name: "Mitglied",
description: "Default member role with access to own data only",
permission_set_name: "own_data",
is_system_role: true
},
%{
name: "Vorstand",
description: "Board member with read access to all member data",
permission_set_name: "read_only",
is_system_role: false
},
%{
name: "Kassenwart",
description: "Treasurer with full member and payment management",
permission_set_name: "normal_user",
is_system_role: false
},
%{
name: "Buchhaltung",
description: "Accounting with read-only access for auditing",
permission_set_name: "read_only",
is_system_role: false
},
%{
name: "Admin",
description: "Administrator with unrestricted access",
permission_set_name: "admin",
is_system_role: false
}
]
role ->
role
# Create or update each role
Enum.each(role_configs, fn role_data ->
# Bind role name to variable to avoid issues with ^ pinning in macros
role_name = role_data.name
case Mv.Authorization.Role
|> Ash.Query.filter(name == ^role_name)
|> Ash.read_one(authorize?: false, domain: Mv.Authorization) do
{:ok, existing_role} when not is_nil(existing_role) ->
# Role exists - update if needed (preserve is_system_role)
if existing_role.permission_set_name != role_data.permission_set_name or
existing_role.description != role_data.description do
existing_role
|> Ash.Changeset.for_update(:update_role, %{
description: role_data.description,
permission_set_name: role_data.permission_set_name
})
|> Ash.update!(authorize?: false, domain: Mv.Authorization)
end
{:error, _error} ->
nil
{:ok, nil} ->
# Role doesn't exist - create it
Mv.Authorization.Role
|> Ash.Changeset.for_create(:create_role_with_system_flag, role_data)
|> Ash.create!(authorize?: false, domain: Mv.Authorization)
{:error, error} ->
IO.puts("Warning: Failed to check for role #{role_data.name}: #{inspect(error)}")
end
end)
# Get admin role for assignment to admin user
admin_role =
case Mv.Authorization.Role
|> Ash.Query.filter(name == "Admin")
|> Ash.read_one(authorize?: false, domain: Mv.Authorization) do
{:ok, role} when not is_nil(role) -> role
_ -> nil
end
# Assign admin role to admin user if role was created/found
if admin_role do
admin_user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!()
if is_nil(admin_role) do
raise "Failed to create or find admin role. Cannot proceed with member seeding."
end
# Assign admin role to user with ADMIN_EMAIL (if user exists)
# This handles both existing users (e.g., from OIDC) and newly created users
case Accounts.User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
{:ok, existing_admin_user} when not is_nil(existing_admin_user) ->
# User already exists (e.g., via OIDC) - assign admin role
# Use authorize?: false for bootstrap - this is initial setup
existing_admin_user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
{:ok, nil} ->
# User doesn't exist - create admin user with password
# Use authorize?: false for bootstrap - no admin user exists yet to use as actor
Accounts.create_user!(%{email: admin_email},
upsert?: true,
upsert_identity: :unique_email,
authorize?: false
)
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!(authorize?: false)
|> then(fn user ->
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
end)
{:error, error} ->
raise "Failed to check for existing admin user: #{inspect(error)}"
end
# Load admin user with role for use as actor in member operations
# This ensures all member operations have proper authorization
# If admin role creation failed, we cannot proceed with member operations
admin_user_with_role =
if admin_role do
admin_user
|> Ash.load!(:role)
else
raise "Failed to create or find admin role. Cannot proceed with member seeding."
case Accounts.User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
{:ok, user} when not is_nil(user) ->
user
|> Ash.load!(:role, authorize?: false)
{:ok, nil} ->
raise "Admin user not found after creation/assignment"
{:error, error} ->
raise "Failed to load admin user: #{inspect(error)}"
end
# Create system user for systemic operations (email sync, validations, cycle generation)
# This user is used by Mv.Helpers.SystemActor for operations that must always run
# Email is configurable via SYSTEM_ACTOR_EMAIL environment variable
system_user_email = Mv.Helpers.SystemActor.system_user_email()
case Accounts.User
|> Ash.Query.filter(email == ^system_user_email)
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
{:ok, existing_system_user} when not is_nil(existing_system_user) ->
# System user already exists - ensure it has admin role
# Use authorize?: false for bootstrap
existing_system_user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
{:ok, nil} ->
# System user doesn't exist - create it with admin role
# SECURITY: System user must NOT be able to log in:
# - No password (hashed_password = nil) - prevents password login
# - No OIDC ID (oidc_id = nil) - prevents OIDC login
# - This user is ONLY for internal system operations via SystemActor
# If either hashed_password or oidc_id is set, the user could potentially log in
# Use authorize?: false for bootstrap - system user creation happens before system actor exists
Accounts.create_user!(%{email: system_user_email},
upsert?: true,
upsert_identity: :unique_email,
authorize?: false
)
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
{:error, error} ->
# Log error but don't fail seeds - SystemActor will fall back to admin user
IO.puts("Warning: Failed to create system user: #{inspect(error)}")
IO.puts("SystemActor will fall back to admin user (#{admin_email})")
end
# Load all membership fee types for assignment
# Sort by name to ensure deterministic order
all_fee_types =
@ -332,9 +456,20 @@ additional_users = [
created_users =
Enum.map(additional_users, fn user_attrs ->
Accounts.create_user!(user_attrs, upsert?: true, upsert_identity: :unique_email)
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!()
# Use admin user as actor for additional user creation (not bootstrap)
user =
Accounts.create_user!(user_attrs,
upsert?: true,
upsert_identity: :unique_email,
actor: admin_user_with_role
)
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!(actor: admin_user_with_role)
# Reload user to ensure all fields (including member_id) are loaded
Accounts.User
|> Ash.Query.filter(id == ^user.id)
|> Ash.read_one!(domain: Mv.Accounts, actor: admin_user_with_role)
end)
# Create members with linked users to demonstrate the 1:1 relationship
@ -384,11 +519,13 @@ Enum.with_index(linked_members)
member =
if user.member_id == nil do
# User is free, create member and link - use upsert to prevent duplicates
# Use authorize?: false for User lookup during relationship management (bootstrap phase)
Membership.create_member!(
Map.put(member_attrs_without_fee_type, :user, %{id: user.id}),
upsert?: true,
upsert_identity: :unique_email,
actor: admin_user_with_role
actor: admin_user_with_role,
authorize?: false
)
else
# User already has a member, just create the member without linking - use upsert to prevent duplicates
@ -598,7 +735,7 @@ IO.puts("📝 Created sample data:")
IO.puts(" - Global settings: club_name = #{default_club_name}")
IO.puts(" - Membership fee types: 4 types (Yearly, Half-yearly, Quarterly, Monthly)")
IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)")
IO.puts(" - Admin user: admin@mv.local (password: testpassword)")
IO.puts(" - Admin user: #{admin_email} (password: testpassword)")
IO.puts(" - Sample members: Hans, Greta, Friedrich")
IO.puts(

View file

@ -0,0 +1,221 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v7()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "first_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "last_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "join_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "exit_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "notes",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "city",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "street",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "house_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "postal_code",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "search_vector",
"type": "tsvector"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "membership_fee_start_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "members_membership_fee_type_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "membership_fee_types"
},
"scale": null,
"size": null,
"source": "membership_fee_type_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "107B69E0A6FDBE7FAE4B1EABBF3E8C3B1F004B8D96B3759C95071169288968CC",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "members_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "members"
}

View file

@ -0,0 +1,172 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "hashed_password",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "oidc_id",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "users_role_id_fkey",
"on_delete": "restrict",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "roles"
},
"scale": null,
"size": null,
"source": "role_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "users_member_id_fkey",
"on_delete": "nilify",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "3E8D3C1A8834053B947F08369B81216A0B13019E5FD6FBFB706968FABA49EC06",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
}
],
"name": "unique_member",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_oidc_id_index",
"keys": [
{
"type": "atom",
"value": "oidc_id"
}
],
"name": "unique_oidc_id",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "users"
}

View file

@ -7,6 +7,11 @@ defmodule Mv.Accounts.EmailSyncEdgeCasesTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Email sync edge cases" do
@valid_user_attrs %{
email: "user@example.com"
@ -18,15 +23,15 @@ defmodule Mv.Accounts.EmailSyncEdgeCasesTest do
email: "member@example.com"
}
test "simultaneous email updates use user email as source of truth" do
test "simultaneous email updates use user email as source of truth", %{actor: actor} do
# Create linked user and member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}), actor: actor)
# Verify link and initial sync
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert synced_member.email == "user@example.com"
# Scenario: Both emails are updated "simultaneously"
@ -35,58 +40,60 @@ defmodule Mv.Accounts.EmailSyncEdgeCasesTest do
# Update member email first
{:ok, _updated_member} =
Membership.update_member(member, %{email: "member-new@example.com"})
Membership.update_member(member, %{email: "member-new@example.com"}, actor: actor)
# Verify it synced to user
{:ok, user_after_member_update} = Ash.get(Mv.Accounts.User, user.id)
{:ok, user_after_member_update} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
assert to_string(user_after_member_update.email) == "member-new@example.com"
# Now update user email - this should override
{:ok, _updated_user} =
Accounts.update_user(user_after_member_update, %{email: "user-final@example.com"})
Accounts.update_user(user_after_member_update, %{email: "user-final@example.com"},
actor: actor
)
# Reload both
{:ok, final_user} = Ash.get(Mv.Accounts.User, user.id)
{:ok, final_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, final_user} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
{:ok, final_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
# User email should be the final truth
assert to_string(final_user.email) == "user-final@example.com"
assert final_member.email == "user-final@example.com"
end
test "email validation works for both user and member" do
test "email validation works for both user and member", %{actor: actor} do
# Test that invalid emails are rejected for both resources
# Invalid email for user
invalid_user_result = Accounts.create_user(%{email: "not-an-email"})
invalid_user_result = Accounts.create_user(%{email: "not-an-email"}, actor: actor)
assert {:error, %Ash.Error.Invalid{}} = invalid_user_result
# Invalid email for member
invalid_member_attrs = Map.put(@valid_member_attrs, :email, "also-not-an-email")
invalid_member_result = Membership.create_member(invalid_member_attrs)
invalid_member_result = Membership.create_member(invalid_member_attrs, actor: actor)
assert {:error, %Ash.Error.Invalid{}} = invalid_member_result
# Valid emails should work
{:ok, _user} = Accounts.create_user(@valid_user_attrs)
{:ok, _member} = Membership.create_member(@valid_member_attrs)
{:ok, _user} = Accounts.create_user(@valid_user_attrs, actor: actor)
{:ok, _member} = Membership.create_member(@valid_member_attrs, actor: actor)
end
test "identity constraints prevent duplicate emails" do
test "identity constraints prevent duplicate emails", %{actor: actor} do
# Create first user with an email
{:ok, user1} = Accounts.create_user(%{email: "duplicate@example.com"})
{:ok, user1} = Accounts.create_user(%{email: "duplicate@example.com"}, actor: actor)
assert to_string(user1.email) == "duplicate@example.com"
# Try to create second user with same email - should fail due to unique constraint
result = Accounts.create_user(%{email: "duplicate@example.com"})
result = Accounts.create_user(%{email: "duplicate@example.com"}, actor: actor)
assert {:error, %Ash.Error.Invalid{}} = result
# Same for members
member_attrs = Map.put(@valid_member_attrs, :email, "member-dup@example.com")
{:ok, member1} = Membership.create_member(member_attrs)
{:ok, member1} = Membership.create_member(member_attrs, actor: actor)
assert member1.email == "member-dup@example.com"
# Try to create second member with same email - should fail
result2 = Membership.create_member(member_attrs)
result2 = Membership.create_member(member_attrs, actor: actor)
assert {:error, %Ash.Error.Invalid{}} = result2
end
end

View file

@ -4,121 +4,177 @@ defmodule Mv.Accounts.EmailUniquenessTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Email uniqueness validation - Creation" do
test "CAN create member with existing unlinked user email" do
test "CAN create member with existing unlinked user email", %{actor: actor} do
# Create a user with email
{:ok, _user} =
Accounts.create_user(%{
email: "existing@example.com"
})
Accounts.create_user(
%{
email: "existing@example.com"
},
actor: actor
)
# Create member with same email - should succeed
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
},
actor: actor
)
assert to_string(member.email) == "existing@example.com"
end
test "CAN create user with existing unlinked member email" do
test "CAN create user with existing unlinked member email", %{actor: actor} do
# Create a member with email
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
},
actor: actor
)
# Create user with same email - should succeed
{:ok, user} =
Accounts.create_user(%{
email: "existing@example.com"
})
Accounts.create_user(
%{
email: "existing@example.com"
},
actor: actor
)
assert to_string(user.email) == "existing@example.com"
end
end
describe "Email uniqueness validation - Updating unlinked entities" do
test "unlinked member email CAN be changed to an existing unlinked user email" do
test "unlinked member email CAN be changed to an existing unlinked user email", %{
actor: actor
} do
# Create a user with email
{:ok, _user} =
Accounts.create_user(%{
email: "existing_user@example.com"
})
Accounts.create_user(
%{
email: "existing_user@example.com"
},
actor: actor
)
# Create an unlinked member with different email
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
},
actor: actor
)
# Change member email to existing user email - should succeed (member is unlinked)
{:ok, updated_member} =
Membership.update_member(member, %{
email: "existing_user@example.com"
})
Membership.update_member(
member,
%{
email: "existing_user@example.com"
},
actor: actor
)
assert to_string(updated_member.email) == "existing_user@example.com"
end
test "unlinked user email CAN be changed to an existing unlinked member email" do
test "unlinked user email CAN be changed to an existing unlinked member email", %{
actor: actor
} do
# Create a member with email
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing_member@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "existing_member@example.com"
},
actor: actor
)
# Create an unlinked user with different email
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
Accounts.create_user(
%{
email: "user@example.com"
},
actor: actor
)
# Change user email to existing member email - should succeed (user is unlinked)
{:ok, updated_user} =
Accounts.update_user(user, %{
email: "existing_member@example.com"
})
Accounts.update_user(
user,
%{
email: "existing_member@example.com"
},
actor: actor
)
assert to_string(updated_user.email) == "existing_member@example.com"
end
test "unlinked member email CANNOT be changed to an existing linked user email" do
test "unlinked member email CANNOT be changed to an existing linked user email", %{
actor: actor
} do
# Create a user and link it to a member - this makes the user "linked"
{:ok, user} =
Accounts.create_user(%{
email: "linked_user@example.com"
})
Accounts.create_user(
%{
email: "linked_user@example.com"
},
actor: actor
)
{:ok, _member_a} =
Membership.create_member(%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user.id}
},
actor: actor
)
# Create an unlinked member with different email
{:ok, member_b} =
Membership.create_member(%{
first_name: "Member",
last_name: "B",
email: "member_b@example.com"
})
Membership.create_member(
%{
first_name: "Member",
last_name: "B",
email: "member_b@example.com"
},
actor: actor
)
# Try to change unlinked member's email to linked user's email - should fail
result =
Membership.update_member(member_b, %{
email: "linked_user@example.com"
})
Membership.update_member(
member_b,
%{
email: "linked_user@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -129,37 +185,52 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end)
end
test "unlinked user email CANNOT be changed to an existing linked member email" do
test "unlinked user email CANNOT be changed to an existing linked member email", %{
actor: actor
} do
# Create a user and link it to a member - this makes the member "linked"
{:ok, user_a} =
Accounts.create_user(%{
email: "user_a@example.com"
})
Accounts.create_user(
%{
email: "user_a@example.com"
},
actor: actor
)
{:ok, _member_a} =
Membership.create_member(%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user_a.id}
})
Membership.create_member(
%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user_a.id}
},
actor: actor
)
# Reload user to get updated member_id and linked member email
{:ok, user_a_reloaded} = Ash.get(Mv.Accounts.User, user_a.id)
{:ok, user_a_with_member} = Ash.load(user_a_reloaded, :member)
{:ok, user_a_reloaded} = Ash.get(Mv.Accounts.User, user_a.id, actor: actor)
{:ok, user_a_with_member} = Ash.load(user_a_reloaded, :member, actor: actor)
linked_member_email = to_string(user_a_with_member.member.email)
# Create an unlinked user with different email
{:ok, user_b} =
Accounts.create_user(%{
email: "user_b@example.com"
})
Accounts.create_user(
%{
email: "user_b@example.com"
},
actor: actor
)
# Try to change unlinked user's email to linked member's email - should fail
result =
Accounts.update_user(user_b, %{
email: linked_member_email
})
Accounts.update_user(
user_b,
%{
email: linked_member_email
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -172,28 +243,37 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end
describe "Email uniqueness validation - Creating with linked emails" do
test "CANNOT create member with existing linked user email" do
test "CANNOT create member with existing linked user email", %{actor: actor} do
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "linked@example.com"
})
Accounts.create_user(
%{
email: "linked@example.com"
},
actor: actor
)
{:ok, _member} =
Membership.create_member(%{
first_name: "First",
last_name: "Member",
email: "temp@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "First",
last_name: "Member",
email: "temp@example.com",
user: %{id: user.id}
},
actor: actor
)
# Try to create a new member with the linked user's email - should fail
result =
Membership.create_member(%{
first_name: "Second",
last_name: "Member",
email: "linked@example.com"
})
Membership.create_member(
%{
first_name: "Second",
last_name: "Member",
email: "linked@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -204,31 +284,40 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end)
end
test "CANNOT create user with existing linked member email" do
test "CANNOT create user with existing linked member email", %{actor: actor} do
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
Accounts.create_user(
%{
email: "user@example.com"
},
actor: actor
)
{:ok, _member} =
Membership.create_member(%{
first_name: "Member",
last_name: "One",
email: "temp@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "Member",
last_name: "One",
email: "temp@example.com",
user: %{id: user.id}
},
actor: actor
)
# Reload user to get the linked member's email
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
{:ok, user_with_member} = Ash.load(user_reloaded, :member)
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
{:ok, user_with_member} = Ash.load(user_reloaded, :member, actor: actor)
linked_member_email = to_string(user_with_member.member.email)
# Try to create a new user with the linked member's email - should fail
result =
Accounts.create_user(%{
email: linked_member_email
})
Accounts.create_user(
%{
email: linked_member_email
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -241,32 +330,45 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end
describe "Email uniqueness validation - Updating linked entities" do
test "linked member email CANNOT be changed to an existing user email" do
test "linked member email CANNOT be changed to an existing user email", %{actor: actor} do
# Create a user with email
{:ok, _other_user} =
Accounts.create_user(%{
email: "other_user@example.com"
})
Accounts.create_user(
%{
email: "other_user@example.com"
},
actor: actor
)
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
Accounts.create_user(
%{
email: "user@example.com"
},
actor: actor
)
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
},
actor: actor
)
# Try to change linked member's email to other user's email - should fail
result =
Membership.update_member(member, %{
email: "other_user@example.com"
})
Membership.update_member(
member,
%{
email: "other_user@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -277,37 +379,50 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end)
end
test "linked user email CANNOT be changed to an existing member email" do
test "linked user email CANNOT be changed to an existing member email", %{actor: actor} do
# Create a member with email
{:ok, _other_member} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Doe",
email: "other_member@example.com"
})
Membership.create_member(
%{
first_name: "Jane",
last_name: "Doe",
email: "other_member@example.com"
},
actor: actor
)
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
Accounts.create_user(
%{
email: "user@example.com"
},
actor: actor
)
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
},
actor: actor
)
# Reload user to get updated member_id
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
# Try to change linked user's email to other member's email - should fail
result =
Accounts.update_user(user_reloaded, %{
email: "other_member@example.com"
})
Accounts.update_user(
user_reloaded,
%{
email: "other_member@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -320,34 +435,49 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end
describe "Email uniqueness validation - Linking" do
test "CANNOT link user to member if user email is already used by another unlinked member" do
test "CANNOT link user to member if user email is already used by another unlinked member", %{
actor: actor
} do
# Create a member with email
{:ok, _other_member} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Doe",
email: "duplicate@example.com"
})
Membership.create_member(
%{
first_name: "Jane",
last_name: "Doe",
email: "duplicate@example.com"
},
actor: actor
)
# Create a user with same email
{:ok, user} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
Accounts.create_user(
%{
email: "duplicate@example.com"
},
actor: actor
)
# Create a member to link with the user
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Smith",
email: "john@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Smith",
email: "john@example.com"
},
actor: actor
)
# Try to link user to member - should fail because user.email is already used by other_member
result =
Accounts.update_user(user, %{
member: %{id: member.id}
})
Accounts.update_user(
user,
%{
member: %{id: member.id}
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -358,120 +488,160 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end)
end
test "CAN link member to user even if member email is used by another user (member email gets overridden)" do
test "CAN link member to user even if member email is used by another user (member email gets overridden)",
%{actor: actor} do
# Create a user with email
{:ok, _other_user} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
Accounts.create_user(
%{
email: "duplicate@example.com"
},
actor: actor
)
# Create a member with same email
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
},
actor: actor
)
# Create a user to link with the member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
Accounts.create_user(
%{
email: "user@example.com"
},
actor: actor
)
# Link member to user - should succeed because member.email will be overridden
{:ok, updated_member} =
Membership.update_member(member, %{
user: %{id: user.id}
})
Membership.update_member(
member,
%{
user: %{id: user.id}
},
actor: actor
)
# Member email should now be the same as user email
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, updated_member.id)
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, updated_member.id, actor: actor)
assert to_string(member_reloaded.email) == "user@example.com"
end
end
describe "Email syncing" do
test "member email syncs to linked user email without validation error" do
test "member email syncs to linked user email without validation error", %{actor: actor} do
# Create a user
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
Accounts.create_user(
%{
email: "user@example.com"
},
actor: actor
)
# Create a member linked to this user
# The override change will set member.email = user.email automatically
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "member@example.com",
user: %{id: user.id}
},
actor: actor
)
# Member email should have been overridden to user email
# This happens through our sync mechanism, which should NOT trigger
# the "email already used" validation because it's the same user
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert member_after_link.email == "user@example.com"
end
test "user email syncs to linked member without validation error" do
test "user email syncs to linked member without validation error", %{actor: actor} do
# Create a member
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
},
actor: actor
)
# Create a user linked to this member
# The override change will set member.email = user.email automatically
{:ok, _user} =
Accounts.create_user(%{
email: "user@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "user@example.com",
member: %{id: member.id}
},
actor: actor
)
# Member email should have been overridden to user email
# This happens through our sync mechanism, which should NOT trigger
# the "email already used" validation because it's the same member
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert member_after_link.email == "user@example.com"
end
test "two unlinked users cannot have the same email" do
test "two unlinked users cannot have the same email", %{actor: actor} do
# Create first user
{:ok, _user1} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
Accounts.create_user(
%{
email: "duplicate@example.com"
},
actor: actor
)
# Try to create second user with same email
result =
Accounts.create_user(%{
email: "duplicate@example.com"
})
Accounts.create_user(
%{
email: "duplicate@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{}} = result
end
test "two unlinked members cannot have the same email (members have unique constraint)" do
test "two unlinked members cannot have the same email (members have unique constraint)", %{
actor: actor
} do
# Create first member
{:ok, _member1} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
},
actor: actor
)
# Try to create second member with same email - should fail
result =
Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "duplicate@example.com"
})
Membership.create_member(
%{
first_name: "Jane",
last_name: "Smith",
email: "duplicate@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{}} = result
# Members DO have a unique email constraint at database level

View file

@ -10,6 +10,11 @@ defmodule Mv.Accounts.UserAuthenticationTest do
use MvWeb.ConnCase, async: true
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Password authentication user identification" do
@tag :test_proposal
test "password login uses email as identifier" do
@ -27,7 +32,7 @@ defmodule Mv.Accounts.UserAuthenticationTest do
{:ok, users} =
Mv.Accounts.User
|> Ash.Query.filter(email == ^email_to_find)
|> Ash.read()
|> Ash.read(actor: user)
assert length(users) == 1
found_user = List.first(users)
@ -113,11 +118,16 @@ defmodule Mv.Accounts.UserAuthenticationTest do
# Use sign_in_with_rauthy to find user by oidc_id
# Note: This test will FAIL until we implement the security fix
# that changes the filter from email to oidc_id
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
case result do
{:ok, [found_user]} ->
@ -141,11 +151,16 @@ defmodule Mv.Accounts.UserAuthenticationTest do
}
# Should create via register_with_rauthy
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, new_user} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert to_string(new_user.email) == "newuser@example.com"
assert new_user.oidc_id == "brand_new_oidc_789"
@ -170,12 +185,12 @@ defmodule Mv.Accounts.UserAuthenticationTest do
{:ok, users1} =
Mv.Accounts.User
|> Ash.Query.filter(oidc_id == "oidc_unique_1")
|> Ash.read()
|> Ash.read(actor: user1)
{:ok, users2} =
Mv.Accounts.User
|> Ash.Query.filter(oidc_id == "oidc_unique_2")
|> Ash.read()
|> Ash.read(actor: user2)
assert length(users1) == 1
assert length(users2) == 1
@ -205,11 +220,16 @@ defmodule Mv.Accounts.UserAuthenticationTest do
}
# Should NOT find the user (security requirement)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
case result do
@ -241,11 +261,16 @@ defmodule Mv.Accounts.UserAuthenticationTest do
}
# Should NOT find the user because oidc_id is nil
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
case result do

View file

@ -8,6 +8,11 @@ defmodule Mv.Accounts.UserEmailSyncTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "User email synchronization to linked Member" do
@valid_user_attrs %{
email: "user@example.com"
@ -19,96 +24,100 @@ defmodule Mv.Accounts.UserEmailSyncTest do
email: "member@example.com"
}
test "updating user email syncs to linked member" do
test "updating user email syncs to linked member", %{actor: actor} do
# Create a member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
assert member.email == "member@example.com"
# Create a user linked to the member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}), actor: actor)
# Verify initial state - member email should be overridden by user email
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert member_after_link.email == "user@example.com"
# Update user email
{:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"})
{:ok, updated_user} =
Accounts.update_user(user, %{email: "newemail@example.com"}, actor: actor)
assert to_string(updated_user.email) == "newemail@example.com"
# Verify member email was also updated
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert synced_member.email == "newemail@example.com"
end
test "creating user linked to member overrides member email" do
test "creating user linked to member overrides member email", %{actor: actor} do
# Create a member with their own email
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
assert member.email == "member@example.com"
# Create a user linked to this member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}), actor: actor)
assert to_string(user.email) == "user@example.com"
assert user.member_id == member.id
# Verify member email was overridden with user email
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert updated_member.email == "user@example.com"
end
test "linking user to existing member syncs user email to member" do
test "linking user to existing member syncs user email to member", %{actor: actor} do
# Create a standalone member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
assert member.email == "member@example.com"
# Create a standalone user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
assert to_string(user.email) == "user@example.com"
assert user.member_id == nil
# Link the user to the member
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}})
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}}, actor: actor)
assert linked_user.member_id == member.id
# Verify member email was overridden with user email
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert synced_member.email == "user@example.com"
end
test "updating user email when no member linked does not error" do
test "updating user email when no member linked does not error", %{actor: actor} do
# Create a standalone user without member link
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
assert to_string(user.email) == "user@example.com"
assert user.member_id == nil
# Update user email - should work fine without error
{:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"})
{:ok, updated_user} =
Accounts.update_user(user, %{email: "newemail@example.com"}, actor: actor)
assert to_string(updated_user.email) == "newemail@example.com"
assert updated_user.member_id == nil
end
test "unlinking user from member does not sync email" do
test "unlinking user from member does not sync email", %{actor: actor} do
# Create member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
# Create user linked to member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}), actor: actor)
assert user.member_id == member.id
# Verify member email was synced
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert synced_member.email == "user@example.com"
# Unlink user from member
{:ok, unlinked_user} = Accounts.update_user(user, %{member: nil})
{:ok, unlinked_user} = Accounts.update_user(user, %{member: nil}, actor: actor)
assert unlinked_user.member_id == nil
# Member email should remain unchanged after unlinking
{:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id)
{:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert member_after_unlink.email == "user@example.com"
end
end
@ -119,6 +128,8 @@ defmodule Mv.Accounts.UserEmailSyncTest do
email = "test@example.com"
password = "securepassword123"
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create user with password strategy (simulating registration)
{:ok, user} =
Mv.Accounts.User
@ -126,7 +137,7 @@ defmodule Mv.Accounts.UserEmailSyncTest do
email: email,
password: password
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert to_string(user.email) == email
assert user.hashed_password != nil
@ -138,7 +149,7 @@ defmodule Mv.Accounts.UserEmailSyncTest do
email: email,
password: password
})
|> Ash.read_one()
|> Ash.read_one(actor: system_actor)
assert signed_in_user.id == user.id
assert to_string(signed_in_user.email) == email
@ -153,6 +164,8 @@ defmodule Mv.Accounts.UserEmailSyncTest do
oauth_tokens = %{"access_token" => "mock_token"}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Simulate OIDC registration
{:ok, user} =
Mv.Accounts.User
@ -160,7 +173,7 @@ defmodule Mv.Accounts.UserEmailSyncTest do
user_info: user_info,
oauth_tokens: oauth_tokens
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert to_string(user.email) == "oidc@example.com"
assert user.oidc_id == "oidc-user-123"

View file

@ -18,71 +18,86 @@ defmodule Mv.Accounts.UserMemberDeletionTest do
email: "john@example.com"
}
test "deleting a member sets the user's member_id to NULL" do
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
test "deleting a member sets the user's member_id to NULL", %{actor: actor} do
# Create a member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
# Create a user linked to the member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}), actor: actor)
# Verify the relationship is established
{:ok, user_before_delete} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
{:ok, user_before_delete} =
Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member])
assert user_before_delete.member_id == member.id
assert user_before_delete.member.id == member.id
# Delete the member
:ok = Membership.destroy_member(member)
:ok = Membership.destroy_member(member, actor: actor)
# Verify the user still exists but member_id is NULL
{:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
{:ok, user_after_delete} =
Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member])
assert user_after_delete.id == user.id
assert user_after_delete.member_id == nil
assert user_after_delete.member == nil
end
test "user can be linked to a new member after old member is deleted" do
test "user can be linked to a new member after old member is deleted", %{actor: actor} do
# Create first member
{:ok, member1} = Membership.create_member(@valid_member_attrs)
{:ok, member1} = Membership.create_member(@valid_member_attrs, actor: actor)
# Create user linked to first member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member1.id}))
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member1.id}), actor: actor)
assert user.member_id == member1.id
# Delete first member
:ok = Membership.destroy_member(member1)
:ok = Membership.destroy_member(member1, actor: actor)
# Reload user from database to get updated member_id (should be NULL)
{:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id)
{:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
assert user_after_delete.member_id == nil
# Create second member
{:ok, member2} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
})
Membership.create_member(
%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
},
actor: actor
)
# Link user to second member (use reloaded user)
{:ok, updated_user} = Accounts.update_user(user_after_delete, %{member: %{id: member2.id}})
{:ok, updated_user} =
Accounts.update_user(user_after_delete, %{member: %{id: member2.id}}, actor: actor)
# Verify new relationship
{:ok, final_user} = Ash.get(Mv.Accounts.User, updated_user.id, load: [:member])
{:ok, final_user} =
Ash.get(Mv.Accounts.User, updated_user.id, actor: actor, load: [:member])
assert final_user.member_id == member2.id
assert final_user.member.id == member2.id
end
test "member without linked user can be deleted normally" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "member without linked user can be deleted normally", %{actor: actor} do
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
# Delete member (no users linked)
assert :ok = Membership.destroy_member(member)
assert :ok = Membership.destroy_member(member, actor: actor)
# Verify member is deleted
assert {:error, _} = Ash.get(Mv.Membership.Member, member.id)
assert {:error, _} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
end
end
end

View file

@ -10,51 +10,70 @@ defmodule Mv.Accounts.UserMemberLinkingEmailTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "link with same email" do
test "succeeds when user.email == member.email" do
test "succeeds when user.email == member.email", %{actor: actor} do
# Create member with specific email
{:ok, member} =
Membership.create_member(%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
})
Membership.create_member(
%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
},
actor: actor
)
# Create user with same email and link to member
result =
Accounts.create_user(%{
email: "alice@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "alice@example.com",
member: %{id: member.id}
},
actor: actor
)
# Should succeed without errors
assert {:ok, user} = result
assert to_string(user.email) == "alice@example.com"
# Reload to verify link
user = Ash.load!(user, [:member], domain: Mv.Accounts)
user = Ash.load!(user, [:member], domain: Mv.Accounts, actor: actor)
assert user.member.id == member.id
assert user.member.email == "alice@example.com"
end
test "no validation error triggered when updating linked pair with same email" do
test "no validation error triggered when updating linked pair with same email", %{
actor: actor
} do
# Create member
{:ok, member} =
Membership.create_member(%{
first_name: "Bob",
last_name: "Smith",
email: "bob@example.com"
})
Membership.create_member(
%{
first_name: "Bob",
last_name: "Smith",
email: "bob@example.com"
},
actor: actor
)
# Create user and link
{:ok, user} =
Accounts.create_user(%{
email: "bob@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "bob@example.com",
member: %{id: member.id}
},
actor: actor
)
# Update user (should not trigger email validation error)
result = Accounts.update_user(user, %{email: "bob@example.com"})
result = Accounts.update_user(user, %{email: "bob@example.com"}, actor: actor)
assert {:ok, updated_user} = result
assert to_string(updated_user.email) == "bob@example.com"
@ -62,70 +81,88 @@ defmodule Mv.Accounts.UserMemberLinkingEmailTest do
end
describe "link with different emails" do
test "fails if member.email is used by a DIFFERENT linked user" do
test "fails if member.email is used by a DIFFERENT linked user", %{actor: actor} do
# Create first user and link to a different member
{:ok, other_member} =
Membership.create_member(%{
first_name: "Other",
last_name: "Member",
email: "other@example.com"
})
Membership.create_member(
%{
first_name: "Other",
last_name: "Member",
email: "other@example.com"
},
actor: actor
)
{:ok, _user1} =
Accounts.create_user(%{
email: "user1@example.com",
member: %{id: other_member.id}
})
Accounts.create_user(
%{
email: "user1@example.com",
member: %{id: other_member.id}
},
actor: actor
)
# Reload to ensure email sync happened
_other_member = Ash.reload!(other_member)
_other_member = Ash.reload!(other_member, actor: actor)
# Create a NEW member with different email
{:ok, member} =
Membership.create_member(%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
})
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: actor
)
# Try to create user2 with email that matches the linked other_member
result =
Accounts.create_user(%{
email: "user1@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "user1@example.com",
member: %{id: member.id}
},
actor: actor
)
# Should fail because user1@example.com is already used by other_member (which is linked to user1)
assert {:error, _error} = result
end
test "succeeds for unique emails" do
test "succeeds for unique emails", %{actor: actor} do
# Create member
{:ok, member} =
Membership.create_member(%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
})
Membership.create_member(
%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
},
actor: actor
)
# Create user with different but unique email
result =
Accounts.create_user(%{
email: "user@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "user@example.com",
member: %{id: member.id}
},
actor: actor
)
# Should succeed
assert {:ok, user} = result
# Email sync should update member's email to match user's
user = Ash.load!(user, [:member], domain: Mv.Accounts)
user = Ash.load!(user, [:member], domain: Mv.Accounts, actor: actor)
assert user.member.email == "user@example.com"
end
end
describe "edge cases" do
test "unlinking and relinking with same email works (Problem #4)" do
test "unlinking and relinking with same email works (Problem #4)", %{actor: actor} do
# This is the exact scenario from Problem #4:
# 1. Link user and member (both have same email)
# 2. Unlink them (member keeps the email)
@ -133,34 +170,40 @@ defmodule Mv.Accounts.UserMemberLinkingEmailTest do
# Create member
{:ok, member} =
Membership.create_member(%{
first_name: "Emma",
last_name: "Davis",
email: "emma@example.com"
})
Membership.create_member(
%{
first_name: "Emma",
last_name: "Davis",
email: "emma@example.com"
},
actor: actor
)
# Create user and link
{:ok, user} =
Accounts.create_user(%{
email: "emma@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "emma@example.com",
member: %{id: member.id}
},
actor: actor
)
# Verify they are linked
user = Ash.load!(user, [:member], domain: Mv.Accounts)
user = Ash.load!(user, [:member], domain: Mv.Accounts, actor: actor)
assert user.member.id == member.id
assert user.member.email == "emma@example.com"
# Unlink
{:ok, unlinked_user} = Accounts.update_user(user, %{member: nil})
{:ok, unlinked_user} = Accounts.update_user(user, %{member: nil}, actor: actor)
assert is_nil(unlinked_user.member_id)
# Member still has the email after unlink
member = Ash.reload!(member)
member = Ash.reload!(member, actor: actor)
assert member.email == "emma@example.com"
# Relink (should work - this is Problem #4)
result = Accounts.update_user(unlinked_user, %{member: %{id: member.id}})
result = Accounts.update_user(unlinked_user, %{member: %{id: member.id}}, actor: actor)
assert {:ok, relinked_user} = result
assert relinked_user.member_id == member.id

View file

@ -9,121 +9,150 @@ defmodule Mv.Accounts.UserMemberLinkingTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "User-Member Linking with Email Sync" do
test "link user to member with different email syncs member email" do
test "link user to member with different email syncs member email", %{actor: actor} do
# Create user with one email
{:ok, user} = Accounts.create_user(%{email: "user@example.com"})
{:ok, user} = Accounts.create_user(%{email: "user@example.com"}, actor: actor)
# Create member with different email
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
},
actor: actor
)
# Link user to member
{:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}})
{:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}}, actor: actor)
# Verify link exists
user_with_member = Ash.get!(Mv.Accounts.User, updated_user.id, load: [:member])
user_with_member =
Ash.get!(Mv.Accounts.User, updated_user.id, actor: actor, load: [:member])
assert user_with_member.member.id == member.id
# Verify member email was synced to match user email
synced_member = Ash.get!(Mv.Membership.Member, member.id)
synced_member = Ash.get!(Mv.Membership.Member, member.id, actor: actor)
assert synced_member.email == "user@example.com"
end
test "unlink member from user sets member to nil" do
test "unlink member from user sets member to nil", %{actor: actor} do
# Create and link user and member
{:ok, user} = Accounts.create_user(%{email: "user@example.com"})
{:ok, user} = Accounts.create_user(%{email: "user@example.com"}, actor: actor)
{:ok, member} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
})
Membership.create_member(
%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
},
actor: actor
)
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}})
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}}, actor: actor)
# Verify link exists
user_with_member = Ash.get!(Mv.Accounts.User, linked_user.id, load: [:member])
user_with_member = Ash.get!(Mv.Accounts.User, linked_user.id, actor: actor, load: [:member])
assert user_with_member.member.id == member.id
# Unlink by setting member to nil
{:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil})
{:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil}, actor: actor)
# Verify link is removed
user_without_member = Ash.get!(Mv.Accounts.User, unlinked_user.id, load: [:member])
user_without_member =
Ash.get!(Mv.Accounts.User, unlinked_user.id, actor: actor, load: [:member])
assert is_nil(user_without_member.member)
# Verify member still exists independently
member_still_exists = Ash.get!(Mv.Membership.Member, member.id)
member_still_exists = Ash.get!(Mv.Membership.Member, member.id, actor: actor)
assert member_still_exists.id == member.id
end
test "cannot link member already linked to another user" do
test "cannot link member already linked to another user", %{actor: actor} do
# Create first user and link to member
{:ok, user1} = Accounts.create_user(%{email: "user1@example.com"})
{:ok, user1} = Accounts.create_user(%{email: "user1@example.com"}, actor: actor)
{:ok, member} =
Membership.create_member(%{
first_name: "Bob",
last_name: "Wilson",
email: "bob@example.com"
})
Membership.create_member(
%{
first_name: "Bob",
last_name: "Wilson",
email: "bob@example.com"
},
actor: actor
)
{:ok, _linked_user1} = Accounts.update_user(user1, %{member: %{id: member.id}})
{:ok, _linked_user1} =
Accounts.update_user(user1, %{member: %{id: member.id}}, actor: actor)
# Create second user and try to link to same member
{:ok, user2} = Accounts.create_user(%{email: "user2@example.com"})
{:ok, user2} = Accounts.create_user(%{email: "user2@example.com"}, actor: actor)
# Should fail because member is already linked
assert {:error, %Ash.Error.Invalid{}} =
Accounts.update_user(user2, %{member: %{id: member.id}})
Accounts.update_user(user2, %{member: %{id: member.id}}, actor: actor)
end
test "cannot change member link directly, must unlink first" do
test "cannot change member link directly, must unlink first", %{actor: actor} do
# Create user and link to first member
{:ok, user} = Accounts.create_user(%{email: "user@example.com"})
{:ok, user} = Accounts.create_user(%{email: "user@example.com"}, actor: actor)
{:ok, member1} =
Membership.create_member(%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
})
Membership.create_member(
%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
},
actor: actor
)
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member1.id}})
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member1.id}}, actor: actor)
# Create second member
{:ok, member2} =
Membership.create_member(%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
})
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: actor
)
# Try to directly change member link (should fail)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Accounts.update_user(linked_user, %{member: %{id: member2.id}})
Accounts.update_user(linked_user, %{member: %{id: member2.id}}, actor: actor)
# Verify error message mentions "Remove existing member first"
error_messages = Enum.map(errors, & &1.message)
assert Enum.any?(error_messages, &String.contains?(&1, "Remove existing member first"))
# Two-step process: first unlink, then link new member
{:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil})
{:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil}, actor: actor)
# After unlinking, member1 still has the user's email
# Change member1's email to avoid conflict when relinking to member2
{:ok, _} = Membership.update_member(member1, %{email: "alice_changed@example.com"})
{:ok, _} =
Membership.update_member(member1, %{email: "alice_changed@example.com"}, actor: actor)
{:ok, relinked_user} = Accounts.update_user(unlinked_user, %{member: %{id: member2.id}})
{:ok, relinked_user} =
Accounts.update_user(unlinked_user, %{member: %{id: member2.id}}, actor: actor)
# Verify new link is established
user_with_new_member = Ash.get!(Mv.Accounts.User, relinked_user.id, load: [:member])
user_with_new_member =
Ash.get!(Mv.Accounts.User, relinked_user.id, actor: actor, load: [:member])
assert user_with_new_member.member.id == member2.id
end
end

View file

@ -5,6 +5,11 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "User-Member Relationship - Basic Tests" do
@valid_user_attrs %{
email: "test@example.com"
@ -16,22 +21,26 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
email: "john@example.com"
}
test "user can exist without member" do
{:ok, user} = Accounts.create_user(@valid_user_attrs)
test "user can exist without member", %{actor: actor} do
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
assert user.member_id == nil
# Load the relationship to test it
{:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
{:ok, user_with_member} =
Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member])
assert user_with_member.member == nil
end
test "member can exist without user" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "member can exist without user", %{actor: actor} do
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
assert member.id != nil
assert member.first_name == "John"
# Load the relationship to test it
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
{:ok, member_with_user} =
Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user])
assert member_with_user.user == nil
end
end
@ -47,47 +56,58 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
email: "alice@example.com"
}
test "user can be linked to member during user creation" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "user can be linked to member during user creation", %{actor: actor} do
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
user_attrs = Map.put(@valid_user_attrs, :member, %{id: member.id})
{:ok, user} = Accounts.create_user(user_attrs)
{:ok, user} = Accounts.create_user(user_attrs, actor: actor)
# Load the relationship to test it
{:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
{:ok, user_with_member} =
Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member])
assert user_with_member.member.id == member.id
end
test "member can be linked to user during member creation using manage_relationship" do
{:ok, user} = Accounts.create_user(@valid_user_attrs)
test "member can be linked to user during member creation using manage_relationship", %{
actor: actor
} do
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
member_attrs = Map.put(@valid_member_attrs, :user, %{id: user.id})
{:ok, member} = Membership.create_member(member_attrs)
{:ok, member} = Membership.create_member(member_attrs, actor: actor)
# Load the relationship to test it
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
{:ok, member_with_user} =
Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user])
assert member_with_user.user.id == user.id
end
test "user can be linked to member during update" do
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "user can be linked to member during update", %{actor: actor} do
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
{:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}})
{:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}}, actor: actor)
# Load the relationship to test it
{:ok, user_with_member} = Ash.get(Mv.Accounts.User, updated_user.id, load: [:member])
{:ok, user_with_member} =
Ash.get(Mv.Accounts.User, updated_user.id, actor: actor, load: [:member])
assert user_with_member.member.id == member.id
end
test "member can be linked to user during update using manage_relationship" do
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "member can be linked to user during update using manage_relationship", %{actor: actor} do
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
{:ok, _updated_member} = Membership.update_member(member, %{user: %{id: user.id}})
{:ok, _updated_member} =
Membership.update_member(member, %{user: %{id: user.id}}, actor: actor)
# Load the relationship to test it
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
{:ok, member_with_user} =
Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user])
assert member_with_user.user.id == user.id
end
end
@ -103,25 +123,39 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
email: "bob@example.com"
}
test "ash resolves inverse relationship automatically" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
test "ash resolves inverse relationship automatically", %{actor: actor} do
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
user_attrs = Map.put(@valid_user_attrs, :member, %{id: member.id})
{:ok, user} = Accounts.create_user(user_attrs)
{:ok, user} = Accounts.create_user(user_attrs, actor: actor)
# Load relationships
{:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
{:ok, user_with_member} =
Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member])
{:ok, member_with_user} =
Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user])
assert user_with_member.member.id == member.id
assert member_with_user.user.id == user.id
end
test "member can find associated user" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "member can find associated user", %{actor: actor} do
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
{:ok, user} =
Accounts.create_user(%{email: "test3@example.com", member: %{id: member.id}},
actor: actor
)
{:ok, member_with_user} =
Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user])
{:ok, user} = Accounts.create_user(%{email: "test3@example.com", member: %{id: member.id}})
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert member_with_user.user.id == user.id
end
end
@ -137,61 +171,77 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
email: "charlie@example.com"
}
test "prevents overwriting a member of already linked user on update" do
{:ok, existing_member} = Membership.create_member(@valid_member_attrs)
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
test "prevents overwriting a member of already linked user on update", %{actor: actor} do
{:ok, existing_member} = Membership.create_member(@valid_member_attrs, actor: actor)
user_attrs = Map.put(@valid_user_attrs, :member, %{id: existing_member.id})
{:ok, user} = Accounts.create_user(user_attrs)
{:ok, user} = Accounts.create_user(user_attrs, actor: actor)
{:ok, member2} =
Membership.create_member(%{
first_name: "Dave",
last_name: "Wilson",
email: "dave@example.com"
})
Membership.create_member(
%{
first_name: "Dave",
last_name: "Wilson",
email: "dave@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{}} =
Accounts.update_user(user, %{member: %{id: member2.id}})
Accounts.update_user(user, %{member: %{id: member2.id}}, actor: actor)
end
test "prevents linking user to already linked member on update" do
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "prevents linking user to already linked member on update", %{actor: actor} do
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs, actor: actor)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
{:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}})
{:ok, _updated_user} =
Accounts.update_user(existing_user, %{member: %{id: member.id}}, actor: actor)
{:ok, user2} = Accounts.create_user(%{email: "test5@example.com"})
{:ok, user2} = Accounts.create_user(%{email: "test5@example.com"}, actor: actor)
assert {:error, %Ash.Error.Invalid{}} =
Accounts.update_user(user2, %{member: %{id: member.id}})
Accounts.update_user(user2, %{member: %{id: member.id}}, actor: actor)
end
test "prevents linking member to already linked user on creation" do
{:ok, existing_member} = Membership.create_member(@valid_member_attrs)
test "prevents linking member to already linked user on creation", %{actor: actor} do
{:ok, existing_member} = Membership.create_member(@valid_member_attrs, actor: actor)
user_attrs = Map.put(@valid_user_attrs, :member, %{id: existing_member.id})
{:ok, user} = Accounts.create_user(user_attrs)
{:ok, user} = Accounts.create_user(user_attrs, actor: actor)
assert {:error, %Ash.Error.Invalid{}} =
Membership.create_member(%{
first_name: "Dave",
last_name: "Wilson",
email: "dave@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "Dave",
last_name: "Wilson",
email: "dave@example.com",
user: %{id: user.id}
},
actor: actor
)
end
test "prevents linking user to already linked member on creation" do
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "prevents linking user to already linked member on creation", %{actor: actor} do
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs, actor: actor)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
{:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}})
{:ok, _updated_user} =
Accounts.update_user(existing_user, %{member: %{id: member.id}}, actor: actor)
assert {:error, %Ash.Error.Invalid{}} =
Accounts.create_user(%{
email: "test5@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "test5@example.com",
member: %{id: member.id}
},
actor: actor
)
end
end
end

View file

@ -0,0 +1,8 @@
first_name;last_name;email;street;postal_code;city
Alice;Smith;alice.smith@example.com;Main Street 1;12345;Berlin
1 first_name last_name email street postal_code city
2 Alice Smith alice.smith@example.com Main Street 1 12345 Berlin

11
test/fixtures/csv_with_empty_lines.csv vendored Normal file
View file

@ -0,0 +1,11 @@
first_name;last_name;email;street;postal_code;city
Alice;Smith;alice.smith@example.com;Main Street 1;12345;Berlin
Bob;Johnson;invalid-email;Park Avenue 2;54321;Munich
1 first_name last_name email street postal_code city
2 Alice Smith alice.smith@example.com Main Street 1 12345 Berlin
3 Bob Johnson invalid-email Park Avenue 2 54321 Munich

View file

@ -0,0 +1,10 @@
first_name;last_name;email;street;postal_code;city;UnknownCustomField
Alice;Smith;alice.smith@example.com;Main Street 1;12345;Berlin;SomeValue
Bob;Johnson;bob.johnson@example.com;Park Avenue 2;54321;Munich;AnotherValue
1 first_name last_name email street postal_code city UnknownCustomField
2 Alice Smith alice.smith@example.com Main Street 1 12345 Berlin SomeValue
3 Bob Johnson bob.johnson@example.com Park Avenue 2 54321 Munich AnotherValue

10
test/fixtures/invalid_member_import.csv vendored Normal file
View file

@ -0,0 +1,10 @@
first_name;last_name;email;street;postal_code;city
Alice;Smith;invalid-email;Main Street 1;12345;Berlin
Bob;Johnson;;Park Avenue 2;54321;Munich
1 first_name last_name email street postal_code city
2 Alice Smith invalid-email Main Street 1 12345 Berlin
3 Bob Johnson Park Avenue 2 54321 Munich

10
test/fixtures/valid_member_import.csv vendored Normal file
View file

@ -0,0 +1,10 @@
first_name;last_name;email;street;postal_code;city
Alice;Smith;alice.smith@example.com;Main Street 1;12345;Berlin
Bob;Johnson;bob.johnson@example.com;Park Avenue 2;54321;Munich
1 first_name last_name email street postal_code city
2 Alice Smith alice.smith@example.com Main Street 1 12345 Berlin
3 Bob Johnson bob.johnson@example.com Park Avenue 2 54321 Munich

View file

@ -13,23 +13,28 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "assigned_members_count calculation" do
test "returns 0 for custom field without any values" do
test "returns 0 for custom field without any values", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count, actor: actor)
assert custom_field_with_count.assigned_members_count == 0
end
test "returns correct count for custom field with one member" do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
test "returns correct count for custom field with one member", %{actor: actor} do
{:ok, member} = create_member(actor)
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
{:ok, _custom_field_value} =
CustomFieldValue
@ -38,17 +43,17 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
|> Ash.create(actor: actor)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count, actor: actor)
assert custom_field_with_count.assigned_members_count == 1
end
test "returns correct count for custom field with multiple members" do
{:ok, member1} = create_member()
{:ok, member2} = create_member()
{:ok, member3} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
test "returns correct count for custom field with multiple members", %{actor: actor} do
{:ok, member1} = create_member(actor)
{:ok, member2} = create_member(actor)
{:ok, member3} = create_member(actor)
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
# Create custom field value for each member
for member <- [member1, member2, member3] do
@ -59,16 +64,16 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
|> Ash.create(actor: actor)
end
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count, actor: actor)
assert custom_field_with_count.assigned_members_count == 3
end
test "counts distinct members (not multiple values per member)" do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
test "counts distinct members (not multiple values per member)", %{actor: actor} do
{:ok, member} = create_member(actor)
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
# Create custom field value for member
{:ok, _} =
@ -78,9 +83,9 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
|> Ash.create(actor: actor)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count, actor: actor)
# Should still be 1, not 2, even if we tried to create multiple (which would fail due to uniqueness)
assert custom_field_with_count.assigned_members_count == 1
@ -88,9 +93,9 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
end
describe "prepare_deletion action" do
test "loads assigned_members_count for deletion preparation" do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
test "loads assigned_members_count for deletion preparation", %{actor: actor} do
{:ok, member} = create_member(actor)
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
{:ok, _} =
CustomFieldValue
@ -99,43 +104,43 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
|> Ash.create(actor: actor)
# Use prepare_deletion action
[prepared_custom_field] =
CustomField
|> Ash.Query.for_read(:prepare_deletion, %{id: custom_field.id})
|> Ash.read!()
|> Ash.read!(actor: actor)
assert prepared_custom_field.assigned_members_count == 1
assert prepared_custom_field.id == custom_field.id
end
test "returns empty list for non-existent custom field" do
test "returns empty list for non-existent custom field", %{actor: actor} do
non_existent_id = Ash.UUID.generate()
result =
CustomField
|> Ash.Query.for_read(:prepare_deletion, %{id: non_existent_id})
|> Ash.read!()
|> Ash.read!(actor: actor)
assert result == []
end
end
describe "destroy_with_values action" do
test "deletes custom field without any values" do
{:ok, custom_field} = create_custom_field("test_field", :string)
test "deletes custom field without any values", %{actor: actor} do
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
assert :ok = Ash.destroy(custom_field)
assert :ok = Ash.destroy(custom_field, actor: actor)
# Verify custom field is deleted
assert {:error, _} = Ash.get(CustomField, custom_field.id)
assert {:error, _} = Ash.get(CustomField, custom_field.id, actor: actor)
end
test "deletes custom field and cascades to all its values" do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
test "deletes custom field and cascades to all its values", %{actor: actor} do
{:ok, member} = create_member(actor)
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
{:ok, custom_field_value} =
CustomFieldValue
@ -144,25 +149,25 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
|> Ash.create(actor: actor)
# Delete custom field
assert :ok = Ash.destroy(custom_field)
assert :ok = Ash.destroy(custom_field, actor: actor)
# Verify custom field is deleted
assert {:error, _} = Ash.get(CustomField, custom_field.id)
assert {:error, _} = Ash.get(CustomField, custom_field.id, actor: actor)
# Verify custom field value is also deleted (CASCADE)
assert {:error, _} = Ash.get(CustomFieldValue, custom_field_value.id)
assert {:error, _} = Ash.get(CustomFieldValue, custom_field_value.id, actor: actor)
# Verify member still exists
assert {:ok, _} = Ash.get(Member, member.id)
assert {:ok, _} = Ash.get(Member, member.id, actor: actor)
end
test "deletes only values of the specific custom field" do
{:ok, member} = create_member()
{:ok, custom_field1} = create_custom_field("field1", :string)
{:ok, custom_field2} = create_custom_field("field2", :string)
test "deletes only values of the specific custom field", %{actor: actor} do
{:ok, member} = create_member(actor)
{:ok, custom_field1} = create_custom_field("field1", :string, actor)
{:ok, custom_field2} = create_custom_field("field2", :string, actor)
# Create value for custom_field1
{:ok, value1} =
@ -172,7 +177,7 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field1.id,
value: %{"_union_type" => "string", "_union_value" => "value1"}
})
|> Ash.create()
|> Ash.create(actor: actor)
# Create value for custom_field2
{:ok, value2} =
@ -182,25 +187,25 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field2.id,
value: %{"_union_type" => "string", "_union_value" => "value2"}
})
|> Ash.create()
|> Ash.create(actor: actor)
# Delete custom_field1
assert :ok = Ash.destroy(custom_field1)
assert :ok = Ash.destroy(custom_field1, actor: actor)
# Verify custom_field1 and value1 are deleted
assert {:error, _} = Ash.get(CustomField, custom_field1.id)
assert {:error, _} = Ash.get(CustomFieldValue, value1.id)
assert {:error, _} = Ash.get(CustomField, custom_field1.id, actor: actor)
assert {:error, _} = Ash.get(CustomFieldValue, value1.id, actor: actor)
# Verify custom_field2 and value2 still exist
assert {:ok, _} = Ash.get(CustomField, custom_field2.id)
assert {:ok, _} = Ash.get(CustomFieldValue, value2.id)
assert {:ok, _} = Ash.get(CustomField, custom_field2.id, actor: actor)
assert {:ok, _} = Ash.get(CustomFieldValue, value2.id, actor: actor)
end
test "deletes custom field with values from multiple members" do
{:ok, member1} = create_member()
{:ok, member2} = create_member()
{:ok, member3} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
test "deletes custom field with values from multiple members", %{actor: actor} do
{:ok, member1} = create_member(actor)
{:ok, member2} = create_member(actor)
{:ok, member3} = create_member(actor)
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
# Create value for each member
values =
@ -212,43 +217,43 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
|> Ash.create(actor: actor)
value
end
# Delete custom field
assert :ok = Ash.destroy(custom_field)
assert :ok = Ash.destroy(custom_field, actor: actor)
# Verify all values are deleted
for value <- values do
assert {:error, _} = Ash.get(CustomFieldValue, value.id)
assert {:error, _} = Ash.get(CustomFieldValue, value.id, actor: actor)
end
# Verify all members still exist
for member <- [member1, member2, member3] do
assert {:ok, _} = Ash.get(Member, member.id)
assert {:ok, _} = Ash.get(Member, member.id, actor: actor)
end
end
end
# Helper functions
defp create_member do
defp create_member(actor) do
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User#{System.unique_integer([:positive])}",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
|> Ash.create(actor: actor)
end
defp create_custom_field(name, value_type) do
defp create_custom_field(name, value_type, actor) do
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "#{name}_#{System.unique_integer([:positive])}",
value_type: value_type
})
|> Ash.create()
|> Ash.create(actor: actor)
end
end

View file

@ -12,8 +12,13 @@ defmodule Mv.Membership.CustomFieldShowInOverviewTest do
alias Mv.Membership.CustomField
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "show_in_overview attribute" do
test "creates custom field with show_in_overview: true" do
test "creates custom field with show_in_overview: true", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
@ -21,24 +26,24 @@ defmodule Mv.Membership.CustomFieldShowInOverviewTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.show_in_overview == true
end
test "creates custom field with show_in_overview: true (default)" do
test "creates custom field with show_in_overview: true (default)", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_hide",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.show_in_overview == true
end
test "updates show_in_overview to true" do
test "updates show_in_overview to true", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
@ -46,17 +51,17 @@ defmodule Mv.Membership.CustomFieldShowInOverviewTest do
value_type: :string,
show_in_overview: false
})
|> Ash.create()
|> Ash.create(actor: actor)
assert {:ok, updated_field} =
custom_field
|> Ash.Changeset.for_update(:update, %{show_in_overview: true})
|> Ash.update()
|> Ash.update(actor: actor)
assert updated_field.show_in_overview == true
end
test "updates show_in_overview to false" do
test "updates show_in_overview to false", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
@ -64,12 +69,12 @@ defmodule Mv.Membership.CustomFieldShowInOverviewTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: actor)
assert {:ok, updated_field} =
custom_field
|> Ash.Changeset.for_update(:update, %{show_in_overview: false})
|> Ash.update()
|> Ash.update(actor: actor)
assert updated_field.show_in_overview == false
end

View file

@ -13,94 +13,99 @@ defmodule Mv.Membership.CustomFieldSlugTest do
alias Mv.Membership.CustomField
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "automatic slug generation on create" do
test "generates slug from name with simple ASCII text" do
test "generates slug from name with simple ASCII text", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Mobile Phone",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "mobile-phone"
end
test "generates slug from name with German umlauts" do
test "generates slug from name with German umlauts", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Café Müller",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "cafe-muller"
end
test "generates slug with lowercase conversion" do
test "generates slug with lowercase conversion", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "TEST NAME",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "test-name"
end
test "generates slug by removing special characters" do
test "generates slug by removing special characters", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "E-Mail & Address!",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "e-mail-address"
end
test "generates slug by replacing multiple spaces with single hyphen" do
test "generates slug by replacing multiple spaces with single hyphen", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Multiple Spaces",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "multiple-spaces"
end
test "trims leading and trailing hyphens" do
test "trims leading and trailing hyphens", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "-Test-",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "test"
end
test "handles unicode characters properly (ß becomes ss)" do
test "handles unicode characters properly (ß becomes ss)", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Straße",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "strasse"
end
end
describe "slug uniqueness" do
test "prevents creating custom field with duplicate slug" do
test "prevents creating custom field with duplicate slug", %{actor: actor} do
# Create first custom field
{:ok, _custom_field} =
CustomField
@ -108,7 +113,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: "Test",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Attempt to create second custom field with same slug (different case in name)
assert {:error, %Ash.Error.Invalid{} = error} =
@ -117,19 +122,19 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: "test",
value_type: :integer
})
|> Ash.create()
|> Ash.create(actor: actor)
assert Exception.message(error) =~ "has already been taken"
end
test "allows custom fields with different slugs" do
test "allows custom fields with different slugs", %{actor: actor} do
{:ok, custom_field1} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test One",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
{:ok, custom_field2} =
CustomField
@ -137,21 +142,21 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: "Test Two",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field1.slug == "test-one"
assert custom_field2.slug == "test-two"
assert custom_field1.slug != custom_field2.slug
end
test "prevents duplicate slugs when names differ only in special characters" do
test "prevents duplicate slugs when names differ only in special characters", %{actor: actor} do
{:ok, custom_field1} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test!!!",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field1.slug == "test"
@ -162,7 +167,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: "Test???",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Should fail with uniqueness constraint error
assert Exception.message(error) =~ "has already been taken"
@ -170,7 +175,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
end
describe "slug immutability" do
test "slug cannot be manually set on create" do
test "slug cannot be manually set on create", %{actor: actor} do
# Attempting to set slug manually should fail because slug is not writable
result =
CustomField
@ -179,14 +184,14 @@ defmodule Mv.Membership.CustomFieldSlugTest do
value_type: :string,
slug: "custom-slug"
})
|> Ash.create()
|> Ash.create(actor: actor)
# Should fail because slug is not an accepted input
assert {:error, %Ash.Error.Invalid{}} = result
assert Exception.message(elem(result, 1)) =~ "No such input"
end
test "slug does not change when name is updated" do
test "slug does not change when name is updated", %{actor: actor} do
# Create custom field
{:ok, custom_field} =
CustomField
@ -194,7 +199,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: "Original Name",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
original_slug = custom_field.slug
assert original_slug == "original-name"
@ -205,7 +210,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|> Ash.Changeset.for_update(:update, %{
name: "New Different Name"
})
|> Ash.update()
|> Ash.update(actor: actor)
# Slug should remain unchanged
assert updated_custom_field.slug == original_slug
@ -213,14 +218,14 @@ defmodule Mv.Membership.CustomFieldSlugTest do
assert updated_custom_field.name == "New Different Name"
end
test "slug cannot be manually updated" do
test "slug cannot be manually updated", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
original_slug = custom_field.slug
assert original_slug == "test"
@ -231,20 +236,20 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|> Ash.Changeset.for_update(:update, %{
slug: "new-slug"
})
|> Ash.update()
|> Ash.update(actor: actor)
# Should fail because slug is not an accepted input
assert {:error, %Ash.Error.Invalid{}} = result
assert Exception.message(elem(result, 1)) =~ "No such input"
# Reload to verify slug hasn't changed
reloaded = Ash.get!(CustomField, custom_field.id)
reloaded = Ash.get!(CustomField, custom_field.id, actor: actor)
assert reloaded.slug == "test"
end
end
describe "slug edge cases" do
test "handles very long names by truncating slug" do
test "handles very long names by truncating slug", %{actor: actor} do
# Create a name at the maximum length (100 chars)
long_name = String.duplicate("abcdefghij", 10)
# 100 characters exactly
@ -255,7 +260,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: long_name,
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Slug should be truncated to maximum 100 characters
assert String.length(custom_field.slug) <= 100
@ -263,7 +268,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
assert custom_field.slug == long_name
end
test "rejects name with only special characters" do
test "rejects name with only special characters", %{actor: actor} do
# When name contains only special characters, slug would be empty
# This should fail validation
assert {:error, %Ash.Error.Invalid{} = error} =
@ -272,59 +277,59 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: "!!!",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Should fail because slug would be empty
error_message = Exception.message(error)
assert error_message =~ "Slug cannot be empty" or error_message =~ "is required"
end
test "handles mixed special characters and text" do
test "handles mixed special characters and text", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test@#$%Name",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# slugify keeps the hyphen between words
assert custom_field.slug == "test-name"
end
test "handles numbers in name" do
test "handles numbers in name", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Field 123 Test",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "field-123-test"
end
test "handles consecutive hyphens in name" do
test "handles consecutive hyphens in name", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test---Name",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Should reduce multiple hyphens to single hyphen
assert custom_field.slug == "test-name"
end
test "handles name with dots and underscores" do
test "handles name with dots and underscores", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test.field_name",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Dots and underscores should be handled (either kept or converted)
assert custom_field.slug =~ ~r/^[a-z0-9-]+$/
@ -332,45 +337,45 @@ defmodule Mv.Membership.CustomFieldSlugTest do
end
describe "slug in queries and responses" do
test "slug is included in struct after create" do
test "slug is included in struct after create", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Slug should be present in the struct
assert Map.has_key?(custom_field, :slug)
assert custom_field.slug != nil
end
test "can load custom field and slug is present" do
test "can load custom field and slug is present", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Load it back
loaded_custom_field = Ash.get!(CustomField, custom_field.id)
loaded_custom_field = Ash.get!(CustomField, custom_field.id, actor: actor)
assert loaded_custom_field.slug == "test"
end
test "slug is returned in list queries" do
test "slug is returned in list queries", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
custom_fields = Ash.read!(CustomField)
custom_fields = Ash.read!(CustomField, actor: actor)
found = Enum.find(custom_fields, &(&1.id == custom_field.id))
assert found.slug == "test"
@ -379,18 +384,18 @@ defmodule Mv.Membership.CustomFieldSlugTest do
describe "slug-based lookup (future feature)" do
@tag :skip
test "can find custom field by slug" do
test "can find custom field by slug", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test Field",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# This test is for future implementation
# We might add a custom action like :by_slug
found = Ash.get!(CustomField, custom_field.slug, load: [:slug])
found = Ash.get!(CustomField, custom_field.slug, load: [:slug], actor: actor)
assert found.id == custom_field.id
end
end

View file

@ -13,8 +13,13 @@ defmodule Mv.Membership.CustomFieldValidationTest do
alias Mv.Membership.CustomField
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "name validation" do
test "accepts name with exactly 100 characters" do
test "accepts name with exactly 100 characters", %{actor: actor} do
name = String.duplicate("a", 100)
assert {:ok, custom_field} =
@ -23,13 +28,13 @@ defmodule Mv.Membership.CustomFieldValidationTest do
name: name,
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.name == name
assert String.length(custom_field.name) == 100
end
test "rejects name with 101 characters" do
test "rejects name with 101 characters", %{actor: actor} do
name = String.duplicate("a", 101)
assert {:error, changeset} =
@ -38,50 +43,50 @@ defmodule Mv.Membership.CustomFieldValidationTest do
name: name,
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert [%{field: :name, message: message}] = changeset.errors
assert message =~ "max" or message =~ "length" or message =~ "100"
end
test "trims whitespace from name" do
test "trims whitespace from name", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: " test_field ",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.name == "test_field"
end
test "rejects empty name" do
test "rejects empty name", %{actor: actor} do
assert {:error, changeset} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
end
test "rejects nil name" do
test "rejects nil name", %{actor: actor} do
assert {:error, changeset} =
CustomField
|> Ash.Changeset.for_create(:create, %{
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
end
end
describe "description validation" do
test "accepts description with exactly 500 characters" do
test "accepts description with exactly 500 characters", %{actor: actor} do
description = String.duplicate("a", 500)
assert {:ok, custom_field} =
@ -91,13 +96,13 @@ defmodule Mv.Membership.CustomFieldValidationTest do
value_type: :string,
description: description
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.description == description
assert String.length(custom_field.description) == 500
end
test "rejects description with 501 characters" do
test "rejects description with 501 characters", %{actor: actor} do
description = String.duplicate("a", 501)
assert {:error, changeset} =
@ -107,13 +112,13 @@ defmodule Mv.Membership.CustomFieldValidationTest do
value_type: :string,
description: description
})
|> Ash.create()
|> Ash.create(actor: actor)
assert [%{field: :description, message: message}] = changeset.errors
assert message =~ "max" or message =~ "length" or message =~ "500"
end
test "trims whitespace from description" do
test "trims whitespace from description", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
@ -121,24 +126,24 @@ defmodule Mv.Membership.CustomFieldValidationTest do
value_type: :string,
description: " A nice description "
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.description == "A nice description"
end
test "accepts nil description (optional field)" do
test "accepts nil description (optional field)", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.description == nil
end
test "accepts empty description after trimming" do
test "accepts empty description after trimming", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
@ -146,7 +151,7 @@ defmodule Mv.Membership.CustomFieldValidationTest do
value_type: :string,
description: " "
})
|> Ash.create()
|> Ash.create(actor: actor)
# After trimming whitespace, becomes nil (empty strings are converted to nil)
assert custom_field.description == nil
@ -154,14 +159,14 @@ defmodule Mv.Membership.CustomFieldValidationTest do
end
describe "name uniqueness" do
test "rejects duplicate names" do
test "rejects duplicate names", %{actor: actor} do
assert {:ok, _} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "unique_field",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert {:error, changeset} =
CustomField
@ -169,14 +174,14 @@ defmodule Mv.Membership.CustomFieldValidationTest do
name: "unique_field",
value_type: :integer
})
|> Ash.create()
|> Ash.create(actor: actor)
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
end
end
describe "value_type validation" do
test "accepts all valid value types" do
test "accepts all valid value types", %{actor: actor} do
for value_type <- [:string, :integer, :boolean, :date, :email] do
assert {:ok, custom_field} =
CustomField
@ -184,20 +189,20 @@ defmodule Mv.Membership.CustomFieldValidationTest do
name: "field_#{value_type}",
value_type: value_type
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.value_type == value_type
end
end
test "rejects invalid value type" do
test "rejects invalid value type", %{actor: actor} do
assert {:error, changeset} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "invalid_field",
value_type: :invalid_type
})
|> Ash.create()
|> Ash.create(actor: actor)
assert [%{field: :value_type}] = changeset.errors
end

View file

@ -13,6 +13,8 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create a test member
{:ok, member} =
Member
@ -21,7 +23,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
last_name: "User",
email: "test.validation@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom fields for different types
{:ok, string_field} =
@ -30,7 +32,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
name: "string_field",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, integer_field} =
CustomField
@ -38,7 +40,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
name: "integer_field",
value_type: :integer
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, email_field} =
CustomField
@ -46,9 +48,10 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
name: "email_field",
value_type: :email
})
|> Ash.create()
|> Ash.create(actor: system_actor)
%{
actor: system_actor,
member: member,
string_field: string_field,
integer_field: integer_field,
@ -58,6 +61,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
describe "string value length validation" do
test "accepts string value with exactly 10,000 characters", %{
actor: system_actor,
member: member,
string_field: string_field
} do
@ -73,13 +77,14 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
"_union_value" => value_string
}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == value_string
assert String.length(custom_field_value.value.value) == 10_000
end
test "rejects string value with 10,001 characters", %{
actor: system_actor,
member: member,
string_field: string_field
} do
@ -92,14 +97,18 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => value_string}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert Enum.any?(changeset.errors, fn error ->
error.field == :value and (error.message =~ "max" or error.message =~ "length")
end)
end
test "trims whitespace from string value", %{member: member, string_field: string_field} do
test "trims whitespace from string value", %{
actor: system_actor,
member: member,
string_field: string_field
} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -107,12 +116,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => " test value "}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == "test value"
end
test "accepts empty string value", %{member: member, string_field: string_field} do
test "accepts empty string value", %{
actor: system_actor,
member: member,
string_field: string_field
} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -120,13 +133,17 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => ""}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Empty strings after trimming become nil
assert custom_field_value.value.value == nil
end
test "accepts string with special characters", %{member: member, string_field: string_field} do
test "accepts string with special characters", %{
actor: system_actor,
member: member,
string_field: string_field
} do
special_string = "Hello 世界! 🎉 @#$%^&*()"
assert {:ok, custom_field_value} =
@ -136,14 +153,18 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => special_string}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == special_string
end
end
describe "integer value validation" do
test "accepts valid integer value", %{member: member, integer_field: integer_field} do
test "accepts valid integer value", %{
actor: system_actor,
member: member,
integer_field: integer_field
} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -151,12 +172,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => 42}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == 42
end
test "accepts negative integer", %{member: member, integer_field: integer_field} do
test "accepts negative integer", %{
actor: system_actor,
member: member,
integer_field: integer_field
} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -164,12 +189,12 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => -100}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == -100
end
test "accepts zero", %{member: member, integer_field: integer_field} do
test "accepts zero", %{actor: system_actor, member: member, integer_field: integer_field} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -177,14 +202,18 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => 0}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == 0
end
end
describe "email value validation" do
test "accepts nil value (optional field)", %{member: member, email_field: email_field} do
test "accepts nil value (optional field)", %{
actor: system_actor,
member: member,
email_field: email_field
} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -192,12 +221,13 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => nil}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == nil
end
test "accepts empty string (becomes nil after trim)", %{
actor: system_actor,
member: member,
email_field: email_field
} do
@ -208,13 +238,13 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => ""}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Empty string after trim should become nil
assert custom_field_value.value.value == nil
end
test "accepts valid email", %{member: member, email_field: email_field} do
test "accepts valid email", %{actor: system_actor, member: member, email_field: email_field} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -222,12 +252,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => "test@example.com"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == "test@example.com"
end
test "rejects invalid email format", %{member: member, email_field: email_field} do
test "rejects invalid email format", %{
actor: system_actor,
member: member,
email_field: email_field
} do
assert {:error, changeset} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -235,12 +269,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => "not-an-email"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert Enum.any?(changeset.errors, fn error -> error.field == :value end)
end
test "rejects email longer than 254 characters", %{member: member, email_field: email_field} do
test "rejects email longer than 254 characters", %{
actor: system_actor,
member: member,
email_field: email_field
} do
# Create an email with >254 chars (243 + 12 = 255)
long_email = String.duplicate("a", 243) <> "@example.com"
@ -251,12 +289,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => long_email}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert Enum.any?(changeset.errors, fn error -> error.field == :value end)
end
test "trims whitespace from email", %{member: member, email_field: email_field} do
test "trims whitespace from email", %{
actor: system_actor,
member: member,
email_field: email_field
} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -264,7 +306,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => " test@example.com "}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == "test@example.com"
end
@ -272,6 +314,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
describe "uniqueness constraint" do
test "rejects duplicate custom_field_id per member", %{
actor: system_actor,
member: member,
string_field: string_field
} do
@ -283,7 +326,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "first value"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Try to create second custom field value with same custom_field_id for same member
assert {:error, changeset} =
@ -293,7 +336,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "second value"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Should have uniqueness error
assert Enum.any?(changeset.errors, fn error ->

Some files were not shown because too many files have changed in this diff Show more