Compare commits

..

23 commits

Author SHA1 Message Date
a19026e430
docs: update roles and permissions architecture and implementation plan
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-13 16:17:01 +01:00
1084f67f1f
docs: Add roles and permissions architecture and implementation plan
Complete RBAC system design with permission sets, Ash policies, and UI authorization.
Implementation broken down into 18 issues across 4 sprints with TDD approach.
Includes database schema, caching strategy, and comprehensive test coverage.
2025-11-13 13:43:58 +01:00
87c5db020d Merge pull request 'Code documentation and refactoring' (#201) from feature/refactoring into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #201
2025-11-13 11:21:30 +01:00
7375b83167
docs: add @doc to public functions in EmailSync, Validations, and Senders
All checks were successful
continuous-integration/drone/push Build is passing
Document public API functions with @doc for better tooling support:
- EmailSync Changes: sync_user_email_to_member, sync_member_email_to_user
- Validations: email_not_used_by_other_member, email_not_used_by_other_user
- Senders: send_new_user_confirmation_email, send_password_reset_email
2025-11-13 11:20:33 +01:00
c416d0fb91
refactor: split long sort handler into smaller functions
Extract determine_new_sort/2, update_sort_components/4, and push_sort_url/3
from handle_info({:sort, ...}). Reduces function from 46 to 7 lines.
2025-11-13 11:20:33 +01:00
150bba2ef8
docs: enable Credo ModuleDoc check and fix remaining modules
Add @moduledoc to Secrets, LiveHelpers, AuthOverrides, and Membership domain.
Enable Credo.Check.Readability.ModuleDoc in .credo.exs.
2025-11-13 11:20:33 +01:00
6922086fa1
docs: add @doc to public functions in MemberLive.Index
Document LiveView callbacks (mount, handle_event, handle_info, handle_params)
with comprehensive descriptions of their purpose and supported operations.
2025-11-13 11:20:32 +01:00
1805916359
docs: add @moduledoc to all LiveView modules
Add comprehensive module documentation to 12 LiveView modules covering
member, user, property, and property_type management views.
2025-11-13 11:20:32 +01:00
8fd981806e
docs: add @moduledoc to core membership resources
Add comprehensive module documentation to Member, Property, PropertyType, and Email.
Improves code discoverability and enables ExDoc generation.
2025-11-13 11:20:32 +01:00
a4ed2498e7 Merge pull request 'Docs, Code Guidelines and Progress Log' (#193) from feature/guidelines_docs into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #193
2025-11-13 11:17:29 +01:00
92e3e50d49 docs: add feature roadmap
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-13 11:15:57 +01:00
3852655597 docs: add comprehensive project documentation and reduce redundancy
Add CODE_GUIDELINES.md, database schema docs, and development-progress-log.md.
Refactor README.md to eliminate redundant information by linking to detailed docs.
Establish clear documentation hierarchy for better maintainability.
2025-11-13 11:15:57 +01:00
7305c63130 Merge pull request 'Implement fuzzy search' (#187) from feature/162_fuzzy_search into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #187
2025-11-12 13:10:30 +01:00
56516d78b6 Merge branch 'main' into feature/162_fuzzy_search
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-12 12:00:58 +01:00
44f88f1ddd test: aded more tests for fuzzy search
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-12 11:55:48 +01:00
a69ccf0ff9 fix: added email serach and ommitted fields 2025-11-12 11:55:35 +01:00
0c75776915 formatting
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-30 17:20:07 +01:00
3481b9dadf fix: updated fuzzy search after merge with sorting
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-30 17:16:23 +01:00
5e51f99797 test: adds tests for search 2025-10-30 16:50:02 +01:00
5406318e8d feat(liveview): use fuzzy search in live view 2025-10-30 16:50:02 +01:00
f6bfeadb7b feat(member). added search action to ressource 2025-10-30 16:48:45 +01:00
c7c6d329fb chore: enable trigram extension 2025-10-30 16:48:45 +01:00
e920d6b39c chore: added trigram migration for fuzzy search 2025-10-30 16:48:45 +01:00
40 changed files with 10910 additions and 4217 deletions

View file

@ -158,11 +158,11 @@
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.WrongTestFileExtension, []}
{Credo.Check.Warning.WrongTestFileExtension, []},
# Module documentation check (enabled after adding @moduledoc to all modules)
{Credo.Check.Readability.ModuleDoc, []}
],
disabled: [
# Checks disabled by the Mitgliederverwaltung Team
{Credo.Check.Readability.ModuleDoc, []},
#
# Checks scheduled for next check update (opt-in for now)
{Credo.Check.Refactor.UtcNowTruncate, []},

2578
CODE_GUIDELINES.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -161,27 +161,33 @@ Now you can log in to Mila via OIDC!
## 🏗️ Architecture
- **Backend:** Elixir, Phoenix, LiveView, Ash Framework
- **Frontend:** Phoenix LiveView + DaisyUI + Heroicons
- **Database:** PostgreSQL (via AshPostgres)
- **Auth:** AshAuthentication (OIDC + password strategy)
- **Mail:** Swoosh
- **i18n:** Gettext
**Tech Stack Overview:**
- **Backend:** Elixir + Phoenix + Ash Framework
- **Frontend:** Phoenix LiveView + Tailwind CSS + DaisyUI
- **Database:** PostgreSQL
- **Auth:** AshAuthentication (OIDC + password)
Code structure:
- `lib/mv/` — core Ash resources/domains (`Accounts`, `Membership`)
**Code Structure:**
- `lib/accounts/` & `lib/membership/` — Ash resources and domains
- `lib/mv_web/` — Phoenix controllers, LiveViews, components
- `assets/` — frontend assets (Tailwind, JS, etc.)
- `assets/` — Tailwind, JavaScript, static files
📚 **Full tech stack details:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
📖 **Implementation history:** See [`docs/development-progress-log.md`](docs/development-progress-log.md)
🗄️ **Database schema:** See [`docs/database-schema-readme.md`](docs/database-schema-readme.md)
## 🧑‍💻 Development
Useful `just` commands:
- `just run` — start DB, Mailcrab, Rauthy, app
- `just test` — run tests
- `just lint` — run code style checks (credo, formatter)
- `just audit` — run security audits
- `just reset-database` — reset local DB
- `just regen-migrations <name>` — regenerate migrations
**Common commands:**
```bash
just run # Start full dev environment
just test # Run test suite
just lint # Code style checks
just audit # Security audits
just reset-database # Reset local DB
```
📚 **Full development guidelines:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
## 📦 Production Deployment

View file

@ -0,0 +1,392 @@
# Database Schema Documentation
## Overview
This document provides a comprehensive overview of the Mila Membership Management System database schema.
## Quick Links
- **DBML File:** [`database_schema.dbml`](./database_schema.dbml)
- **Visualize Online:**
- [dbdiagram.io](https://dbdiagram.io) - Upload the DBML file
- [dbdocs.io](https://dbdocs.io) - Generate interactive documentation
## Schema Statistics
| Metric | Count |
|--------|-------|
| **Tables** | 5 |
| **Domains** | 2 (Accounts, Membership) |
| **Relationships** | 3 |
| **Indexes** | 15+ |
| **Triggers** | 1 (Full-text search) |
## Tables Overview
### Accounts Domain
#### `users`
- **Purpose:** User authentication and session management
- **Rows (Estimated):** Low to Medium (typically 10-50% of members)
- **Key Features:**
- Dual authentication (Password + OIDC)
- Optional 1:1 link to members
- Email as source of truth when linked
#### `tokens`
- **Purpose:** JWT token storage for AshAuthentication
- **Rows (Estimated):** Medium to High (multiple tokens per user)
- **Key Features:**
- Token lifecycle management
- Revocation support
- Multiple token purposes
### Membership Domain
#### `members`
- **Purpose:** Club member master data
- **Rows (Estimated):** High (core entity)
- **Key Features:**
- Complete member profile
- Full-text search via tsvector
- Bidirectional email sync with users
- Flexible address and contact data
#### `properties`
- **Purpose:** Dynamic custom member attributes
- **Rows (Estimated):** Variable (N per member)
- **Key Features:**
- Union type value storage (JSONB)
- Multiple data types supported
- One property per type per member
#### `property_types`
- **Purpose:** Schema definitions for custom properties
- **Rows (Estimated):** Low (admin-defined)
- **Key Features:**
- Type definitions
- Immutable and required flags
- Centralized property management
## Key Relationships
```
User (0..1) ←→ (0..1) Member
Tokens (N)
Member (1) → (N) Properties
PropertyType (1)
```
### Relationship Details
1. **User ↔ Member (Optional 1:1, both sides optional)**
- A User can have 0 or 1 Member (`user.member_id` can be NULL)
- A Member can have 0 or 1 User (optional `has_one` relationship)
- Both entities can exist independently
- Email synchronization when linked (User.email is source of truth)
- `ON DELETE SET NULL` on user side (User preserved when Member deleted)
2. **Member → Properties (1:N)**
- One member, many properties
- `ON DELETE CASCADE` - properties deleted with member
- Composite unique constraint (member_id, property_type_id)
3. **Property → PropertyType (N:1)**
- Properties reference type definition
- `ON DELETE RESTRICT` - cannot delete type if in use
- Type defines data structure
## Important Business Rules
### Email Synchronization
- **User.email** is the source of truth when linked
- On linking: Member.email ← User.email (overwrite)
- After linking: Changes sync bidirectionally
- Validation prevents email conflicts
### Authentication Strategies
- **Password:** Email + hashed_password
- **OIDC:** Email + oidc_id (Rauthy provider)
- At least one method required per user
### Member Constraints
- First name and last name required (min 1 char)
- Email unique, validated format (5-254 chars)
- Birth date cannot be in future
- Join date cannot be in future
- Exit date must be after join date
- Phone: `+?[0-9\- ]{6,20}`
- Postal code: 5 digits
### Property System
- Maximum one property per type per member
- Value stored as union type in JSONB
- Supported types: string, integer, boolean, date, email
- Types can be marked as immutable or required
## Indexes
### Performance Indexes
**members:**
- `search_vector` (GIN) - Full-text search
- `email` - Email lookups
- `last_name` - Name sorting
- `join_date` - Date filtering
- `paid` (partial) - Payment status queries
**properties:**
- `member_id` - Member property lookups
- `property_type_id` - Type-based queries
- Composite `(member_id, property_type_id)` - Uniqueness
**tokens:**
- `subject` - User token lookups
- `expires_at` - Token cleanup
- `purpose` - Purpose-based queries
**users:**
- `email` (unique) - Login lookups
- `oidc_id` (unique) - OIDC authentication
- `member_id` (unique) - Member linkage
## Full-Text Search
### Implementation
- **Trigger:** `members_search_vector_trigger()`
- **Function:** Automatically updates `search_vector` on INSERT/UPDATE
- **Index Type:** GIN (Generalized Inverted Index)
### Weighted Fields
- **Weight A (highest):** first_name, last_name
- **Weight B:** email, notes
- **Weight C:** birth_date, phone_number, city, street, house_number, postal_code
- **Weight D (lowest):** join_date, exit_date
### Usage Example
```sql
SELECT * FROM members
WHERE search_vector @@ to_tsquery('simple', 'john & doe');
```
## Database Extensions
### Required PostgreSQL Extensions
1. **uuid-ossp**
- Purpose: UUID generation functions
- Used for: `gen_random_uuid()`, `uuid_generate_v7()`
2. **citext**
- Purpose: Case-insensitive text type
- Used for: `users.email` (case-insensitive email matching)
### Installation
```sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "citext";
```
## Migration Strategy
### Ash Migrations
This project uses Ash Framework's migration system:
```bash
# Generate new migration
mix ash.codegen --name add_new_feature
# Apply migrations
mix ash.setup
# Rollback migrations
mix ash_postgres.rollback -n 1
```
### Migration Files Location
```
priv/repo/migrations/
├── 20250421101957_initialize_extensions_1.exs
├── 20250528163901_initial_migration.exs
├── 20250617090641_member_fields.exs
├── 20250620110850_add_accounts_domain.exs
├── 20250912085235_AddSearchVectorToMembers.exs
├── 20250926180341_add_unique_email_to_members.exs
└── 20251016130855_add_constraints_for_user_member_and_property.exs
```
## Data Integrity
### Foreign Key Behaviors
| Relationship | On Delete | Rationale |
|--------------|-----------|-----------|
| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted |
| `properties.member_id → members.id` | CASCADE | Delete properties with member |
| `properties.property_type_id → property_types.id` | RESTRICT | Prevent deletion of types in use |
### Validation Layers
1. **Database Level:**
- CHECK constraints
- NOT NULL constraints
- UNIQUE indexes
- Foreign key constraints
2. **Application Level (Ash):**
- Custom validators
- Email format validation (EctoCommons.EmailValidator)
- Business rule validation
- Cross-entity validation
3. **UI Level:**
- Client-side form validation
- Real-time feedback
- Error messages
## Performance Considerations
### Query Patterns
**High Frequency:**
- Member search (uses GIN index on search_vector)
- Member list with filters (uses indexes on join_date, paid)
- User authentication (uses unique index on email/oidc_id)
- Property lookups by member (uses index on member_id)
**Medium Frequency:**
- Member CRUD operations
- Property updates
- Token validation
**Low Frequency:**
- PropertyType management
- User-Member linking
- Bulk operations
### Optimization Tips
1. **Use indexes:** All critical query paths have indexes
2. **Preload relationships:** Use Ash's `load` to avoid N+1
3. **Pagination:** Use keyset pagination (configured by default)
4. **Partial indexes:** `members.paid` index only non-NULL values
5. **Search optimization:** Full-text search via tsvector, not LIKE
## Visualization
### Using dbdiagram.io
1. Visit [https://dbdiagram.io](https://dbdiagram.io)
2. Click "Import" → "From file"
3. Upload `database_schema.dbml`
4. View interactive diagram with relationships
### Using dbdocs.io
1. Install dbdocs CLI: `npm install -g dbdocs`
2. Generate docs: `dbdocs build database_schema.dbml`
3. View generated documentation
### VS Code Extension
Install "DBML Language" extension to view/edit DBML files with:
- Syntax highlighting
- Inline documentation
- Error checking
## Security Considerations
### Sensitive Data
**Encrypted:**
- `users.hashed_password` (bcrypt)
**Should Not Log:**
- hashed_password
- tokens (jti, purpose, extra_data)
**Personal Data (GDPR):**
- All member fields (name, email, birth_date, address)
- User email
- Token subject
### Access Control
- Implement through Ash policies
- Row-level security considerations for future
- Audit logging for sensitive operations
## Backup Recommendations
### Critical Tables (Priority 1)
- `members` - Core business data
- `users` - Authentication data
- `property_types` - Schema definitions
### Important Tables (Priority 2)
- `properties` - Member custom data
- `tokens` - Can be regenerated but good to backup
### Backup Strategy
```bash
# Full database backup
pg_dump -Fc mv_prod > backup_$(date +%Y%m%d).dump
# Restore
pg_restore -d mv_prod backup_20251110.dump
```
## Testing
### Test Database
- Separate test database: `mv_test`
- Sandbox mode via Ecto.Adapters.SQL.Sandbox
- Reset between tests
### Seed Data
```bash
# Load seed data
mix run priv/repo/seeds.exs
```
## Future Considerations
### Potential Additions
1. **Audit Log Table**
- Track changes to members
- Compliance and history tracking
2. **Payment Tracking**
- Payment history table
- Transaction records
- Fee calculation
3. **Document Storage**
- Member documents/attachments
- File metadata table
4. **Email Queue**
- Outbound email tracking
- Delivery status
5. **Roles & Permissions**
- User roles (admin, treasurer, member)
- Permission management
## Resources
- **Ash Framework:** [https://hexdocs.pm/ash](https://hexdocs.pm/ash)
- **AshPostgres:** [https://hexdocs.pm/ash_postgres](https://hexdocs.pm/ash_postgres)
- **DBML Specification:** [https://dbml.dbdiagram.io](https://dbml.dbdiagram.io)
- **PostgreSQL Docs:** [https://www.postgresql.org/docs/](https://www.postgresql.org/docs/)
---
**Last Updated:** 2025-11-10
**Schema Version:** 1.0
**Database:** PostgreSQL 17.6 (dev) / 16 (prod)

329
docs/database_schema.dbml Normal file
View file

@ -0,0 +1,329 @@
// Mila - Membership Management System
// Database Schema Documentation
//
// This file can be used with:
// - https://dbdiagram.io
// - https://dbdocs.io
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
//
// Version: 1.0
// Last Updated: 2025-11-10
Project mila_membership_management {
database_type: 'PostgreSQL'
Note: '''
# Mila Membership Management System
A membership management application for small to mid-sized clubs.
## Key Features:
- User authentication (OIDC + Password)
- Member management with flexible custom properties
- Bidirectional email synchronization between users and members
- Full-text search capabilities
- GDPR-compliant data management
## Domains:
- **Accounts**: User authentication and session management
- **Membership**: Club member data and custom properties
'''
}
// ============================================
// ACCOUNTS DOMAIN
// ============================================
Table users {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
email citext [not null, unique, note: 'Email address (case-insensitive) - source of truth when linked to member']
hashed_password text [null, note: 'Bcrypt-hashed password (null for OIDC-only users)']
oidc_id text [null, unique, note: 'External OIDC identifier from authentication provider (e.g., Rauthy)']
member_id uuid [null, unique, note: 'Optional 1:1 link to member record']
indexes {
email [unique, name: 'users_unique_email_index']
oidc_id [unique, name: 'users_unique_oidc_id_index']
member_id [unique, name: 'users_unique_member_index']
}
Note: '''
**User Authentication Table**
Handles user login accounts with two authentication strategies:
1. Password-based authentication (email + hashed_password)
2. OIDC/SSO authentication (email + oidc_id)
**Relationship with Members:**
- Optional 1:1 relationship with members table (0..1 ↔ 0..1)
- A user can have 0 or 1 member (user.member_id can be NULL)
- A member can have 0 or 1 user (optional has_one relationship)
- Both entities can exist independently
- When linked, user.email is the source of truth
- Email changes sync bidirectionally between user ↔ member
**Constraints:**
- At least one auth method required (password OR oidc_id)
- Email must be unique across all users
- OIDC ID must be unique if present
- Member can only be linked to one user (enforced by unique index)
**Deletion Behavior:**
- When member is deleted → user.member_id set to NULL (user preserved)
- When user is deleted → member.user relationship cleared (member preserved)
'''
}
Table tokens {
jti text [pk, not null, note: 'JWT ID - unique token identifier']
subject text [not null, note: 'Token subject (usually user ID)']
purpose text [not null, note: 'Token purpose (e.g., "access", "refresh", "password_reset")']
expires_at timestamp [not null, note: 'Token expiration timestamp (UTC)']
extra_data jsonb [null, note: 'Additional token metadata']
created_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 {
subject [name: 'tokens_subject_idx', note: 'For user token lookups']
expires_at [name: 'tokens_expires_at_idx', note: 'For token cleanup queries']
purpose [name: 'tokens_purpose_idx', note: 'For purpose-based queries']
}
Note: '''
**AshAuthentication Token Management**
Stores JWT tokens for authentication and authorization.
**Token Purposes:**
- `access`: Short-lived access tokens for API requests
- `refresh`: Long-lived tokens for obtaining new access tokens
- `password_reset`: Temporary tokens for password reset flow
- `email_confirmation`: Temporary tokens for email verification
**Token Lifecycle:**
- Tokens are created during login/registration
- Can be revoked by deleting the record
- Expired tokens should be cleaned up periodically
- `store_all_tokens? true` enables token tracking
'''
}
// ============================================
// MEMBERSHIP DOMAIN
// ============================================
Table members {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key (sortable by creation time)']
first_name text [not null, note: 'Member first name (min length: 1)']
last_name text [not null, note: 'Member last name (min length: 1)']
email text [not null, unique, note: 'Member email address (5-254 chars, validated)']
birth_date date [null, note: 'Date of birth (cannot be in future)']
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']
city text [null, note: 'City of residence']
street text [null, note: 'Street name']
house_number text [null, note: 'House number']
postal_code text [null, note: '5-digit German postal code']
search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
indexes {
email [unique, name: 'members_unique_email_index']
search_vector [type: gin, name: 'members_search_vector_idx', note: 'GIN index for full-text search']
email [name: 'members_email_idx']
last_name [name: 'members_last_name_idx', note: 'For name sorting']
join_date [name: 'members_join_date_idx', note: 'For date filters']
(paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL']
}
Note: '''
**Club Member Master Data**
Core entity for membership management containing:
- Personal information (name, birth date, email)
- Contact details (phone, address)
- Membership status (join/exit dates, payment status)
- Additional notes
**Email Synchronization:**
When a member is linked to a user:
- User.email is the source of truth (overwrites member.email on link)
- Subsequent changes to either email sync bidirectionally
- Validates that email is not already used by another unlinked user
**Full-Text Search:**
- `search_vector` is auto-updated via trigger
- Weighted fields: first_name (A), last_name (A), email (B), notes (B)
- Supports flexible member search across multiple fields
**Relationships:**
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
- 1:N with properties (custom dynamic fields)
**Validation Rules:**
- first_name, last_name: min 1 character
- email: 5-254 characters, valid email format
- birth_date: cannot be in future
- join_date: cannot be in future
- exit_date: must be after join_date (if both present)
- phone_number: matches pattern ^\+?[0-9\- ]{6,20}$
- postal_code: exactly 5 digits
'''
}
Table properties {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
value jsonb [null, note: 'Union type value storage (format: {type: "string", value: "example"})']
member_id uuid [not null, note: 'Link to member']
property_type_id uuid [not null, note: 'Link to property type definition']
indexes {
(member_id, property_type_id) [unique, name: 'properties_unique_property_per_member_index', note: 'One property per type per member']
member_id [name: 'properties_member_id_idx']
property_type_id [name: 'properties_property_type_id_idx']
}
Note: '''
**Dynamic Custom Member Properties**
Provides flexible, extensible attributes for members beyond the fixed schema.
**Value Storage:**
- Stored as JSONB map with type discrimination
- Format: `{type: "string|integer|boolean|date|email", value: <actual_value>}`
- Allows multiple data types in single column
**Supported Types:**
- `string`: Text data
- `integer`: Numeric data
- `boolean`: True/False flags
- `date`: Date values
- `email`: Validated email addresses
**Constraints:**
- Each member can have only ONE property per property_type
- Properties are deleted when member is deleted (CASCADE)
- Property type cannot be deleted if properties exist (RESTRICT)
**Use Cases:**
- Custom membership numbers
- Additional contact methods
- Club-specific attributes
- Flexible data model without schema migrations
'''
}
Table property_types {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
name text [not null, unique, note: 'Property name/identifier (e.g., "membership_number")']
value_type text [not null, note: 'Data type: string | integer | boolean | date | email']
description text [null, note: 'Human-readable description']
immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation']
required boolean [not null, default: false, note: 'If true, all members must have this property']
indexes {
name [unique, name: 'property_types_unique_name_index']
}
Note: '''
**Property Type Definitions**
Defines the schema and behavior for custom member properties.
**Attributes:**
- `name`: Unique identifier for the property type
- `value_type`: Enforces data type consistency
- `description`: Documentation for users/admins
- `immutable`: Prevents changes after initial creation (e.g., membership numbers)
- `required`: Enforces that all members must have this property
**Constraints:**
- `value_type` must be one of: string, integer, boolean, date, email
- `name` must be unique across all property types
- Cannot be deleted if properties reference it (ON DELETE RESTRICT)
**Examples:**
- Membership Number (string, immutable, required)
- Emergency Contact (string, mutable, optional)
- Certified Trainer (boolean, mutable, optional)
- Certification Date (date, immutable, optional)
'''
}
// ============================================
// RELATIONSHIPS
// ============================================
// Optional 1:1 User ↔ Member Link
// - A user can have 0 or 1 linked member (optional)
// - A member can have 0 or 1 linked user (optional)
// - Both can exist independently
// - ON DELETE SET NULL: User preserved when member deleted
// - Email Synchronization: When linking occurs, user.email becomes source of truth
Ref: users.member_id - members.id [delete: set null]
// Member → Properties (1:N)
// - One member can have multiple properties
// - Each property belongs to exactly one member
// - ON DELETE CASCADE: Properties deleted when member deleted
// - UNIQUE constraint: One property per type per member
Ref: properties.member_id > members.id [delete: cascade]
// Property → PropertyType (N:1)
// - Many properties can reference one property type
// - Property type defines the schema/behavior
// - ON DELETE RESTRICT: Cannot delete type if properties exist
Ref: properties.property_type_id > property_types.id [delete: restrict]
// ============================================
// ENUMS
// ============================================
// Valid data types for property values
// Determines how Property.value is interpreted
Enum property_value_type {
string [note: 'Text data']
integer [note: 'Numeric data']
boolean [note: 'True/False flags']
date [note: 'Date values']
email [note: 'Validated email addresses']
}
// Token purposes for different authentication flows
Enum token_purpose {
access [note: 'Short-lived access tokens']
refresh [note: 'Long-lived refresh tokens']
password_reset [note: 'Password reset tokens']
email_confirmation [note: 'Email verification tokens']
}
// ============================================
// TABLE GROUPS
// ============================================
TableGroup accounts_domain {
users
tokens
Note: '''
**Accounts Domain**
Handles user authentication and session management using AshAuthentication.
Supports multiple authentication strategies (Password, OIDC).
'''
}
TableGroup membership_domain {
members
properties
property_types
Note: '''
**Membership Domain**
Core business logic for club membership management.
Supports flexible, extensible member data model.
'''
}

File diff suppressed because it is too large Load diff

743
docs/feature-roadmap.md Normal file
View file

@ -0,0 +1,743 @@
# Feature Roadmap & Implementation Plan
**Project:** Mila - Membership Management System
**Last Updated:** 2025-11-10
**Status:** Planning Phase
---
## Table of Contents
1. [Phase 1: Feature Area Breakdown](#phase-1-feature-area-breakdown)
2. [Phase 2: API Endpoint Definition](#phase-2-api-endpoint-definition)
3. [Phase 3: Implementation Task Creation](#phase-3-implementation-task-creation)
4. [Phase 4: Task Organization and Prioritization](#phase-4-task-organization-and-prioritization)
---
## Phase 1: Feature Area Breakdown
### Feature Areas
#### 1. **Authentication & Authorization** 🔐
**Current State:**
- ✅ OIDC authentication (Rauthy)
- ✅ Password-based authentication
- ✅ User sessions and tokens
- ✅ Basic authentication flows
**Open Issues:**
- [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - Ensure correct handling of Password login vs OIDC login (M)
- [#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)
**Missing Features:**
- ❌ Role-based access control (RBAC)
- ❌ Permission system
- ❌ Password reset flow
- ❌ Email verification
- ❌ Two-factor authentication (future)
**Related Issues:**
- [#191](https://git.local-it.org/local-it/mitgliederverwaltung/issues/191) - Implement Roles in Ash (M)
- [#190](https://git.local-it.org/local-it/mitgliederverwaltung/issues/190) - Implement Permissions in Ash (M)
- [#151](https://git.local-it.org/local-it/mitgliederverwaltung/issues/151) - Define implementation plan for roles and permissions (M) [3/7 tasks done]
---
#### 2. **Member Management** 👥
**Current State:**
- ✅ Member CRUD operations
- ✅ Member profile with personal data
- ✅ Address management
- ✅ Membership status tracking
- ✅ Full-text search (PostgreSQL tsvector)
- ✅ Sorting by basic fields
- ✅ User-Member linking (optional 1:1)
- ✅ Email synchronization between User and Member
**Open Issues:**
- [#169](https://git.local-it.org/local-it/mitgliederverwaltung/issues/169) - Allow combined creation of Users/Members (M, Low priority)
- [#168](https://git.local-it.org/local-it/mitgliederverwaltung/issues/168) - Allow user-member association in edit/create views (M, High priority)
- [#165](https://git.local-it.org/local-it/mitgliederverwaltung/issues/165) - Pagination for list of members (S, Low priority)
- [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Implement fuzzy and substring search (M, Medium priority)
- [#160](https://git.local-it.org/local-it/mitgliederverwaltung/issues/160) - Implement clear icon in searchbar (S, Low priority)
- [#154](https://git.local-it.org/local-it/mitgliederverwaltung/issues/154) - Concept advanced search (Low priority, needs refinement)
**Missing Features:**
- ❌ Fuzzy search
- ❌ Advanced filters (date ranges, multiple criteria)
- ❌ Pagination (currently all members loaded)
- ❌ Bulk operations (bulk delete, bulk update)
- ❌ Member import/export (CSV, Excel)
- ❌ Member profile photos/avatars
- ❌ Member history/audit log
- ❌ Duplicate detection
---
#### 3. **Custom Fields (Property System)** 🔧
**Current State:**
- ✅ Property types (string, integer, boolean, date, email)
- ✅ Property type management
- ✅ Dynamic property assignment to members
- ✅ Union type storage (JSONB)
**Open Issues:**
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) [0/3 tasks]
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority)
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
**Missing Features:**
- ❌ Default field visibility configuration
- ❌ Field groups/categories
- ❌ Conditional fields (show field X if field Y = value)
- ❌ Field validation rules (min/max, regex patterns)
- ❌ Required custom fields
- ❌ Multi-select fields
- ❌ File upload fields
- ❌ Sorting by custom fields
- ❌ Searching by custom fields
---
#### 4. **User Management** 👤
**Current State:**
- ✅ User CRUD operations
- ✅ User list view
- ✅ User profile view
- ✅ Admin password setting
- ✅ User-Member relationship
**Missing Features:**
- ❌ User roles assignment UI
- ❌ User permissions management
- ❌ User activity log
- ❌ User invitation system
- ❌ User onboarding flow
- ❌ Self-service profile editing
- ❌ Password change flow
---
#### 5. **Navigation & UX** 🧭
**Current State:**
- ✅ Basic navigation structure
- ✅ Navbar with profile button
- ✅ Member list as landing page
- ✅ Breadcrumbs (basic)
**Open Issues:**
- [#188](https://git.local-it.org/local-it/mitgliederverwaltung/issues/188) - Check if searching just on typing is accessible (S, Low priority)
- [#174](https://git.local-it.org/local-it/mitgliederverwaltung/issues/174) - Accessibility - aria-sort in tables (S, Low priority)
**Missing Features:**
- ❌ Dashboard/Home page
- ❌ Quick actions menu
- ❌ Recent activity widget
- ❌ Keyboard shortcuts
- ❌ Mobile navigation
- ❌ Context-sensitive help
- ❌ Onboarding tooltips
---
#### 6. **Internationalization (i18n)** 🌍
**Current State:**
- ✅ Gettext integration
- ✅ German translations
- ✅ English translations
- ✅ Translation files for auth, errors, default
**Open Issues:**
- [#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)
**Missing Features:**
- ❌ Language switcher UI
- ❌ User-specific language preferences
- ❌ Date/time localization
- ❌ Number formatting (currency, decimals)
- ❌ Complete translation coverage
- ❌ RTL support (future)
---
#### 7. **Payment & Fees Management** 💰
**Current State:**
- ✅ Basic "paid" boolean field on members
- ⚠️ 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)
**Missing Features:**
- ❌ 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
- ❌ Payment reports
**Related Milestones:**
- Import transactions via vereinfacht API
---
#### 8. **Admin Panel & Configuration** ⚙️
**Current State:**
- ✅ AshAdmin integration (basic)
- ⚠️ 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
- ❌ Property type management UI (user-facing)
- ❌ Role and permission management UI
- ❌ System health dashboard
- ❌ Audit log viewer
- ❌ Backup/restore functionality
**Related Milestones:**
- As Admin I can configure settings globally
---
#### 9. **Communication & Notifications** 📧
**Current State:**
- ✅ Swoosh mailer integration
- ✅ Email confirmation (via AshAuthentication)
- ✅ Password reset emails (via AshAuthentication)
- ⚠️ No member communication features
**Missing Features:**
- ❌ Email broadcast to members
- ❌ Email templates (customizable)
- ❌ Email to member groups/filters
---
#### 10. **Reporting & Analytics** 📊
**Current State:**
- ❌ No reporting features
**Missing Features:**
- ❌ Member statistics dashboard
- ❌ Membership growth charts
- ❌ Payment reports
- ❌ Custom report builder
- ❌ Export to PDF/CSV/Excel
- ❌ Scheduled reports
- ❌ Data visualization
---
#### 11. **Data Import/Export** 📥📤
**Current State:**
- ✅ Seed data script
- ⚠️ No user-facing import/export
**Missing Features:**
- ❌ CSV import for members
- ❌ Excel import for members
- ❌ Import validation and preview
- ❌ Import error handling
- ❌ Bulk data export
- ❌ Backup export
- ❌ Data migration tools
---
#### 12. **Testing & Quality Assurance** 🧪
**Current State:**
- ✅ ExUnit test suite
- ✅ Unit tests for resources
- ✅ Integration tests for email sync
- ✅ LiveView tests
- ✅ Component tests
- ✅ CI/CD pipeline (Drone)
**Missing Features:**
- ❌ E2E tests (browser automation)
- ❌ Performance testing
- ❌ Load testing
- ❌ Security penetration testing
- ❌ Accessibility testing automation
- ❌ Visual regression testing
- ❌ Test coverage reporting
---
#### 13. **Infrastructure & DevOps** 🚀
**Current State:**
- ✅ Docker Compose for development
- ✅ Production Dockerfile
- ✅ Drone CI/CD pipeline
- ✅ Renovate for dependency updates
- ⚠️ No staging environment
**Open Issues:**
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
**Missing Features:**
- ❌ Staging environment
- ❌ Automated deployment
- ❌ Database backup automation
- ❌ Monitoring and alerting
- ❌ Error tracking (Sentry, etc.)
- ❌ Log aggregation
- ❌ Health checks and uptime monitoring
**Related Milestones:**
- We have a staging environment
- We implement security measures
---
#### 14. **Security & Compliance** 🔒
**Current State:**
- ✅ OIDC authentication
- ✅ Password hashing (bcrypt)
- ✅ CSRF protection
- ✅ SQL injection prevention (Ecto)
- ✅ Sobelow security scans
- ✅ Dependency auditing
**Missing Features:**
- ❌ Role-based access control (see #1)
- ❌ Audit logging
- ❌ GDPR compliance features (data export, deletion)
- ❌ Session management (timeout, concurrent sessions)
- ❌ Rate limiting
- ❌ IP whitelisting/blacklisting
- ❌ Security headers configuration
- ❌ Data retention policies
**Related Milestones:**
- We implement security measures
---
#### 15. **Accessibility & Usability**
**Current State:**
- ✅ Semantic HTML
- ✅ Basic ARIA labels
- ⚠️ Needs comprehensive audit
**Open Issues:**
- [#188](https://git.local-it.org/local-it/mitgliederverwaltung/issues/188) - Check if searching just on typing is accessible (S, Low priority)
- [#174](https://git.local-it.org/local-it/mitgliederverwaltung/issues/174) - Accessibility - aria-sort in tables (S, Low priority)
**Missing Features:**
- ❌ Comprehensive accessibility audit (WCAG 2.1 Level AA)
- ❌ Keyboard navigation improvements
- ❌ Screen reader optimization
- ❌ High contrast mode
- ❌ Font size adjustments
- ❌ Focus management
- ❌ Skip links
- ❌ Error announcements
---
### Feature Area Summary
| Feature Area | Current Status | Priority | Complexity |
|--------------|----------------|----------|------------|
| **Authentication & Authorization** | 40% complete | **High** | Medium |
| **Member Management** | 70% complete | **High** | Low-Medium |
| **Custom Fields** | 50% complete | **High** | Medium |
| **User Management** | 60% complete | Medium | Low |
| **Navigation & UX** | 50% complete | Medium | Low |
| **Internationalization** | 70% complete | Low | Low |
| **Payment & Fees** | 5% complete | **High** | High |
| **Admin Panel** | 20% complete | Medium | Medium |
| **Communication** | 30% complete | Medium | Medium |
| **Reporting** | 0% complete | Medium | Medium-High |
| **Import/Export** | 10% complete | Low | Medium |
| **Testing & QA** | 60% complete | Medium | Low-Medium |
| **Infrastructure** | 70% complete | Medium | Medium |
| **Security** | 50% complete | **High** | Medium-High |
| **Accessibility** | 40% complete | Medium | Medium |
---
### Open Milestones (From Issues)
1. ✅ **Ich kann einen neuen Kontakt anlegen** (Closed)
2. 🔄 **I can search through the list of members - fulltext** (Open) - Related: #162, #154
3. 🔄 **I can sort the list of members for specific fields** (Open) - Related: #153
4. 🔄 **We have a intuitive navigation structure** (Open)
5. 🔄 **We have different roles and permissions** (Open) - Related: #191, #190, #151
6. 🔄 **As Admin I can configure settings globally** (Open)
7. 🔄 **Accounts & Logins** (Open) - Related: #171, #169, #168
8. 🔄 **I can add custom fields** (Open) - Related: #194, #157, #161
9. 🔄 **Import transactions via vereinfacht API** (Open) - Related: #156
10. 🔄 **We have a staging environment** (Open)
11. 🔄 **We implement security measures** (Open)
---
---
## Phase 2: API Endpoint Definition
### Endpoint Types
Since this is a **Phoenix LiveView** application with **Ash Framework**, we have three types of endpoints:
1. **LiveView Endpoints** - Mount points and event handlers
2. **HTTP Controller Endpoints** - Traditional REST-style endpoints
3. **Ash Resource Actions** - Backend data layer API
### Authentication Requirements Legend
- 🔓 **Public** - No authentication required
- 🔐 **Authenticated** - Requires valid user session
- 👤 **User Role** - Requires specific user role
- 🛡️ **Admin Only** - Requires admin privileges
---
### 1. Authentication & Authorization Endpoints
#### HTTP Controller Endpoints
| Method | Route | Purpose | Auth | Request | Response |
|--------|-------|---------|------|---------|----------|
| `GET` | `/auth/user/password/sign_in` | Show password login form | 🔓 | - | HTML form |
| `POST` | `/auth/user/password/sign_in` | Submit password login | 🔓 | `{email, password}` | Redirect + session cookie |
| `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/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 |
| `POST` | `/auth/user/password/reset/:token` | Submit new password | 🔓 | `{password, password_confirmation}` | Redirect to login |
#### Ash Resource Actions
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `User` | `:sign_in_with_password` | Password authentication | 🔓 | `{email, password}` | `{:ok, user}` or `{:error, reason}` |
| `User` | `:sign_in_with_rauthy` | OIDC authentication | 🔓 | `{oidc_id, email, user_info}` | `{:ok, user}` or `{:error, reason}` |
| `User` | `:register_with_password` | Create user with password | 🔓 | `{email, password}` | `{:ok, user}` |
| `User` | `:register_with_rauthy` | Create user via OIDC | 🔓 | `{oidc_id, email}` | `{:ok, user}` |
| `User` | `:request_password_reset` | Generate reset token | 🔓 | `{email}` | `{:ok, token}` |
| `User` | `:reset_password` | Reset password with token | 🔓 | `{token, password}` | `{:ok, user}` |
| `Token` | `:revoke` | Revoke authentication token | 🔐 | `{jti}` | `{:ok, token}` |
#### **NEW: Role & Permission Actions** (Issue #191, #190, #151)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `Role` | `:create` | Create new role | 🛡️ | `{name, description, permissions}` | `{:ok, role}` |
| `Role` | `:list` | List all roles | 🔐 | - | `[%Role{}]` |
| `Role` | `:update` | Update role | 🛡️ | `{id, name, permissions}` | `{:ok, role}` |
| `Role` | `:delete` | Delete role | 🛡️ | `{id}` | `{:ok, role}` |
| `User` | `:assign_role` | Assign role to user | 🛡️ | `{user_id, role_id}` | `{:ok, user}` |
| `User` | `:remove_role` | Remove role from user | 🛡️ | `{user_id, role_id}` | `{:ok, user}` |
| `Permission` | `:list` | List all permissions | 🔐 | - | `[%Permission{}]` |
| `Permission` | `:check` | Check user permission | 🔐 | `{user_id, resource, action}` | `{:ok, boolean}` |
---
### 2. Member Management Endpoints
#### LiveView Endpoints
| Mount | Purpose | Auth | Query Params | Events |
|-------|---------|------|--------------|--------|
| `/members` | Member list with search/sort | 🔐 | `?search=&sort_by=&sort_dir=` | `search`, `sort`, `delete`, `select` |
| `/members/new` | Create new member form | 🔐 | - | `save`, `cancel`, `add_property` |
| `/members/:id` | Member detail view | 🔐 | - | `edit`, `delete`, `link_user` |
| `/members/:id/edit` | Edit member form | 🔐 | - | `save`, `cancel`, `add_property`, `remove_property` |
#### LiveView Event Handlers
| Event | Purpose | Params | Response |
|-------|---------|--------|----------|
| `search` | Trigger search | `%{"search" => query}` | Update member list |
| `sort` | Sort member list | `%{"field" => field}` | Update sorted list |
| `delete` | Delete member | `%{"id" => id}` | Redirect to list |
| `save` | Create/update member | `%{"member" => attrs}` | Redirect or show errors |
| `link_user` | Link user to member | `%{"user_id" => id}` | Update member view |
| `unlink_user` | Unlink user from member | - | Update member view |
| `add_property` | Add custom property | `%{"property_type_id" => id, "value" => val}` | Update form |
| `remove_property` | Remove custom property | `%{"property_id" => id}` | Update form |
#### Ash Resource Actions
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `Member` | `:create_member` | Create member | 🔐 | `{first_name, last_name, email, ...}` | `{:ok, member}` |
| `Member` | `:read` | List/search members | 🔐 | `{search, sort_by, limit, offset}` | `[%Member{}]` |
| `Member` | `:update_member` | Update member | 🔐 | `{id, attrs}` | `{:ok, member}` |
| `Member` | `:destroy` | Delete member | 🔐 | `{id}` | `{:ok, member}` |
| `Member` | `:search_fulltext` | Full-text search | 🔐 | `{query}` | `[%Member{}]` |
| `Member` | `:link_to_user` | Link member to user | 🔐 | `{member_id, user_id}` | `{:ok, member}` |
| `Member` | `:unlink_from_user` | Unlink from user | 🔐 | `{member_id}` | `{:ok, member}` |
#### **NEW: Enhanced Search & Filter Actions** (Issue #162, #154, #165)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `Member` | `:fuzzy_search` | Fuzzy text search | 🔐 | `{query, threshold}` | `[%Member{}]` |
| `Member` | `:advanced_search` | Multi-criteria search | 🔐 | `{filters: [{field, op, value}]}` | `[%Member{}]` |
| `Member` | `:paginate` | Paginated member list | 🔐 | `{page, per_page, filters}` | `{members, total, page_info}` |
| `Member` | `:sort_by_custom_field` | Sort by property | 🔐 | `{property_type_id, direction}` | `[%Member{}]` |
| `Member` | `:bulk_delete` | Delete multiple members | 🛡️ | `{ids: [id1, id2, ...]}` | `{:ok, count}` |
| `Member` | `:bulk_update` | Update multiple members | 🛡️ | `{ids, attrs}` | `{:ok, count}` |
| `Member` | `:export` | Export to CSV/Excel | 🔐 | `{format, filters}` | File download |
| `Member` | `:import` | Import from CSV | 🛡️ | `{file, mapping}` | `{:ok, imported_count, errors}` |
---
### 3. Custom Fields (Property System) Endpoints
#### LiveView Endpoints
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/property-types` | List property types | 🛡️ | `new`, `edit`, `delete` |
| `/property-types/new` | Create property type | 🛡️ | `save`, `cancel` |
| `/property-types/:id/edit` | Edit property type | 🛡️ | `save`, `cancel`, `delete` |
#### Ash Resource Actions
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `PropertyType` | `:create` | Create property type | 🛡️ | `{name, value_type, description, ...}` | `{:ok, property_type}` |
| `PropertyType` | `:read` | List property types | 🔐 | - | `[%PropertyType{}]` |
| `PropertyType` | `:update` | Update property type | 🛡️ | `{id, attrs}` | `{:ok, property_type}` |
| `PropertyType` | `:destroy` | Delete property type | 🛡️ | `{id}` | `{:ok, property_type}` |
| `Property` | `:create` | Add property to member | 🔐 | `{member_id, property_type_id, value}` | `{:ok, property}` |
| `Property` | `:update` | Update property value | 🔐 | `{id, value}` | `{:ok, property}` |
| `Property` | `:destroy` | Remove property | 🔐 | `{id}` | `{:ok, property}` |
#### **NEW: Enhanced Custom Fields** (Issue #194, #157, #161, #153)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `PropertyType` | `:set_default_visibility` | Show/hide by default | 🛡️ | `{id, visible}` | `{:ok, property_type}` |
| `PropertyType` | `:set_required` | Mark as required | 🛡️ | `{id, required}` | `{:ok, property_type}` |
| `PropertyType` | `:add_validation` | Add validation rule | 🛡️ | `{id, rule_type, params}` | `{:ok, property_type}` |
| `PropertyType` | `:create_group` | Create field group | 🛡️ | `{name, property_type_ids}` | `{:ok, group}` |
| `Property` | `:validate_value` | Validate property value | 🔐 | `{property_type_id, value}` | `{:ok, valid}` or `{:error, reason}` |
---
### 4. User Management Endpoints
#### LiveView Endpoints
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/users` | User list | 🛡️ | `new`, `edit`, `delete`, `assign_role` |
| `/users/new` | Create user form | 🛡️ | `save`, `cancel` |
| `/users/:id` | User detail view | 🔐 | `edit`, `delete`, `change_password` |
| `/users/:id/edit` | Edit user form | 🔐 | `save`, `cancel`, `link_member` |
| `/profile` | Current user profile | 🔐 | `edit`, `change_password` |
#### Ash Resource Actions
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `User` | `:create_user` | Create user (admin) | 🛡️ | `{email, member_id?}` | `{:ok, user}` |
| `User` | `:read` | List users | 🛡️ | - | `[%User{}]` |
| `User` | `:update_user` | Update user | 🔐 | `{id, email, member_id?}` | `{:ok, user}` |
| `User` | `:destroy` | Delete user | 🛡️ | `{id}` | `{:ok, user}` |
| `User` | `:admin_set_password` | Set password (admin) | 🛡️ | `{id, password}` | `{:ok, user}` |
| `User` | `:change_password` | Change own password | 🔐 | `{current_password, new_password}` | `{:ok, user}` |
#### **NEW: Combined User/Member Management** (Issue #169, #168)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `User` | `:create_with_member` | Create user + member together | 🛡️ | `{user: {...}, member: {...}}` | `{:ok, %{user, member}}` |
| `User` | `:invite_user` | Send invitation email | 🛡️ | `{email, role_id, member_id?}` | `{:ok, invitation}` |
| `User` | `:accept_invitation` | Accept invitation | 🔓 | `{token, password}` | `{:ok, user}` |
---
### 5. Navigation & UX Endpoints
#### LiveView Endpoints
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/` | Dashboard/Home | 🔐 | - |
| `/dashboard` | Dashboard view | 🔐 | Contextual based on role |
#### HTTP Controller Endpoints
| Method | Route | Purpose | Auth | Request | Response |
|--------|-------|---------|------|---------|----------|
| `GET` | `/health` | Health check | 🔓 | - | `{"status": "ok"}` |
| `GET` | `/` | Root redirect | - | - | Redirect to dashboard or login |
---
### 6. Internationalization Endpoints
#### HTTP Controller Endpoints
| 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 (NEW - Issue #156)
| 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 (NEW)
| 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 (NEW)
| 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 (NEW)
| 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{}]` |
---
### 9. Communication & Notifications Endpoints
#### LiveView Endpoints (NEW)
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/communications` | Communication history | 🔐 | `new`, `view` |
| `/communications/new` | Create email broadcast | 🔐 | `select_recipients`, `preview`, `send` |
| `/notifications` | User notifications | 🔐 | `mark_read`, `mark_all_read` |
#### Ash Resource Actions (NEW)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `EmailBroadcast` | `:create` | Create broadcast | 🔐 | `{subject, body, recipient_filter}` | `{:ok, broadcast}` |
| `EmailBroadcast` | `:send` | Send broadcast | 🔐 | `{id}` | `{:ok, sent_count}` |
| `EmailTemplate` | `:create` | Create template | 🛡️ | `{name, subject, body}` | `{:ok, template}` |
| `EmailTemplate` | `:render` | Render template | 🔐 | `{id, variables}` | `rendered_html` |
| `Notification` | `:create` | Create notification | System | `{user_id, type, message}` | `{:ok, notification}` |
| `Notification` | `:list_for_user` | Get user notifications | 🔐 | `{user_id}` | `[%Notification{}]` |
| `Notification` | `:mark_read` | Mark as read | 🔐 | `{id}` | `{:ok, notification}` |
---
### 10. Reporting & Analytics Endpoints
#### LiveView Endpoints (NEW)
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/reports` | Reports dashboard | 🔐 | `generate`, `schedule` |
| `/reports/members` | Member statistics | 🔐 | `filter`, `export` |
| `/reports/payments` | Payment reports | 🔐 | `filter`, `export` |
| `/reports/custom` | Custom report builder | 🛡️ | `build`, `save`, `run` |
#### Ash Resource Actions (NEW)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `Report` | `:generate_member_stats` | Member statistics | 🔐 | `{date_range, filters}` | Statistics object |
| `Report` | `:generate_payment_stats` | Payment statistics | 🔐 | `{date_range}` | Statistics object |
| `Report` | `:export_to_csv` | Export report to CSV | 🔐 | `{report_type, filters}` | CSV file |
| `Report` | `:export_to_pdf` | Export report to PDF | 🔐 | `{report_type, filters}` | PDF file |
| `Report` | `:schedule` | Schedule recurring report | 🛡️ | `{report_type, frequency, recipients}` | `{:ok, schedule}` |
---
### 11. Data Import/Export Endpoints
#### LiveView Endpoints (NEW)
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/import` | Data import wizard | 🛡️ | `upload`, `map_fields`, `preview`, `import` |
| `/export` | Data export tool | 🔐 | `select_data`, `configure`, `export` |
#### Ash Resource Actions (NEW)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `Member` | `:import_csv` | Import members from CSV | 🛡️ | `{file, field_mapping}` | `{:ok, imported, errors}` |
| `Member` | `:validate_import` | Validate import data | 🛡️ | `{file, field_mapping}` | `{:ok, validation_results}` |
| `Member` | `:export_csv` | Export members to CSV | 🔐 | `{filters}` | CSV file |
| `Member` | `:export_excel` | Export members to Excel | 🔐 | `{filters}` | Excel file |
| `Database` | `:export_backup` | Full database backup | 🛡️ | - | Backup file |
| `Database` | `:import_backup` | Restore from backup | 🛡️ | `{file}` | `{:ok, restored}` |
---
---
**References:**
- Open Issues: https://git.local-it.org/local-it/mitgliederverwaltung/issues
- Project Board: Sprint 8 (23.10 - 13.11)
- Architecture: See [`CODE_GUIDELINES.md`](../CODE_GUIDELINES.md)
- Database Schema: See [`database-schema-readme.md`](database-schema-readme.md)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,506 @@
# Roles and Permissions - Architecture Overview
**Project:** Mila - Membership Management System
**Feature:** Role-Based Access Control (RBAC) with Hardcoded Permission Sets
**Version:** 2.0
**Last Updated:** 2025-11-13
**Status:** Architecture Design - MVP Approach
---
## Purpose of This Document
This document provides a high-level, conceptual overview of the Roles and Permissions architecture without code examples. It is designed for quick understanding of architectural decisions and concepts.
**For detailed technical implementation:** See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
---
## Table of Contents
1. [Overview](#overview)
2. [Requirements Summary](#requirements-summary)
3. [Evaluated Approaches](#evaluated-approaches)
4. [Selected Architecture](#selected-architecture)
5. [Permission System Design](#permission-system-design)
6. [User-Member Linking Strategy](#user-member-linking-strategy)
7. [Field-Level Permissions Strategy](#field-level-permissions-strategy)
8. [Migration Strategy](#migration-strategy)
9. [Related Documents](#related-documents)
---
## Overview
The Mila membership management system requires a flexible authorization system that controls:
- **Who** can access **what** resources
- **Which** pages users can view
- **How** users interact with their own vs. others' data
### Key Design Principles
1. **Simplicity First:** Start with hardcoded permissions for fast MVP delivery
2. **Performance:** No database queries for permission checks in MVP
3. **Clear Migration Path:** Easy upgrade to database-backed permissions when needed
4. **Security:** Explicit action-based authorization with no ambiguity
5. **Maintainability:** Permission logic reviewable in Git, testable as pure functions
### Core Concepts
**Permission Set:** Defines a collection of permissions (e.g., "read_only", "admin")
**Role:** A named job function that references one Permission Set (e.g., "Vorstand" uses "read_only")
**User:** Each user has exactly one Role, inheriting that Role's Permission Set
**Scope:** Defines the breadth of access - "own" (only own data), "linked" (data connected to user), "all" (everything)
---
## Evaluated Approaches
During the design phase, we evaluated multiple implementation approaches to find the optimal balance between simplicity, performance, and future extensibility.
### Approach 1: JSONB in Roles Table
Store all permissions as a single JSONB column directly in the roles table.
**Advantages:**
- Simplest database schema (single table)
- Very flexible structure
- No additional tables needed
- Fast to implement
**Disadvantages:**
- Poor queryability (can't efficiently filter by specific permissions)
- No referential integrity
- Difficult to validate structure
- Hard to audit permission changes
- Can't leverage database indexes effectively
**Verdict:** Rejected - Poor queryability makes it unsuitable for complex permission logic.
---
### Approach 2: Normalized Database Tables
Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization.
**Advantages:**
- Fully queryable with SQL
- Runtime configurable permissions
- Strong referential integrity
- Easy to audit changes
- Can index for performance
**Disadvantages:**
- Complex database schema (4+ tables)
- DB queries required for every permission check
- Requires ETS cache for performance
- Needs admin UI for permission management
- Longer implementation time (4-5 weeks)
- Overkill for fixed set of 4 permission sets
**Verdict:** Deferred to Phase 3 - Excellent for runtime configuration but too complex for MVP.
---
### Approach 3: Custom Authorizer
Implement a custom Ash Authorizer from scratch instead of using Ash Policies.
**Advantages:**
- Complete control over authorization logic
- Can implement any custom behavior
- Not constrained by Ash Policy DSL
**Disadvantages:**
- Significantly more code to write and maintain
- Loses benefits of Ash's declarative policies
- Harder to test than built-in policy system
- Mixes declarative and imperative approaches
- Must reimplement filter generation for queries
- Higher bug risk
**Verdict:** Rejected - Too much custom code, reduces maintainability and loses Ash ecosystem benefits.
---
### Approach 4: Simple Role Enum
Add a simple `:role` enum field directly on User resource with hardcoded checks in each policy.
**Advantages:**
- Very simple to implement (< 1 week)
- No extra tables needed
- Fast performance
- Easy to understand
**Disadvantages:**
- No separation between roles and permissions
- Can't add new roles without code changes
- No dynamic permission configuration
- Not extensible to field-level permissions
- Violates separation of concerns (role = job function, not permission set)
- Difficult to maintain as requirements grow
**Verdict:** Rejected - Too inflexible, doesn't meet requirement for configurable permissions and role separation.
---
### Approach 5: Hardcoded Permissions with Migration Path (SELECTED for MVP)
Permission Sets hardcoded in Elixir module, only Roles table in database.
**Advantages:**
- Fast implementation (2-3 weeks vs 4-5 weeks)
- Maximum performance (zero DB queries, < 1 microsecond)
- Simple to test (pure functions)
- Code-reviewable permissions (visible in Git)
- No migration needed for existing data
- Clearly defined 4 permission sets as required
- Clear migration path to database-backed solution (Phase 3)
- Maintains separation of roles and permission sets
**Disadvantages:**
- Permissions not editable at runtime (only role assignment possible)
- New permissions require code deployment
- Not suitable if permissions change frequently (> 1x/week)
- Limited to the 4 predefined permission sets
**Why Selected:**
- MVP requirement is for 4 fixed permission sets (not custom ones)
- No stated requirement for runtime permission editing
- Performance is critical for authorization checks
- Fast time-to-market (2-3 weeks)
- Clear upgrade path when runtime configuration becomes necessary
**Migration Path:**
When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module.
---
## Requirements Summary
### Four Predefined Permission Sets
1. **own_data** - Access only to own user account and linked member profile
2. **read_only** - Read access to all members and custom fields
3. **normal_user** - Create/Read/Update members and full CRUD on custom fields (no member deletion for safety)
4. **admin** - Unrestricted access to all resources including user management
### Example Roles
- **Mitglied (Member)** - Uses "own_data" permission set, default role
- **Vorstand (Board)** - Uses "read_only" permission set
- **Kassenwart (Treasurer)** - Uses "normal_user" permission set
- **Buchhaltung (Accounting)** - Uses "read_only" permission set
- **Admin** - Uses "admin" permission set
### Authorization Levels
**Resource Level (MVP):**
- Controls create, read, update, destroy actions on resources
- Resources: Member, User, Property, PropertyType, Role
**Page Level (MVP):**
- Controls access to LiveView pages
- Example: "/members/new" requires Member.create permission
**Field Level (Phase 2 - Future):**
- Controls read/write access to specific fields
- Example: Only Treasurer can see payment_history field
### Special Cases
1. **Own Credentials:** Users can always edit their own email and password
2. **Linked Member Email:** Only admins can edit email of members linked to users
3. **User-Member Linking:** Only admins can link/unlink users to members (except self-service creation)
---
## Selected Architecture
### Conceptual Model
```
Elixir Module: PermissionSets
↓ (defines)
Permission Set (:own_data, :read_only, :normal_user, :admin)
↓ (referenced by)
Role (stored in DB: "Vorstand" → "read_only")
↓ (assigned to)
User (each user has one role_id)
```
### Database Schema (MVP)
**Single Table: roles**
Contains:
- id (UUID)
- name (e.g., "Vorstand")
- description
- permission_set_name (String: "own_data", "read_only", "normal_user", "admin")
- is_system_role (boolean, protects critical roles)
**No Permission Tables:** Permission Sets are hardcoded in Elixir module.
### Why This Approach?
**Fast Implementation:** 2-3 weeks instead of 4-5 weeks
**Maximum Performance:**
- Zero database queries for permission checks
- Pure function calls (< 1 microsecond)
- No caching needed
**Code Review:**
- Permissions visible in Git diffs
- Easy to review changes
- No accidental runtime modifications
**Clear Upgrade Path:**
- Phase 1 (MVP): Hardcoded
- Phase 2: Add field-level permissions
- Phase 3: Migrate to database-backed with admin UI
**Meets Requirements:**
- Four predefined permission sets ✓
- Dynamic role creation ✓ (Roles in DB)
- Role-to-user assignment ✓
- No requirement for runtime permission changes stated
---
## Permission System Design
### Permission Structure
Each Permission Set contains:
**Resources:** List of resource permissions
- resource: "Member", "User", "Property", etc.
- action: :read, :create, :update, :destroy
- scope: :own, :linked, :all
- granted: true/false
**Pages:** List of accessible page paths
- Examples: "/", "/members", "/members/:id/edit"
- "*" for admin (all pages)
### Scope Definitions
**:own** - Only records where id == actor.id
- Example: User can read their own User record
**:linked** - Only records where user_id == actor.id
- Example: User can read Member linked to their account
**:all** - All records without restriction
- Example: Admin can read all Members
### How Authorization Works
1. User attempts action on resource (e.g., read Member)
2. System loads user's role from database
3. Role contains permission_set_name string
4. PermissionSets module returns permissions for that set
5. Custom Policy Check evaluates permissions against action
6. Access granted or denied based on scope
### Custom Policy Check
A reusable Ash Policy Check that:
- Reads user's permission_set_name from their role
- Calls PermissionSets.get_permissions/1
- Matches resource + action against permissions list
- Applies scope filters (own/linked/all)
- Returns authorized, forbidden, or filtered query
---
## User-Member Linking Strategy
### Problem Statement
Users need to create member profiles for themselves (self-service), but only admins should be able to:
- Link existing members to users
- Unlink members from users
- Create members pre-linked to arbitrary users
### Selected Approach: Separate Ash Actions
Instead of complex field-level validation, we use action-based authorization.
### Actions on Member Resource
**1. create_member_for_self** (All authenticated users)
- Automatically sets user_id = actor.id
- User cannot specify different user_id
- UI: "Create My Profile" button
**2. create_member** (Admin only)
- Can set user_id to any user or leave unlinked
- Full flexibility for admin
- UI: Admin member management form
**3. link_member_to_user** (Admin only)
- Updates existing member to set user_id
- Connects unlinked member to user account
**4. unlink_member_from_user** (Admin only)
- Sets user_id to nil
- Disconnects member from user account
**5. update** (Permission-based)
- Normal updates (name, address, etc.)
- user_id NOT in accept list (prevents manipulation)
- Available to users with Member.update permission
### Why Separate Actions?
**Explicit Semantics:** Each action has clear, single purpose
**Server-Side Security:** user_id set by server, not client input
**Better UX:** Different UI flows for different use cases
**Simple Policies:** Authorization at action level, not field level
**Easy Testing:** Each action independently testable
---
## Field-Level Permissions Strategy
### Status: Phase 2 (Future Implementation)
Field-level permissions are NOT implemented in MVP but have a clear strategy defined.
### Problem Statement
Some scenarios require field-level control:
- **Read restrictions:** Hide payment_history from certain roles
- **Write restrictions:** Only treasurer can edit payment fields
- **Complexity:** Ash Policies work at resource level, not field level
### Selected Strategy
**For Read Restrictions:**
Use Ash Calculations or Custom Preparations
- Calculations: Dynamically compute field based on permissions
- Preparations: Filter select to only allowed fields
- Field returns nil or "[Hidden]" if unauthorized
**For Write Restrictions:**
Use Custom Validations
- Validate changeset against field permissions
- Similar to existing linked-member email validation
- Return error if field modification not allowed
### Why This Strategy?
**Leverages Ash Features:** Uses built-in mechanisms, not custom authorizer
**Performance:** Calculations are lazy, Preparations run once per query
**Maintainable:** Clear validation logic, standard Ash patterns
**Extensible:** Easy to add new field restrictions
### Implementation Timeline
**Phase 1 (MVP):** No field-level permissions
**Phase 2:** Extend PermissionSets to include field permissions, implement Calculations/Validations
**Phase 3:** If migrating to database, add permission_set_fields table
---
## Migration Strategy
### Phase 1: MVP with Hardcoded Permissions (2-3 weeks)
**What's Included:**
- Roles table in database
- PermissionSets Elixir module with 4 predefined sets
- Custom Policy Check reading from module
- UI Authorization Helpers for LiveView
- Admin UI for role management (create, assign, delete roles)
**Limitations:**
- Permissions not editable at runtime
- New permissions require code deployment
- Only 4 permission sets available
**Benefits:**
- Fast implementation
- Maximum performance
- Simple testing and review
### Phase 2: Field-Level Permissions (Future, 2-3 weeks)
**When Needed:** Business requires field-level restrictions
**Implementation:**
- Extend PermissionSets module with :fields key
- Add Ash Calculations for read restrictions
- Add custom validations for write restrictions
- Update UI Helpers
**Migration:** No database changes, pure code additions
### Phase 3: Database-Backed Permissions (Future, 3-4 weeks)
**When Needed:** Runtime permission configuration required
**Implementation:**
- Create permission tables in database
- Seed script to migrate hardcoded permissions
- Update PermissionSets module to query database
- Add ETS cache for performance
- Build admin UI for permission management
**Migration:** Seamless, no changes to existing Policies or UI code
### Decision Matrix: When to Migrate?
| Scenario | Recommended Phase |
|----------|-------------------|
| MVP with 4 fixed permission sets | Phase 1 |
| Need field-level restrictions | Phase 2 |
| Permission changes < 1x/month | Stay Phase 1 |
| Need runtime permission config | Phase 3 |
| Custom permission sets needed | Phase 3 |
| Permission changes > 1x/week | Phase 3 |
---
## Related Documents
**This Document (Overview):** High-level concepts, no code examples
**[roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md):** Complete technical specification with code examples
**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Detailed implementation plan with TDD approach
**[CODE_GUIDELINES.md](../CODE_GUIDELINES.md):** Project coding standards
---
## Summary
The selected architecture uses **hardcoded Permission Sets in Elixir** for the MVP, providing:
- **Speed:** 2-3 weeks implementation vs 4-5 weeks
- **Performance:** Zero database queries for authorization
- **Clarity:** Permissions in Git, reviewable and testable
- **Flexibility:** Clear migration path to database-backed system
**User-Member linking** uses **separate Ash Actions** for clarity and security.
**Field-level permissions** have a **defined strategy** (Calculations + Validations) for Phase 2 implementation.
The approach balances pragmatism for MVP delivery with extensibility for future requirements.

View file

@ -1,4 +1,37 @@
defmodule Mv.Membership.Email do
@moduledoc """
Custom Ash type for validated email addresses.
## Overview
This type extends `:string` with email-specific validation constraints.
It ensures that email values stored in Property resources are valid email
addresses according to a standard regex pattern.
## Validation Rules
- Minimum length: 5 characters
- Maximum length: 254 characters (RFC 5321 maximum)
- Pattern: Standard email format (username@domain.tld)
- Automatic trimming of leading/trailing whitespace
## Usage
This type is used in the Property union type for properties with
`value_type: :email` in PropertyType definitions.
## Example
# In a property type definition
PropertyType.create!(%{
name: "work_email",
value_type: :email
})
# Valid values
"user@example.com"
"first.last@company.co.uk"
# Invalid values
"not-an-email" # Missing @ and domain
"a@b" # Too short
"""
@match_pattern ~S/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
@match_regex Regex.compile!(@match_pattern)
@min_length 5

View file

@ -1,8 +1,43 @@
defmodule Mv.Membership.Member do
@moduledoc """
Ash resource representing a club member.
## Overview
Members are the core entity in the membership management system. Each member
can have:
- Personal information (name, email, phone, address)
- Optional link to a User account (1:1 relationship)
- Dynamic custom properties via PropertyType system
- Full-text searchable profile
## Email Synchronization
When a member is linked to a user account, emails are automatically synchronized
bidirectionally. User.email is the source of truth on initial link.
See `Mv.EmailSync` for details.
## Relationships
- `has_many :properties` - Dynamic custom fields
- `has_one :user` - Optional authentication account link
## Validations
- Required: first_name, last_name, email
- Email format validation (using EctoCommons.EmailValidator)
- Phone number format: international format with 6-20 digits
- Postal code format: exactly 5 digits (German format)
- Date validations: birth_date and join_date not in future, exit_date after join_date
- Email uniqueness: prevents conflicts with unlinked users
## Full-Text Search
Members have a `search_vector` attribute (tsvector) that is automatically
updated via database trigger. Search includes name, email, notes, and contact fields.
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
require Ash.Query
import Ash.Expr
postgres do
table "members"
repo Mv.Repo
@ -108,6 +143,50 @@ defmodule Mv.Membership.Member do
where [changing(:user)]
end
end
# Action to handle fuzzy search on specific fields
read :search do
argument :query, :string, allow_nil?: true
argument :similarity_threshold, :float, allow_nil?: true
prepare fn query, _ctx ->
q = Ash.Query.get_argument(query, :query) || ""
# 0.2 as similarity threshold (recommended) - lower value can lead to more results but also to more unspecific results
threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2
if is_binary(q) and String.trim(q) != "" do
q2 = String.trim(q)
pat = "%" <> q2 <> "%"
# FTS as main filter and fuzzy search just for first name, last name and strees
query
|> Ash.Query.filter(
expr(
# Substring on numeric-like fields (best effort, supports middle substrings)
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or
contains(postal_code, ^q2) or
contains(house_number, ^q2) or
contains(phone_number, ^q2) or
contains(email, ^q2) or
contains(city, ^q2) or ilike(city, ^pat) or
fragment("? % first_name", ^q2) or
fragment("? % last_name", ^q2) or
fragment("? % street", ^q2) or
fragment("word_similarity(?, first_name) > ?", ^q2, ^threshold) or
fragment("word_similarity(?, last_name) > ?", ^q2, ^threshold) or
fragment("word_similarity(?, street) > ?", ^q2, ^threshold) or
fragment("similarity(first_name, ?) > ?", ^q2, ^threshold) or
fragment("similarity(last_name, ?) > ?", ^q2, ^threshold) or
fragment("similarity(street, ?) > ?", ^q2, ^threshold)
)
)
else
query
end
end
end
end
validations do
@ -281,4 +360,21 @@ defmodule Mv.Membership.Member do
identities do
identity :unique_email, [:email]
end
# Fuzzy Search function that can be called by live view and calls search action
def fuzzy_search(query, opts) do
q = (opts[:query] || opts["query"] || "") |> to_string()
if String.trim(q) == "" do
query
else
args =
case opts[:fields] || opts["fields"] do
nil -> %{query: q}
fields -> %{query: q, fields: fields}
end
Ash.Query.for_read(query, :search, args)
end
end
end

View file

@ -1,4 +1,21 @@
defmodule Mv.Membership do
@moduledoc """
Ash Domain for membership management.
## Resources
- `Member` - Club members with personal information and custom properties
- `Property` - Dynamic custom field values attached to members
- `PropertyType` - Schema definitions for custom properties
## Public API
The domain exposes these main actions:
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
- Property management: `create_property/1`, `list_property/0`, etc.
- PropertyType management: `create_property_type/1`, `list_property_types/0`, etc.
## Admin Interface
The domain is configured with AshAdmin for management UI.
"""
use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix]

View file

@ -1,4 +1,36 @@
defmodule Mv.Membership.Property do
@moduledoc """
Ash resource representing a custom property value for a member.
## Overview
Properties implement the Entity-Attribute-Value (EAV) pattern, allowing
dynamic custom fields to be attached to members. Each property links a
member to a property type and stores the actual value.
## Value Storage
Values are stored using Ash's union type with JSONB storage format:
```json
{
"type": "string",
"value": "example"
}
```
## Supported Types
- `:string` - Text data
- `:integer` - Numeric data
- `:boolean` - True/false flags
- `:date` - Date values
- `:email` - Validated email addresses (custom type)
## Relationships
- `belongs_to :member` - The member this property belongs to (CASCADE delete)
- `belongs_to :property_type` - The property type definition
## Constraints
- Each member can have only one property per property type (unique composite index)
- Properties are deleted when the associated member is deleted (CASCADE)
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer

View file

@ -1,4 +1,48 @@
defmodule Mv.Membership.PropertyType do
@moduledoc """
Ash resource defining the schema for custom member properties.
## Overview
PropertyTypes define the "schema" for custom fields in the membership system.
Each PropertyType specifies the name, data type, and behavior of a custom field
that can be attached to members via Property resources.
## Attributes
- `name` - Unique identifier for the property (e.g., "phone_mobile", "birthday")
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
- `description` - Optional human-readable description
- `immutable` - If true, property values cannot be changed after creation
- `required` - If true, all members must have this property (future feature)
## Supported Value Types
- `:string` - Text data (unlimited length)
- `:integer` - Numeric data (64-bit integers)
- `:boolean` - True/false flags
- `:date` - Date values (no time component)
- `:email` - Validated email addresses
## Relationships
- `has_many :properties` - All property values of this type
## Constraints
- Name must be unique across all property types
- Cannot delete a property type that has existing property values (RESTRICT)
## Examples
# Create a new property type
PropertyType.create!(%{
name: "phone_mobile",
value_type: :string,
description: "Mobile phone number"
})
# Create a required property type
PropertyType.create!(%{
name: "emergency_contact",
value_type: :string,
required: true
})
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer

View file

@ -10,6 +10,20 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
alias Mv.Mailer
@doc """
Sends a confirmation email to a new user.
This function is called automatically by AshAuthentication when a new
user registers and needs to confirm their email address.
## Parameters
- `user` - The user record who needs to confirm their email
- `token` - The confirmation token to include in the email link
- `_opts` - Additional options (unused)
## Returns
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
"""
@impl true
def send(user, token, _) do
new()

View file

@ -10,6 +10,20 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
alias Mv.Mailer
@doc """
Sends a password reset email to a user.
This function is called automatically by AshAuthentication when a user
requests a password reset.
## Parameters
- `user` - The user record requesting the password reset
- `token` - The password reset token to include in the email link
- `_opts` - Additional options (unused)
## Returns
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
"""
@impl true
def send(user, token, _) do
new()

View file

@ -9,6 +9,22 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
"""
use Ash.Resource.Validation
@doc """
Validates email uniqueness across linked User-Member pairs.
This validation ensures that when a user is linked to a member, their email
does not conflict with another member's email. It only runs when necessary
to avoid blocking valid operations (see `@moduledoc` for trigger conditions).
## Parameters
- `changeset` - The Ash changeset being validated
- `_opts` - Options passed to the validation (unused)
- `_context` - Ash context map (unused)
## Returns
- `:ok` if validation passes or should be skipped
- `{:error, field: :email, message: ..., value: ...}` if validation fails
"""
@impl true
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)

View file

@ -10,6 +10,21 @@ defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
use Ash.Resource.Change
alias Mv.EmailSync.{Helpers, Loader}
@doc """
Implements the email synchronization from Member to User.
This function is called automatically by Ash when the configured trigger
conditions are met (see `@moduledoc` for trigger details).
## Parameters
- `changeset` - The Ash changeset being processed
- `_opts` - Options passed to the change (unused)
- `context` - Ash context map containing metadata (e.g., `:syncing_email` flag)
## Returns
Modified changeset with email synchronization applied, or original changeset
if recursion detected.
"""
@impl true
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses

View file

@ -12,6 +12,21 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
use Ash.Resource.Change
alias Mv.EmailSync.{Helpers, Loader}
@doc """
Implements the email synchronization from User to Member.
This function is called automatically by Ash when the configured trigger
conditions are met (see `@moduledoc` for trigger details).
## Parameters
- `changeset` - The Ash changeset being processed
- `_opts` - Options passed to the change (unused)
- `context` - Ash context map containing metadata (e.g., `:syncing_email` flag)
## Returns
Modified changeset with email synchronization applied, or original changeset
if recursion detected.
"""
@impl true
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses

View file

@ -9,6 +9,22 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
"""
use Ash.Resource.Validation
@doc """
Validates email uniqueness across linked Member-User pairs.
This validation ensures that when a member is linked to a user, their email
does not conflict with another user's email. It only runs when necessary
to avoid blocking valid operations (see `@moduledoc` for trigger conditions).
## Parameters
- `changeset` - The Ash changeset being validated
- `_opts` - Options passed to the validation (unused)
- `_context` - Ash context map (unused)
## Returns
- `:ok` if validation passes or should be skipped
- `{:error, field: :email, message: ..., value: ...}` if validation fails
"""
@impl true
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)

View file

@ -5,7 +5,7 @@ defmodule Mv.Repo do
@impl true
def installed_extensions do
# Add extensions here, and the migration generator will install them.
["ash-functions", "citext"]
["ash-functions", "citext", "pg_trgm"]
end
# Don't open unnecessary transactions

View file

@ -1,4 +1,23 @@
defmodule Mv.Secrets do
@moduledoc """
Secret provider for AshAuthentication.
## Purpose
Provides runtime configuration secrets for Ash Authentication strategies,
particularly for OIDC (Rauthy) authentication.
## Configuration Source
Secrets are read from the `:rauthy` key in the application configuration,
which is typically set in `config/runtime.exs` from environment variables:
- `OIDC_CLIENT_ID`
- `OIDC_CLIENT_SECRET`
- `OIDC_BASE_URL`
- `OIDC_REDIRECT_URI`
## Usage
This module is automatically called by AshAuthentication when resolving
secrets for the User resource's OIDC strategy.
"""
use AshAuthentication.Secret
def secret_for(

View file

@ -1,4 +1,16 @@
defmodule MvWeb.AuthOverrides do
@moduledoc """
UI customizations for AshAuthentication Phoenix components.
## Overrides
- `SignIn` - Restricts form width to prevent full-width display
- `Banner` - Replaces default logo with "Mitgliederverwaltung" text
- `HorizontalRule` - Translates "or" text to German
## Documentation
For complete reference on available overrides, see:
https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
"""
use AshAuthentication.Phoenix.Overrides
use Gettext, backend: MvWeb.Gettext

View file

@ -1,4 +1,33 @@
defmodule MvWeb.MemberLive.Form do
@moduledoc """
LiveView form for creating and editing members.
## Features
- Create new members with personal information
- Edit existing member details
- Manage custom properties (dynamic fields)
- Real-time validation with visual feedback
- Link/unlink user accounts
## Form Fields
**Required:**
- first_name, last_name, email
**Optional:**
- birth_date, phone_number, address fields (city, street, house_number, postal_code)
- join_date, exit_date
- paid status
- notes
## Custom Properties
Members can have dynamic custom properties defined by PropertyTypes.
The form dynamically renders inputs based on available PropertyTypes.
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update member)
- Property management events for adding/removing custom fields
"""
use MvWeb, :live_view
@impl true

View file

@ -1,8 +1,37 @@
defmodule MvWeb.MemberLive.Index do
use MvWeb, :live_view
import Ash.Expr
import Ash.Query
@moduledoc """
LiveView for displaying and managing the member list.
## Features
- Full-text search across member profiles using PostgreSQL tsvector
- Sortable columns (name, email, address fields)
- Bulk selection for future batch operations
- Real-time updates via LiveView
- Bookmarkable URLs with query parameters
## URL Parameters
- `query` - Search query string for full-text search
- `sort_field` - Field to sort by (e.g., :first_name, :email, :join_date)
- `sort_order` - Sort direction (:asc or :desc)
## Events
- `delete` - Remove a member from the database
- `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members
## Implementation Notes
- Search uses PostgreSQL full-text search (plainto_tsquery)
- Sort state is synced with URL for bookmarkability
- Components communicate via `handle_info` for decoupling
"""
use MvWeb, :live_view
@doc """
Initializes the LiveView state.
Sets up initial assigns for page title, search query, sort configuration,
and member selection. Actual data loading happens in `handle_params/3`.
"""
@impl true
def mount(_params, _session, socket) do
socket =
@ -21,7 +50,14 @@ defmodule MvWeb.MemberLive.Index do
# Handle Events
# -----------------------------------------------------------------
# Delete a member
@doc """
Handles member-related UI events.
## Supported events:
- `"delete"` - Removes a member from the database
- `"select_member"` - Toggles individual member selection
- `"select_all"` - Toggles selection of all visible members
"""
@impl true
def handle_event("delete", %{"id" => id}, socket) do
member = Ash.get!(Mv.Membership.Member, id)
@ -31,7 +67,6 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :members, updated_members)}
end
# Selects one member in the list of members
@impl true
def handle_event("select_member", %{"id" => id}, socket) do
selected =
@ -44,7 +79,6 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :selected_members, selected)}
end
# Selects all members in the list of members
@impl true
def handle_event("select_all", _params, socket) do
members = socket.assigns.members
@ -65,57 +99,23 @@ defmodule MvWeb.MemberLive.Index do
# Handle Infos from Child Components
# -----------------------------------------------------------------
# Sorts the list of members according to a field, when you click on the column header
@doc """
Handles messages from child components.
## Supported messages:
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
"""
@impl true
def handle_info({:sort, field_str}, socket) do
field = String.to_existing_atom(field_str)
old_field = socket.assigns.sort_field
{new_field, new_order} = determine_new_sort(field, socket)
{new_order, new_field} =
if socket.assigns.sort_field == field do
{toggle_order(socket.assigns.sort_order), field}
else
{:asc, field}
end
active_id = :"sort_#{new_field}"
old_id = :"sort_#{old_field}"
# Update the new SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: active_id,
sort_field: new_field,
sort_order: new_order
)
# Reset the current SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: old_id,
sort_field: new_field,
sort_order: new_order
)
existing_search_query = socket.assigns.query
# Build the URL with queries
query_params = %{
"query" => existing_search_query,
"sort_field" => Atom.to_string(new_field),
"sort_order" => Atom.to_string(new_order)
}
# Set the new path with params
new_path = ~p"/members?#{query_params}"
# Push the new URL
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
socket
|> update_sort_components(socket.assigns.sort_field, new_field, new_order)
|> push_sort_url(new_field, new_order)
end
# Function to handle search
@impl true
def handle_info({:search_changed, q}, socket) do
socket = load_members(socket, q)
@ -144,6 +144,13 @@ defmodule MvWeb.MemberLive.Index do
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
@doc """
Handles URL parameter changes.
Parses query parameters for search query, sort field, and sort order,
then loads members accordingly. This enables bookmarkable URLs and
browser back/forward navigation.
"""
@impl true
def handle_params(params, _url, socket) do
socket =
@ -158,6 +165,55 @@ defmodule MvWeb.MemberLive.Index do
# -------------------------------------------------------------
# FUNCTIONS
# -------------------------------------------------------------
# Determines new sort field and order based on current state
defp determine_new_sort(field, socket) do
if socket.assigns.sort_field == field do
{field, toggle_order(socket.assigns.sort_order)}
else
{field, :asc}
end
end
# Updates both the active and old SortHeader components
defp update_sort_components(socket, old_field, new_field, new_order) do
active_id = :"sort_#{new_field}"
old_id = :"sort_#{old_field}"
# Update the new SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: active_id,
sort_field: new_field,
sort_order: new_order
)
# Reset the current SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: old_id,
sort_field: new_field,
sort_order: new_order
)
socket
end
# Builds sort URL and pushes navigation patch
defp push_sort_url(socket, field, order) do
query_params = %{
"query" => socket.assigns.query,
"sort_field" => Atom.to_string(field),
"sort_order" => Atom.to_string(order)
}
new_path = ~p"/members?#{query_params}"
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
# Load members eg based on a query for sorting
defp load_members(socket, search_query) do
query =
@ -194,7 +250,9 @@ defmodule MvWeb.MemberLive.Index do
defp apply_search_filter(query, search_query) do
if search_query && String.trim(search_query) != "" do
query
|> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^search_query)))
|> Mv.Membership.Member.fuzzy_search(%{
query: search_query
})
else
query
end

View file

@ -1,4 +1,26 @@
defmodule MvWeb.MemberLive.Show do
@moduledoc """
LiveView for displaying a single member's details.
## Features
- Display all member information (personal, contact, address)
- Show linked user account (if exists)
- Display custom properties
- Navigate to edit form
- Return to member list
## Displayed Information
- Basic: name, email, dates (birth, join, exit)
- Contact: phone number
- Address: street, house number, postal code, city
- Status: paid flag
- Relationships: linked user account
- Custom: dynamic properties from PropertyTypes
## Navigation
- Back to member list
- Edit member (with return_to parameter for back navigation)
"""
use MvWeb, :live_view
import Ash.Query

View file

@ -1,4 +1,35 @@
defmodule MvWeb.PropertyLive.Form do
@moduledoc """
LiveView form for creating and editing properties.
## Features
- Create new properties with member and type selection
- Edit existing property values
- Value input adapts to property type (string, integer, boolean, date, email)
- Real-time validation
## Form Fields
**Required:**
- member - Select which member owns this property
- property_type - Select the type (defines value type)
- value - The actual value (input type depends on property type)
## Value Types
The form dynamically renders appropriate inputs based on property type:
- String: text input
- Integer: number input
- Boolean: checkbox
- Date: date picker
- Email: email input with validation
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update property)
## Note
Properties are typically managed through the member edit form,
not through this standalone form.
"""
use MvWeb, :live_view
@impl true

View file

@ -1,4 +1,26 @@
defmodule MvWeb.PropertyLive.Index do
@moduledoc """
LiveView for displaying and managing properties.
## Features
- List all properties with their values and types
- Show which member each property belongs to
- Display property type information
- Navigate to property details and edit forms
- Delete properties
## Relationships
Each property is linked to:
- A member (the property owner)
- A property type (defining value type and behavior)
## Events
- `delete` - Remove a property from the database
## Note
Properties are typically managed through the member edit form.
This view provides a global overview of all properties.
"""
use MvWeb, :live_view
@impl true

View file

@ -1,4 +1,24 @@
defmodule MvWeb.PropertyLive.Show do
@moduledoc """
LiveView for displaying a single property's details.
## Features
- Display property value and type
- Show linked member
- Show property type definition
- Navigate to edit form
- Return to property list
## Displayed Information
- Property value (formatted based on type)
- Property type name and description
- Member information (who owns this property)
- Property metadata (ID, timestamps if added)
## Navigation
- Back to property list
- Edit property
"""
use MvWeb, :live_view
@impl true

View file

@ -1,4 +1,38 @@
defmodule MvWeb.PropertyTypeLive.Form do
@moduledoc """
LiveView form for creating and editing property types (admin).
## Features
- Create new property type definitions
- Edit existing property types
- Select value type from supported types
- Set immutable and required flags
- Real-time validation
## Form Fields
**Required:**
- name - Unique identifier (e.g., "phone_mobile", "emergency_contact")
- value_type - Data type (:string, :integer, :boolean, :date, :email)
**Optional:**
- description - Human-readable explanation
- immutable - If true, values cannot be changed after creation (default: false)
- required - If true, all members must have this property (default: false)
## Value Type Selection
- `:string` - Text data (unlimited length)
- `:integer` - Numeric data
- `:boolean` - True/false flags
- `:date` - Date values
- `:email` - Validated email addresses
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update property type)
## Security
Property type management is restricted to admin users.
"""
use MvWeb, :live_view
@impl true

View file

@ -1,4 +1,28 @@
defmodule MvWeb.PropertyTypeLive.Index do
@moduledoc """
LiveView for managing property type definitions (admin).
## Features
- List all property types
- Display type information (name, value type, description)
- Show immutable and required flags
- Create new property types
- Edit existing property types
- Delete property types (if no properties use them)
## Displayed Information
- Name: Unique identifier for the property type
- Value type: Data type constraint (string, integer, boolean, date, email)
- Description: Human-readable explanation
- Immutable: Whether property values can be changed after creation
- Required: Whether all members must have this property (future feature)
## Events
- `delete` - Remove a property type (only if no properties exist)
## Security
Property type management is restricted to admin users.
"""
use MvWeb, :live_view
@impl true

View file

@ -1,4 +1,27 @@
defmodule MvWeb.PropertyTypeLive.Show do
@moduledoc """
LiveView for displaying a single property type's details (admin).
## Features
- Display property type definition
- Show all attributes (name, value type, description, flags)
- Navigate to edit form
- Return to property type list
## Displayed Information
- Name: Unique identifier
- Value type: Data type constraint
- Description: Optional explanation
- Immutable flag: Whether values can be changed
- Required flag: Whether all members need this property
## Navigation
- Back to property type list
- Edit property type
## Security
Property type details are restricted to admin users.
"""
use MvWeb, :live_view
@impl true

View file

@ -1,4 +1,36 @@
defmodule MvWeb.UserLive.Form do
@moduledoc """
LiveView form for creating and editing users.
## Features
- Create new users with email
- Edit existing user details
- Optional password setting (checkbox to toggle)
- Link/unlink member accounts
- Email synchronization with linked members
## Form Fields
**Required:**
- email
**Optional:**
- password (for password authentication strategy)
- linked member (select from existing members)
## Password Management
- New users: Can optionally set password with confirmation
- Existing users: Can change password (no confirmation required, admin action)
- Checkbox toggles password section visibility
## Member Linking
Users can be linked to existing member accounts. When linked, emails are
synchronized bidirectionally with User.email as the source of truth.
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update user)
- `toggle_password_section` - Show/hide password fields
"""
use MvWeb, :live_view
@impl true

View file

@ -1,4 +1,25 @@
defmodule MvWeb.UserLive.Index do
@moduledoc """
LiveView for displaying and managing the user list.
## Features
- List all users with email and linked member
- Sort users by email (default)
- Delete users
- Navigate to user details and edit forms
- Bulk selection for future batch operations
## Relationships
Displays linked member information when a user is connected to a member account.
## Events
- `delete` - Remove a user from the database
- `select_user` - Toggle individual user selection
- `select_all` - Toggle selection of all visible users
## Security
User deletion requires admin permissions (enforced by Ash policies).
"""
use MvWeb, :live_view
import MvWeb.TableComponents

View file

@ -1,4 +1,29 @@
defmodule MvWeb.UserLive.Show do
@moduledoc """
LiveView for displaying a single user's details.
## Features
- Display user information (email, OIDC ID)
- Show authentication methods (password, OIDC)
- Display linked member account (if exists)
- Navigate to edit form
- Return to user list
## Displayed Information
- Email address
- OIDC ID (if authenticated via OIDC)
- Password authentication status
- Linked member (name and email)
## Authentication Status
Shows which authentication methods are enabled for the user:
- Password authentication (has hashed_password)
- OIDC authentication (has oidc_id)
## Navigation
- Back to user list
- Edit user (with return_to parameter for back navigation)
"""
use MvWeb, :live_view
@impl true

View file

@ -1,4 +1,16 @@
defmodule MvWeb.LiveHelpers do
@moduledoc """
Shared LiveView lifecycle hooks and helper functions.
## on_mount Hooks
- `:default` - Sets the user's locale from session (defaults to "de")
## Usage
Add to LiveView modules via:
```elixir
on_mount {MvWeb.LiveHelpers, :default}
```
"""
def on_mount(:default, _params, session, socket) do
locale = session["locale"] || "de"
Gettext.put_locale(locale)

View file

@ -0,0 +1,66 @@
defmodule Mv.Repo.Migrations.AddTrigramToMembers do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
# activate trigram-extension
execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
# -------------------------------------------------
# TrigramIndizes (GIN) for fields we want to search in
# -------------------------------------------------
#
# `gin_trgm_ops` ist the operator-class-name
#
execute("""
CREATE INDEX members_first_name_trgm_idx
ON members
USING GIN (first_name gin_trgm_ops);
""")
execute("""
CREATE INDEX members_last_name_trgm_idx
ON members
USING GIN (last_name gin_trgm_ops);
""")
execute("""
CREATE INDEX members_email_trgm_idx
ON members
USING GIN (email gin_trgm_ops);
""")
execute("""
CREATE INDEX members_city_trgm_idx
ON members
USING GIN (city gin_trgm_ops);
""")
execute("""
CREATE INDEX members_street_trgm_idx
ON members
USING GIN (street gin_trgm_ops);
""")
execute("""
CREATE INDEX members_notes_trgm_idx
ON members
USING GIN (notes gin_trgm_ops);
""")
end
def down do
execute("DROP INDEX IF EXISTS members_first_name_trgm_idx;")
execute("DROP INDEX IF EXISTS members_last_name_trgm_idx;")
execute("DROP INDEX IF EXISTS members_email_trgm_idx;")
execute("DROP INDEX IF EXISTS members_city_trgm_idx;")
execute("DROP INDEX IF EXISTS members_street_trgm_idx;")
execute("DROP INDEX IF EXISTS members_notes_trgm_idx;")
end
end

View file

@ -0,0 +1,199 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v7()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "first_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "last_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "birth_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "paid",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "phone_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "join_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "exit_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "notes",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "city",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "street",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "house_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "postal_code",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "search_vector",
"type": "tsvector"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "9019AD59832AB926899B6A871A368CF65F757533795E4E38D5C0EE6AE58BE070",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "members"
}

View file

@ -0,0 +1,443 @@
defmodule Mv.Membership.FuzzySearchTest do
use Mv.DataCase, async: false
test "fuzzy_search/2 function exists" do
assert function_exported?(Mv.Membership.Member, :fuzzy_search, 2)
end
test "fuzzy_search returns only John Doe by fuzzy query 'john'" do
{:ok, john} =
Mv.Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john.doe@example.com"
})
{:ok, _jane} =
Mv.Membership.create_member(%{
first_name: "Adriana",
last_name: "Smith",
email: "adriana.smith@example.com"
})
{:ok, alice} =
Mv.Membership.create_member(%{
first_name: "Alice",
last_name: "Johnson",
email: "alice.johnson@example.com"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{
query: "john"
})
|> Ash.read!()
assert Enum.map(result, & &1.id) == [john.id, alice.id]
end
test "fuzzy_search finds 'Thomas' when searching misspelled 'tomas'" do
{:ok, thomas} =
Mv.Membership.create_member(%{
first_name: "Thomas",
last_name: "Doe",
email: "john.doe@example.com"
})
{:ok, jane} =
Mv.Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane.smith@example.com"
})
{:ok, _alice} =
Mv.Membership.create_member(%{
first_name: "Alice",
last_name: "Johnson",
email: "alice.johnson@example.com"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{
query: "tomas"
})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert thomas.id in ids
refute jane.id in ids
assert length(ids) >= 1
end
test "empty query returns all members" do
{:ok, a} =
Mv.Membership.create_member(%{first_name: "A", last_name: "One", email: "a1@example.com"})
{:ok, b} =
Mv.Membership.create_member(%{first_name: "B", last_name: "Two", email: "b2@example.com"})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: ""})
|> Ash.read!()
assert Enum.sort(Enum.map(result, & &1.id))
|> Enum.uniq()
|> Enum.sort()
|> Enum.all?(fn id -> id in [a.id, b.id] end)
end
test "substring numeric search matches postal_code mid-string" do
{:ok, m1} =
Mv.Membership.create_member(%{
first_name: "Num",
last_name: "One",
email: "n1@example.com",
postal_code: "12345"
})
{:ok, _m2} =
Mv.Membership.create_member(%{
first_name: "Num",
last_name: "Two",
email: "n2@example.com",
postal_code: "67890"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "345"})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert m1.id in ids
end
test "substring numeric search matches house_number mid-string" do
{:ok, m1} =
Mv.Membership.create_member(%{
first_name: "Home",
last_name: "One",
email: "h1@example.com",
house_number: "A345B"
})
{:ok, _m2} =
Mv.Membership.create_member(%{
first_name: "Home",
last_name: "Two",
email: "h2@example.com",
house_number: "77"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "345"})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert m1.id in ids
end
test "fuzzy matches street misspelling" do
{:ok, s1} =
Mv.Membership.create_member(%{
first_name: "Road",
last_name: "Test",
email: "s1@example.com",
street: "Main Street"
})
{:ok, _s2} =
Mv.Membership.create_member(%{
first_name: "Road",
last_name: "Other",
email: "s2@example.com",
street: "Second Avenue"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "mainn"})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert s1.id in ids
end
test "substring in city matches mid-string" do
{:ok, b} =
Mv.Membership.create_member(%{
first_name: "City",
last_name: "One",
email: "city1@example.com",
city: "Berlin"
})
{:ok, _m} =
Mv.Membership.create_member(%{
first_name: "City",
last_name: "Two",
email: "city2@example.com",
city: "München"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "erl"})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert b.id in ids
end
test "blank character handling: query with spaces matches full name" do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john.doe@example.com"
})
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane.smith@example.com"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "john doe"})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "blank character handling: query with multiple spaces is handled" do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Mary",
last_name: "Jane",
email: "mary.jane@example.com"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "mary jane"})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "special character handling: @ symbol in query matches email" do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "User",
email: "test.user@example.com"
})
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "Other",
last_name: "Person",
email: "other.person@different.org"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "example"})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "special character handling: dot in query matches email" do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Dot",
last_name: "Test",
email: "dot.test@example.com"
})
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "No",
last_name: "Dot",
email: "nodot@example.com"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "dot.test"})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "special character handling: hyphen in query matches data" do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Mary-Jane",
last_name: "Watson",
email: "mary.jane@example.com"
})
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "Mary",
last_name: "Smith",
email: "mary.smith@example.com"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "mary-jane"})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "unicode character handling: umlaut ö in query matches data" do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Jörg",
last_name: "Schmidt",
email: "joerg.schmidt@example.com"
})
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "John",
last_name: "Smith",
email: "john.smith@example.com"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "jörg"})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "unicode character handling: umlaut ä in query matches data" do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Märta",
last_name: "Andersson",
email: "maerta.andersson@example.com"
})
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "Marta",
last_name: "Johnson",
email: "marta.johnson@example.com"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "märta"})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "unicode character handling: umlaut ü in query matches data" do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Günther",
last_name: "Müller",
email: "guenther.mueller@example.com"
})
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "Gunter",
last_name: "Miller",
email: "gunter.miller@example.com"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "müller"})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "unicode character handling: query without umlaut matches data with umlaut" do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Müller",
last_name: "Schmidt",
email: "mueller.schmidt@example.com"
})
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "Miller",
last_name: "Smith",
email: "miller.smith@example.com"
})
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "muller"})
|> Ash.read!()
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "very long search strings: handles long query without error" do
{:ok, _member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "User",
email: "test@example.com"
})
long_query = String.duplicate("a", 1000)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: long_query})
|> Ash.read!()
# Should not crash, may return empty or some results
assert is_list(result)
end
test "very long search strings: handles extremely long query" do
{:ok, _member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "User",
email: "test@example.com"
})
very_long_query = String.duplicate("test query ", 1000)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: very_long_query})
|> Ash.read!()
# Should not crash, may return empty or some results
assert is_list(result)
end
end