Concept for Groups #354
166 changed files with 16396 additions and 10033 deletions
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# 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
|
||||
|
||||
policy action_type(:destroy) do
|
||||
authorize_if actor_attribute_equals(:role, :admin)
|
||||
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:**
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -40,14 +40,16 @@ Our philosophy: **software should help people spend less time on administration
|
|||
## 🔑 Features
|
||||
|
||||
- ✅ Manage member data with ease
|
||||
- 🚧 Overview of membership fees & payment status
|
||||
- ✅ Full-text search
|
||||
- 🚧 Sorting & filtering
|
||||
- 🚧 Roles & permissions (e.g. board, treasurer)
|
||||
- ✅ Membership fees & payment status tracking
|
||||
- ✅ Full-text search with fuzzy matching
|
||||
- ✅ Sorting & filtering
|
||||
- ✅ Roles & permissions (RBAC system with 4 permission sets)
|
||||
- ✅ Custom fields (flexible per club needs)
|
||||
- ✅ SSO via OIDC (works with Authentik, Rauthy, Keycloak, etc.)
|
||||
- ✅ Sidebar navigation (standard-compliant, accessible)
|
||||
- ✅ Global settings management
|
||||
- 🚧 Self-service & online application
|
||||
- 🚧 Accessibility, GDPR, usability improvements
|
||||
- ✅ Accessibility improvements (WCAG 2.1 AA compliant keyboard navigation)
|
||||
- 🚧 Email sending
|
||||
|
||||
## 🚀 Quick Start (Development)
|
||||
|
|
@ -187,8 +189,9 @@ The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/
|
|||
- **Auth:** AshAuthentication (OIDC + password)
|
||||
|
||||
**Code Structure:**
|
||||
- `lib/accounts/` & `lib/membership/` — Ash resources and domains
|
||||
- `lib/accounts/` & `lib/membership/` & `lib/membership_fees/` & `lib/mv/authorization/` — Ash resources and domains
|
||||
- `lib/mv_web/` — Phoenix controllers, LiveViews, components
|
||||
- `lib/mv/` — Shared helpers and business logic
|
||||
- `assets/` — Tailwind, JavaScript, static files
|
||||
|
||||
📚 **Full tech stack details:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
'''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1515
docs/groups-architecture.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
330
docs/policy-bypass-vs-haspermission.md
Normal file
330
docs/policy-bypass-vs-haspermission.md
Normal 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`
|
||||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -524,60 +525,68 @@ Add authorization policies to the Member resource using the new `HasPermission`
|
|||
**Dependencies:** #6 (HasPermission check)
|
||||
**Can work in parallel:** Yes (parallel with #7, #9, #10)
|
||||
**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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
@ -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
269
docs/user-resource-policies-implementation-summary.md
Normal file
269
docs/user-resource-policies-implementation-summary.md
Normal 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** 🎉
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
99
lib/mv/authorization/actor.ex
Normal file
99
lib/mv/authorization/actor.ex
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
453
lib/mv/helpers/system_actor.ex
Normal file
453
lib/mv/helpers/system_actor.ex
Normal 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
|
||||
|
|
@ -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)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
# Maps a canonical field and its variants to normalized tuples
|
||||
defp map_variants_to_normalized({canonical, variants}) do
|
||||
|> Enum.flat_map(fn {canonical, variants} ->
|
||||
Enum.map(variants, fn variant ->
|
||||
{normalize_header(variant), canonical}
|
||||
end)
|
||||
end)
|
||||
|> Map.new()
|
||||
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 """
|
||||
|
|
|
|||
|
|
@ -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,18 +194,12 @@ 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
|
||||
# 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
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
defp member_field?(_), do: false
|
||||
|
||||
# Validates that row count doesn't exceed limit
|
||||
defp validate_row_count(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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
444
lib/mv_web/live/components/member_filter_component.ex
Normal file
444
lib/mv_web/live/components/member_filter_component.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
# 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_form()}
|
||||
|> 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
# 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
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -554,11 +554,15 @@ 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
|
||||
# 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
|
||||
|
||||
case CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
{:ok, _new_cycles, _notifications} ->
|
||||
# Reload member with cycles
|
||||
actor = current_actor(socket)
|
||||
|
|
@ -595,6 +599,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign(:regenerating, false)
|
||||
|> put_flash(:error, format_error(error))}
|
||||
end
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("Only administrators can regenerate cycles"))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("edit_cycle_amount", %{"cycle_id" => cycle_id}, socket) do
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,53 +125,176 @@ 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(%{
|
||||
# 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 full access",
|
||||
permission_set_name: "admin"
|
||||
}) do
|
||||
{:ok, role} -> role
|
||||
{:error, _error} -> nil
|
||||
description: "Administrator with unrestricted access",
|
||||
permission_set_name: "admin",
|
||||
is_system_role: false
|
||||
}
|
||||
]
|
||||
|
||||
# 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
|
||||
|
||||
role ->
|
||||
role
|
||||
{: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
|
||||
|
||||
{:error, _error} ->
|
||||
nil
|
||||
if is_nil(admin_role) do
|
||||
raise "Failed to create or find admin role. Cannot proceed with member seeding."
|
||||
end
|
||||
|
||||
# Assign admin role to admin user if role was created/found
|
||||
if admin_role do
|
||||
admin_user
|
||||
# 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!()
|
||||
|> 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
|
||||
|
|
@ -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)
|
||||
# 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!()
|
||||
|> 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(
|
||||
|
|
|
|||
221
priv/resource_snapshots/repo/members/20260125155125.json
Normal file
221
priv/resource_snapshots/repo/members/20260125155125.json
Normal 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"
|
||||
}
|
||||
172
priv/resource_snapshots/repo/users/20260125155125.json
Normal file
172
priv/resource_snapshots/repo/users/20260125155125.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(%{
|
||||
Accounts.create_user(
|
||||
%{
|
||||
email: "existing@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Create member with same email - should succeed
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
Accounts.create_user(
|
||||
%{
|
||||
email: "existing_user@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Create an unlinked member with different email
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
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, %{
|
||||
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(%{
|
||||
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(%{
|
||||
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, %{
|
||||
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(%{
|
||||
Accounts.create_user(
|
||||
%{
|
||||
email: "linked_user@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _member_a} =
|
||||
Membership.create_member(%{
|
||||
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(%{
|
||||
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, %{
|
||||
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(%{
|
||||
Accounts.create_user(
|
||||
%{
|
||||
email: "user_a@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _member_a} =
|
||||
Membership.create_member(%{
|
||||
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(%{
|
||||
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, %{
|
||||
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(%{
|
||||
Accounts.create_user(
|
||||
%{
|
||||
email: "linked@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _member} =
|
||||
Membership.create_member(%{
|
||||
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(%{
|
||||
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(%{
|
||||
Accounts.create_user(
|
||||
%{
|
||||
email: "user@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _member} =
|
||||
Membership.create_member(%{
|
||||
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(%{
|
||||
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(%{
|
||||
Accounts.create_user(
|
||||
%{
|
||||
email: "other_user@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Create a user and link it to a member
|
||||
{:ok, user} =
|
||||
Accounts.create_user(%{
|
||||
Accounts.create_user(
|
||||
%{
|
||||
email: "user@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
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, %{
|
||||
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(%{
|
||||
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(%{
|
||||
Accounts.create_user(
|
||||
%{
|
||||
email: "user@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _member} =
|
||||
Membership.create_member(%{
|
||||
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, %{
|
||||
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(%{
|
||||
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(%{
|
||||
Accounts.create_user(
|
||||
%{
|
||||
email: "duplicate@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Create a member to link with the user
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
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, %{
|
||||
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(%{
|
||||
Accounts.create_user(
|
||||
%{
|
||||
email: "duplicate@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Create a member with same email
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
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(%{
|
||||
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, %{
|
||||
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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
Accounts.create_user(
|
||||
%{
|
||||
email: "duplicate@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Try to create second user with same email
|
||||
result =
|
||||
Accounts.create_user(%{
|
||||
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(%{
|
||||
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(%{
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(%{
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
Membership.create_member(
|
||||
%{
|
||||
first_name: "Bob",
|
||||
last_name: "Smith",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Create user and link
|
||||
{:ok, user} =
|
||||
Accounts.create_user(%{
|
||||
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(%{
|
||||
Membership.create_member(
|
||||
%{
|
||||
first_name: "Other",
|
||||
last_name: "Member",
|
||||
email: "other@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _user1} =
|
||||
Accounts.create_user(%{
|
||||
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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
Membership.create_member(
|
||||
%{
|
||||
first_name: "Emma",
|
||||
last_name: "Davis",
|
||||
email: "emma@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Create user and link
|
||||
{:ok, user} =
|
||||
Accounts.create_user(%{
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(%{
|
||||
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(%{
|
||||
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(%{
|
||||
Accounts.create_user(
|
||||
%{
|
||||
email: "test5@example.com",
|
||||
member: %{id: member.id}
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
8
test/fixtures/csv_with_bom_semicolon.csv
vendored
Normal file
8
test/fixtures/csv_with_bom_semicolon.csv
vendored
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
11
test/fixtures/csv_with_empty_lines.csv
vendored
Normal file
11
test/fixtures/csv_with_empty_lines.csv
vendored
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
10
test/fixtures/csv_with_unknown_custom_field.csv
vendored
Normal file
10
test/fixtures/csv_with_unknown_custom_field.csv
vendored
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
10
test/fixtures/invalid_member_import.csv
vendored
Normal file
10
test/fixtures/invalid_member_import.csv
vendored
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
10
test/fixtures/valid_member_import.csv
vendored
Normal file
10
test/fixtures/valid_member_import.csv
vendored
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -1,70 +1,93 @@
|
|||
defmodule Mv.Membership.FuzzySearchTest do
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
test "fuzzy_search/2 function exists" do
|
||||
assert function_exported?(Mv.Membership.Member, :fuzzy_search, 2)
|
||||
end
|
||||
|
||||
test "fuzzy_search returns only John Doe by fuzzy query 'john'" do
|
||||
test "fuzzy_search returns only John Doe by fuzzy query 'john'", %{actor: actor} do
|
||||
{:ok, john} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john.doe@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _jane} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Adriana",
|
||||
last_name: "Smith",
|
||||
email: "adriana.smith@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, alice} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Alice",
|
||||
last_name: "Johnson",
|
||||
email: "alice.johnson@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{
|
||||
query: "john"
|
||||
})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
assert Enum.map(result, & &1.id) == [john.id, alice.id]
|
||||
end
|
||||
|
||||
test "fuzzy_search finds 'Thomas' when searching misspelled 'tomas'" do
|
||||
test "fuzzy_search finds 'Thomas' when searching misspelled 'tomas'", %{actor: actor} do
|
||||
{:ok, thomas} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Thomas",
|
||||
last_name: "Doe",
|
||||
email: "john.doe@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, jane} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Jane",
|
||||
last_name: "Smith",
|
||||
email: "jane.smith@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _alice} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Alice",
|
||||
last_name: "Johnson",
|
||||
email: "alice.johnson@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{
|
||||
query: "tomas"
|
||||
})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert thomas.id in ids
|
||||
|
|
@ -72,17 +95,21 @@ defmodule Mv.Membership.FuzzySearchTest do
|
|||
assert not Enum.empty?(ids)
|
||||
end
|
||||
|
||||
test "empty query returns all members" do
|
||||
test "empty query returns all members", %{actor: actor} do
|
||||
{:ok, a} =
|
||||
Mv.Membership.create_member(%{first_name: "A", last_name: "One", email: "a1@example.com"})
|
||||
Mv.Membership.create_member(%{first_name: "A", last_name: "One", email: "a1@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, b} =
|
||||
Mv.Membership.create_member(%{first_name: "B", last_name: "Two", email: "b2@example.com"})
|
||||
Mv.Membership.create_member(%{first_name: "B", last_name: "Two", email: "b2@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: ""})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
assert Enum.sort(Enum.map(result, & &1.id))
|
||||
|> Enum.uniq()
|
||||
|
|
@ -90,352 +117,435 @@ defmodule Mv.Membership.FuzzySearchTest do
|
|||
|> Enum.all?(fn id -> id in [a.id, b.id] end)
|
||||
end
|
||||
|
||||
test "substring numeric search matches postal_code mid-string" do
|
||||
test "substring numeric search matches postal_code mid-string", %{actor: actor} do
|
||||
{:ok, m1} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Num",
|
||||
last_name: "One",
|
||||
email: "n1@example.com",
|
||||
postal_code: "12345"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _m2} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Num",
|
||||
last_name: "Two",
|
||||
email: "n2@example.com",
|
||||
postal_code: "67890"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "345"})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert m1.id in ids
|
||||
end
|
||||
|
||||
test "substring numeric search matches house_number mid-string" do
|
||||
test "substring numeric search matches house_number mid-string", %{actor: actor} do
|
||||
{:ok, m1} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Home",
|
||||
last_name: "One",
|
||||
email: "h1@example.com",
|
||||
house_number: "A345B"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _m2} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Home",
|
||||
last_name: "Two",
|
||||
email: "h2@example.com",
|
||||
house_number: "77"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "345"})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert m1.id in ids
|
||||
end
|
||||
|
||||
test "fuzzy matches street misspelling" do
|
||||
test "fuzzy matches street misspelling", %{actor: actor} do
|
||||
{:ok, s1} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Road",
|
||||
last_name: "Test",
|
||||
email: "s1@example.com",
|
||||
street: "Main Street"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _s2} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Road",
|
||||
last_name: "Other",
|
||||
email: "s2@example.com",
|
||||
street: "Second Avenue"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "mainn"})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert s1.id in ids
|
||||
end
|
||||
|
||||
test "substring in city matches mid-string" do
|
||||
test "substring in city matches mid-string", %{actor: actor} do
|
||||
{:ok, b} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "City",
|
||||
last_name: "One",
|
||||
email: "city1@example.com",
|
||||
city: "Berlin"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _m} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "City",
|
||||
last_name: "Two",
|
||||
email: "city2@example.com",
|
||||
city: "München"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "erl"})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert b.id in ids
|
||||
end
|
||||
|
||||
test "blank character handling: query with spaces matches full name" do
|
||||
test "blank character handling: query with spaces matches full name", %{actor: actor} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john.doe@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Jane",
|
||||
last_name: "Smith",
|
||||
email: "jane.smith@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "john doe"})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "blank character handling: query with multiple spaces is handled" do
|
||||
test "blank character handling: query with multiple spaces is handled", %{actor: actor} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Mary",
|
||||
last_name: "Jane",
|
||||
email: "mary.jane@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "mary jane"})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "special character handling: @ symbol in query matches email" do
|
||||
test "special character handling: @ symbol in query matches email", %{actor: actor} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test.user@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Other",
|
||||
last_name: "Person",
|
||||
email: "other.person@different.org"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "example"})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "special character handling: dot in query matches email" do
|
||||
test "special character handling: dot in query matches email", %{actor: actor} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Dot",
|
||||
last_name: "Test",
|
||||
email: "dot.test@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "No",
|
||||
last_name: "Dot",
|
||||
email: "nodot@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "dot.test"})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "special character handling: hyphen in query matches data" do
|
||||
test "special character handling: hyphen in query matches data", %{actor: actor} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Mary-Jane",
|
||||
last_name: "Watson",
|
||||
email: "mary.jane@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Mary",
|
||||
last_name: "Smith",
|
||||
email: "mary.smith@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "mary-jane"})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "unicode character handling: umlaut ö in query matches data" do
|
||||
test "unicode character handling: umlaut ö in query matches data", %{actor: actor} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Jörg",
|
||||
last_name: "Schmidt",
|
||||
email: "joerg.schmidt@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "John",
|
||||
last_name: "Smith",
|
||||
email: "john.smith@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "jörg"})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "unicode character handling: umlaut ä in query matches data" do
|
||||
test "unicode character handling: umlaut ä in query matches data", %{actor: actor} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Märta",
|
||||
last_name: "Andersson",
|
||||
email: "maerta.andersson@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Marta",
|
||||
last_name: "Johnson",
|
||||
email: "marta.johnson@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "märta"})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "unicode character handling: umlaut ü in query matches data" do
|
||||
test "unicode character handling: umlaut ü in query matches data", %{actor: actor} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Günther",
|
||||
last_name: "Müller",
|
||||
email: "guenther.mueller@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Gunter",
|
||||
last_name: "Miller",
|
||||
email: "gunter.miller@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "müller"})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "unicode character handling: query without umlaut matches data with umlaut" do
|
||||
test "unicode character handling: query without umlaut matches data with umlaut", %{
|
||||
actor: actor
|
||||
} do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Müller",
|
||||
last_name: "Schmidt",
|
||||
email: "mueller.schmidt@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Miller",
|
||||
last_name: "Smith",
|
||||
email: "miller.smith@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "muller"})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "very long search strings: handles long query without error" do
|
||||
test "very long search strings: handles long query without error", %{actor: actor} do
|
||||
{:ok, _member} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
long_query = String.duplicate("a", 1000)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: long_query})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
# Should not crash, may return empty or some results
|
||||
assert is_list(result)
|
||||
end
|
||||
|
||||
test "very long search strings: handles extremely long query" do
|
||||
test "very long search strings: handles extremely long query", %{actor: actor} do
|
||||
{:ok, _member} =
|
||||
Mv.Membership.create_member(%{
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test@example.com"
|
||||
})
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
very_long_query = String.duplicate("test query ", 1000)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: very_long_query})
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
# Should not crash, may return empty or some results
|
||||
assert is_list(result)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue