Compare commits
16 commits
docs/updat
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9be5dc8751 | |||
| 6dc398fa5a | |||
| 67072f0c52 | |||
| 0673684cc1 | |||
| b44d8a9d70 | |||
| 8a5d012895 | |||
| 3bbe9895ee | |||
| 31cf07c071 | |||
| 68e19bea18 | |||
| 699d4385cb | |||
| 448a032878 | |||
| 4b41ab37bb | |||
| aa3fb0c49b | |||
| fb71b7ddb1 | |||
| aa62e03409 | |||
| cc6d72b6b1 |
29 changed files with 6020 additions and 978 deletions
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -8,27 +8,6 @@ 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
|
||||
|
|
@ -40,22 +19,8 @@ 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,18 +83,7 @@ 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/
|
||||
|
|
@ -107,11 +96,6 @@ 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
|
||||
|
|
@ -123,7 +107,7 @@ lib/
|
|||
│ │ ├── table_components.ex
|
||||
│ │ ├── layouts.ex
|
||||
│ │ └── layouts/ # Layout templates
|
||||
│ │ ├── sidebar.ex
|
||||
│ │ ├── navbar.ex
|
||||
│ │ └── root.html.heex
|
||||
│ ├── controllers/ # HTTP controllers
|
||||
│ │ ├── auth_controller.ex
|
||||
|
|
@ -132,11 +116,6 @@ 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
|
||||
|
|
@ -144,16 +123,11 @@ lib/
|
|||
│ │ ├── member_live/ # Member CRUD LiveViews
|
||||
│ │ ├── custom_field_value_live/ # CustomFieldValue CRUD LiveViews
|
||||
│ │ ├── custom_field_live/
|
||||
│ │ ├── 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)
|
||||
│ │ └── user_live/ # User management LiveViews
|
||||
│ ├── auth_overrides.ex # AshAuthentication overrides
|
||||
│ ├── endpoint.ex # Phoenix endpoint
|
||||
│ ├── gettext.ex # I18n configuration
|
||||
│ ├── live_helpers.ex # LiveView lifecycle hooks and helpers
|
||||
│ ├── live_helpers.ex # LiveView helpers
|
||||
│ ├── live_user_auth.ex # LiveView authentication
|
||||
│ ├── router.ex # Application router
|
||||
│ └── telemetry.ex # Telemetry configuration
|
||||
|
|
@ -202,7 +176,7 @@ test/
|
|||
**Module Naming:**
|
||||
|
||||
- **Modules:** Use `PascalCase` with full namespace (e.g., `Mv.Accounts.User`)
|
||||
- **Domains:** Top-level domains are `Mv.Accounts`, `Mv.Membership`, `Mv.MembershipFees`, and `Mv.Authorization`
|
||||
- **Domains:** Top-level domains are `Mv.Accounts` and `Mv.Membership`
|
||||
- **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`)
|
||||
|
||||
|
|
@ -844,17 +818,14 @@ end
|
|||
|
||||
```heex
|
||||
<!-- Leverage DaisyUI component classes -->
|
||||
<!-- 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 class="navbar bg-base-100">
|
||||
<div class="navbar-start">
|
||||
<a class="btn btn-ghost text-xl">Mila</a>
|
||||
</div>
|
||||
<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 class="navbar-end">
|
||||
<.link navigate={~p"/members"} class="btn btn-primary">
|
||||
Members
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
|
@ -1564,57 +1535,15 @@ policies do
|
|||
authorize_if always()
|
||||
end
|
||||
|
||||
# Use HasPermission check for role-based authorization
|
||||
policy action_type([:read, :update, :create, :destroy]) do
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
# Specific permissions
|
||||
policy action_type([:read, :update]) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
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")}
|
||||
policy action_type(:destroy) do
|
||||
authorize_if actor_attribute_equals(:role, :admin)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("save", %{"member" => params}, socket) do
|
||||
actor = current_actor(socket)
|
||||
form = AshPhoenix.Form.for_create(Mv.Membership.Member, :create)
|
||||
|
||||
case submit_form(form, params, actor) do
|
||||
{:ok, member} ->
|
||||
{:noreply, push_navigate(socket, to: ~p"/members/#{member.id}")}
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, :form, form)}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Never use bang calls (`Ash.read!`, `Ash.get!`) without error handling:**
|
||||
|
||||
```elixir
|
||||
# Bad - will crash on authorization errors
|
||||
members = Ash.read!(Mv.Membership.Member, actor: actor)
|
||||
|
||||
# Good - proper error handling
|
||||
case Ash.read(Mv.Membership.Member, actor: actor) do
|
||||
{:ok, members} -> # success
|
||||
{:error, %Ash.Error.Forbidden{}} -> # handle authorization error
|
||||
{:error, error} -> # handle other errors
|
||||
end
|
||||
```
|
||||
|
||||
### 5.2 Password Security
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -40,16 +40,14 @@ Our philosophy: **software should help people spend less time on administration
|
|||
## 🔑 Features
|
||||
|
||||
- ✅ Manage member data with ease
|
||||
- ✅ Membership fees & payment status tracking
|
||||
- ✅ Full-text search with fuzzy matching
|
||||
- ✅ Sorting & filtering
|
||||
- ✅ Roles & permissions (RBAC system with 4 permission sets)
|
||||
- 🚧 Overview of membership fees & payment status
|
||||
- ✅ Full-text search
|
||||
- 🚧 Sorting & filtering
|
||||
- 🚧 Roles & permissions (e.g. board, treasurer)
|
||||
- ✅ 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 improvements (WCAG 2.1 AA compliant keyboard navigation)
|
||||
- 🚧 Accessibility, GDPR, usability improvements
|
||||
- 🚧 Email sending
|
||||
|
||||
## 🚀 Quick Start (Development)
|
||||
|
|
@ -189,9 +187,8 @@ The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/
|
|||
- **Auth:** AshAuthentication (OIDC + password)
|
||||
|
||||
**Code Structure:**
|
||||
- `lib/accounts/` & `lib/membership/` & `lib/membership_fees/` & `lib/mv/authorization/` — Ash resources and domains
|
||||
- `lib/accounts/` & `lib/membership/` — 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)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
**Version:** 1.0
|
||||
**Date:** 2025-01-XX
|
||||
**Last Updated:** 2026-01-13
|
||||
**Status:** Templates Created - Import Logic Pending
|
||||
**Status:** Ready for Implementation
|
||||
**Related Documents:**
|
||||
- [Feature Roadmap](./feature-roadmap.md) - Overall feature planning
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ This document provides a comprehensive overview of the Mila Membership Managemen
|
|||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| **Tables** | 9 |
|
||||
| **Domains** | 4 (Accounts, Membership, MembershipFees, Authorization) |
|
||||
| **Relationships** | 7 |
|
||||
| **Indexes** | 20+ |
|
||||
| **Tables** | 5 |
|
||||
| **Domains** | 2 (Accounts, Membership) |
|
||||
| **Relationships** | 3 |
|
||||
| **Indexes** | 15+ |
|
||||
| **Triggers** | 1 (Full-text search) |
|
||||
|
||||
## Tables Overview
|
||||
|
|
@ -68,39 +68,16 @@ 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) CustomFieldValues (N)
|
||||
↓ ↓
|
||||
Role (N:1) CustomField (1)
|
||||
|
||||
Member (1) → (N) MembershipFeeCycles
|
||||
↓
|
||||
MembershipFeeType (1)
|
||||
Tokens (N)
|
||||
|
||||
Settings (1) → MembershipFeeType (0..1)
|
||||
Member (1) → (N) Properties
|
||||
↓
|
||||
CustomField (1)
|
||||
```
|
||||
|
||||
### Relationship Details
|
||||
|
|
@ -112,39 +89,16 @@ Settings (1) → MembershipFeeType (0..1)
|
|||
- Email synchronization when linked (User.email is source of truth)
|
||||
- `ON DELETE SET NULL` on user side (User preserved when Member deleted)
|
||||
|
||||
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)**
|
||||
2. **Member → Properties (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)
|
||||
|
||||
4. **CustomFieldValue → CustomField (N:1)**
|
||||
- Custom field values reference type definition
|
||||
3. **CustomFieldValue → CustomField (N:1)**
|
||||
- Properties 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
|
||||
|
|
@ -187,6 +141,7 @@ Settings (1) → MembershipFeeType (0..1)
|
|||
- `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
|
||||
|
|
@ -213,14 +168,14 @@ Settings (1) → MembershipFeeType (0..1)
|
|||
### Weighted Fields
|
||||
- **Weight A (highest):** first_name, last_name
|
||||
- **Weight B:** email, notes
|
||||
- **Weight C:** city, street, house_number, postal_code, custom_field_values
|
||||
- **Weight C:** phone_number, 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 city, etc.)
|
||||
- Custom field values receive weight 'C' (same as phone_number, city, etc.)
|
||||
- The search vector is automatically updated when custom field values are created, updated, or deleted via database triggers
|
||||
|
||||
### Usage Example
|
||||
|
|
@ -376,7 +331,7 @@ priv/repo/migrations/
|
|||
|
||||
**High Frequency:**
|
||||
- Member search (uses GIN index on search_vector)
|
||||
- Member list with filters (uses indexes on join_date, membership_fee_type_id)
|
||||
- Member list with filters (uses indexes on join_date, paid)
|
||||
- User authentication (uses unique index on email/oidc_id)
|
||||
- CustomFieldValue lookups by member (uses index on member_id)
|
||||
|
||||
|
|
@ -395,7 +350,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. **GIN indexes:** Full-text search and fuzzy search on multiple fields
|
||||
4. **Partial indexes:** `members.paid` index only non-NULL values
|
||||
5. **Search optimization:** Full-text search via tsvector, not LIKE
|
||||
|
||||
## Visualization
|
||||
|
|
@ -509,7 +464,7 @@ mix run priv/repo/seeds.exs
|
|||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-01-13
|
||||
**Schema Version:** 1.4
|
||||
**Last Updated:** 2025-11-13
|
||||
**Schema Version:** 1.1
|
||||
**Database:** PostgreSQL 17.6 (dev) / 16 (prod)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
// - https://dbdocs.io
|
||||
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
|
||||
//
|
||||
// Version: 1.4
|
||||
// Last Updated: 2026-01-13
|
||||
// Version: 1.3
|
||||
// Last Updated: 2025-12-11
|
||||
|
||||
Project mila_membership_management {
|
||||
database_type: 'PostgreSQL'
|
||||
|
|
@ -28,7 +28,6 @@ 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)
|
||||
|
|
@ -121,9 +120,11 @@ Table tokens {
|
|||
|
||||
Table members {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key (sortable by creation time)']
|
||||
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)']
|
||||
first_name text [not null, note: 'Member first name (min length: 1)']
|
||||
last_name text [not null, note: 'Member last name (min length: 1)']
|
||||
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']
|
||||
|
|
@ -147,6 +148,7 @@ 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']
|
||||
}
|
||||
|
||||
|
|
@ -155,8 +157,8 @@ Table members {
|
|||
|
||||
Core entity for membership management containing:
|
||||
- Personal information (name, email)
|
||||
- Contact details (address)
|
||||
- Membership status (join/exit dates, membership fee cycles)
|
||||
- Contact details (phone, address)
|
||||
- Membership status (join/exit dates, payment status)
|
||||
- Additional notes
|
||||
|
||||
**Email Synchronization:**
|
||||
|
|
@ -184,11 +186,12 @@ Table members {
|
|||
- 1:N with membership_fee_cycles - billing history
|
||||
|
||||
**Validation Rules:**
|
||||
- first_name, last_name: optional, but if present min 1 character
|
||||
- email: 5-254 characters, valid email format (required)
|
||||
- first_name, last_name: min 1 character
|
||||
- email: 5-254 characters, valid email format
|
||||
- join_date: cannot be in future
|
||||
- exit_date: must be after join_date (if both present)
|
||||
- postal_code: exactly 5 digits (if present)
|
||||
- phone_number: matches pattern ^\+?[0-9\- ]{6,20}$
|
||||
- postal_code: exactly 5 digits
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
@ -497,138 +500,3 @@ 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.0-rc.3**: Real-time UI without JavaScript complexity
|
||||
- **Phoenix LiveView 1.1**: 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,15 +80,14 @@ 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.46.0
|
||||
- Just 1.43.0
|
||||
|
||||
#### 4. Database Setup
|
||||
|
||||
**PostgreSQL Extensions:**
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- UUID generation (via uuid_generate_v7 function)
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- UUID generation
|
||||
CREATE EXTENSION IF NOT EXISTS "citext"; -- Case-insensitive text
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Trigram-based fuzzy search
|
||||
```
|
||||
|
||||
**Migration Strategy:**
|
||||
|
|
@ -469,7 +468,7 @@ end
|
|||
- **Tailwind:** Utility-first, no custom CSS
|
||||
- **DaisyUI:** Pre-built components, consistent design
|
||||
- **Heroicons:** Icon library, inline SVG
|
||||
- **Phoenix LiveView 1.1.0-rc.3:** Server-rendered, minimal JavaScript
|
||||
- **Phoenix LiveView:** Server-rendered, minimal JavaScript
|
||||
|
||||
**Trade-offs:**
|
||||
- Larger HTML (utility classes)
|
||||
|
|
@ -599,33 +598,14 @@ end
|
|||
|
||||
#### Database Migrations
|
||||
|
||||
**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
|
||||
**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
|
||||
|
||||
**Learning:** Ash's code generation from resources ensures schema always matches code.
|
||||
|
||||
|
|
@ -1582,7 +1562,7 @@ Effective workflow:
|
|||
|
||||
This project demonstrates a modern Phoenix application built with:
|
||||
- ✅ **Ash Framework** for declarative resources and policies
|
||||
- ✅ **Phoenix LiveView 1.1.0-rc.3** for real-time, server-rendered UI
|
||||
- ✅ **Phoenix LiveView** 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)
|
||||
|
|
@ -1590,19 +1570,15 @@ This project demonstrates a modern Phoenix application built with:
|
|||
- ✅ **Flexible data model** (EAV pattern with union types)
|
||||
|
||||
**Key Achievements:**
|
||||
- 🎯 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)
|
||||
- 🎯 8 sprints completed
|
||||
- 🚀 82 pull requests merged
|
||||
- ✅ Core features implemented (CRUD, search, auth, sync)
|
||||
- 📚 Comprehensive documentation
|
||||
- 🔒 Security-focused (audits, validations, policies)
|
||||
- 🐳 Docker-ready for self-hosting
|
||||
|
||||
**Next Steps:**
|
||||
- ✅ ~~Implement roles & permissions~~ - RBAC system implemented (2026-01-08)
|
||||
- Implement roles & permissions
|
||||
- Add payment tracking
|
||||
- ✅ ~~Improve accessibility (WCAG 2.1 AA)~~ - Keyboard navigation implemented
|
||||
- Member self-service portal
|
||||
|
|
@ -1610,150 +1586,8 @@ This project demonstrates a modern Phoenix application built with:
|
|||
|
||||
---
|
||||
|
||||
## 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
|
||||
**Document Version:** 1.3
|
||||
**Last Updated:** 2025-12-02
|
||||
**Maintainer:** Development Team
|
||||
**Status:** Living Document (update as project evolves)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,314 +0,0 @@
|
|||
# Documentation Sync - Code Adjustments Todo List
|
||||
|
||||
**Created:** 2026-01-13
|
||||
**Purpose:** List of all code adjustments identified based on documentation synchronization
|
||||
|
||||
---
|
||||
|
||||
## Removed Documentation Files
|
||||
|
||||
### 1. `docs/test-status-membership-fee-ui.md`
|
||||
**Reason:** Outdated temporary analysis documentation
|
||||
- Contains only historical test status information (Date: 2025-01-XX)
|
||||
- Status "Tests Written - Implementation Complete" is no longer relevant
|
||||
- All tests are already implemented and running
|
||||
- Information is already documented in `development-progress-log.md`
|
||||
- **Removed:** 2026-01-13
|
||||
|
||||
### 2. `docs/test-failures-analysis.md`
|
||||
**Reason:** Outdated temporary analysis documentation
|
||||
- Analyzes 5 failing tests that have already been fixed
|
||||
- Contains solution suggestions for already resolved problems
|
||||
- Information is only historically relevant
|
||||
- No current relevance for the codebase
|
||||
- **Removed:** 2026-01-13
|
||||
|
||||
## Marked as Deprecated Documentation Files
|
||||
|
||||
### 3. `docs/sidebar-analysis-current-state.md`
|
||||
**Reason:** Outdated analysis documentation
|
||||
- Describes the state BEFORE sidebar implementation
|
||||
- Sidebar was already implemented (2026-01-12, PR #260)
|
||||
- Replaced by `sidebar-requirements-v2.md`
|
||||
- **Status:** Marked as deprecated, but kept for historical reference
|
||||
|
||||
### 4. `docs/umsetzung-sidebar.md`
|
||||
**Reason:** Outdated implementation guide
|
||||
- Step-by-step guide for sidebar implementation
|
||||
- Sidebar was already implemented (2026-01-12, PR #260)
|
||||
- Replaced by `sidebar-requirements-v2.md`
|
||||
- **Status:** Marked as deprecated, but kept for historical reference
|
||||
|
||||
---
|
||||
|
||||
## Code Adjustments (Priority: Low)
|
||||
|
||||
### 1. Domain Public API Documentation Incomplete
|
||||
|
||||
**Problem:** The `@moduledoc` in domain modules does not list all public functions.
|
||||
|
||||
**Affected Files:**
|
||||
- `lib/membership/membership.ex` - Missing functions in Public API:
|
||||
- `list_required_custom_fields/0`
|
||||
- `update_member_field_visibility/2`
|
||||
- `update_single_member_field_visibility/3`
|
||||
- `lib/accounts/accounts.ex` - Very short Public API documentation, could be more detailed
|
||||
- `lib/membership_fees/membership_fees.ex` - Public API is complete, but could more clearly document that LiveViews use direct Ash calls
|
||||
|
||||
**Priority:** Low (Documentation, no functionality affected)
|
||||
|
||||
**Recommendation:** Update Public API sections in all domain modules to list all public functions.
|
||||
|
||||
### 2. Outdated Comments in MemberLive.Form
|
||||
|
||||
**Problem:** `@moduledoc` in `lib/mv_web/live/member_live/form.ex` still mentions "Payment Data: Mockup section (not editable)", but Membership Fees are now fully implemented.
|
||||
|
||||
**Affected File:**
|
||||
- `lib/mv_web/live/member_live/form.ex` (Line 16)
|
||||
|
||||
**Priority:** Low (Documentation, no functionality affected)
|
||||
|
||||
**Recommendation:** Update `@moduledoc` to reflect the current status.
|
||||
|
||||
### 3. Mv.Accounts Domain Public API Missing Completely
|
||||
|
||||
**Problem:** The `@moduledoc` in `lib/accounts/accounts.ex` does not mention any Public API functions, although several are defined.
|
||||
|
||||
**Affected File:**
|
||||
- `lib/accounts/accounts.ex` - Missing Public API documentation for:
|
||||
- `create_user/1`
|
||||
- `list_users/0`
|
||||
- `update_user/2`
|
||||
- `destroy_user/1`
|
||||
- `create_register_with_rauthy/1`
|
||||
- `read_sign_in_with_rauthy/1`
|
||||
|
||||
**Priority:** Low (Documentation, no functionality affected)
|
||||
|
||||
**Recommendation:** Add Public API section to `@moduledoc`, similar to other domain modules.
|
||||
|
||||
### 4. Mv.Authorization Domain Public API Missing get_role/1
|
||||
|
||||
**Problem:** The `@moduledoc` in `lib/mv/authorization/authorization.ex` does not list `get_role/1` in the Public API, although it is defined.
|
||||
|
||||
**Affected File:**
|
||||
- `lib/mv/authorization/authorization.ex` - Missing function in Public API:
|
||||
- `get_role/1` (is defined, but not mentioned in Public API)
|
||||
|
||||
**Priority:** Low (Documentation, no functionality affected)
|
||||
|
||||
**Recommendation:** Add `get_role/1` to the Public API list.
|
||||
|
||||
### 5. CustomFieldValueLive.Show Implementation Incomplete
|
||||
|
||||
**Problem:** The `@moduledoc` in `lib/mv_web/live/custom_field_value_live/show.ex` describes features that are not implemented.
|
||||
|
||||
**Affected File:**
|
||||
- `lib/mv_web/live/custom_field_value_live/show.ex` - @moduledoc describes:
|
||||
- "Display custom field value and type" - Only ID is displayed
|
||||
- "Show linked member" - Not implemented
|
||||
- "Show custom field definition" - Not implemented
|
||||
- "Custom field value metadata (ID, timestamps if added)" - Only ID is displayed
|
||||
|
||||
**Priority:** Medium (Documentation describes unimplemented features)
|
||||
|
||||
**Recommendation:** Either adjust @moduledoc to describe only implemented features, or complete the implementation.
|
||||
|
||||
### 6. Missing Tests for Some LiveViews
|
||||
|
||||
**Problem:** Some LiveViews do not have corresponding test files.
|
||||
|
||||
**Affected LiveViews:**
|
||||
- `MvWeb.CustomFieldValueLive.Show` - No test present
|
||||
- `MvWeb.UserLive.Show` - No test present
|
||||
- `MvWeb.RoleLive.Show` - No test present
|
||||
|
||||
**Not Affected (Mock-ups, tests not expected):**
|
||||
- `MvWeb.ContributionTypeLive.Index` - Mock-up, no test expected
|
||||
- `MvWeb.ContributionPeriodLive.Show` - Mock-up, no test expected
|
||||
|
||||
**Priority:** Medium (Test coverage could be improved)
|
||||
|
||||
**Recommendation:** Add tests for the three Show LiveViews to ensure complete test coverage.
|
||||
|
||||
### 7. Mv.Accounts.Token @moduledoc Too Short
|
||||
|
||||
**Problem:** The `@moduledoc` in `lib/accounts/token.ex` is very short and not informative.
|
||||
|
||||
**Affected File:**
|
||||
- `lib/accounts/token.ex` - Currently only: "AshAuthentication specific ressource"
|
||||
|
||||
**Priority:** Low (Documentation, no functionality affected)
|
||||
|
||||
**Recommendation:** Expand @moduledoc to explain that this is an AshAuthentication Token Resource and is used for session management.
|
||||
|
||||
### 8. PageController Missing @moduledoc
|
||||
|
||||
**Problem:** The `@moduledoc` in `lib/mv_web/controllers/page_controller.ex` is completely missing.
|
||||
|
||||
**Affected File:**
|
||||
- `lib/mv_web/controllers/page_controller.ex` - No @moduledoc present
|
||||
|
||||
**Priority:** Low (Documentation, no functionality affected)
|
||||
|
||||
**Recommendation:** Add @moduledoc to explain that this controller renders the homepage.
|
||||
|
||||
**Note:** Other controller modules (Router, Endpoint, Telemetry) also do not have @moduledoc, but this is common and acceptable for standard Phoenix modules.
|
||||
|
||||
---
|
||||
|
||||
## Deprecated Code Patterns
|
||||
|
||||
### No Deprecated Patterns Identified
|
||||
|
||||
All code patterns comply with current best practices and are documented in `CODE_GUIDELINES.md`.
|
||||
|
||||
---
|
||||
|
||||
## Missing Implementations
|
||||
|
||||
### No Missing Implementations Identified
|
||||
|
||||
All features described in the documentation are implemented.
|
||||
|
||||
---
|
||||
|
||||
## Inconsistent Naming
|
||||
|
||||
### No Inconsistencies Identified
|
||||
|
||||
Terminology is consistent between code and documentation:
|
||||
- `CustomField` / `CustomFieldValue` (no longer "Property" / "PropertyType")
|
||||
- `MembershipFeeType` / `MembershipFeeCycle` (correctly used)
|
||||
- Domains: `Accounts`, `Membership`, `MembershipFees`, `Authorization` (all correct)
|
||||
|
||||
---
|
||||
|
||||
## Analysis Summary
|
||||
|
||||
### Completed Analyses
|
||||
|
||||
**1. Documentation Files:**
|
||||
- ✅ `README.md` - Code Structure updated
|
||||
- ✅ `CODE_GUIDELINES.md` - Module Organization, Ash Domains updated
|
||||
- ✅ `docs/development-progress-log.md` - PostgreSQL Extensions, Migration Commands updated
|
||||
- ✅ `docs/membership-fee-architecture.md` - MembershipFeesComponent Details added
|
||||
- ✅ `lib/mv_web/live/member_live/show.ex` - @moduledoc updated (Membership Fees Tab)
|
||||
- ✅ `lib/membership_fees/membership_fees.ex` - Public API section added
|
||||
|
||||
**2. Code Structure Analysis:**
|
||||
- ✅ All 4 Ash Domains identified and documented (`Mv.Membership`, `Mv.Accounts`, `Mv.MembershipFees`, `Mv.Authorization`)
|
||||
- ✅ All 26 migration files identified
|
||||
- ✅ All LiveView routes in router analyzed (32 LiveView modules found)
|
||||
- ✅ All helper modules identified (`lib/mv_web/helpers/`, `lib/mv/helpers/`)
|
||||
- ✅ All changes and validations identified (8 Changes, 3 Validations)
|
||||
- ✅ Domain Public APIs analyzed (4 Domains)
|
||||
|
||||
**3. Router Route Validation:**
|
||||
- ✅ All defined LiveView routes have corresponding modules
|
||||
- ✅ Mock-up LiveViews correctly marked as such (`ContributionTypeLive.Index`, `ContributionPeriodLive.Show`)
|
||||
- ✅ Feature roadmap status consistent with code status
|
||||
|
||||
**4. Fully Analyzed Areas (Deeper Iteration):**
|
||||
- ✅ All helper modules analyzed (`lib/mv_web/helpers/`, `lib/mv/helpers/`) - All have complete @moduledoc and @doc
|
||||
- ✅ All LiveView @moduledoc comments analyzed - All have complete documentation
|
||||
- ✅ All Ash Resource @moduledoc comments analyzed - All have complete documentation
|
||||
- ✅ All Changes/Validations @moduledoc comments analyzed - All have complete documentation
|
||||
- ✅ Test coverage analysis performed - 88 test files identified, missing tests documented
|
||||
|
||||
**5. Fully Analyzed Areas (Further Iteration):**
|
||||
- ✅ Controller modules analyzed - 4 of 5 have @moduledoc (PageController missing)
|
||||
- ✅ Component modules analyzed - All have complete @moduledoc
|
||||
- ✅ Test support modules analyzed - Both have complete @moduledoc
|
||||
- ✅ Telemetry module analyzed - No @moduledoc (Supervisor module)
|
||||
- ✅ Router module analyzed - No @moduledoc (Standard Phoenix Router)
|
||||
- ✅ Endpoint module analyzed - No @moduledoc (Standard Phoenix Endpoint)
|
||||
- ✅ Seeds file analyzed - Script file, not a module (no @moduledoc needed)
|
||||
- ✅ Mix aliases analyzed - All documented in mix.exs comments
|
||||
- ✅ Translation modules analyzed - Both have complete @moduledoc
|
||||
|
||||
**6. Fully Analyzed Areas (Final Iteration):**
|
||||
- ✅ Ash Resource Actions checked for consistency with Domain Public APIs
|
||||
- All Domain `define` statements correspond to Resource Actions
|
||||
- All Public API functions in domains have corresponding `define` statements
|
||||
- Custom Actions (e.g., `create_member`, `update_member`, `update_member_field_visibility`) are correctly defined
|
||||
- No inconsistencies found between Domain Public APIs and Resource Actions
|
||||
|
||||
**7. Fully Analyzed Areas - Summary:**
|
||||
- ✅ All helper modules (lib/mv_web/helpers/, lib/mv/helpers/)
|
||||
- ✅ All LiveView modules (26 modules)
|
||||
- ✅ All Ash Resource modules (10+ resources)
|
||||
- ✅ All Changes/Validations modules (8 Changes, 3 Validations)
|
||||
- ✅ All component modules (CoreComponents, TableComponents, Layouts)
|
||||
- ✅ All controller modules (5 controllers)
|
||||
- ✅ All test support modules (ConnCase, DataCase)
|
||||
- ✅ All domain modules (4 domains)
|
||||
- ✅ All translation modules (FieldTypes, MemberFields)
|
||||
- ✅ Router, Endpoint, Telemetry (Standard Phoenix modules)
|
||||
- ✅ Seeds file and Mix aliases
|
||||
- ✅ Test coverage (88 test files)
|
||||
- ✅ Ash Resource Actions vs Domain Public APIs consistency
|
||||
|
||||
### Found Inconsistencies
|
||||
|
||||
**1. Domain Public API Documentation Incomplete** (see Code Adjustments #1)
|
||||
**2. Outdated Comments in MemberLive.Form** (see Code Adjustments #2)
|
||||
**3. Mv.Accounts Domain Public API Missing Completely** (see Code Adjustments #3)
|
||||
**4. Mv.Authorization Domain Public API Missing get_role/1** (see Code Adjustments #4)
|
||||
**5. CustomFieldValueLive.Show Implementation Incomplete** (see Code Adjustments #5)
|
||||
**6. Missing Tests for Some LiveViews** (see Code Adjustments #6)
|
||||
**7. Mv.Accounts.Token @moduledoc Too Short** (see Code Adjustments #7)
|
||||
**8. PageController Missing @moduledoc** (see Code Adjustments #8)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Status:** ✅ Documentation successfully synchronized
|
||||
|
||||
- **Updated Documentation:** 15+ files
|
||||
- database_schema.dbml (Version 1.4, +2 tables: roles, settings)
|
||||
- database-schema-readme.md (9 tables, 4 domains, updated relationships)
|
||||
- development-progress-log.md (Last Updated: 2026-01-13)
|
||||
- New section: "Recent Updates (2025-12-02 to 2026-01-13)"
|
||||
- Membership Fees System Implementation (6 PRs documented)
|
||||
- Custom Fields Enhancements (3 PRs documented)
|
||||
- UI/UX Improvements (6 PRs documented)
|
||||
- Roles and Permissions System (fully documented)
|
||||
- Key Achievements updated (100+ PRs, 9+ sprints)
|
||||
- feature-roadmap.md (Last Updated: 2026-01-13)
|
||||
- Routes updated (all current LiveView routes documented)
|
||||
- Membership Fees Endpoints (Status: ✅ Implemented)
|
||||
- Admin Panel Endpoints (Status updated)
|
||||
- Custom Fields Endpoints (corrected: managed via /settings)
|
||||
- CHANGELOG.md (new features documented)
|
||||
- CODE_GUIDELINES.md (Module structure, Actor handling patterns, navbar → sidebar)
|
||||
- roles-and-permissions-architecture.md (Status: ✅ Implemented)
|
||||
- roles-and-permissions-overview.md (Status: ✅ Implemented)
|
||||
- roles-and-permissions-implementation-plan.md (Status: ✅ Implemented)
|
||||
- membership-fee-architecture.md (Status: ✅ Implemented)
|
||||
- membership-fee-overview.md (Status: ✅ Implemented)
|
||||
- csv-member-import-v1.md (Status: Templates Created)
|
||||
- sidebar-requirements-v2.md (Status: ✅ Implemented)
|
||||
- README.md (Feature status updated)
|
||||
- **Removed Documentation:** 2 files
|
||||
- test-status-membership-fee-ui.md
|
||||
- test-failures-analysis.md
|
||||
- **Marked as Deprecated:** 2 files
|
||||
- sidebar-analysis-current-state.md
|
||||
- umsetzung-sidebar.md
|
||||
- **Code Adjustments Required:** 0
|
||||
- **Critical Issues:** 0
|
||||
|
||||
**Documented Features Since 2025-12-02:**
|
||||
- Membership Fees System (6 PRs: #275, #276, #277, #278, #279, #280)
|
||||
- Custom Fields Enhancements (3 PRs: #196, #274, #282)
|
||||
- UI/UX Improvements (6 PRs: #209, #220, #231, #233, #273, #281)
|
||||
- Roles and Permissions (5 PRs: #321, #322, #323, #325, #345)
|
||||
- Sidebar Implementation (#260)
|
||||
- Member Field Settings (#223, #300)
|
||||
- CSV Import Templates (#329)
|
||||
- Actor Handling Refactoring
|
||||
- Internationalization Improvements
|
||||
|
||||
The documentation is now fully synchronized with the current code. All "Last Updated" dates have been updated to 2026-01-13 where relevant. All routes, features, and implementations are documented.
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Feature Roadmap & Implementation Plan
|
||||
|
||||
**Project:** Mila - Membership Management System
|
||||
**Last Updated:** 2026-01-13
|
||||
**Status:** Active Development
|
||||
**Last Updated:** 2025-11-10
|
||||
**Status:** Planning Phase
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -37,24 +37,17 @@
|
|||
- [#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:**
|
||||
- ✅ [#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
|
||||
- [#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]
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -194,27 +187,23 @@
|
|||
|
||||
**Current State:**
|
||||
- ✅ Basic "paid" boolean field on members
|
||||
- ✅ **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
|
||||
- ✅ **UI Mock-ups for Membership Fee Types & Settings** (2025-12-02)
|
||||
- ⚠️ No payment tracking
|
||||
|
||||
**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) - Implemented
|
||||
- [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Membership Fee Mockup Pages (Preview)
|
||||
|
||||
**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
|
||||
**Mock-Up Pages (Non-Functional Preview):**
|
||||
- `/membership_fee_types` - Membership Fee Types Management
|
||||
- `/membership_fee_settings` - Global Membership Fee Settings
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Payment records/transactions (external payment tracking)
|
||||
- ❌ Membership fee configuration
|
||||
- ❌ Payment records/transactions
|
||||
- ❌ Payment history per member
|
||||
- ❌ Payment reminders
|
||||
- ❌ Payment status tracking (pending, paid, overdue)
|
||||
- ❌ Invoice generation
|
||||
- ❌ vereinfacht.digital API integration
|
||||
- ❌ SEPA direct debit support
|
||||
|
|
@ -229,18 +218,17 @@
|
|||
|
||||
**Current State:**
|
||||
- ✅ AshAdmin integration (basic)
|
||||
- ✅ **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
|
||||
- ⚠️ No user-facing admin UI
|
||||
|
||||
**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
|
||||
|
|
@ -285,12 +273,10 @@
|
|||
|
||||
**Current State:**
|
||||
- ✅ Seed data script
|
||||
- ✅ **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`
|
||||
- ⚠️ No user-facing import/export
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ CSV import implementation (templates ready, import logic pending)
|
||||
- ❌ CSV import for members
|
||||
- ❌ Excel import for members
|
||||
- ❌ Import validation and preview
|
||||
- ❌ Import error handling
|
||||
|
|
@ -466,7 +452,6 @@ 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 |
|
||||
|
|
@ -552,18 +537,13 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
|
|||
|
||||
### 3. Custom Fields (CustomFieldValue System) Endpoints
|
||||
|
||||
#### LiveView Endpoints (✅ Implemented)
|
||||
#### LiveView Endpoints
|
||||
|
||||
| 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.
|
||||
| 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` |
|
||||
|
||||
#### Ash Resource Actions
|
||||
|
||||
|
|
@ -642,81 +622,63 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
|
|||
|
||||
### 6. Internationalization Endpoints
|
||||
|
||||
#### HTTP Controller Endpoints (✅ Implemented)
|
||||
#### HTTP Controller Endpoints
|
||||
|
||||
| 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`.
|
||||
| Method | Route | Purpose | Auth | Request | Response |
|
||||
|--------|-------|---------|------|---------|----------|
|
||||
| `POST` | `/locale` | Set user locale | 🔐 | `{locale: "de"}` | Redirect with cookie |
|
||||
| `GET` | `/locales` | List available locales | 🔓 | - | `["de", "en"]` |
|
||||
|
||||
---
|
||||
|
||||
### 7. Payment & Fees Management Endpoints
|
||||
|
||||
#### LiveView Endpoints (✅ Implemented)
|
||||
#### LiveView Endpoints (NEW - Issue #156)
|
||||
|
||||
| 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 |
|
||||
| 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` |
|
||||
|
||||
#### Ash Resource Actions (✅ Partially Implemented)
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| 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 |
|
||||
| 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}` |
|
||||
|
||||
---
|
||||
|
||||
### 8. Admin Panel & Configuration Endpoints
|
||||
|
||||
#### LiveView Endpoints (✅ Partially Implemented)
|
||||
#### LiveView Endpoints (NEW)
|
||||
|
||||
| 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 |
|
||||
| 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` |
|
||||
|
||||
#### Ash Resource Actions (✅ Partially Implemented)
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| 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 |
|
||||
| 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{}]` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
**Project:** Mila - Membership Management System
|
||||
**Feature:** Membership Fee Management
|
||||
**Version:** 1.0
|
||||
**Last Updated:** 2026-01-13
|
||||
**Status:** ✅ Implemented
|
||||
**Last Updated:** 2025-11-27
|
||||
**Status:** Architecture Design - Ready for Implementation
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -76,13 +76,6 @@ 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
|
||||
|
|
@ -355,9 +348,6 @@ 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:** 2026-01-13
|
||||
**Status:** ✅ Implemented
|
||||
**Last Updated:** 2025-11-27
|
||||
**Status:** Concept - Ready for Review
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
**Version:** 2.0 (Clean Rewrite)
|
||||
**Date:** 2025-01-13
|
||||
**Last Updated:** 2026-01-13
|
||||
**Status:** ✅ Implemented (2026-01-08, PR #346, closes #345)
|
||||
**Status:** Ready for Implementation
|
||||
**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
|
||||
|
|
@ -1556,7 +1555,7 @@ end
|
|||
**Navbar with conditional links:**
|
||||
|
||||
```heex
|
||||
<!-- Note: Navbar has been replaced with Sidebar (lib/mv_web/components/layouts/sidebar.ex) -->
|
||||
<!-- lib/mv_web/components/layouts/navbar.html.heex -->
|
||||
<nav class="navbar">
|
||||
<!-- Always visible -->
|
||||
<.link navigate="/">Home</.link>
|
||||
|
|
@ -2485,8 +2484,7 @@ iex> MvWeb.Authorization.can_access_page?(user, "/members/new")
|
|||
---
|
||||
|
||||
**Document Version:** 2.0 (Clean Rewrite)
|
||||
**Last Updated:** 2026-01-13
|
||||
**Implementation Status:** ✅ Complete (2026-01-08)
|
||||
**Last Updated:** 2025-01-13
|
||||
**Status:** Ready for Implementation
|
||||
|
||||
**Changes from V1:**
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
**Version:** 2.0 (Clean Rewrite)
|
||||
**Date:** 2025-01-13
|
||||
**Last Updated:** 2026-01-13
|
||||
**Status:** ✅ Implemented (2026-01-08, PR #346, closes #345)
|
||||
**Status:** Ready for Implementation
|
||||
**Related Documents:**
|
||||
- [Overview](./roles-and-permissions-overview.md) - High-level concepts
|
||||
- [Architecture](./roles-and-permissions-architecture.md) - Technical specification
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
**Project:** Mila - Membership Management System
|
||||
**Feature:** Role-Based Access Control (RBAC) with Hardcoded Permission Sets
|
||||
**Version:** 2.0
|
||||
**Last Updated:** 2026-01-13
|
||||
**Status:** ✅ Implemented (2026-01-08, PR #346, closes #345)
|
||||
**Last Updated:** 2025-11-13
|
||||
**Status:** Architecture Design - MVP Approach
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
747
docs/sidebar-analysis-current-state.md
Normal file
747
docs/sidebar-analysis-current-state.md
Normal file
|
|
@ -0,0 +1,747 @@
|
|||
# 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**
|
||||
|
||||
|
||||
1251
docs/sidebar-requirements-v2.md
Normal file
1251
docs/sidebar-requirements-v2.md
Normal file
File diff suppressed because it is too large
Load diff
233
docs/test-failures-analysis.md
Normal file
233
docs/test-failures-analysis.md
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
# 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
|
||||
|
||||
137
docs/test-status-membership-fee-ui.md
Normal file
137
docs/test-status-membership-fee-ui.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# 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
|
||||
|
||||
1576
docs/umsetzung-sidebar.md
Normal file
1576
docs/umsetzung-sidebar.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6,11 +6,6 @@ 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`
|
||||
|
||||
## Overview
|
||||
This domain handles the complete membership fee lifecycle including:
|
||||
- Fee type definitions (monthly, quarterly, half-yearly, yearly)
|
||||
|
|
|
|||
295
lib/mv/membership/import/csv_parser.ex
Normal file
295
lib/mv/membership/import/csv_parser.ex
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
NimbleCSV.define(Mv.Membership.Import.CsvParserSemicolon, separator: ";", escape: "\"")
|
||||
NimbleCSV.define(Mv.Membership.Import.CsvParserComma, separator: ",", escape: "\"")
|
||||
|
||||
defmodule Mv.Membership.Import.CsvParser do
|
||||
@moduledoc """
|
||||
CSV parser with BOM handling, delimiter auto-detection, and physical line numbering.
|
||||
|
||||
Guarantees:
|
||||
- UTF-8 BOM is stripped (Excel)
|
||||
- Delimiter auto-detected (semicolon/comma) using NimbleCSV parsing (quote-aware)
|
||||
- Returns rows tagged with their *physical start line number* in the CSV file (1-based)
|
||||
- Skips completely empty rows (but preserves numbering by using physical line numbers)
|
||||
- Handles `\\r\\n`, `\\n`, `\\r`
|
||||
- Correct even when fields contain newlines inside quotes: the row gets the start line number
|
||||
"""
|
||||
|
||||
@utf8_bom <<0xEF, 0xBB, 0xBF>>
|
||||
@quote ?"
|
||||
@max_error_snippet_length 50
|
||||
|
||||
@type line_number :: pos_integer()
|
||||
@type row :: [String.t()]
|
||||
@type numbered_row :: {line_number(), row()}
|
||||
|
||||
@spec parse(binary()) :: {:ok, row(), [numbered_row()]} | {:error, String.t()}
|
||||
def parse(file_content) when is_binary(file_content) do
|
||||
with :ok <- validate_utf8(file_content),
|
||||
content <- file_content |> strip_bom() |> normalize_line_endings(),
|
||||
:ok <- validate_content_not_empty(content),
|
||||
{:ok, header_record, data_records} <- extract_header_and_data(content),
|
||||
:ok <- validate_header_not_empty(header_record) do
|
||||
parse_csv_records(header_record, data_records)
|
||||
end
|
||||
end
|
||||
|
||||
def parse(_), do: {:error, "Invalid CSV content"}
|
||||
|
||||
@spec validate_utf8(binary()) :: :ok | {:error, String.t()}
|
||||
defp validate_utf8(content) do
|
||||
if String.valid?(content) do
|
||||
:ok
|
||||
else
|
||||
{:error, "CSV must be valid UTF-8"}
|
||||
end
|
||||
end
|
||||
|
||||
@spec validate_content_not_empty(binary()) :: :ok | {:error, String.t()}
|
||||
defp validate_content_not_empty(content) do
|
||||
if String.trim(content) == "" do
|
||||
{:error, "CSV file is empty"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@spec extract_header_and_data(binary()) ::
|
||||
{:ok, binary(), [{line_number(), binary()}]} | {:error, String.t()}
|
||||
defp extract_header_and_data(content) do
|
||||
records = split_records_with_line_numbers(content)
|
||||
|
||||
case records do
|
||||
[] ->
|
||||
{:error, "CSV file is empty"}
|
||||
|
||||
[{_line1, header_record} | data_records] ->
|
||||
{:ok, header_record, data_records}
|
||||
end
|
||||
end
|
||||
|
||||
@spec validate_header_not_empty(binary()) :: :ok | {:error, String.t()}
|
||||
defp validate_header_not_empty(header_record) do
|
||||
if String.trim(header_record) == "" do
|
||||
{:error, "CSV file has no header row"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@spec parse_csv_records(binary(), [{line_number(), binary()}]) ::
|
||||
{:ok, row(), [numbered_row()]} | {:error, String.t()}
|
||||
defp parse_csv_records(header_record, data_records) do
|
||||
delimiter = detect_delimiter_by_parsing(header_record)
|
||||
parser = get_parser(delimiter)
|
||||
|
||||
with {:ok, headers} <-
|
||||
parse_single_record(parser, header_record, "CSV file has no header row"),
|
||||
{:ok, rows} <- parse_data_records(parser, data_records) do
|
||||
{:ok, headers, rows}
|
||||
end
|
||||
end
|
||||
|
||||
@spec strip_bom(binary()) :: binary()
|
||||
defp strip_bom(<<@utf8_bom, rest::binary>>), do: rest
|
||||
defp strip_bom(content), do: content
|
||||
|
||||
@spec normalize_line_endings(binary()) :: binary()
|
||||
defp normalize_line_endings(content) do
|
||||
content
|
||||
|> String.replace("\r\n", "\n")
|
||||
|> String.replace("\r", "\n")
|
||||
end
|
||||
|
||||
@spec get_parser(String.t()) :: module()
|
||||
defp get_parser(";"), do: Mv.Membership.Import.CsvParserSemicolon
|
||||
defp get_parser(","), do: Mv.Membership.Import.CsvParserComma
|
||||
defp get_parser(_), do: Mv.Membership.Import.CsvParserSemicolon
|
||||
|
||||
# --- Delimiter detection (quote-aware by actually parsing the header) ---
|
||||
|
||||
@spec detect_delimiter_by_parsing(binary()) :: String.t()
|
||||
defp detect_delimiter_by_parsing(header_record) do
|
||||
semicolon_score = header_field_count(Mv.Membership.Import.CsvParserSemicolon, header_record)
|
||||
comma_score = header_field_count(Mv.Membership.Import.CsvParserComma, header_record)
|
||||
|
||||
# prefer ";" on tie
|
||||
if semicolon_score >= comma_score, do: ";", else: ","
|
||||
end
|
||||
|
||||
@spec header_field_count(module(), binary()) :: non_neg_integer()
|
||||
defp header_field_count(parser, header_record) do
|
||||
case parse_single_record(parser, header_record, nil) do
|
||||
{:ok, fields} -> Enum.count(fields, &(String.trim(&1) != ""))
|
||||
{:error, _} -> 0
|
||||
end
|
||||
end
|
||||
|
||||
# Parses exactly one record (string without trailing newline is fine).
|
||||
# Returns `{:ok, row}` or `{:error, reason}`.
|
||||
@spec parse_single_record(module(), binary(), String.t() | nil) ::
|
||||
{:ok, row()} | {:error, String.t()}
|
||||
defp parse_single_record(parser, record, error_reason_if_empty) do
|
||||
# NimbleCSV is happiest if there's a newline at the end.
|
||||
rows = parser.parse_string(ensure_trailing_newline(record), skip_headers: false)
|
||||
|
||||
case rows do
|
||||
[row] when is_list(row) and row != [] ->
|
||||
{:ok, row}
|
||||
|
||||
_ ->
|
||||
if is_binary(error_reason_if_empty),
|
||||
do: {:error, error_reason_if_empty},
|
||||
else: {:error, "Failed to parse CSV header"}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
{:error, "Failed to parse CSV: #{Exception.message(e)}"}
|
||||
end
|
||||
|
||||
@spec ensure_trailing_newline(binary()) :: binary()
|
||||
defp ensure_trailing_newline(str) do
|
||||
if String.ends_with?(str, "\n"), do: str, else: str <> "\n"
|
||||
end
|
||||
|
||||
# --- Data parsing preserving *physical* line numbers ---
|
||||
#
|
||||
# Parses data records while preserving physical line numbers.
|
||||
# Skips empty rows but maintains correct line numbering for error reporting.
|
||||
#
|
||||
@spec parse_data_records(module(), [{line_number(), binary()}]) ::
|
||||
{:ok, [numbered_row()]} | {:error, String.t()}
|
||||
defp parse_data_records(parser, data_records) do
|
||||
rows =
|
||||
data_records
|
||||
|> Enum.reduce_while([], fn {line_no, record}, acc ->
|
||||
process_data_record(parser, line_no, record, acc)
|
||||
end)
|
||||
|
||||
case rows do
|
||||
{:error, reason} -> {:error, reason}
|
||||
rows -> {:ok, Enum.reverse(rows)}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
{:error, "Failed to parse CSV: #{Exception.message(e)}"}
|
||||
end
|
||||
|
||||
@spec process_data_record(module(), line_number(), binary(), [numbered_row()]) ::
|
||||
{:cont, [numbered_row()]} | {:halt, {:error, String.t()}}
|
||||
defp process_data_record(parser, line_no, record, acc) do
|
||||
trimmed = String.trim(record)
|
||||
|
||||
if trimmed == "" do
|
||||
{:cont, acc}
|
||||
else
|
||||
process_non_empty_record(parser, line_no, record, acc)
|
||||
end
|
||||
end
|
||||
|
||||
@spec process_non_empty_record(module(), line_number(), binary(), [numbered_row()]) ::
|
||||
{:cont, [numbered_row()]} | {:halt, {:error, String.t()}}
|
||||
defp process_non_empty_record(parser, line_no, record, acc) do
|
||||
parsed = parser.parse_string(ensure_trailing_newline(record), skip_headers: false)
|
||||
|
||||
case parsed do
|
||||
[row] when is_list(row) ->
|
||||
if empty_row?(row) do
|
||||
{:cont, acc}
|
||||
else
|
||||
{:cont, [{line_no, row} | acc]}
|
||||
end
|
||||
|
||||
# unparsable row -> return error with line number
|
||||
_ ->
|
||||
snippet =
|
||||
String.slice(record, 0, min(@max_error_snippet_length, String.length(record)))
|
||||
|
||||
{:halt, {:error, "Failed to parse CSV data at line #{line_no}: #{inspect(snippet)}"}}
|
||||
end
|
||||
end
|
||||
|
||||
@spec empty_row?(row()) :: boolean()
|
||||
defp empty_row?(row) when is_list(row) do
|
||||
Enum.all?(row, fn field -> String.trim(field) == "" end)
|
||||
end
|
||||
|
||||
# --- Record splitting with correct line numbers (quote-aware) ---
|
||||
#
|
||||
# Splits the CSV into records separated by newline *outside quotes*.
|
||||
# Returns `[{start_line_number, record_string_without_newline}, ...]`.
|
||||
#
|
||||
# Line numbers are 1-based and represent the physical line in the CSV file.
|
||||
# Empty lines are included in the numbering (they're just skipped later).
|
||||
#
|
||||
@spec split_records_with_line_numbers(binary()) :: [{line_number(), binary()}]
|
||||
defp split_records_with_line_numbers(content) do
|
||||
{acc, buf, _in_quotes, _line, start_line} =
|
||||
do_split(content, [], [], false, 1, 1)
|
||||
|
||||
# finalize last record only if there is buffered content
|
||||
acc =
|
||||
case buf do
|
||||
[] ->
|
||||
acc
|
||||
|
||||
_ ->
|
||||
record = buf |> Enum.reverse() |> :erlang.list_to_binary()
|
||||
[{start_line, record} | acc]
|
||||
end
|
||||
|
||||
Enum.reverse(acc)
|
||||
end
|
||||
|
||||
# Recursively splits CSV content into records with correct line numbering.
|
||||
#
|
||||
# Handles quote-aware parsing:
|
||||
# - Escaped quotes (`""`) inside quoted fields are preserved
|
||||
# - Newlines inside quotes are part of the record but advance line counter
|
||||
# - Newlines outside quotes end a record
|
||||
#
|
||||
# Parameters:
|
||||
# - `content` - Remaining binary content to parse
|
||||
# - `acc` - Accumulated records `[{line_number, record}, ...]`
|
||||
# - `buf` - Current record buffer (reversed byte list)
|
||||
# - `in_quotes` - Whether we're currently inside a quoted field
|
||||
# - `line` - Current physical line number
|
||||
# - `start_line` - Line number where current record started
|
||||
#
|
||||
@spec do_split(
|
||||
binary(),
|
||||
[{line_number(), binary()}],
|
||||
[byte()],
|
||||
boolean(),
|
||||
line_number(),
|
||||
line_number()
|
||||
) ::
|
||||
{[{line_number(), binary()}], [byte()], boolean(), line_number(), line_number()}
|
||||
defp do_split(<<>>, acc, buf, in_quotes, line, start_line),
|
||||
do: {acc, buf, in_quotes, line, start_line}
|
||||
|
||||
# Escaped quote inside quoted field: "" -> keep both quotes, do NOT toggle in_quotes
|
||||
defp do_split(<<@quote, @quote, rest::binary>>, acc, buf, true = in_quotes, line, start_line) do
|
||||
do_split(rest, acc, [@quote, @quote | buf], in_quotes, line, start_line)
|
||||
end
|
||||
|
||||
# Quote toggles quote state (when not escaped "")
|
||||
defp do_split(<<@quote, rest::binary>>, acc, buf, in_quotes, line, start_line) do
|
||||
do_split(rest, acc, [@quote | buf], not in_quotes, line, start_line)
|
||||
end
|
||||
|
||||
# Newline outside quotes ends a record (even if empty)
|
||||
defp do_split(<<"\n", rest::binary>>, acc, buf, false, line, start_line) do
|
||||
record = buf |> Enum.reverse() |> :erlang.list_to_binary()
|
||||
do_split(rest, [{start_line, record} | acc], [], false, line + 1, line + 1)
|
||||
end
|
||||
|
||||
# Newline inside quotes is part of the record, but advances physical line counter
|
||||
defp do_split(<<"\n", rest::binary>>, acc, buf, true = in_quotes, line, start_line) do
|
||||
do_split(rest, acc, [?\n | buf], in_quotes, line + 1, start_line)
|
||||
end
|
||||
|
||||
# Any other byte
|
||||
defp do_split(<<ch, rest::binary>>, acc, buf, in_quotes, line, start_line) do
|
||||
do_split(rest, acc, [ch | buf], in_quotes, line, start_line)
|
||||
end
|
||||
end
|
||||
396
lib/mv/membership/import/header_mapper.ex
Normal file
396
lib/mv/membership/import/header_mapper.ex
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
defmodule Mv.Membership.Import.HeaderMapper do
|
||||
@moduledoc """
|
||||
Maps CSV headers to canonical member fields and custom fields.
|
||||
|
||||
Provides header normalization and mapping functionality for CSV imports.
|
||||
Handles bilingual header variants (English/German) and custom field detection.
|
||||
|
||||
## Header Normalization
|
||||
|
||||
Headers are normalized using the following rules:
|
||||
- Trim whitespace
|
||||
- Convert to lowercase
|
||||
- Unicode normalization (ß → ss, ä → ae, ö → oe, ü → ue)
|
||||
- Remove all whitespace (ensures "first name" == "firstname")
|
||||
- Unify hyphen variants (en dash, minus sign → standard hyphen)
|
||||
- Remove or unify punctuation (parentheses, slashes → spaces)
|
||||
|
||||
## Member Field Mapping
|
||||
|
||||
Maps CSV headers to canonical member fields:
|
||||
- `email` (required)
|
||||
- `first_name` (optional)
|
||||
- `last_name` (optional)
|
||||
- `street` (optional)
|
||||
- `postal_code` (optional)
|
||||
- `city` (optional)
|
||||
|
||||
Supports both English and German variants (e.g., "Email" / "E-Mail", "First Name" / "Vorname").
|
||||
|
||||
## Custom Field Detection
|
||||
|
||||
Custom fields are detected by matching normalized header names to custom field names.
|
||||
Member fields have priority over custom fields (member field wins in case of collision).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> HeaderMapper.normalize_header(" E-Mail ")
|
||||
"e-mail"
|
||||
|
||||
iex> HeaderMapper.build_maps(["Email", "First Name"], [])
|
||||
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}}
|
||||
|
||||
iex> HeaderMapper.build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
|
||||
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}}
|
||||
"""
|
||||
|
||||
@type column_map :: %{atom() => non_neg_integer()}
|
||||
@type custom_field_map :: %{String.t() => non_neg_integer()}
|
||||
@type unknown_headers :: [String.t()]
|
||||
|
||||
# Required member fields
|
||||
@required_member_fields [:email]
|
||||
|
||||
# Canonical member fields with their raw variants
|
||||
# These will be normalized at runtime when building the lookup map
|
||||
@member_field_variants_raw %{
|
||||
email: [
|
||||
"email",
|
||||
"e-mail",
|
||||
"e_mail",
|
||||
"e mail",
|
||||
"e-mail adresse",
|
||||
"e-mail-adresse",
|
||||
"mail"
|
||||
],
|
||||
first_name: [
|
||||
"first name",
|
||||
"firstname",
|
||||
"vorname"
|
||||
],
|
||||
last_name: [
|
||||
"last name",
|
||||
"lastname",
|
||||
"surname",
|
||||
"nachname",
|
||||
"familienname"
|
||||
],
|
||||
street: [
|
||||
"street",
|
||||
"address",
|
||||
"strasse"
|
||||
],
|
||||
postal_code: [
|
||||
"postal code",
|
||||
"postal_code",
|
||||
"zip",
|
||||
"postcode",
|
||||
"plz",
|
||||
"postleitzahl"
|
||||
],
|
||||
city: [
|
||||
"city",
|
||||
"town",
|
||||
"stadt",
|
||||
"ort"
|
||||
]
|
||||
}
|
||||
|
||||
# Build reverse map: normalized_variant -> canonical_field
|
||||
# Cached on first access for performance
|
||||
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.map(variants, fn variant ->
|
||||
{normalize_header(variant), canonical}
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Normalizes a CSV header string for comparison.
|
||||
|
||||
Applies the following transformations:
|
||||
- Trim whitespace
|
||||
- Convert to lowercase
|
||||
- Unicode transliteration (ß → ss, ä → ae, ö → oe, ü → ue)
|
||||
- Unify hyphen variants (en dash U+2013, minus sign U+2212 → standard hyphen)
|
||||
- Remove or unify punctuation (parentheses, slashes → spaces)
|
||||
- Remove all whitespace (ensures "first name" == "firstname")
|
||||
- Final trim
|
||||
|
||||
## Examples
|
||||
|
||||
iex> normalize_header(" E-Mail ")
|
||||
"e-mail"
|
||||
|
||||
iex> normalize_header("Straße")
|
||||
"strasse"
|
||||
|
||||
iex> normalize_header("E-Mail (privat)")
|
||||
"e-mailprivat"
|
||||
|
||||
iex> normalize_header("First Name")
|
||||
"firstname"
|
||||
|
||||
"""
|
||||
@spec normalize_header(String.t()) :: String.t()
|
||||
def normalize_header(header) when is_binary(header) do
|
||||
header
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
|> transliterate_unicode()
|
||||
|> unify_hyphens()
|
||||
|> normalize_punctuation()
|
||||
|> compress_whitespace()
|
||||
|> String.trim()
|
||||
end
|
||||
|
||||
def normalize_header(_), do: ""
|
||||
|
||||
@doc """
|
||||
Builds column maps for member fields and custom fields from CSV headers.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `headers` - List of CSV header strings (in column order, 0-based indices)
|
||||
- `custom_fields` - List of custom field maps/structs with at least `:id` and `:name` keys
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers}}` on success
|
||||
- `{:error, reason}` on error (missing required field, duplicate headers)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> build_maps(["Email", "First Name"], [])
|
||||
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}}
|
||||
|
||||
iex> build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
|
||||
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}}
|
||||
|
||||
"""
|
||||
@spec build_maps([String.t()], [map()]) ::
|
||||
{:ok, %{member: column_map(), custom: custom_field_map(), unknown: unknown_headers()}}
|
||||
| {:error, String.t()}
|
||||
def build_maps(headers, custom_fields) when is_list(headers) and is_list(custom_fields) do
|
||||
with {:ok, member_map, unknown_after_member} <- build_member_map(headers),
|
||||
{:ok, custom_map, unknown_after_custom} <-
|
||||
build_custom_field_map(headers, unknown_after_member, custom_fields, member_map) do
|
||||
unknown = Enum.map(unknown_after_custom, &Enum.at(headers, &1))
|
||||
{:ok, %{member: member_map, custom: custom_map, unknown: unknown}}
|
||||
end
|
||||
end
|
||||
|
||||
# --- Private Functions ---
|
||||
|
||||
# Transliterates German umlauts and special characters
|
||||
defp transliterate_unicode(str) do
|
||||
str
|
||||
|> String.replace("ß", "ss")
|
||||
|> String.replace("ä", "ae")
|
||||
|> String.replace("ö", "oe")
|
||||
|> String.replace("ü", "ue")
|
||||
|> String.replace("Ä", "ae")
|
||||
|> String.replace("Ö", "oe")
|
||||
|> String.replace("Ü", "ue")
|
||||
end
|
||||
|
||||
# Unifies different hyphen variants to standard hyphen
|
||||
defp unify_hyphens(str) do
|
||||
str
|
||||
# en dash
|
||||
|> String.replace(<<0x2013::utf8>>, "-")
|
||||
# em dash
|
||||
|> String.replace(<<0x2014::utf8>>, "-")
|
||||
# minus sign
|
||||
|> String.replace(<<0x2212::utf8>>, "-")
|
||||
end
|
||||
|
||||
# Normalizes punctuation: parentheses, slashes, underscores become spaces
|
||||
defp normalize_punctuation(str) do
|
||||
str
|
||||
|> String.replace("_", " ")
|
||||
|> String.replace(~r/[()\[\]{}]/, " ")
|
||||
|> String.replace(~r/[\/\\]/, " ")
|
||||
end
|
||||
|
||||
# Compresses multiple whitespace characters to single space, then removes all spaces
|
||||
# This ensures "first name" and "firstname" normalize to the same value
|
||||
defp compress_whitespace(str) do
|
||||
str
|
||||
|> String.replace(~r/\s+/, " ")
|
||||
|> String.replace(" ", "")
|
||||
end
|
||||
|
||||
# Builds member field column map
|
||||
defp build_member_map(headers) do
|
||||
result =
|
||||
headers
|
||||
|> Enum.with_index()
|
||||
|> Enum.reduce_while({%{}, []}, fn {header, index}, {acc_map, acc_unknown} ->
|
||||
normalized = normalize_header(header)
|
||||
|
||||
case process_member_header(header, index, normalized, acc_map, %{}) do
|
||||
{:error, reason} ->
|
||||
{:halt, {:error, reason}}
|
||||
|
||||
{:ok, new_map, _} ->
|
||||
{:cont, {new_map, acc_unknown}}
|
||||
|
||||
{:unknown} ->
|
||||
{:cont, {acc_map, [index | acc_unknown]}}
|
||||
end
|
||||
end)
|
||||
|
||||
case result do
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
||||
{member_map, unknown_indices} ->
|
||||
validate_required_fields(member_map, unknown_indices)
|
||||
end
|
||||
end
|
||||
|
||||
# Processes a single header for member field mapping
|
||||
defp process_member_header(_header, _index, normalized, acc_map, acc_seen)
|
||||
when normalized == "" do
|
||||
{:ok, acc_map, acc_seen}
|
||||
end
|
||||
|
||||
defp process_member_header(_header, index, normalized, acc_map, _acc_seen) do
|
||||
case Map.get(normalized_to_canonical(), normalized) do
|
||||
nil ->
|
||||
{:unknown}
|
||||
|
||||
canonical ->
|
||||
if Map.has_key?(acc_map, canonical) do
|
||||
{:error, "duplicate header for #{canonical} (normalized: #{normalized})"}
|
||||
else
|
||||
{:ok, Map.put(acc_map, canonical, index), %{}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Validates that all required member fields are present
|
||||
defp validate_required_fields(member_map, unknown_indices) do
|
||||
missing_required =
|
||||
@required_member_fields
|
||||
|> Enum.filter(&(not Map.has_key?(member_map, &1)))
|
||||
|
||||
if Enum.empty?(missing_required) do
|
||||
{:ok, member_map, Enum.reverse(unknown_indices)}
|
||||
else
|
||||
missing_field = List.first(missing_required)
|
||||
variants = Map.get(@member_field_variants_raw, missing_field, [])
|
||||
accepted = Enum.join(variants, ", ")
|
||||
|
||||
{:error, "Missing required header: #{missing_field} (accepted: #{accepted})"}
|
||||
end
|
||||
end
|
||||
|
||||
# Builds custom field column map from unmatched headers
|
||||
defp build_custom_field_map(headers, unknown_indices, custom_fields, _member_map) do
|
||||
custom_field_lookup = build_custom_field_lookup(custom_fields)
|
||||
|
||||
result =
|
||||
unknown_indices
|
||||
|> Enum.reduce_while({%{}, []}, fn index, {acc_map, acc_unknown} ->
|
||||
header = Enum.at(headers, index)
|
||||
normalized = normalize_header(header)
|
||||
|
||||
case process_custom_field_header(
|
||||
header,
|
||||
index,
|
||||
normalized,
|
||||
custom_field_lookup,
|
||||
acc_map,
|
||||
%{}
|
||||
) do
|
||||
{:error, reason} ->
|
||||
{:halt, {:error, reason}}
|
||||
|
||||
{:ok, new_map, _} ->
|
||||
{:cont, {new_map, acc_unknown}}
|
||||
|
||||
{:unknown} ->
|
||||
{:cont, {acc_map, [index | acc_unknown]}}
|
||||
end
|
||||
end)
|
||||
|
||||
case result do
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
||||
{custom_map, remaining_unknown} ->
|
||||
{:ok, custom_map, Enum.reverse(remaining_unknown)}
|
||||
end
|
||||
end
|
||||
|
||||
# Builds normalized custom field name -> id lookup map
|
||||
defp build_custom_field_lookup(custom_fields) do
|
||||
custom_fields
|
||||
|> Enum.reduce(%{}, fn cf, acc ->
|
||||
name = Map.get(cf, :name) || Map.get(cf, "name")
|
||||
id = Map.get(cf, :id) || Map.get(cf, "id")
|
||||
|
||||
if name && id do
|
||||
normalized_name = normalize_header(name)
|
||||
Map.put(acc, normalized_name, id)
|
||||
else
|
||||
acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Processes a single header for custom field mapping
|
||||
defp process_custom_field_header(
|
||||
_header,
|
||||
_index,
|
||||
normalized,
|
||||
_custom_field_lookup,
|
||||
acc_map,
|
||||
_acc_seen
|
||||
)
|
||||
when normalized == "" do
|
||||
{:ok, acc_map, %{}}
|
||||
end
|
||||
|
||||
defp process_custom_field_header(
|
||||
_header,
|
||||
index,
|
||||
normalized,
|
||||
custom_field_lookup,
|
||||
acc_map,
|
||||
_acc_seen
|
||||
) do
|
||||
if Map.has_key?(custom_field_lookup, normalized) do
|
||||
custom_field_id = custom_field_lookup[normalized]
|
||||
|
||||
if Map.has_key?(acc_map, custom_field_id) do
|
||||
{:error, "duplicate custom field header (normalized: #{normalized})"}
|
||||
else
|
||||
{:ok, Map.put(acc_map, custom_field_id, index), %{}}
|
||||
end
|
||||
else
|
||||
{:unknown}
|
||||
end
|
||||
end
|
||||
end
|
||||
476
lib/mv/membership/import/member_csv.ex
Normal file
476
lib/mv/membership/import/member_csv.ex
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
defmodule Mv.Membership.Import.MemberCSV do
|
||||
@moduledoc """
|
||||
Service module for importing members from CSV files.
|
||||
|
||||
require Ash.Query
|
||||
|
||||
This module provides the core API for CSV member import functionality:
|
||||
- `prepare/2` - Parses and validates CSV content, returns import state
|
||||
- `process_chunk/3` - Processes a chunk of rows and creates members
|
||||
|
||||
## Error Handling
|
||||
|
||||
Errors are returned as `%MemberCSV.Error{}` structs containing:
|
||||
- `csv_line_number` - The physical line number in the CSV file (or `nil` for general errors)
|
||||
- `field` - The field name (atom) or `nil` if not field-specific
|
||||
- `message` - Human-readable error message (or `nil` for general errors)
|
||||
|
||||
## Import State
|
||||
|
||||
The `import_state` returned by `prepare/2` contains:
|
||||
- `chunks` - List of row chunks ready for processing
|
||||
- `column_map` - Map of canonical field names to column indices
|
||||
- `custom_field_map` - Map of custom field names to column indices
|
||||
- `warnings` - List of warning messages (e.g., unknown custom field columns)
|
||||
|
||||
## Chunk Results
|
||||
|
||||
The `chunk_result` returned by `process_chunk/3` contains:
|
||||
- `inserted` - Number of successfully created members
|
||||
- `failed` - Number of failed member creations
|
||||
- `errors` - List of `%MemberCSV.Error{}` structs (capped at 50 per import)
|
||||
|
||||
## Examples
|
||||
|
||||
# Prepare CSV for import
|
||||
{:ok, import_state} = MemberCSV.prepare(csv_content)
|
||||
|
||||
# Process first chunk
|
||||
chunk = Enum.at(import_state.chunks, 0)
|
||||
{:ok, result} = MemberCSV.process_chunk(chunk, import_state.column_map)
|
||||
"""
|
||||
|
||||
defmodule Error do
|
||||
@moduledoc """
|
||||
Error struct for CSV import errors.
|
||||
|
||||
## Fields
|
||||
|
||||
- `csv_line_number` - The physical line number in the CSV file (1-based, header is line 1)
|
||||
- `field` - The field name as an atom (e.g., `:email`) or `nil` if not field-specific
|
||||
- `message` - Human-readable error message
|
||||
"""
|
||||
defstruct csv_line_number: nil, field: nil, message: nil
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
csv_line_number: pos_integer() | nil,
|
||||
field: atom() | nil,
|
||||
message: String.t() | nil
|
||||
}
|
||||
end
|
||||
|
||||
@type import_state :: %{
|
||||
chunks: list(list({pos_integer(), map()})),
|
||||
column_map: %{atom() => non_neg_integer()},
|
||||
custom_field_map: %{String.t() => non_neg_integer()},
|
||||
custom_field_lookup: %{String.t() => %{id: String.t(), value_type: atom()}},
|
||||
warnings: list(String.t())
|
||||
}
|
||||
|
||||
@type chunk_result :: %{
|
||||
inserted: non_neg_integer(),
|
||||
failed: non_neg_integer(),
|
||||
errors: list(Error.t())
|
||||
}
|
||||
|
||||
alias Mv.Membership.Import.CsvParser
|
||||
alias Mv.Membership.Import.HeaderMapper
|
||||
|
||||
@doc """
|
||||
Prepares CSV content for import by parsing, mapping headers, and validating limits.
|
||||
|
||||
This function:
|
||||
1. Strips UTF-8 BOM if present
|
||||
2. Detects CSV delimiter (semicolon or comma)
|
||||
3. Parses headers and data rows
|
||||
4. Maps headers to canonical member fields
|
||||
5. Maps custom field columns by name
|
||||
6. Validates row count limits
|
||||
7. Chunks rows for processing
|
||||
|
||||
## Parameters
|
||||
|
||||
- `file_content` - The raw CSV file content as a string
|
||||
- `opts` - Optional keyword list:
|
||||
- `:max_rows` - Maximum number of data rows allowed (default: 1000)
|
||||
- `:chunk_size` - Number of rows per chunk (default: 200)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, import_state}` - Successfully prepared import state
|
||||
- `{:error, reason}` - Error reason (string or error struct)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MemberCSV.prepare("email\\njohn@example.com")
|
||||
{:ok, %{chunks: [...], column_map: %{email: 0}, ...}}
|
||||
|
||||
iex> MemberCSV.prepare("")
|
||||
{:error, "CSV file is empty"}
|
||||
"""
|
||||
@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)
|
||||
|
||||
with {:ok, headers, rows} <- CsvParser.parse(file_content),
|
||||
{:ok, custom_fields} <- load_custom_fields(),
|
||||
{:ok, maps, warnings} <- build_header_maps(headers, custom_fields),
|
||||
:ok <- validate_row_count(rows, max_rows) do
|
||||
chunks = chunk_rows(rows, maps, chunk_size)
|
||||
|
||||
# Build custom field lookup for efficient value processing
|
||||
custom_field_lookup = build_custom_field_lookup(custom_fields)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
chunks: chunks,
|
||||
column_map: maps.member,
|
||||
custom_field_map: maps.custom,
|
||||
custom_field_lookup: custom_field_lookup,
|
||||
warnings: warnings
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
# Loads all custom fields from the database
|
||||
defp load_custom_fields do
|
||||
custom_fields =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.read!()
|
||||
|
||||
{:ok, custom_fields}
|
||||
rescue
|
||||
e ->
|
||||
{:error, "Failed to load custom fields: #{Exception.message(e)}"}
|
||||
end
|
||||
|
||||
# Builds custom field lookup map for efficient value processing
|
||||
defp build_custom_field_lookup(custom_fields) do
|
||||
custom_fields
|
||||
|> Enum.reduce(%{}, fn cf, acc ->
|
||||
id_str = to_string(cf.id)
|
||||
Map.put(acc, id_str, %{id: cf.id, value_type: cf.value_type})
|
||||
end)
|
||||
end
|
||||
|
||||
# Builds header maps using HeaderMapper and collects warnings for unknown custom fields
|
||||
defp build_header_maps(headers, custom_fields) do
|
||||
# Convert custom fields to maps with id and name
|
||||
custom_field_maps =
|
||||
Enum.map(custom_fields, fn cf ->
|
||||
%{id: to_string(cf.id), name: cf.name}
|
||||
end)
|
||||
|
||||
case HeaderMapper.build_maps(headers, custom_field_maps) do
|
||||
{:ok, %{member: member_map, custom: custom_map, unknown: unknown}} ->
|
||||
# Build warnings for unknown custom field columns
|
||||
warnings =
|
||||
unknown
|
||||
|> Enum.filter(fn header ->
|
||||
# Check if it could be a custom field (not a known member field)
|
||||
normalized = HeaderMapper.normalize_header(header)
|
||||
# If it's not empty and not a member field, it might be a custom field
|
||||
normalized != "" && not member_field?(normalized)
|
||||
end)
|
||||
|> Enum.map(fn header ->
|
||||
"Unknown column '#{header}' will be ignored. " <>
|
||||
"If this is a custom field, create it in Mila before importing."
|
||||
end)
|
||||
|
||||
{:ok, %{member: member_map, custom: custom_map}, warnings}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if a normalized header matches a member field
|
||||
# Uses HeaderMapper's internal logic to check if header would map to a member field
|
||||
defp member_field?(normalized) do
|
||||
# Try to build maps with just this header - if it maps to a member field, it's a member field
|
||||
case HeaderMapper.build_maps([normalized], []) do
|
||||
{:ok, %{member: member_map}} ->
|
||||
# If member_map is not empty, it's a member field
|
||||
map_size(member_map) > 0
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Validates that row count doesn't exceed limit
|
||||
defp validate_row_count(rows, max_rows) do
|
||||
if length(rows) > max_rows do
|
||||
{:error, "CSV file exceeds maximum row limit of #{max_rows} rows"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# Chunks rows and converts them to row maps using column maps
|
||||
defp chunk_rows(rows, maps, chunk_size) do
|
||||
rows
|
||||
|> Enum.chunk_every(chunk_size)
|
||||
|> Enum.map(fn chunk ->
|
||||
Enum.map(chunk, fn {line_number, row_values} ->
|
||||
row_map = build_row_map(row_values, maps)
|
||||
{line_number, row_map}
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
# Builds a row map from raw row values using column maps
|
||||
defp build_row_map(row_values, maps) do
|
||||
member_map =
|
||||
maps.member
|
||||
|> Enum.reduce(%{}, fn {field, index}, acc ->
|
||||
value = Enum.at(row_values, index, "")
|
||||
Map.put(acc, field, value)
|
||||
end)
|
||||
|
||||
custom_map =
|
||||
maps.custom
|
||||
|> Enum.reduce(%{}, fn {custom_field_id, index}, acc ->
|
||||
value = Enum.at(row_values, index, "")
|
||||
Map.put(acc, custom_field_id, value)
|
||||
end)
|
||||
|
||||
%{member: member_map, custom: custom_map}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Processes a chunk of CSV rows and creates members.
|
||||
|
||||
This function:
|
||||
1. Validates each row
|
||||
2. Creates members via Ash resource
|
||||
3. Creates custom field values for each member
|
||||
4. Collects errors with correct CSV line numbers
|
||||
5. Returns chunk processing results
|
||||
|
||||
## Parameters
|
||||
|
||||
- `chunk_rows_with_lines` - List of tuples `{csv_line_number, row_map}` where:
|
||||
- `csv_line_number` - Physical line number in CSV (1-based)
|
||||
- `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
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, chunk_result}` - Chunk processing results
|
||||
- `{:error, reason}` - Error reason (string)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> chunk = [{2, %{member: %{email: "john@example.com"}, custom: %{}}}]
|
||||
iex> column_map = %{email: 0}
|
||||
iex> custom_field_map = %{}
|
||||
iex> MemberCSV.process_chunk(chunk, column_map, custom_field_map)
|
||||
{:ok, %{inserted: 1, failed: 0, errors: []}}
|
||||
"""
|
||||
@spec process_chunk(
|
||||
list({pos_integer(), map()}),
|
||||
%{atom() => non_neg_integer()},
|
||||
%{String.t() => non_neg_integer()},
|
||||
keyword()
|
||||
) :: {: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, %{})
|
||||
|
||||
{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
|
||||
{:ok, _member} ->
|
||||
{acc_inserted + 1, acc_failed, acc_errors}
|
||||
|
||||
{:error, error} ->
|
||||
{acc_inserted, acc_failed + 1, [error | acc_errors]}
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, %{inserted: inserted, failed: failed, errors: Enum.reverse(errors)}}
|
||||
end
|
||||
|
||||
# Processes a single row and creates member with custom field values
|
||||
defp process_row(
|
||||
%{member: member_attrs, custom: custom_attrs},
|
||||
line_number,
|
||||
custom_field_lookup
|
||||
) do
|
||||
# Prepare custom field values for Ash
|
||||
custom_field_values = prepare_custom_field_values(custom_attrs, custom_field_lookup)
|
||||
|
||||
# Create member with custom field values
|
||||
member_attrs_with_cf =
|
||||
member_attrs
|
||||
|> Map.put(:custom_field_values, custom_field_values)
|
||||
|> trim_string_values()
|
||||
|
||||
# Only include custom_field_values if not empty
|
||||
final_attrs =
|
||||
if Enum.empty?(custom_field_values) do
|
||||
Map.delete(member_attrs_with_cf, :custom_field_values)
|
||||
else
|
||||
member_attrs_with_cf
|
||||
end
|
||||
|
||||
case Mv.Membership.create_member(final_attrs) do
|
||||
{:ok, member} ->
|
||||
{:ok, member}
|
||||
|
||||
{:error, %Ash.Error.Invalid{} = error} ->
|
||||
{:error, format_ash_error(error, line_number)}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}}
|
||||
end
|
||||
|
||||
# Prepares custom field values from row map for Ash
|
||||
defp prepare_custom_field_values(custom_attrs, custom_field_lookup) when is_map(custom_attrs) do
|
||||
custom_attrs
|
||||
|> Enum.filter(fn {_id, value} -> value != nil && value != "" end)
|
||||
|> Enum.map(fn {custom_field_id_str, value} ->
|
||||
case Map.get(custom_field_lookup, custom_field_id_str) do
|
||||
nil ->
|
||||
# Custom field not found, skip
|
||||
nil
|
||||
|
||||
%{id: custom_field_id, value_type: value_type} ->
|
||||
%{
|
||||
"custom_field_id" => to_string(custom_field_id),
|
||||
"value" => format_custom_field_value(value, value_type)
|
||||
}
|
||||
end
|
||||
end)
|
||||
|> Enum.filter(&(&1 != nil))
|
||||
end
|
||||
|
||||
defp prepare_custom_field_values(_, _), do: []
|
||||
|
||||
# Formats a custom field value according to its type
|
||||
# Uses _union_type and _union_value format as expected by Ash
|
||||
defp format_custom_field_value(value, :string) when is_binary(value) do
|
||||
%{"_union_type" => "string", "_union_value" => String.trim(value)}
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :integer) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int_value, _} -> %{"_union_type" => "integer", "_union_value" => int_value}
|
||||
:error -> %{"_union_type" => "string", "_union_value" => String.trim(value)}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :boolean) when is_binary(value) do
|
||||
bool_value =
|
||||
value
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
|> case do
|
||||
"true" -> true
|
||||
"1" -> true
|
||||
"yes" -> true
|
||||
"ja" -> true
|
||||
_ -> false
|
||||
end
|
||||
|
||||
%{"_union_type" => "boolean", "_union_value" => bool_value}
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :date) when is_binary(value) do
|
||||
case Date.from_iso8601(String.trim(value)) do
|
||||
{:ok, date} -> %{"_union_type" => "date", "_union_value" => date}
|
||||
{:error, _} -> %{"_union_type" => "string", "_union_value" => String.trim(value)}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :email) when is_binary(value) do
|
||||
%{"_union_type" => "email", "_union_value" => String.trim(value)}
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, _type) when is_binary(value) do
|
||||
# Default to string if type is unknown
|
||||
%{"_union_type" => "string", "_union_value" => String.trim(value)}
|
||||
end
|
||||
|
||||
# Trims all string values in member attributes
|
||||
defp trim_string_values(attrs) do
|
||||
Enum.reduce(attrs, %{}, fn {key, value}, acc ->
|
||||
trimmed_value =
|
||||
if is_binary(value) do
|
||||
String.trim(value)
|
||||
else
|
||||
value
|
||||
end
|
||||
|
||||
Map.put(acc, key, trimmed_value)
|
||||
end)
|
||||
end
|
||||
|
||||
# Formats Ash errors into MemberCSV.Error structs
|
||||
defp format_ash_error(%Ash.Error.Invalid{errors: errors}, line_number) do
|
||||
# Try to find email-related errors first (for better error messages)
|
||||
email_error =
|
||||
Enum.find(errors, fn error ->
|
||||
case error do
|
||||
%{field: :email} -> true
|
||||
_ -> false
|
||||
end
|
||||
end)
|
||||
|
||||
case email_error || List.first(errors) do
|
||||
%{field: field, message: message} when is_atom(field) ->
|
||||
%Error{
|
||||
csv_line_number: line_number,
|
||||
field: field,
|
||||
message: format_error_message(message, field)
|
||||
}
|
||||
|
||||
%{message: message} ->
|
||||
%Error{
|
||||
csv_line_number: line_number,
|
||||
field: nil,
|
||||
message: format_error_message(message, nil)
|
||||
}
|
||||
|
||||
_ ->
|
||||
%Error{
|
||||
csv_line_number: line_number,
|
||||
field: nil,
|
||||
message: "Validation failed"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Formats error messages, handling common cases like email uniqueness
|
||||
defp format_error_message(message, field) when is_binary(message) do
|
||||
if email_uniqueness_error?(message, field) do
|
||||
"email has already been taken"
|
||||
else
|
||||
message
|
||||
end
|
||||
end
|
||||
|
||||
defp format_error_message(message, _field), do: to_string(message)
|
||||
|
||||
# Checks if error message indicates email uniqueness constraint violation
|
||||
defp email_uniqueness_error?(message, :email) do
|
||||
message_lower = String.downcase(message)
|
||||
|
||||
String.contains?(message_lower, "unique") or
|
||||
String.contains?(message_lower, "constraint") or
|
||||
String.contains?(message_lower, "duplicate") or
|
||||
String.contains?(message_lower, "already been taken") or
|
||||
String.contains?(message_lower, "already exists") or
|
||||
String.contains?(message_lower, "violates unique constraint")
|
||||
end
|
||||
|
||||
defp email_uniqueness_error?(_message, _field), do: false
|
||||
end
|
||||
|
|
@ -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)
|
||||
- Membership Fees: Tab showing all membership fee cycles with status management (via MembershipFeesComponent)
|
||||
- Payment Data: Mockup section with placeholder data
|
||||
|
||||
## Navigation
|
||||
- Back to member list
|
||||
|
|
|
|||
3
mix.exs
3
mix.exs
|
|
@ -78,7 +78,8 @@ defmodule Mv.MixProject do
|
|||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:picosat_elixir, "~> 0.1", only: [:dev, :test]},
|
||||
{:ecto_commons, "~> 0.3"},
|
||||
{:slugify, "~> 1.3"}
|
||||
{:slugify, "~> 1.3"},
|
||||
{:nimble_csv, "~> 1.0"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
1
mix.lock
1
mix.lock
|
|
@ -47,6 +47,7 @@
|
|||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
|
||||
"nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
|
||||
|
|
|
|||
215
test/mv/membership/import/csv_parser_test.exs
Normal file
215
test/mv/membership/import/csv_parser_test.exs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
defmodule Mv.Membership.Import.CsvParserTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mv.Membership.Import.CsvParser
|
||||
|
||||
describe "parse/1" do
|
||||
test "returns {:ok, headers, rows} for valid CSV with semicolon delimiter" do
|
||||
csv_content = "email;first_name\njohn@example.com;John"
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
assert headers == ["email", "first_name"]
|
||||
assert rows == [{2, ["john@example.com", "John"]}]
|
||||
end
|
||||
|
||||
test "returns {:ok, headers, rows} for valid CSV with comma delimiter" do
|
||||
csv_content = "email,first_name\njohn@example.com,John"
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
assert headers == ["email", "first_name"]
|
||||
assert rows == [{2, ["john@example.com", "John"]}]
|
||||
end
|
||||
|
||||
test "detects semicolon delimiter when both delimiters present" do
|
||||
csv_content = "email;first_name,last_name\njohn@example.com;John,Doe"
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
# Should detect semicolon as primary delimiter
|
||||
assert length(headers) >= 2
|
||||
assert length(rows) == 1
|
||||
end
|
||||
|
||||
test "prefers semicolon delimiter when recognition is tied" do
|
||||
# CSV where both delimiters would yield same number of fields
|
||||
csv_content = "email;name\njohn@example.com;John"
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
# Should prefer semicolon
|
||||
assert headers == ["email", "name"]
|
||||
assert rows == [{2, ["john@example.com", "John"]}]
|
||||
end
|
||||
|
||||
test "defaults to semicolon delimiter when no headers recognized" do
|
||||
csv_content = "unknown1;unknown2\nvalue1;value2"
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
# Should default to semicolon
|
||||
assert headers == ["unknown1", "unknown2"]
|
||||
assert rows == [{2, ["value1", "value2"]}]
|
||||
end
|
||||
end
|
||||
|
||||
describe "BOM handling" do
|
||||
test "strips UTF-8 BOM from file content" do
|
||||
bom = <<0xEF, 0xBB, 0xBF>>
|
||||
csv_content = bom <> "email;first_name\njohn@example.com;John"
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
assert headers == ["email", "first_name"]
|
||||
assert rows == [{2, ["john@example.com", "John"]}]
|
||||
end
|
||||
|
||||
test "parses CSV with BOM correctly (Excel export compatibility)" do
|
||||
bom = <<0xEF, 0xBB, 0xBF>>
|
||||
|
||||
csv_content =
|
||||
bom <>
|
||||
"email;first_name;last_name\njohn@example.com;John;Doe\njane@example.com;Jane;Smith"
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
assert headers == ["email", "first_name", "last_name"]
|
||||
assert length(rows) == 2
|
||||
assert Enum.at(rows, 0) == {2, ["john@example.com", "John", "Doe"]}
|
||||
assert Enum.at(rows, 1) == {3, ["jane@example.com", "Jane", "Smith"]}
|
||||
end
|
||||
end
|
||||
|
||||
describe "line number handling" do
|
||||
test "header row is line 1, first data row is line 2" do
|
||||
csv_content = "email\njohn@example.com"
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
assert headers == ["email"]
|
||||
assert rows == [{2, ["john@example.com"]}]
|
||||
end
|
||||
|
||||
test "preserves correct line numbers when empty lines are skipped" do
|
||||
csv_content = "email;first_name\n\njohn@example.com;John\n\njane@example.com;Jane"
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
assert headers == ["email", "first_name"]
|
||||
# Line 2 is empty (skipped), line 3 has data
|
||||
assert Enum.at(rows, 0) == {3, ["john@example.com", "John"]}
|
||||
# Line 4 is empty (skipped), line 5 has data
|
||||
assert Enum.at(rows, 1) == {5, ["jane@example.com", "Jane"]}
|
||||
end
|
||||
|
||||
test "skips completely empty rows but preserves line numbers" do
|
||||
csv_content = "email\n\n\njohn@example.com"
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
assert headers == ["email"]
|
||||
# Lines 2 & 3 are empty (skipped), line 4 has data
|
||||
assert rows == [{4, ["john@example.com"]}]
|
||||
end
|
||||
end
|
||||
|
||||
describe "line ending handling" do
|
||||
test "handles \\r\\n line endings correctly" do
|
||||
csv_content = "email;first_name\r\njohn@example.com;John\r\njane@example.com;Jane"
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
assert headers == ["email", "first_name"]
|
||||
assert length(rows) == 2
|
||||
assert Enum.at(rows, 0) == {2, ["john@example.com", "John"]}
|
||||
assert Enum.at(rows, 1) == {3, ["jane@example.com", "Jane"]}
|
||||
end
|
||||
|
||||
test "handles \\n line endings correctly" do
|
||||
csv_content = "email;first_name\njohn@example.com;John\njane@example.com;Jane"
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
assert headers == ["email", "first_name"]
|
||||
assert length(rows) == 2
|
||||
assert Enum.at(rows, 0) == {2, ["john@example.com", "John"]}
|
||||
assert Enum.at(rows, 1) == {3, ["jane@example.com", "Jane"]}
|
||||
end
|
||||
end
|
||||
|
||||
describe "quoted fields" do
|
||||
test "parses quoted fields correctly" do
|
||||
csv_content = "email;name\njohn@example.com;\"John Doe\""
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
assert headers == ["email", "name"]
|
||||
assert rows == [{2, ["john@example.com", "John Doe"]}]
|
||||
end
|
||||
|
||||
test "handles escaped quotes (\"\") inside quoted fields" do
|
||||
csv_content = "email;name\njohn@example.com;\"John \"\"Johnny\"\" Doe\""
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
assert headers == ["email", "name"]
|
||||
assert rows == [{2, ["john@example.com", "John \"Johnny\" Doe"]}]
|
||||
end
|
||||
|
||||
test "handles multiline quoted fields with correct line numbering" do
|
||||
# Header line 1
|
||||
# Data record starts line 2, contains "foo\nbar" in a field
|
||||
# Record ends physically at line 3
|
||||
# Expected: row gets line number 2 (start line)
|
||||
csv_content = "email;description\njohn@example.com;\"foo\nbar\""
|
||||
|
||||
assert {:ok, headers, rows} = CsvParser.parse(csv_content)
|
||||
|
||||
assert headers == ["email", "description"]
|
||||
assert rows == [{2, ["john@example.com", "foo\nbar"]}]
|
||||
end
|
||||
end
|
||||
|
||||
describe "error handling" do
|
||||
test "returns {:error, reason} for empty file" do
|
||||
assert {:error, reason} = CsvParser.parse("")
|
||||
assert reason =~ "empty"
|
||||
end
|
||||
|
||||
test "returns {:error, reason} when no header row found" do
|
||||
# Only whitespace after BOM strip
|
||||
assert {:error, reason} = CsvParser.parse(" \n ")
|
||||
assert reason =~ "CSV file is empty"
|
||||
end
|
||||
|
||||
test "returns {:error, reason} for invalid UTF-8 content" do
|
||||
# Invalid UTF-8 sequence
|
||||
invalid_utf8 = <<0xFF, 0xFE, 0xFD>>
|
||||
|
||||
assert {:error, reason} = CsvParser.parse(invalid_utf8)
|
||||
assert reason =~ "UTF-8"
|
||||
end
|
||||
|
||||
test "returns {:error, reason} for unparsable data row" do
|
||||
# Malformed CSV row that cannot be parsed
|
||||
# NimbleCSV will throw an exception for unclosed quotes
|
||||
csv_content = "email;name\njohn@example.com;\"unclosed quote"
|
||||
|
||||
assert {:error, reason} = CsvParser.parse(csv_content)
|
||||
assert is_binary(reason)
|
||||
# Error message should indicate parsing failure
|
||||
assert reason =~ "parse" or reason =~ "CSV"
|
||||
end
|
||||
end
|
||||
|
||||
describe "module documentation" do
|
||||
test "module has @moduledoc" do
|
||||
assert Code.ensure_loaded?(CsvParser)
|
||||
|
||||
{:docs_v1, _, _, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(CsvParser)
|
||||
assert is_binary(moduledoc)
|
||||
assert String.length(moduledoc) > 0
|
||||
end
|
||||
end
|
||||
end
|
||||
244
test/mv/membership/import/header_mapper_test.exs
Normal file
244
test/mv/membership/import/header_mapper_test.exs
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
defmodule Mv.Membership.Import.HeaderMapperTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mv.Membership.Import.HeaderMapper
|
||||
|
||||
describe "normalize_header/1" do
|
||||
test "trims whitespace" do
|
||||
assert HeaderMapper.normalize_header(" email ") == "email"
|
||||
end
|
||||
|
||||
test "converts to lowercase" do
|
||||
assert HeaderMapper.normalize_header("EMAIL") == "email"
|
||||
assert HeaderMapper.normalize_header("E-Mail") == "e-mail"
|
||||
end
|
||||
|
||||
test "normalizes Unicode characters" do
|
||||
# ß -> ss
|
||||
assert HeaderMapper.normalize_header("Straße") == "strasse"
|
||||
# Umlaute transliteration (ä -> ae, ö -> oe, ü -> ue)
|
||||
assert HeaderMapper.normalize_header("Müller") == "mueller"
|
||||
assert HeaderMapper.normalize_header("Köln") == "koeln"
|
||||
assert HeaderMapper.normalize_header("Grün") == "gruen"
|
||||
end
|
||||
|
||||
test "compresses and removes whitespace" do
|
||||
# Whitespace is removed entirely to ensure "first name" == "firstname"
|
||||
assert HeaderMapper.normalize_header("first name") == "firstname"
|
||||
assert HeaderMapper.normalize_header("email address") == "emailaddress"
|
||||
end
|
||||
|
||||
test "unifies hyphen variants" do
|
||||
# Different Unicode hyphen characters should become standard hyphen
|
||||
# en dash
|
||||
assert HeaderMapper.normalize_header("E–Mail") == "e-mail"
|
||||
# minus sign
|
||||
assert HeaderMapper.normalize_header("E−Mail") == "e-mail"
|
||||
# standard hyphen
|
||||
assert HeaderMapper.normalize_header("E-Mail") == "e-mail"
|
||||
end
|
||||
|
||||
test "removes or unifies punctuation" do
|
||||
# Parentheses, slashes, etc. are removed (whitespace is also removed)
|
||||
assert HeaderMapper.normalize_header("E-Mail (privat)") == "e-mailprivat"
|
||||
assert HeaderMapper.normalize_header("Telefon / Mobil") == "telefonmobil"
|
||||
end
|
||||
|
||||
test "handles empty strings" do
|
||||
assert HeaderMapper.normalize_header("") == ""
|
||||
assert HeaderMapper.normalize_header(" ") == ""
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_maps/2" do
|
||||
test "maps English email variant correctly" do
|
||||
headers = ["Email"]
|
||||
|
||||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||||
HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert member_map[:email] == 0
|
||||
assert custom_map == %{}
|
||||
assert unknown == []
|
||||
end
|
||||
|
||||
test "maps German email variant correctly" do
|
||||
headers = ["E-Mail"]
|
||||
|
||||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||||
HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert member_map[:email] == 0
|
||||
assert custom_map == %{}
|
||||
assert unknown == []
|
||||
end
|
||||
|
||||
test "maps multiple member fields" do
|
||||
headers = ["Email", "First Name", "Last Name"]
|
||||
|
||||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||||
HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert member_map[:email] == 0
|
||||
assert member_map[:first_name] == 1
|
||||
assert member_map[:last_name] == 2
|
||||
assert custom_map == %{}
|
||||
assert unknown == []
|
||||
end
|
||||
|
||||
test "handles Unicode and whitespace in headers" do
|
||||
headers = [" E-Mail ", "Straße", " Telefon / Mobil "]
|
||||
|
||||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||||
HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert member_map[:email] == 0
|
||||
assert member_map[:street] == 1
|
||||
# "Telefon / Mobil" is not a known member field, so it should be unknown
|
||||
assert length(unknown) == 1
|
||||
assert custom_map == %{}
|
||||
end
|
||||
|
||||
test "returns error when duplicate headers normalize to same field" do
|
||||
headers = ["Email", "E-Mail"]
|
||||
|
||||
assert {:error, reason} = HeaderMapper.build_maps(headers, [])
|
||||
assert reason =~ "duplicate"
|
||||
assert reason =~ "email"
|
||||
end
|
||||
|
||||
test "returns error when required field email is missing" do
|
||||
headers = ["First Name", "Last Name"]
|
||||
|
||||
assert {:error, reason} = HeaderMapper.build_maps(headers, [])
|
||||
assert reason =~ "Missing required header"
|
||||
assert reason =~ "email"
|
||||
assert reason =~ "accepted"
|
||||
end
|
||||
|
||||
test "collects unknown columns" do
|
||||
headers = ["Email", "FooBar", "UnknownColumn"]
|
||||
|
||||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||||
HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert member_map[:email] == 0
|
||||
assert length(unknown) == 2
|
||||
assert "FooBar" in unknown or "foobar" in unknown
|
||||
assert "UnknownColumn" in unknown or "unknowncolumn" in unknown
|
||||
assert custom_map == %{}
|
||||
end
|
||||
|
||||
test "ignores empty headers after normalization" do
|
||||
headers = ["Email", " ", ""]
|
||||
|
||||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||||
HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert member_map[:email] == 0
|
||||
assert custom_map == %{}
|
||||
assert unknown == []
|
||||
end
|
||||
|
||||
test "maps custom field columns correctly" do
|
||||
headers = ["Email", "Lieblingsfarbe"]
|
||||
custom_fields = [%{id: "cf1", name: "Lieblingsfarbe"}]
|
||||
|
||||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||||
HeaderMapper.build_maps(headers, custom_fields)
|
||||
|
||||
assert member_map[:email] == 0
|
||||
assert custom_map["cf1"] == 1
|
||||
assert unknown == []
|
||||
end
|
||||
|
||||
test "custom field collision: member field wins" do
|
||||
headers = ["Email"]
|
||||
# Custom field with name "Email" should not override member field
|
||||
custom_fields = [%{id: "cf1", name: "Email"}]
|
||||
|
||||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||||
HeaderMapper.build_maps(headers, custom_fields)
|
||||
|
||||
assert member_map[:email] == 0
|
||||
# Custom field should not be in custom_map because member field has priority
|
||||
assert custom_map == %{}
|
||||
assert unknown == []
|
||||
end
|
||||
|
||||
test "handles custom field with Unicode normalization" do
|
||||
headers = ["Email", "Straße"]
|
||||
custom_fields = [%{id: "cf1", name: "Straße"}]
|
||||
|
||||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||||
HeaderMapper.build_maps(headers, custom_fields)
|
||||
|
||||
assert member_map[:email] == 0
|
||||
# "Straße" is a member field (street), so it should be in member_map, not custom_map
|
||||
assert member_map[:street] == 1
|
||||
assert custom_map == %{}
|
||||
assert unknown == []
|
||||
end
|
||||
|
||||
test "handles unknown custom field columns" do
|
||||
headers = ["Email", "UnknownCustomField"]
|
||||
custom_fields = [%{id: "cf1", name: "KnownField"}]
|
||||
|
||||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||||
HeaderMapper.build_maps(headers, custom_fields)
|
||||
|
||||
assert member_map[:email] == 0
|
||||
assert custom_map == %{}
|
||||
# UnknownCustomField should be in unknown list
|
||||
assert length(unknown) == 1
|
||||
end
|
||||
|
||||
test "handles duplicate custom field names after normalization" do
|
||||
headers = ["Email", "CustomField", "Custom Field"]
|
||||
custom_fields = [%{id: "cf1", name: "CustomField"}]
|
||||
|
||||
# Both "CustomField" and "Custom Field" normalize to the same, so this should error
|
||||
assert {:error, reason} = HeaderMapper.build_maps(headers, custom_fields)
|
||||
assert reason =~ "duplicate"
|
||||
end
|
||||
|
||||
test "maps all supported member fields" do
|
||||
headers = [
|
||||
"Email",
|
||||
"First Name",
|
||||
"Last Name",
|
||||
"Street",
|
||||
"Postal Code",
|
||||
"City"
|
||||
]
|
||||
|
||||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||||
HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert member_map[:email] == 0
|
||||
assert member_map[:first_name] == 1
|
||||
assert member_map[:last_name] == 2
|
||||
assert member_map[:street] == 3
|
||||
assert member_map[:postal_code] == 4
|
||||
assert member_map[:city] == 5
|
||||
assert custom_map == %{}
|
||||
assert unknown == []
|
||||
end
|
||||
|
||||
test "maps German member field variants" do
|
||||
headers = ["E-Mail", "Vorname", "Nachname", "Straße", "PLZ", "Stadt"]
|
||||
|
||||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||||
HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert member_map[:email] == 0
|
||||
assert member_map[:first_name] == 1
|
||||
assert member_map[:last_name] == 2
|
||||
assert member_map[:street] == 3
|
||||
assert member_map[:postal_code] == 4
|
||||
assert member_map[:city] == 5
|
||||
assert custom_map == %{}
|
||||
assert unknown == []
|
||||
end
|
||||
end
|
||||
end
|
||||
293
test/mv/membership/import/member_csv_test.exs
Normal file
293
test/mv/membership/import/member_csv_test.exs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
defmodule Mv.Membership.Import.MemberCSVTest do
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Membership.Import.MemberCSV
|
||||
|
||||
describe "Error struct" do
|
||||
test "Error struct exists with required fields" do
|
||||
# This will fail at runtime if the struct doesn't exist
|
||||
# We use struct/2 to create the struct at runtime
|
||||
error =
|
||||
struct(MemberCSV.Error, %{
|
||||
csv_line_number: 5,
|
||||
field: :email,
|
||||
message: "is not a valid email"
|
||||
})
|
||||
|
||||
assert error.csv_line_number == 5
|
||||
assert error.field == :email
|
||||
assert error.message == "is not a valid email"
|
||||
end
|
||||
|
||||
test "Error struct allows nil field" do
|
||||
# This will fail at runtime if the struct doesn't exist
|
||||
error =
|
||||
struct(MemberCSV.Error, %{
|
||||
csv_line_number: 10,
|
||||
field: nil,
|
||||
message: "Row is empty"
|
||||
})
|
||||
|
||||
assert error.csv_line_number == 10
|
||||
assert error.field == nil
|
||||
assert error.message == "Row is empty"
|
||||
end
|
||||
end
|
||||
|
||||
describe "prepare/2" do
|
||||
test "function exists and accepts file_content and opts" do
|
||||
file_content = "email\njohn@example.com"
|
||||
opts = []
|
||||
|
||||
# This will fail until the function is implemented
|
||||
result = MemberCSV.prepare(file_content, opts)
|
||||
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end
|
||||
|
||||
test "returns {:ok, import_state} on success" do
|
||||
file_content = "email\njohn@example.com"
|
||||
opts = []
|
||||
|
||||
assert {:ok, import_state} = MemberCSV.prepare(file_content, opts)
|
||||
|
||||
# Check that import_state contains expected fields
|
||||
assert Map.has_key?(import_state, :chunks)
|
||||
assert Map.has_key?(import_state, :column_map)
|
||||
assert Map.has_key?(import_state, :custom_field_map)
|
||||
assert Map.has_key?(import_state, :warnings)
|
||||
assert import_state.column_map[:email] == 0
|
||||
assert import_state.chunks != []
|
||||
end
|
||||
|
||||
test "returns {:error, reason} on failure" do
|
||||
file_content = ""
|
||||
opts = []
|
||||
|
||||
assert {:error, _reason} = MemberCSV.prepare(file_content, opts)
|
||||
end
|
||||
|
||||
test "function has documentation" do
|
||||
# Check that @doc exists by reading the module
|
||||
assert function_exported?(MemberCSV, :prepare, 2)
|
||||
end
|
||||
end
|
||||
|
||||
describe "process_chunk/4" do
|
||||
test "function exists and accepts chunk_rows_with_lines, column_map, custom_field_map, and opts" do
|
||||
chunk_rows_with_lines = [{2, %{member: %{email: "john@example.com"}, custom: %{}}}]
|
||||
column_map = %{email: 0}
|
||||
custom_field_map = %{}
|
||||
opts = []
|
||||
|
||||
# This will fail until the function is implemented
|
||||
result = MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end
|
||||
|
||||
test "creates member successfully with valid data" do
|
||||
chunk_rows_with_lines = [
|
||||
{2, %{member: %{email: "john@example.com", first_name: "John"}, custom: %{}}}
|
||||
]
|
||||
|
||||
column_map = %{email: 0, first_name: 1}
|
||||
custom_field_map = %{}
|
||||
opts = []
|
||||
|
||||
assert {:ok, chunk_result} =
|
||||
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||
|
||||
assert chunk_result.inserted == 1
|
||||
assert chunk_result.failed == 0
|
||||
assert chunk_result.errors == []
|
||||
|
||||
# Verify member was created
|
||||
members = Mv.Membership.list_members!()
|
||||
assert Enum.any?(members, &(&1.email == "john@example.com"))
|
||||
end
|
||||
|
||||
test "returns error for invalid email" do
|
||||
chunk_rows_with_lines = [
|
||||
{2, %{member: %{email: "invalid-email"}, custom: %{}}}
|
||||
]
|
||||
|
||||
column_map = %{email: 0}
|
||||
custom_field_map = %{}
|
||||
opts = []
|
||||
|
||||
assert {:ok, chunk_result} =
|
||||
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||
|
||||
assert chunk_result.inserted == 0
|
||||
assert chunk_result.failed == 1
|
||||
assert length(chunk_result.errors) == 1
|
||||
|
||||
error = List.first(chunk_result.errors)
|
||||
assert error.csv_line_number == 2
|
||||
assert error.field == :email
|
||||
assert error.message =~ "email"
|
||||
end
|
||||
|
||||
test "returns error for duplicate email" do
|
||||
# Create existing member first
|
||||
{:ok, _existing} =
|
||||
Mv.Membership.create_member(%{email: "duplicate@example.com", first_name: "Existing"})
|
||||
|
||||
chunk_rows_with_lines = [
|
||||
{2, %{member: %{email: "duplicate@example.com", first_name: "New"}, custom: %{}}}
|
||||
]
|
||||
|
||||
column_map = %{email: 0, first_name: 1}
|
||||
custom_field_map = %{}
|
||||
opts = []
|
||||
|
||||
assert {:ok, chunk_result} =
|
||||
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||
|
||||
assert chunk_result.inserted == 0
|
||||
assert chunk_result.failed == 1
|
||||
assert length(chunk_result.errors) == 1
|
||||
|
||||
error = List.first(chunk_result.errors)
|
||||
assert error.csv_line_number == 2
|
||||
assert error.field == :email
|
||||
assert error.message =~ "email" or error.message =~ "duplicate" or error.message =~ "unique"
|
||||
end
|
||||
|
||||
test "creates member with custom field values" do
|
||||
# Create custom field first
|
||||
{:ok, custom_field} =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Phone",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
chunk_rows_with_lines = [
|
||||
{2,
|
||||
%{
|
||||
member: %{email: "withcustom@example.com"},
|
||||
custom: %{to_string(custom_field.id) => "123-456-7890"}
|
||||
}}
|
||||
]
|
||||
|
||||
column_map = %{email: 0}
|
||||
custom_field_map = %{to_string(custom_field.id) => 1}
|
||||
|
||||
custom_field_lookup = %{
|
||||
to_string(custom_field.id) => %{id: custom_field.id, value_type: custom_field.value_type}
|
||||
}
|
||||
|
||||
opts = [custom_field_lookup: custom_field_lookup]
|
||||
|
||||
assert {:ok, chunk_result} =
|
||||
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||
|
||||
assert chunk_result.inserted == 1
|
||||
assert chunk_result.failed == 0
|
||||
|
||||
# Verify member and custom field value were created
|
||||
members = Mv.Membership.list_members!()
|
||||
member = Enum.find(members, &(&1.email == "withcustom@example.com"))
|
||||
assert member != nil
|
||||
|
||||
{:ok, member_with_cf} = Ash.load(member, :custom_field_values)
|
||||
assert length(member_with_cf.custom_field_values) == 1
|
||||
cfv = List.first(member_with_cf.custom_field_values)
|
||||
assert cfv.custom_field_id == custom_field.id
|
||||
assert cfv.value.value == "123-456-7890"
|
||||
end
|
||||
|
||||
test "handles multiple rows with mixed success and failure" do
|
||||
chunk_rows_with_lines = [
|
||||
{2, %{member: %{email: "valid1@example.com"}, custom: %{}}},
|
||||
{3, %{member: %{email: "invalid-email"}, custom: %{}}},
|
||||
{4, %{member: %{email: "valid2@example.com"}, custom: %{}}}
|
||||
]
|
||||
|
||||
column_map = %{email: 0}
|
||||
custom_field_map = %{}
|
||||
opts = []
|
||||
|
||||
assert {:ok, chunk_result} =
|
||||
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||
|
||||
assert chunk_result.inserted == 2
|
||||
assert chunk_result.failed == 1
|
||||
assert length(chunk_result.errors) == 1
|
||||
|
||||
error = List.first(chunk_result.errors)
|
||||
assert error.csv_line_number == 3
|
||||
end
|
||||
|
||||
test "preserves CSV line numbers in errors" do
|
||||
chunk_rows_with_lines = [
|
||||
{5, %{member: %{email: "invalid"}, custom: %{}}},
|
||||
{10, %{member: %{email: "also-invalid"}, custom: %{}}}
|
||||
]
|
||||
|
||||
column_map = %{email: 0}
|
||||
custom_field_map = %{}
|
||||
opts = []
|
||||
|
||||
assert {:ok, chunk_result} =
|
||||
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||
|
||||
assert chunk_result.failed == 2
|
||||
assert length(chunk_result.errors) == 2
|
||||
|
||||
line_numbers = Enum.map(chunk_result.errors, & &1.csv_line_number)
|
||||
assert 5 in line_numbers
|
||||
assert 10 in line_numbers
|
||||
end
|
||||
|
||||
test "returns {:ok, chunk_result} on success" do
|
||||
chunk_rows_with_lines = [{2, %{member: %{email: "test@example.com"}, custom: %{}}}]
|
||||
column_map = %{email: 0}
|
||||
custom_field_map = %{}
|
||||
opts = []
|
||||
|
||||
assert {:ok, chunk_result} =
|
||||
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||
|
||||
# Check that chunk_result contains expected fields
|
||||
assert Map.has_key?(chunk_result, :inserted)
|
||||
assert Map.has_key?(chunk_result, :failed)
|
||||
assert Map.has_key?(chunk_result, :errors)
|
||||
assert is_integer(chunk_result.inserted)
|
||||
assert is_integer(chunk_result.failed)
|
||||
assert is_list(chunk_result.errors)
|
||||
end
|
||||
|
||||
test "returns {:ok, _} with zero counts for empty chunk" do
|
||||
chunk_rows_with_lines = []
|
||||
column_map = %{}
|
||||
custom_field_map = %{}
|
||||
opts = []
|
||||
|
||||
assert {:ok, chunk_result} =
|
||||
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||
|
||||
assert chunk_result.inserted == 0
|
||||
assert chunk_result.failed == 0
|
||||
assert chunk_result.errors == []
|
||||
end
|
||||
|
||||
test "function has documentation" do
|
||||
# Check that @doc exists by reading the module
|
||||
assert function_exported?(MemberCSV, :process_chunk, 4)
|
||||
end
|
||||
end
|
||||
|
||||
describe "module documentation" do
|
||||
test "module has @moduledoc" do
|
||||
# Check that the module exists and has documentation
|
||||
assert Code.ensure_loaded?(MemberCSV)
|
||||
|
||||
# Try to get the module documentation
|
||||
{:docs_v1, _, _, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(MemberCSV)
|
||||
assert is_binary(moduledoc)
|
||||
assert String.length(moduledoc) > 0
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue