diff --git a/.drone.yml b/.drone.yml index 483a08a..623114f 100644 --- a/.drone.yml +++ b/.drone.yml @@ -53,8 +53,6 @@ steps: - mix hex.audit # Provide hints for improving code quality - mix credo - # Check that translations are up to date - - mix gettext.extract --check-up-to-date - name: wait_for_postgres image: docker.io/library/postgres:17.6 @@ -166,7 +164,7 @@ environment: steps: - name: renovate - image: renovate/renovate:41.173 + image: renovate/renovate:41.151 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: diff --git a/.gitignore b/.gitignore index 9517a21..63ff39e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,3 @@ npm-debug.log .env .elixir_ls/ - -# Docker secrets directory (generated by `just init-secrets`) -/secrets/ diff --git a/.tool-versions b/.tool-versions index 98239f3..60315fc 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ elixir 1.18.3-otp-27 erlang 27.3.4 -just 1.43.1 +just 1.43.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b4a37..74df997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,15 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PostgreSQL trigram-based member search with typo tolerance - WCAG 2.1 AA compliant autocomplete dropdown with ARIA support - Bilingual UI (German/English) for member linking workflow -- **Bulk email copy feature** - Copy email addresses of selected members to clipboard (#230) - - Email format: "First Last " with semicolon separator (compatible with email clients) - - CopyToClipboard JavaScript hook with fallback for older browsers - - Button shows count of visible selected members (respects search/filter) - - German/English translations -- Docker secrets support via `_FILE` environment variables for all sensitive configuration (SECRET_KEY_BASE, TOKEN_SIGNING_SECRET, OIDC_CLIENT_SECRET, DATABASE_URL, DATABASE_PASSWORD) ### Fixed - Email validation false positive when linking user and member with identical emails (#168 Problem #4) - Relationship data extraction from Ash manage_relationship during validation -- Copy button count now shows only visible selected members when filtering diff --git a/Justfile b/Justfile index b835cf4..b28dbdc 100644 --- a/Justfile +++ b/Justfile @@ -1,7 +1,4 @@ set dotenv-load := true -set export := true - -MIX_QUIET := "1" run: install-dependencies start-database migrate-database seed-database mix phx.server @@ -32,7 +29,6 @@ lint: mix format --check-formatted mix compile --warnings-as-errors mix credo - mix gettext.extract --check-up-to-date audit: mix sobelow --config @@ -87,33 +83,4 @@ regen-migrations migration_name commit_hash='': clean: mix clean rm -rf .elixir_ls - rm -rf _build - -# Remove Git merge conflict markers from gettext files -remove-gettext-conflicts: - #!/usr/bin/env bash - set -euo pipefail - find priv/gettext -type f -exec sed -i '/^<<<<<<>>>>>>/d; /^%%%%%%%/d; /^++++++/d; s/^+//; s/^-//' {} \; - -# Production environment commands -# ================================ - -# Initialize secrets directory with generated secrets (only if not exists) -init-prod-secrets: - #!/usr/bin/env bash - set -euo pipefail - if [ -d "secrets" ]; then - echo "Secrets directory already exists. Skipping generation." - exit 0 - fi - echo "Creating secrets directory and generating secrets..." - mkdir -p secrets - mix phx.gen.secret > secrets/secret_key_base.txt - mix phx.gen.secret > secrets/token_signing_secret.txt - openssl rand -base64 32 | tr -d '\n' > secrets/db_password.txt - touch secrets/oidc_client_secret.txt - echo "Secrets generated in ./secrets/" - -# Start production environment with Docker Compose -start-prod: init-prod-secrets - docker compose -f docker-compose.prod.yml up -d \ No newline at end of file + rm -rf _build \ No newline at end of file diff --git a/README.md b/README.md index 090f4e9..6db7980 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Our philosophy: **software should help people spend less time on administration - 🚧 Sorting & filtering - 🚧 Roles & permissions (e.g. board, treasurer) - βœ… Custom fields (flexible per club needs) -- βœ… SSO via OIDC (works with Authentik, Rauthy, Keycloak, etc.) +- βœ… SSO via OIDC (tested with Rauthy) - 🚧 Self-service & online application - 🚧 Accessibility, GDPR, usability improvements - 🚧 Email sending @@ -147,26 +147,7 @@ Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance i 5. copy client secret to `.env` file 6. abort and run `just run` again -Now you can log in to Mila via OIDC! - -### OIDC with other providers (Authentik, Keycloak, etc.) - -Mila works with any OIDC-compliant provider. The internal strategy is named `:rauthy`, but this is just a name β€” it works with any provider. - -**Important:** The redirect URI must always end with `/auth/user/rauthy/callback`. - -Example for Authentik: -1. Create an OAuth2/OpenID Provider in Authentik -2. Set the redirect URI to: `https://your-domain.com/auth/user/rauthy/callback` -3. Configure environment variables: - ```bash - DOMAIN=your-domain.com # or PHX_HOST=your-domain.com - OIDC_CLIENT_ID=your-client-id - OIDC_BASE_URL=https://auth.example.com/application/o/your-app - OIDC_CLIENT_SECRET=your-client-secret # or use OIDC_CLIENT_SECRET_FILE - ``` - -The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/callback` if not explicitly set. +Now you can log in to Mila via OIDC! ## βš™οΈ Configuration @@ -229,20 +210,13 @@ For testing the production Docker build locally: # Required variables: SECRET_KEY_BASE= TOKEN_SIGNING_SECRET= - DOMAIN=localhost # or PHX_HOST=localhost + PHX_HOST=localhost - # Optional OIDC configuration: + # Optional (have defaults in docker-compose.prod.yml): # OIDC_CLIENT_ID=mv # OIDC_BASE_URL=http://localhost:8080/auth/v1 - # OIDC_CLIENT_SECRET= - # OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/rauthy/callback - - # Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars): - # SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base - # TOKEN_SIGNING_SECRET_FILE=/run/secrets/token_signing_secret - # OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret - # DATABASE_URL_FILE=/run/secrets/database_url - # DATABASE_PASSWORD_FILE=/run/secrets/database_password + # OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback + # OIDC_CLIENT_SECRET= ``` 3. **Start development environment** (for Rauthy): @@ -276,7 +250,7 @@ For actual production deployment: - Set `OIDC_BASE_URL` to your production OIDC provider - Configure proper Docker networks 3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik) -4. **Use secure secrets management** β€” All sensitive environment variables support a `_FILE` suffix for Docker secrets (e.g., `SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base`). See `docker-compose.prod.yml` for an example setup with Docker secrets. +4. **Use secure secrets management** (environment variables, Docker secrets, vault) 5. **Configure database backups** diff --git a/assets/js/app.js b/assets/js/app.js index 883ca30..5b3f462 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -27,33 +27,6 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute(" // Hooks for LiveView components let Hooks = {} -// CopyToClipboard hook: Copies text to clipboard when triggered by server event -Hooks.CopyToClipboard = { - mounted() { - this.handleEvent("copy_to_clipboard", ({text}) => { - if (navigator.clipboard) { - navigator.clipboard.writeText(text).catch(err => { - console.error("Clipboard write failed:", err) - }) - } else { - // Fallback for older browsers - const textArea = document.createElement("textarea") - textArea.value = text - textArea.style.position = "fixed" - textArea.style.left = "-999999px" - document.body.appendChild(textArea) - textArea.select() - try { - document.execCommand("copy") - } catch (err) { - console.error("Fallback clipboard copy failed:", err) - } - document.body.removeChild(textArea) - } - }) - } -} - // ComboBox hook: Prevents form submission when Enter is pressed in dropdown Hooks.ComboBox = { mounted() { @@ -70,14 +43,46 @@ Hooks.ComboBox = { destroyed() { this.el.removeEventListener("keydown", this.handleKeyDown) + }, +}; + +// MemberSortPersistence hook: Persists sorting order to a cookie +Hooks.MemberSortPersistence = { + mounted() { + this.handleEvent("persist_sort", ({ sort_field, sort_order }) => { + const setCookie = (name, value) => { + const secure = window.location.protocol === "https:" ? "Secure" : ""; + document.cookie = `${name}=${encodeURIComponent( + value + )}; path=/; SameSite=Lax; ${secure}`; + }; + setCookie("member_sort_field", sort_field); + setCookie("member_sort_order", sort_order); + }); + }, +}; + +// Helper to read and decode cookie value +const getCookie = (name) => { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) { + return decodeURIComponent(parts.pop().split(";").shift()); } -} + return null; +}; let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, - params: {_csrf_token: csrfToken}, - hooks: Hooks -}) + params: () => { + return { + _csrf_token: csrfToken, + member_sort_field: getCookie("member_sort_field"), + member_sort_order: getCookie("member_sort_order"), + }; + }, + hooks: Hooks, +}); // Listen for custom events from LiveView window.addEventListener("phx:set-input-value", (e) => { diff --git a/config/runtime.exs b/config/runtime.exs index 06a2cd8..c50356c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -7,75 +7,6 @@ import Config # any compile-time configuration in here, as it won't be applied. # The block below contains prod specific runtime configuration. -# Helper function to read environment variables with Docker secrets support. -# Supports the _FILE suffix pattern: if VAR_FILE is set, reads the value from -# that file path. Otherwise falls back to VAR directly. -# VAR_FILE takes priority and must contain the full absolute path to the secret file. -get_env_or_file = fn var_name, default -> - file_var = "#{var_name}_FILE" - - case System.get_env(file_var) do - nil -> - System.get_env(var_name, default) - - file_path -> - case File.read(file_path) do - {:ok, content} -> - String.trim_trailing(content) - - {:error, reason} -> - raise """ - Failed to read secret from file specified in #{file_var}="#{file_path}". - Error: #{inspect(reason)} - """ - end - end -end - -# Same as get_env_or_file but raises if the value is not set -get_env_or_file! = fn var_name, error_message -> - case get_env_or_file.(var_name, nil) do - nil -> raise error_message - value -> value - end -end - -# Build database URL from individual components or use DATABASE_URL directly. -# Supports both approaches: -# 1. DATABASE_URL (or DATABASE_URL_FILE) - full connection URL -# 2. Separate vars: DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD (or _FILE), DATABASE_NAME, DATABASE_PORT -build_database_url = fn -> - case get_env_or_file.("DATABASE_URL", nil) do - nil -> - # Build URL from separate components - host = - System.get_env("DATABASE_HOST") || - raise "DATABASE_HOST is required when DATABASE_URL is not set" - - user = - System.get_env("DATABASE_USER") || - raise "DATABASE_USER is required when DATABASE_URL is not set" - - password = - get_env_or_file!.("DATABASE_PASSWORD", """ - DATABASE_PASSWORD or DATABASE_PASSWORD_FILE is required when DATABASE_URL is not set. - """) - - database = - System.get_env("DATABASE_NAME") || - raise "DATABASE_NAME is required when DATABASE_URL is not set" - - port = System.get_env("DATABASE_PORT", "5432") - - # URL-encode the password to handle special characters - encoded_password = URI.encode_www_form(password) - "ecto://#{user}:#{encoded_password}@#{host}:#{port}/#{database}" - - url -> - url - end -end - # ## Using releases # # If you use `mix release`, you need to explicitly enable the server @@ -90,7 +21,12 @@ if System.get_env("PHX_SERVER") do end if config_env() == :prod do - database_url = build_database_url.() + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] @@ -105,72 +41,45 @@ if config_env() == :prod do # want to use a different value for prod and you most likely don't want # to check this value into version control, so we use an environment # variable instead. - # Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets. secret_key_base = - get_env_or_file!.("SECRET_KEY_BASE", """ - environment variable SECRET_KEY_BASE (or SECRET_KEY_BASE_FILE) is missing. - You can generate one by calling: mix phx.gen.secret - """) - - # PHX_HOST or DOMAIN can be used to set the host for the application. - # DOMAIN is commonly used in deployment environments (e.g., Portainer templates). - host = - System.get_env("PHX_HOST") || - System.get_env("DOMAIN") || - raise "Please define the PHX_HOST or DOMAIN environment variable." + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable." port = String.to_integer(System.get_env("PORT") || "4000") config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") - # OIDC configuration (works with any OIDC provider: Authentik, Rauthy, Keycloak, etc.) - # Note: The strategy is named :rauthy internally, but works with any OIDC provider. - # The redirect_uri callback path is always /auth/user/rauthy/callback regardless of provider. - # - # Supports OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE for Docker secrets. - # OIDC_CLIENT_SECRET is required only if OIDC is being used (indicated by explicit OIDC env vars). - oidc_base_url = System.get_env("OIDC_BASE_URL") - oidc_client_id = System.get_env("OIDC_CLIENT_ID") - oidc_in_use = not is_nil(oidc_base_url) or not is_nil(oidc_client_id) - - client_secret = - if oidc_in_use do - get_env_or_file!.("OIDC_CLIENT_SECRET", """ - environment variable OIDC_CLIENT_SECRET (or OIDC_CLIENT_SECRET_FILE) is missing. - This is required when OIDC authentication is configured (OIDC_BASE_URL or OIDC_CLIENT_ID is set). - """) - else - get_env_or_file.("OIDC_CLIENT_SECRET", nil) - end - - # Build redirect_uri: use OIDC_REDIRECT_URI if set, otherwise build from host. - # Uses HTTPS since production runs behind TLS termination. - default_redirect_uri = "https://#{host}/auth/user/rauthy/callback" - + # Rauthy OIDC configuration config :mv, :rauthy, - client_id: oidc_client_id || "mv", - base_url: oidc_base_url || "http://localhost:8080/auth/v1", - client_secret: client_secret, - redirect_uri: System.get_env("OIDC_REDIRECT_URI") || default_redirect_uri + client_id: System.get_env("OIDC_CLIENT_ID") || "mv", + base_url: System.get_env("OIDC_BASE_URL") || "http://localhost:8080/auth/v1", + client_secret: System.get_env("OIDC_CLIENT_SECRET"), + redirect_uri: + System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback" # Token signing secret from environment variable # This overrides the placeholder value set in prod.exs - # Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets. token_signing_secret = - get_env_or_file!.("TOKEN_SIGNING_SECRET", """ - environment variable TOKEN_SIGNING_SECRET (or TOKEN_SIGNING_SECRET_FILE) is missing. - You can generate one by calling: mix phx.gen.secret - """) + System.get_env("TOKEN_SIGNING_SECRET") || + raise """ + environment variable TOKEN_SIGNING_SECRET is missing. + You can generate one by calling: mix phx.gen.secret + """ config :mv, :token_signing_secret, token_signing_secret config :mv, MvWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], http: [ - # Bind on all IPv4 interfaces. - # Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only. + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 - ip: {0, 0, 0, 0}, + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: port ], secret_key_base: secret_key_base, diff --git a/config/test.exs b/config/test.exs index 2c4d2ba..bcb55eb 100644 --- a/config/test.exs +++ b/config/test.exs @@ -45,6 +45,3 @@ config :mv, :token_signing_secret, "test_secret_key_for_ash_authentication_token config :mv, :session_identifier, :unsafe config :mv, :require_token_presence_for_authentication, false - -# Enable SQL Sandbox for async LiveView tests -config :mv, :sql_sandbox, true diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b4b7a1f..0bb2840 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -2,32 +2,21 @@ services: app: image: git.local-it.org/local-it/mitgliederverwaltung:latest container_name: mv-prod-app - ports: - - "4001:4001" + # Use host network for local testing to access localhost:8080 (Rauthy) + # In real production, remove this and use external OIDC provider + network_mode: host environment: - # Database configuration using separate variables - # Use Docker service name for internal networking - DATABASE_HOST: "db-prod" - DATABASE_PORT: "5432" - DATABASE_USER: "postgres" - DATABASE_NAME: "mv_prod" - DATABASE_PASSWORD_FILE: "/run/secrets/db_password" - # Phoenix secrets via Docker secrets - SECRET_KEY_BASE_FILE: "/run/secrets/secret_key_base" - TOKEN_SIGNING_SECRET_FILE: "/run/secrets/token_signing_secret" - PHX_HOST: "${PHX_HOST:-localhost}" + DATABASE_URL: "ecto://postgres:postgres@localhost:5001/mv_prod" + SECRET_KEY_BASE: "${SECRET_KEY_BASE}" + TOKEN_SIGNING_SECRET: "${TOKEN_SIGNING_SECRET}" + PHX_HOST: "${PHX_HOST}" PORT: "4001" PHX_SERVER: "true" - # Rauthy OIDC config - use host.docker.internal to reach host services + # Rauthy OIDC config - uses localhost because of host network mode OIDC_CLIENT_ID: "mv" - OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1" - OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret" + OIDC_BASE_URL: "http://localhost:8080/auth/v1" + OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}" OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback" - secrets: - - db_password - - secret_key_base - - token_signing_secret - - oidc_client_secret depends_on: - db-prod restart: unless-stopped @@ -37,25 +26,13 @@ services: container_name: mv-prod-db environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD_FILE: /run/secrets/db_password + POSTGRES_PASSWORD: postgres POSTGRES_DB: mv_prod - secrets: - - db_password volumes: - postgres_data_prod:/var/lib/postgresql/data ports: - "5001:5432" restart: unless-stopped -secrets: - db_password: - file: ./secrets/db_password.txt - secret_key_base: - file: ./secrets/secret_key_base.txt - token_signing_secret: - file: ./secrets/token_signing_secret.txt - oidc_client_secret: - file: ./secrets/oidc_client_secret.txt - volumes: postgres_data_prod: diff --git a/docs/contributions-architecture.md b/docs/contributions-architecture.md deleted file mode 100644 index 3718a3b..0000000 --- a/docs/contributions-architecture.md +++ /dev/null @@ -1,653 +0,0 @@ -# Membership Contributions - Technical Architecture - -**Project:** Mila - Membership Management System -**Feature:** Membership Contribution Management -**Version:** 1.0 -**Last Updated:** 2025-11-27 -**Status:** Architecture Design - Ready for Implementation - ---- - -## Purpose - -This document defines the technical architecture for the Membership Contributions system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details. - -**Related Documents:** -- [contributions-overview.md](./contributions-overview.md) - Business logic and requirements -- [database-schema-readme.md](./database-schema-readme.md) - Database documentation -- [database_schema.dbml](./database_schema.dbml) - Database schema definition - ---- - -## Table of Contents - -1. [Architecture Principles](#architecture-principles) -2. [Domain Structure](#domain-structure) -3. [Data Architecture](#data-architecture) -4. [Business Logic Architecture](#business-logic-architecture) -5. [Integration Points](#integration-points) -6. [Acceptance Criteria](#acceptance-criteria) -7. [Testing Strategy](#testing-strategy) -8. [Security Considerations](#security-considerations) -9. [Performance Considerations](#performance-considerations) - ---- - -## Architecture Principles - -### Core Design Decisions - -1. **Single Responsibility:** - - Each module has one clear responsibility - - Period generation separated from status management - - Calendar logic isolated in dedicated module - -2. **No Redundancy:** - - No `period_end` field (calculated from `period_start` + `interval`) - - No `interval_type` field (read from `contribution_type.interval`) - - Eliminates data inconsistencies - -3. **Immutability Where Important:** - - `contribution_type.interval` cannot be changed after creation - - Prevents complex migration scenarios - - Enforced via Ash change validation - -4. **Historical Accuracy:** - - `amount` stored per period for audit trail - - Enables tracking of contribution changes over time - - Old periods retain original amounts - -5. **Calendar-Based Periods:** - - All periods aligned to calendar boundaries - - Simplifies date calculations - - Predictable period generation - ---- - -## Domain Structure - -### Ash Domain: `Mv.Contributions` - -**Purpose:** Encapsulates all contribution-related resources and logic - -**Resources:** -- `ContributionType` - Contribution type definitions (admin-managed) -- `ContributionPeriod` - Individual contribution periods per member - -**Extensions:** -- Member resource extended with contribution fields - -### Module Organization - -``` -lib/ -β”œβ”€β”€ contributions/ -β”‚ β”œβ”€β”€ contributions.ex # Ash domain definition -β”‚ β”œβ”€β”€ contribution_type.ex # ContributionType resource -β”‚ β”œβ”€β”€ contribution_period.ex # ContributionPeriod resource -β”‚ └── changes/ -β”‚ β”œβ”€β”€ prevent_interval_change.ex # Validates interval immutability -β”‚ β”œβ”€β”€ set_contribution_start_date.ex # Auto-sets start date -β”‚ └── validate_same_interval.ex # Validates interval match on type change -β”œβ”€β”€ mv/ -β”‚ └── contributions/ -β”‚ β”œβ”€β”€ period_generator.ex # Period generation algorithm -β”‚ └── calendar_periods.ex # Calendar period calculations -└── membership/ - └── member.ex # Extended with contribution relationships -``` - -### Separation of Concerns - -**Domain Layer (Ash Resources):** -- Data validation -- Relationship management -- Policy enforcement -- Action definitions - -**Business Logic Layer (`Mv.Contributions`):** -- Period generation algorithm -- Calendar calculations -- Date boundary handling -- Status transitions - -**UI Layer (LiveView):** -- User interaction -- Display logic -- Authorization checks -- Form handling - ---- - -## Data Architecture - -### Database Schema Extensions - -**See:** [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation. - -### New Tables - -1. **`contribution_types`** - - Purpose: Define contribution types with fixed intervals - - Key Constraint: `interval` field immutable after creation - - Relationships: has_many members, has_many contribution_periods - -2. **`contribution_periods`** - - Purpose: Individual contribution periods for members - - Key Design: NO `period_end` or `interval_type` fields (calculated) - - Relationships: belongs_to member, belongs_to contribution_type - - Composite uniqueness: One period per member per period_start - -### Member Table Extensions - -**Fields Added:** -- `contribution_type_id` (FK, NOT NULL with default from settings) -- `contribution_start_date` (Date, nullable) - -**Existing Fields Used:** -- `joined_at` - For calculating contribution start -- `left_at` - For limiting period generation -- These fields must remain member fields and should not be replaced by custom fields in the future - -### Settings Integration - -**Global Settings:** -- `contributions.include_joining_period` (Boolean) -- `contributions.default_contribution_type_id` (UUID) - -**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource) - -### Foreign Key Behaviors - -| Relationship | On Delete | Rationale | -|--------------|-----------|-----------| -| `contribution_periods.member_id β†’ members.id` | CASCADE | Remove periods when member deleted | -| `contribution_periods.contribution_type_id β†’ contribution_types.id` | RESTRICT | Prevent type deletion if periods exist | -| `members.contribution_type_id β†’ contribution_types.id` | RESTRICT | Prevent type deletion if assigned to members | - ---- - -## Business Logic Architecture - -### Period Generation System - -**Component:** `Mv.Contributions.PeriodGenerator` - -**Responsibilities:** -- Calculate which periods should exist for a member -- Generate missing periods -- Respect contribution_start_date and left_at boundaries -- Skip existing periods (idempotent) - -**Triggers:** -1. Member contribution type assigned (via Ash change) -2. Member created with contribution type (via Ash change) -3. Scheduled job runs (daily/weekly cron) -4. Admin manual regeneration (UI action) - -**Algorithm Steps:** -1. Retrieve member with contribution_type and dates -2. Determine first period start (based on contribution_start_date) -3. Calculate all period starts from first to today (or left_at) -4. Query existing periods for member -5. Generate missing periods with current contribution_type.amount -6. Insert new periods (batch operation) - -**Edge Case Handling:** -- If contribution_start_date is NULL: Calculate from joined_at + global setting -- If left_at is set: Stop generation at left_at -- If contribution_type changes: Handled separately by regeneration logic - -### Calendar Period Calculations - -**Component:** `Mv.Contributions.CalendarPeriods` - -**Responsibilities:** -- Calculate period boundaries based on interval type -- Determine current period -- Determine last completed period -- Calculate period_end from period_start + interval - -**Functions (high-level):** -- `calculate_period_start/3` - Given date and interval, find period start -- `calculate_period_end/2` - Given period_start and interval, calculate end -- `next_period_start/2` - Given period_start and interval, find next -- `is_current_period?/2` - Check if period contains today -- `is_last_completed_period?/2` - Check if period just ended - -**Interval Logic:** -- **Monthly:** Start = 1st of month, End = last day of month -- **Quarterly:** Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter -- **Half-yearly:** Start = 1st of half (Jan/Jul), End = last day of half -- **Yearly:** Start = Jan 1st, End = Dec 31st - -### Status Management - -**Component:** Ash actions on `ContributionPeriod` - -**Status Transitions:** -- Simple state machine: unpaid ↔ paid ↔ suspended -- No complex validation (all transitions allowed) -- Permissions checked via Ash policies - -**Actions Required:** -- `mark_as_paid` - Set status to :paid -- `mark_as_suspended` - Set status to :suspended -- `mark_as_unpaid` - Set status to :unpaid (error correction) - -**Bulk Operations:** -- `bulk_mark_as_paid` - Mark multiple periods as paid (efficiency) - - low priority, can be a future issue - -### Contribution Type Change Handling - -**Component:** Ash change on `Member.contribution_type_id` - -**Validation:** -- Check if new type has same interval as old type -- If different: Reject change (MVP constraint) -- If same: Allow change - -**Side Effects on Allowed Change:** -1. Keep all existing periods unchanged -2. Find future unpaid periods -3. Delete future unpaid periods -4. Regenerate periods with new contribution_type_id and amount - -**Implementation Pattern:** -- Use Ash change module to validate -- Use after_action hook to trigger regeneration -- Use transaction to ensure atomicity - ---- - -## Integration Points - -### Member Resource Integration - -**Extension Points:** -1. Add fields via migration -2. Add relationships (belongs_to, has_many) -3. Add calculations (current_period_status, overdue_count) -4. Add changes (auto-set contribution_start_date, validate interval) - -**Backward Compatibility:** -- New fields nullable or with defaults -- Existing members get default contribution type from settings -- No breaking changes to existing member functionality - -### Settings System Integration - -**Requirements:** -- Store two global settings -- Provide UI for admin to modify -- Default values if not set -- Validation (e.g., default_contribution_type_id must exist) - -**Access Pattern:** -- Read settings during period generation -- Read settings during member creation -- Write settings only via admin UI - -### Permission System Integration - -**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) - -**Required Permissions:** -- `ContributionType.create/update/destroy` - Admin only -- `ContributionType.read` - Admin, Treasurer, Board -- `ContributionPeriod.update` (status changes) - Admin, Treasurer -- `ContributionPeriod.read` - Admin, Treasurer, Board, Own member - -**Policy Patterns:** -- Use existing HasPermission check -- Leverage existing roles (Admin, Kassenwart) -- Member can read own periods (linked via member_id) - -### LiveView Integration - -**New LiveViews Required:** -1. ContributionType index/form (admin) -2. ContributionPeriod table component (member detail view) -3. Settings form section (admin) -4. Member list column (contribution status) - -**Existing LiveViews to Extend:** -- Member detail view: Add contributions section -- Member list view: Add status column -- Settings page: Add contributions section - -**Authorization Helpers:** -- Use existing `can?/3` helper for UI conditionals -- Check permissions before showing actions - ---- - -## Acceptance Criteria - -### ContributionType Resource - -**AC-CT-1:** Admin can create contribution type with name, amount, interval, description -**AC-CT-2:** Interval field is immutable after creation (validation error on change attempt) -**AC-CT-3:** Admin can update name, amount, description (but not interval) -**AC-CT-4:** Cannot delete contribution type if assigned to members -**AC-CT-5:** Cannot delete contribution type if periods exist referencing it -**AC-CT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly - -### ContributionPeriod Resource - -**AC-CP-1:** Period has period_start, status, amount, notes, member_id, contribution_type_id -**AC-CP-2:** Period_end is calculated, not stored -**AC-CP-3:** Status defaults to :unpaid -**AC-CP-4:** One period per member per period_start (uniqueness constraint) -**AC-CP-5:** Amount is set at generation time from contribution_type.amount -**AC-CP-6:** Periods cascade delete when member deleted -**AC-CP-7:** Admin/Treasurer can change status -**AC-CP-8:** Member can read own periods - -### Member Extensions - -**AC-M-1:** Member has contribution_type_id field (NOT NULL with default) -**AC-M-2:** Member has contribution_start_date field (nullable) -**AC-M-3:** New members get default contribution type from global setting -**AC-M-4:** contribution_start_date auto-set based on joined_at and global setting -**AC-M-5:** Admin can manually override contribution_start_date -**AC-M-6:** Cannot change to contribution type with different interval (MVP) - -### Period Generation - -**AC-PG-1:** Periods generated when member gets contribution type -**AC-PG-2:** Periods generated when member created (via change hook) -**AC-PG-3:** Scheduled job generates missing periods daily -**AC-PG-4:** Generation respects contribution_start_date -**AC-PG-5:** Generation stops at left_at if member exited -**AC-PG-6:** Generation is idempotent (skips existing periods) -**AC-PG-7:** Periods align to calendar boundaries (1st of month/quarter/half/year) -**AC-PG-8:** Amount comes from contribution_type at generation time - -### Calendar Logic - -**AC-CL-1:** Monthly periods: 1st to last day of month -**AC-CL-2:** Quarterly periods: 1st of Jan/Apr/Jul/Oct to last day of quarter -**AC-CL-3:** Half-yearly periods: 1st of Jan/Jul to last day of half -**AC-CL-4:** Yearly periods: Jan 1 to Dec 31 -**AC-CL-5:** Period_end calculated correctly for all interval types -**AC-CL-6:** Current period determined correctly based on today's date -**AC-CL-7:** Last completed period determined correctly - -### Contribution Type Change - -**AC-TC-1:** Can change to type with same interval -**AC-TC-2:** Cannot change to type with different interval (error message) -**AC-TC-3:** On allowed change: future unpaid periods regenerated -**AC-TC-4:** On allowed change: paid/suspended periods unchanged -**AC-TC-5:** On allowed change: amount updated to new type's amount -**AC-TC-6:** Change is atomic (transaction) - -### Settings - -**AC-S-1:** Global setting: include_joining_period (boolean, default true) -**AC-S-2:** Global setting: default_contribution_type_id (UUID, required) -**AC-S-3:** Admin can modify settings via UI -**AC-S-4:** Settings validated (e.g., default type must exist) -**AC-S-5:** Settings applied to new members immediately - -### UI - Member List - -**AC-UI-ML-1:** New column shows contribution status -**AC-UI-ML-2:** Default: Shows last completed period status -**AC-UI-ML-3:** Optional: Toggle to show current period status -**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended) -**AC-UI-ML-5:** Filter: Unpaid in last period -**AC-UI-ML-6:** Filter: Unpaid in current period - -### UI - Member Detail - -**AC-UI-MD-1:** Contributions section shows all periods -**AC-UI-MD-2:** Table columns: Period, Interval, Amount, Status, Actions -**AC-UI-MD-3:** Checkbox per period for bulk marking (low prio) -**AC-UI-MD-4:** "Mark selected as paid" button -**AC-UI-MD-5:** Dropdown to change contribution type (same interval only) -**AC-UI-MD-6:** Warning if different interval selected -**AC-UI-MD-7:** Only show actions if user has permission - -### UI - Contribution Types Admin - -**AC-UI-CTA-1:** List all contribution types -**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count -**AC-UI-CTA-3:** Create new contribution type form -**AC-UI-CTA-4:** Edit form: Name, Amount, Description editable -**AC-UI-CTA-5:** Edit form: Interval grayed out (not editable) -**AC-UI-CTA-6:** Warning on amount change (explain impact) -**AC-UI-CTA-7:** Cannot delete if members assigned -**AC-UI-CTA-8:** Only admin can access - -### UI - Settings Admin - -**AC-UI-SA-1:** Contributions section in settings -**AC-UI-SA-2:** Dropdown to select default contribution type -**AC-UI-SA-3:** Checkbox: Include joining period -**AC-UI-SA-4:** Explanatory text with examples -**AC-UI-SA-5:** Save button with validation - ---- - -## Testing Strategy - -### Unit Testing - -**Period Generator Tests:** -- Correct period_start calculation for all interval types -- Correct period count from start to end date -- Respects contribution_start_date boundary -- Respects left_at boundary -- Skips existing periods (idempotent) -- Handles edge dates (year boundaries, leap years) - -**Calendar Periods Tests:** -- Period boundaries correct for all intervals -- Period_end calculation correct -- Current period detection -- Last completed period detection -- Next period calculation - -**Validation Tests:** -- Interval immutability enforced -- Same interval validation on type change -- Status transitions allowed -- Uniqueness constraints enforced - -### Integration Testing - -**Period Generation Flow:** -- Member creation triggers generation -- Type assignment triggers generation -- Type change regenerates future periods -- Scheduled job generates missing periods -- Left member stops generation - -**Status Management Flow:** -- Mark single period as paid -- Bulk mark multiple periods (low prio) -- Status transitions work -- Permissions enforced - -**Contribution Type Management:** -- Create type -- Update amount (regeneration triggered) -- Cannot update interval -- Cannot delete if in use - -### LiveView Testing - -**Member List:** -- Status column displays correctly -- Toggle between last/current works -- Filters work correctly -- Color coding applied - -**Member Detail:** -- Periods table displays all periods -- Checkboxes work -- Bulk marking works (low prio) -- Type change validation works -- Actions only shown with permission - -**Admin UI:** -- Type CRUD works -- Settings save correctly -- Validations display errors -- Only authorized users can access - -### Edge Case Testing - -**Interval Change Attempt:** -- Error message displayed -- No data modified -- User can cancel/choose different type - -**Exit with Unpaid:** -- Warning shown -- Option to suspend offered -- Exit completes correctly - -**Amount Change:** -- Warning displayed -- Only future unpaid regenerated -- Historical periods unchanged - -**Date Boundaries:** -- Today = period start handled -- Today = period end handled -- Leap year handled - -### Performance Testing - -**Period Generation:** -- Generate 10 years of monthly periods: < 100ms -- Generate for 1000 members: < 5 seconds -- Idempotent check efficient (no full scan) - -**Member List Query:** -- With status column: < 200ms for 1000 members -- Filters applied efficiently -- No N+1 queries - ---- - -## Security Considerations - -### Authorization - -**Permissions Required:** -- ContributionType management: Admin only -- ContributionPeriod status changes: Admin + Treasurer -- View all periods: Admin + Treasurer + Board -- View own periods: All authenticated users - -**Policy Enforcement:** -- All actions protected by Ash policies -- UI shows/hides based on permissions -- Backend validates permissions (never trust UI alone) - -### Data Integrity - -**Validation Layers:** -1. Database constraints (NOT NULL, UNIQUE, CHECK) -2. Ash validations (business rules) -3. UI validations (user experience) - -**Immutability Protection:** -- Interval change prevented at multiple layers -- Period amounts immutable (audit trail) -- Settings changes logged (future) - -### Audit Trail - -**Tracked Information:** -- Period status changes (who, when) - future enhancement -- Type amount changes (implicit via period amounts) -- Member type assignments (via timestamps) - ---- - -## Performance Considerations - -### Database Indexes - -**Required Indexes:** -- `contribution_periods(member_id)` - For member period lookups -- `contribution_periods(contribution_type_id)` - For type queries -- `contribution_periods(status)` - For unpaid filters -- `contribution_periods(period_start)` - For date range queries -- `contribution_periods(member_id, period_start)` - Composite unique index -- `members(contribution_type_id)` - For type membership count - -### Query Optimization - -**Preloading:** -- Load contribution_type with periods (avoid N+1) -- Load periods when displaying member detail -- Use Ash's load for efficient preloading - -**Calculated Fields:** -- period_end calculated on-demand (not stored) -- current_period_status calculated when needed -- Use Ash calculations for lazy evaluation - -**Pagination:** -- Period list paginated if > 50 periods -- Member list already paginated - -### Caching Strategy - -**No caching needed in MVP:** -- Contribution types rarely change -- Period queries are fast -- Settings read infrequently - -**Future caching if needed:** -- Cache settings in application memory -- Cache contribution types list -- Invalidate on change - -### Scheduled Job Performance - -**Period Generation Job:** -- Run daily or weekly (not hourly) -- Batch members (process 100 at a time) -- Skip members with no changes -- Log failures for retry - ---- - -## Future Enhancements - -### Phase 2: Interval Change Support - -**Architecture Changes:** -- Add logic to handle period overlaps -- Calculate prorata amounts if needed -- More complex validation -- Migration path for existing periods - -### Phase 3: Payment Details - -**Architecture Changes:** -- Add PaymentTransaction resource -- Link transactions to periods -- Support multiple payments per period -- Reconciliation logic - -### Phase 4: vereinfacht.digital Integration - -**Architecture Changes:** -- External API client module -- Webhook handling for transactions -- Automatic matching logic -- Manual review interface - ---- - -**End of Architecture Document** - diff --git a/docs/contributions-overview.md b/docs/contributions-overview.md deleted file mode 100644 index e0c4bc8..0000000 --- a/docs/contributions-overview.md +++ /dev/null @@ -1,527 +0,0 @@ -# Membership Contributions - Overview - -**Project:** Mila - Membership Management System -**Feature:** Membership Contribution Management -**Version:** 1.0 -**Last Updated:** 2025-11-27 -**Status:** Concept - Ready for Review - ---- - -## Purpose - -This document provides a comprehensive overview of the Membership Contributions system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format. - -**For detailed implementation:** See [contributions-implementation-plan.md](./contributions-implementation-plan.md) (created after concept iterations) - ---- - -## Table of Contents - -1. [Core Principle](#core-principle) -2. [Terminology](#terminology) -3. [Data Model](#data-model) -4. [Business Logic](#business-logic) -5. [UI/UX Design](#uiux-design) -6. [Edge Cases](#edge-cases) -7. [Technical Integration](#technical-integration) -8. [Implementation Scope](#implementation-scope) - ---- - -## Core Principle - -**Maximum Simplicity:** - -- Minimal complexity -- Clear data model without redundancies -- Intuitive operation -- Calendar period-based (Month/Quarter/Half-Year/Year) - ---- - -## Terminology - -### German ↔ English - -**Core Entities:** - -- Beitragsart ↔ Contribution Type / Membership Fee Type -- Beitragsintervall ↔ Contribution Period -- Mitgliedsbeitrag ↔ Membership Fee / Contribution - -**Status:** - -- bezahlt ↔ paid -- unbezahlt ↔ unpaid -- ausgesetzt ↔ suspended / waived - -**Intervals:** - -- monatlich ↔ monthly -- quartalsweise ↔ quarterly -- halbjΓ€hrlich ↔ half-yearly / semi-annually -- jΓ€hrlich ↔ yearly / annually - -**UI Elements:** - -- "Letztes Intervall" ↔ "Last Period" (e.g., 2023 when in 2024) -- "Aktuelles Intervall" ↔ "Current Period" (e.g., 2024) -- "Als bezahlt markieren" ↔ "Mark as paid" -- "Aussetzen" ↔ "Suspend" / "Waive" - ---- - -## Data Model - -### Contribution Type (ContributionType) - -``` -- id (UUID) -- name (String) - e.g., "Regular", "Reduced", "Student" -- amount (Decimal) - Contribution amount in Euro -- interval (Enum) - :monthly, :quarterly, :half_yearly, :yearly -- description (Text, optional) -- timestamps -``` - -**Important:** - -- `interval` is **IMMUTABLE** after creation! -- Admin can only change `name`, `amount`, `description` -- On change: Future unpaid periods regenerated with new amount - -### Contribution Period (ContributionPeriod) - -``` -- id (UUID) -- member_id (FK β†’ members.id) -- contribution_type_id (FK β†’ contribution_types.id) -- period_start (Date) - Calendar period start (01.01., 01.04., 01.07., 01.10., etc.) -- status (Enum) - :unpaid (default), :paid, :suspended -- amount (Decimal) - Amount at generation time (history when type changes) -- notes (Text, optional) - Admin notes -- timestamps -``` - -**Important:** - -- **NO** `period_end` - calculated from `period_start` + `interval` -- **NO** `interval_type` - read from `contribution_type.interval` -- Avoids redundancy and inconsistencies! - -**Calendar Period Logic:** - -- Monthly: 01.01. - 31.01., 01.02. - 28./29.02., etc. -- Quarterly: 01.01. - 31.03., 01.04. - 30.06., 01.07. - 30.09., 01.10. - 31.12. -- Half-yearly: 01.01. - 30.06., 01.07. - 31.12. -- Yearly: 01.01. - 31.12. - -### Member (Extensions) - -``` -- contribution_type_id (FK β†’ contribution_types.id, NOT NULL, default from settings) -- contribution_start_date (Date, nullable) - When to start generating contributions -- left_at (Date, nullable) - Exit date (existing) -``` - -**Logic for contribution_start_date:** - -- Auto-set based on global setting `include_joining_period` -- If `include_joining_period = true`: First day of joining month/quarter/year -- If `include_joining_period = false`: First day of NEXT period after joining -- Can be manually overridden by admin - -**NO** `include_joining_period` field on Member - unnecessary due to `contribution_start_date`! - -### Global Settings - -``` -key: "contributions.include_joining_period" -value: Boolean (Default: true) - -key: "contributions.default_contribution_type_id" -value: UUID (Required) - Default contribution type for new members -``` - -**Meaning include_joining_period:** - -- `true`: Joining period is included (member pays from joining period) -- `false`: Only from next full period after joining - -**Meaning default_contribution_type_id:** - -- Every new member automatically gets this contribution type -- Must be configured in admin settings -- Prevents: Members without contribution type - ---- - -## Business Logic - -### Period Generation - -**Triggers:** - -- Member gets contribution type assigned (also during member creation) -- New period begins (Cron job daily/weekly) -- Admin requests manual regeneration - -**Algorithm:** - -1. Get `member.contribution_start_date` and `member.contribution_type` -2. Calculate first period based on `contribution_start_date` -3. Generate all periods from start to today (or `left_at` if present) -4. Skip existing periods -5. Set `amount` to current `contribution_type.amount` - -**Example (Yearly):** - -``` -Joining date: 15.03.2023 -include_joining_period: true -β†’ contribution_start_date: 01.01.2023 - -Generated periods: -- 01.01.2023 - 31.12.2023 (joining period) -- 01.01.2024 - 31.12.2024 -- 01.01.2025 - 31.12.2025 (current year) -``` - -**Example (Quarterly):** - -``` -Joining date: 15.03.2023 -include_joining_period: false -β†’ contribution_start_date: 01.04.2023 - -Generated periods: -- 01.04.2023 - 30.06.2023 (first full quarter) -- 01.07.2023 - 30.09.2023 -- 01.10.2023 - 31.12.2023 -- 01.01.2024 - 31.03.2024 -- ... -``` - -### Status Transitions - -``` -unpaid β†’ paid -unpaid β†’ suspended -paid β†’ unpaid -suspended β†’ paid -suspended β†’ unpaid -``` - -**Permissions:** - -- Admin + Treasurer (Kassenwart) can change status -- Uses existing permission system - -### Contribution Type Change - -**MVP - Same Interval Only:** - -- Member can only choose contribution type with **same interval** -- Example: From "Regular (yearly)" to "Reduced (yearly)" βœ“ -- Example: From "Regular (yearly)" to "Reduced (monthly)" βœ— - -**Logic on Change:** - -1. Check: New contribution type has same interval -2. If yes: Set `member.contribution_type_id` -3. Future **unpaid** periods: Delete and regenerate with new amount -4. Paid/suspended periods: Remain unchanged (historical amount) - -**Future - Different Intervals:** - -- Enable interval switching (e.g., yearly β†’ monthly) -- More complex logic for period overlaps -- Needs additional validation - -### Member Exit - -**Logic:** - -- Periods only generated until `member.left_at` -- Existing periods remain visible -- Unpaid exit period can be marked as "suspended" - -**Example:** - -``` -Exit: 15.08.2024 -Yearly period: 01.01.2024 - 31.12.2024 - -β†’ Period 2024 is shown (Status: unpaid) -β†’ Admin can set to "suspended" -β†’ No periods for 2025+ generated -``` - ---- - -## UI/UX Design - -### Member List View - -**New Column: "Contribution Status"** - -**Default Display (Last Period):** - -- Shows status of **last completed** period -- Example in 2024: Shows contribution for 2023 -- Color coding: - - Green: paid βœ“ - - Red: unpaid βœ— - - Gray: suspended ⊘ - -**Optional: Show Current Period** - -- Toggle: "Show current period" (2024) -- Admin decides what to display - -**Filters:** - -- "Unpaid contributions in last period" -- "Unpaid contributions in current period" - -### Member Detail View - -**Section: "Contributions"** - -**Contribution Type Assignment:** - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Contribution Type: [Dropdown] β”‚ -β”‚ ⚠ Only types with same interval β”‚ -β”‚ can be selected β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -**Period Table:** - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Period β”‚ Interval β”‚ Amount β”‚ Status β”‚ Action β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ 01.01.2023- β”‚ Yearly β”‚ 50 € β”‚ β˜‘ Paid β”‚ β”‚ -β”‚ 31.12.2023 β”‚ β”‚ β”‚ β”‚ β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ 01.01.2024- β”‚ Yearly β”‚ 60 € β”‚ ☐ Open β”‚ [Mark β”‚ -β”‚ 31.12.2024 β”‚ β”‚ β”‚ β”‚ as paid]β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ 01.01.2025- β”‚ Yearly β”‚ 60 € β”‚ ☐ Open β”‚ [Mark β”‚ -β”‚ 31.12.2025 β”‚ β”‚ β”‚ β”‚ as paid]β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - -Legend: β˜‘ = paid | ☐ = unpaid | ⊘ = suspended -``` - -**Quick Marking:** - -- Checkbox in each row for fast marking -- Button: "Mark selected as paid/unpaid/suspended" -- Bulk action for multiple periods - -### Admin: Contribution Types Management - -**List:** - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Name β”‚ Amount β”‚ Interval β”‚ Members β”‚ Actions β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ Regular β”‚ 60 € β”‚ Yearly β”‚ 45 β”‚ [Edit] β”‚ -β”‚ Reduced β”‚ 30 € β”‚ Yearly β”‚ 12 β”‚ [Edit] β”‚ -β”‚ Student β”‚ 20 € β”‚ Monthly β”‚ 8 β”‚ [Edit] β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -**Edit:** - -- Name: βœ“ editable -- Amount: βœ“ editable -- Description: βœ“ editable -- Interval: βœ— **NOT** editable (grayed out) - -**Warning on Amount Change:** - -``` -⚠ Change amount to 65 €? - -Impact: -- 45 members affected -- Future unpaid periods will be generated with 65 € -- Already paid periods remain with old amount - -[Cancel] [Confirm] -``` - -### Admin: Settings - -**Contribution Configuration:** - -``` -Default Contribution Type: [Dropdown: Contribution Types] - -Selected: "Regular (60 €, Yearly)" - -This contribution type is automatically assigned to all new members. -Can be changed individually per member. - ---- - -☐ Include joining period - -When active: -Members pay from the period of their joining. - -Example (Yearly): -Joining: 15.03.2023 -β†’ Pays from 2023 - -When inactive: -Members pay from the next full period. - -Example (Yearly): -Joining: 15.03.2023 -β†’ Pays from 2024 -``` - ---- - -## Edge Cases - -### 1. Contribution Type Change with Different Interval - -**MVP:** Blocked (only same interval allowed) - -**UI:** - -``` -Error: Interval change not possible - -Current contribution type: "Regular (Yearly)" -Selected contribution type: "Student (Monthly)" - -Changing the interval is currently not possible. -Please select a contribution type with interval "Yearly". - -[OK] -``` - -**Future:** - -- Allow interval switching -- Calculate overlaps -- Generate new periods without duplicates - -### 2. Exit with Unpaid Contributions - -**Scenario:** - -``` -Member exits: 15.08.2024 -Yearly period 2024: unpaid -``` - -**UI Notice on Exit: (Low Prio)** - -``` -⚠ Unpaid contributions present - -This member has 1 unpaid period(s): -- 2024: 60 € (unpaid) - -Do you want to continue? - -[ ] Mark contribution as "suspended" -[Cancel] [Confirm Exit] -``` - -### 3. Multiple Unpaid Periods - -**Scenario:** Member hasn't paid for 2 years - -**Display:** - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ 2023 β”‚ Yearly β”‚ 50 € β”‚ ☐ Open β”‚ [βœ“] β”‚ -β”‚ 2024 β”‚ Yearly β”‚ 60 € β”‚ ☐ Open β”‚ [βœ“] β”‚ -β”‚ 2025 β”‚ Yearly β”‚ 60 € β”‚ ☐ Open β”‚ [ ] β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - -[Mark selected as paid/unpaid/suspended] (2 selected) -``` - -### 4. Amount Changes - -**Scenario:** - -``` -2023: Regular = 50 € -2024: Regular = 60 € (increase) -``` - -**Result:** - -- Period 2023: Saved with 50 € (history) -- Period 2024: Generated with 60 € (current) -- Both periods show correct historical amount - -### 5. Date Boundaries - -**Problem:** What if today = 01.01.2025? - -**Solution:** - -- Current period (2025) is generated -- Status: unpaid (open) -- Shown in overview - ---- - -## Implementation Scope - -### MVP (Phase 1) - -**Included:** - -- βœ“ Contribution types (CRUD) -- βœ“ Automatic period generation -- βœ“ Status management (paid/unpaid/suspended) -- βœ“ Member overview with contribution status -- βœ“ Period view per member -- βœ“ Quick checkbox marking -- βœ“ Bulk actions -- βœ“ Amount history -- βœ“ Same-interval type change -- βœ“ Default contribution type -- βœ“ Joining period configuration - -**NOT Included:** - -- βœ— Interval change (only same interval) -- βœ— Payment details (date, method) -- βœ— Automatic integration (vereinfacht.digital) -- βœ— Prorata calculation -- βœ— Reports/statistics -- βœ— Reminders/dunning (manual via filters) - -### Future Enhancements - -**Phase 2:** - -- Payment details (date, amount, method) -- Interval change for future unpaid periods -- Manual vereinfacht.digital links per member -- Extended filter options - -**Phase 3:** - -- Automated vereinfacht.digital integration -- Automatic payment matching -- SEPA integration -- Advanced reports diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index 1644f2a..d548b82 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -115,6 +115,7 @@ Member (1) β†’ (N) Properties ### 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}` @@ -168,7 +169,7 @@ Member (1) β†’ (N) Properties ### Weighted Fields - **Weight A (highest):** first_name, last_name - **Weight B:** email, notes -- **Weight C:** phone_number, city, street, house_number, postal_code +- **Weight C:** birth_date, phone_number, city, street, house_number, postal_code - **Weight D (lowest):** join_date, exit_date ### Usage Example @@ -380,7 +381,7 @@ Install "DBML Language" extension to view/edit DBML files with: - tokens (jti, purpose, extra_data) **Personal Data (GDPR):** -- All member fields (name, email, address) +- All member fields (name, email, birth_date, address) - User email - Token subject diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index b620830..33c0647 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -122,6 +122,7 @@ Table members { 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)'] @@ -152,7 +153,7 @@ Table members { **Club Member Master Data** Core entity for membership management containing: - - Personal information (name, email) + - Personal information (name, birth date, email) - Contact details (phone, address) - Membership status (join/exit dates, payment status) - Additional notes @@ -182,6 +183,7 @@ Table members { **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}$ diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 629987e..5669a19 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -1327,33 +1327,6 @@ end --- -## Session: Bulk Email Copy Feature (2025-12-02) - -### Feature Summary -Implemented bulk email copy functionality for selected members (#230). Users can select members and copy their email addresses to clipboard. - -**Key Features:** -- Copy button appears only when visible members are selected -- Email format: `First Last ` with semicolon separator (email client compatible) -- Button shows count of visible selected members (respects search/filter) -- CopyToClipboard JavaScript hook with clipboard API + fallback for older browsers -- Bilingual UI (English/German) - -### Key Decisions - -1. **Email Format:** "First Last " with semicolon - standard for all major email clients -2. **Visible Member Count:** Button shows only visible selected members, not total selected (better UX when filtering) -3. **Serverβ†’Client:** Used `push_event/3` - server formats data, client handles clipboard - -### Files Changed -- `lib/mv_web/live/member_live/index.ex` - Event handler, helper function -- `lib/mv_web/live/member_live/index.html.heex` - Copy button -- `assets/js/app.js` - CopyToClipboard hook -- `test/mv_web/member_live/index_test.exs` - 9 new tests -- `priv/gettext/de/LC_MESSAGES/default.po` - German translations - ---- - ## Session: User-Member Linking UI Enhancement (2025-01-13) ### Feature Summary @@ -1586,8 +1559,8 @@ This project demonstrates a modern Phoenix application built with: --- -**Document Version:** 1.3 -**Last Updated:** 2025-12-02 +**Document Version:** 1.2 +**Last Updated:** 2025-11-27 **Maintainer:** Development Team **Status:** Living Document (update as project evolves) diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 2f86f5e..2313fd7 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -65,7 +65,6 @@ - βœ… Sorting by basic fields - βœ… User-Member linking (optional 1:1) - βœ… Email synchronization between User and Member -- βœ… **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230) **Closed Issues:** - βœ… [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12) @@ -100,10 +99,10 @@ **Closed Issues:** - [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) - [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M) -- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Remove birthday field from default configuration (S) - Closed 2025-12-02 **Open Issues:** - [#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:** @@ -187,16 +186,10 @@ **Current State:** - βœ… Basic "paid" boolean field on members -- βœ… **UI Mock-ups for Contribution Types & Settings** (2025-12-02) - ⚠️ No payment tracking **Open Issues:** - [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority) -- [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Contribution Mockup Pages (Preview) - -**Mock-Up Pages (Non-Functional Preview):** -- `/contribution_types` - Contribution Types Management -- `/contribution_settings` - Global Contribution Settings **Missing Features:** - ❌ Membership fee configuration diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index dbc62b2..749740d 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -54,9 +54,6 @@ defmodule Mv.Accounts.User do auth_method :client_secret_jwt code_verifier true - # Request email and profile scopes from OIDC provider (required for Authentik, Keycloak, etc.) - authorization_params scope: "openid email profile" - # id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87 end @@ -72,7 +69,7 @@ defmodule Mv.Accounts.User do # Default actions for framework/tooling integration: # - :read -> Standard read used across the app and by admin tooling. # - :destroy-> Standard delete used by admin tooling and maintenance tasks. - # + # # NOTE: :create is INTENTIONALLY excluded from defaults! # Using a default :create would bypass email-synchronization logic. # Always use one of these explicit create actions instead: @@ -188,9 +185,7 @@ defmodule Mv.Accounts.User do oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info) # Get the new email from OIDC user_info - # Support both "email" (standard OIDC) and "preferred_username" (Rauthy) - new_email = - Map.get(oidc_user_info, "email") || Map.get(oidc_user_info, "preferred_username") + new_email = Map.get(oidc_user_info, "preferred_username") changeset |> Ash.Changeset.change_attribute(:oidc_id, oidc_id) @@ -244,11 +239,8 @@ defmodule Mv.Accounts.User do change fn changeset, _ctx -> user_info = Ash.Changeset.get_argument(changeset, :user_info) - # Support both "email" (standard OIDC like Authentik, Keycloak) and "preferred_username" (Rauthy) - email = user_info["email"] || user_info["preferred_username"] - changeset - |> Ash.Changeset.change_attribute(:email, email) + |> Ash.Changeset.change_attribute(:email, user_info["preferred_username"]) |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"]) end diff --git a/lib/membership/member.ex b/lib/membership/member.ex index b788dc9..da69861 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -24,7 +24,7 @@ defmodule Mv.Membership.Member do - 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: join_date not in future, exit_date after join_date + - 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 @@ -42,10 +42,6 @@ defmodule Mv.Membership.Member do @member_search_limit 10 @default_similarity_threshold 0.2 - # Use constants from Mv.Constants for member fields - # This ensures consistency across the codebase - @member_fields Mv.Constants.member_fields() - postgres do table "members" repo Mv.Repo @@ -62,7 +58,21 @@ defmodule Mv.Membership.Member do # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true - accept @member_fields + accept [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] change manage_relationship(:custom_field_values, type: :create) @@ -95,7 +105,21 @@ defmodule Mv.Membership.Member do # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true - accept @member_fields + accept [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) @@ -284,6 +308,11 @@ defmodule Mv.Membership.Member do end end + # Birth date not in the future + validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0), + where: [present(:birth_date)], + message: "cannot be in the future" + # Join date not in the future validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0), where: [present(:join_date)], @@ -346,6 +375,10 @@ defmodule Mv.Membership.Member do constraints min_length: 5, max_length: 254 end + attribute :birth_date, :date do + allow_nil? true + end + attribute :paid, :boolean do allow_nil? true end @@ -401,70 +434,6 @@ defmodule Mv.Membership.Member do identity :unique_email, [:email] end - @doc """ - Checks if a member field should be shown in the overview. - - Reads the visibility configuration from Settings resource. If a field is not - configured in settings, it defaults to `true` (visible). - - ## Parameters - - `field` - Atom representing the member field name (e.g., `:email`, `:street`) - - ## Returns - - `true` if the field should be shown in overview (default) - - `false` if the field is configured as hidden in settings - - ## Examples - - iex> Member.show_in_overview?(:email) - true - - iex> Member.show_in_overview?(:street) - true # or false if configured in settings - - """ - @spec show_in_overview?(atom()) :: boolean() - def show_in_overview?(field) when is_atom(field) do - case Mv.Membership.get_settings() do - {:ok, settings} -> - visibility_config = settings.member_field_visibility || %{} - # Normalize map keys to atoms (JSONB may return string keys) - normalized_config = normalize_visibility_config(visibility_config) - - # Get value from normalized config, default to true - Map.get(normalized_config, field, true) - - {:error, _} -> - # If settings can't be loaded, default to visible - true - end - end - - def show_in_overview?(_), do: true - - # Normalizes visibility config map keys from strings to atoms. - # JSONB in PostgreSQL converts atom keys to string keys when storing. - defp normalize_visibility_config(config) when is_map(config) do - Enum.reduce(config, %{}, fn - {key, value}, acc when is_atom(key) -> - Map.put(acc, key, value) - - {key, value}, acc when is_binary(key) -> - try do - atom_key = String.to_existing_atom(key) - Map.put(acc, atom_key, value) - rescue - ArgumentError -> - acc - end - - _, acc -> - acc - end) - end - - defp normalize_visibility_config(_), do: %{} - @doc """ Performs fuzzy search on members using PostgreSQL trigram similarity. diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index f5a708b..cb3691b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -53,7 +53,6 @@ defmodule Mv.Membership do # It's only used internally as fallback in get_settings/0 # Settings should be created via seed script define :update_settings, action: :update - define :update_member_field_visibility, action: :update_member_field_visibility end end @@ -124,37 +123,4 @@ defmodule Mv.Membership do |> Ash.Changeset.for_update(:update, attrs) |> Ash.update(domain: __MODULE__) end - - @doc """ - Updates the member field visibility configuration. - - This is a specialized action for updating only the member field visibility settings. - It validates that all keys are valid member fields and all values are booleans. - - ## Parameters - - - `settings` - The settings record to update - - `visibility_config` - A map of member field names (strings) to boolean visibility values - (e.g., `%{"street" => false, "house_number" => false}`) - - ## Returns - - - `{:ok, updated_settings}` - Successfully updated settings - - `{:error, error}` - Validation or update error - - ## Examples - - iex> {:ok, settings} = Mv.Membership.get_settings() - iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false}) - iex> updated.member_field_visibility - %{"street" => false, "house_number" => false} - - """ - def update_member_field_visibility(settings, visibility_config) do - settings - |> Ash.Changeset.for_update(:update_member_field_visibility, %{ - member_field_visibility: visibility_config - }) - |> Ash.update(domain: __MODULE__) - end end diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 52c0328..38624dc 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -9,8 +9,6 @@ defmodule Mv.Membership.Setting do ## Attributes - `club_name` - The name of the association/club (required, cannot be empty) - - `member_field_visibility` - JSONB map storing visibility configuration for member fields - (e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`. ## Singleton Pattern This resource uses a singleton pattern - there should only be one settings record. @@ -30,9 +28,6 @@ defmodule Mv.Membership.Setting do # Update club name {:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"}) - - # Update member field visibility - {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false}) """ use Ash.Resource, domain: Mv.Membership, @@ -54,65 +49,18 @@ defmodule Mv.Membership.Setting do # Used only as fallback in get_settings/0 if settings don't exist # Settings should normally be created via seed script create :create do - accept [:club_name, :member_field_visibility] + accept [:club_name] end update :update do primary? true - require_atomic? false - accept [:club_name, :member_field_visibility] - end - - update :update_member_field_visibility do - description "Updates the visibility configuration for member fields in the overview" - require_atomic? false - accept [:member_field_visibility] + accept [:club_name] end end validations do validate present(:club_name), on: [:create, :update] validate string_length(:club_name, min: 1), on: [:create, :update] - - # Validate member_field_visibility map structure and content - validate fn changeset, _context -> - visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) - - if visibility && is_map(visibility) do - # Validate all values are booleans - invalid_values = - Enum.filter(visibility, fn {_key, value} -> - not is_boolean(value) - end) - - # Validate all keys are valid member fields - valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) - - invalid_keys = - Enum.filter(visibility, fn {key, _value} -> - key not in valid_field_strings - end) - |> Enum.map(fn {key, _value} -> key end) - - cond do - not Enum.empty?(invalid_values) -> - {:error, - field: :member_field_visibility, - message: "All values in member_field_visibility must be booleans"} - - not Enum.empty?(invalid_keys) -> - {:error, - field: :member_field_visibility, - message: "Invalid member field keys: #{inspect(invalid_keys)}"} - - true -> - :ok - end - else - :ok - end - end, - on: [:create, :update] end attributes do @@ -127,12 +75,6 @@ defmodule Mv.Membership.Setting do min_length: 1 ] - attribute :member_field_visibility, :map, - allow_nil?: true, - public?: true, - description: - "Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans." - timestamps() end end diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex deleted file mode 100644 index 7bfb07b..0000000 --- a/lib/mv/constants.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule Mv.Constants do - @moduledoc """ - Module for defining constants and atoms. - """ - - @member_fields [ - :first_name, - :last_name, - :email, - :paid, - :phone_number, - :join_date, - :exit_date, - :notes, - :city, - :street, - :house_number, - :postal_code - ] - - @custom_field_prefix "custom_field_" - - def member_fields, do: @member_fields - - @doc """ - Returns the prefix used for custom field keys in field visibility maps. - - ## Examples - - iex> Mv.Constants.custom_field_prefix() - "custom_field_" - """ - def custom_field_prefix, do: @custom_field_prefix -end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index be64655..b8fe0fc 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -42,11 +42,7 @@ defmodule MvWeb.CoreComponents do attr :id, :string, doc: "the optional id of flash container" attr :flash, :map, default: %{}, doc: "the map of flash messages to display" attr :title, :string, default: nil - - attr :kind, :atom, - values: [:info, :error, :success, :warning], - doc: "used for styling and flash lookup" - + attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" slot :inner_block, doc: "the optional inner block that renders the flash message" @@ -60,26 +56,22 @@ defmodule MvWeb.CoreComponents do id={@id} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} role="alert" - class="z-50 toast toast-top toast-end" + class="toast toast-top toast-end z-50" {@rest} >
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" /> - <.icon :if={@kind == :success} name="hero-check-circle" class="size-5 shrink-0" /> - <.icon :if={@kind == :warning} name="hero-information-circle" class="size-5 shrink-0" />

{@title}

{msg}

-
@@ -119,123 +111,6 @@ defmodule MvWeb.CoreComponents do end end - @doc """ - Renders a dropdown menu. - - ## Examples - - <.dropdown_menu items={@items} open={@open} phx_target={@myself} /> - """ - attr :id, :string, default: "dropdown-menu" - attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps" - attr :button_label, :string, default: "Dropdown" - attr :icon, :string, default: nil - attr :checkboxes, :boolean, default: false - attr :selected, :map, default: %{} - attr :open, :boolean, default: false, doc: "Whether the dropdown is open" - attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons" - attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events" - - def dropdown_menu(assigns) do - ~H""" -
- - - -
- """ - end - @doc """ Renders an input with label and error messages. @@ -305,7 +180,7 @@ defmodule MvWeb.CoreComponents do end) ~H""" -
+
<.error :for={msg <- @errors}>{msg} @@ -331,15 +202,9 @@ defmodule MvWeb.CoreComponents do def input(%{type: "select"} = assigns) do ~H""" -
+