Compare commits

..

1 commit

Author SHA1 Message Date
c6be9b5104 feat: add playwright and a11y audit and example test 2025-09-04 13:54:57 +02:00
551 changed files with 4140 additions and 128491 deletions

View file

@ -82,20 +82,14 @@
# You can customize the priority of any check # You can customize the priority of any check
# Priority values are: `low, normal, high, higher` # Priority values are: `low, normal, high, higher`
# #
# AliasUsage only for lib and support; test files excluded (many nested module refs by design)
{Credo.Check.Design.AliasUsage, {Credo.Check.Design.AliasUsage,
[ [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
priority: :low,
if_nested_deeper_than: 2,
if_called_more_often_than: 0,
files: %{excluded: ["test/"]}
]},
{Credo.Check.Design.TagFIXME, []}, {Credo.Check.Design.TagFIXME, []},
# You can also customize the exit_status of each check. # You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just # If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero). # set this value to 0 (zero).
# #
{Credo.Check.Design.TagTODO, [exit_status: 0]}, {Credo.Check.Design.TagTODO, [exit_status: 2]},
# #
## Readability Checks ## Readability Checks
@ -164,11 +158,11 @@
{Credo.Check.Warning.UnusedRegexOperation, []}, {Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []}, {Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []}, {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: [ disabled: [
# Checks disabled by the Mitgliederverwaltung Team
{Credo.Check.Readability.ModuleDoc, []},
# #
# Checks scheduled for next check update (opt-in for now) # Checks scheduled for next check update (opt-in for now)
{Credo.Check.Refactor.UtcNowTruncate, []}, {Credo.Check.Refactor.UtcNowTruncate, []},

View file

@ -1,10 +1,10 @@
kind: pipeline kind: pipeline
type: docker type: docker
name: check-fast name: check
services: services:
- name: postgres - name: postgres
image: docker.io/library/postgres:18.3 image: docker.io/library/postgres:17.5
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
@ -52,12 +52,10 @@ steps:
# Check for dependencies that are not maintained anymore # Check for dependencies that are not maintained anymore
- mix hex.audit - mix hex.audit
# Provide hints for improving code quality # Provide hints for improving code quality
- mix credo --strict - mix credo
# Check that translations are up to date
- mix gettext.extract --check-up-to-date
- name: wait_for_postgres - name: wait_for_postgres
image: docker.io/library/postgres:18.3 image: docker.io/library/postgres:17.5
commands: commands:
# Wait for postgres to become available # Wait for postgres to become available
- | - |
@ -72,7 +70,7 @@ steps:
echo "Postgres did not become available, aborting." echo "Postgres did not become available, aborting."
exit 1 exit 1
- name: test-fast - name: test
image: docker.io/library/elixir:1.18.3-otp-27 image: docker.io/library/elixir:1.18.3-otp-27
environment: environment:
MIX_ENV: test MIX_ENV: test
@ -83,114 +81,7 @@ steps:
- mix local.hex --force - mix local.hex --force
# Fetch dependencies # Fetch dependencies
- mix deps.get - mix deps.get
# Run fast tests (excludes slow/performance and UI tests) # Run tests
- mix test --exclude slow --exclude ui --max-cases 2
- name: rebuild-cache
image: drillster/drone-volume-cache
settings:
rebuild: true
mount:
- ./deps
- ./_build
volumes:
- name: cache
path: /cache
volumes:
- name: cache
host:
path: /tmp/drone_cache
---
kind: pipeline
type: docker
name: check-full
services:
- name: postgres
image: docker.io/library/postgres:18.3
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
trigger:
event:
- promote
target:
- production
steps:
- name: compute cache key
image: docker.io/library/elixir:1.18.3-otp-27
commands:
- mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)
- echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key
# Print cache key for debugging
- cat .cache_key
- name: restore-cache
image: drillster/drone-volume-cache
settings:
restore: true
mount:
- ./deps
- ./_build
ttl: 30
volumes:
- name: cache
path: /cache
- name: lint
image: docker.io/library/elixir:1.18.3-otp-27
commands:
# Install hex package manager
- mix local.hex --force
# Fetch dependencies
- mix deps.get
# Check for compilation errors & warnings
- mix compile --warnings-as-errors
# Check formatting
- mix format --check-formatted
# Security checks
- mix sobelow --config
# Check dependencies for known vulnerabilities
- mix deps.audit
# Check for dependencies that are not maintained anymore
- mix hex.audit
# Provide hints for improving code quality
- mix credo --strict
# Check that translations are up to date
- mix gettext.extract --check-up-to-date
- name: wait_for_postgres
image: docker.io/library/postgres:18.3
commands:
# Wait for postgres to become available
- |
for i in {1..20}; do
if pg_isready -h postgres -U postgres; then
exit 0
else
true
fi
sleep 2
done
echo "Postgres did not become available, aborting."
exit 1
- name: test-all
image: docker.io/library/elixir:1.18.3-otp-27
environment:
MIX_ENV: test
TEST_POSTGRES_HOST: postgres
TEST_POSTGRES_PORT: 5432
commands:
# Install hex package manager
- mix local.hex --force
# Fetch dependencies
- mix deps.get
# Run all tests (including slow/performance and UI tests)
- mix test - mix test
- name: rebuild-cache - name: rebuild-cache
@ -209,64 +100,6 @@ volumes:
host: host:
path: /tmp/drone_cache path: /tmp/drone_cache
---
kind: pipeline
type: docker
name: build-and-publish
trigger:
branch:
- main
event:
- push
steps:
- name: build-and-publish-container-branch
image: plugins/docker
settings:
registry: git.local-it.org
repo: git.local-it.org/local-it/mitgliederverwaltung
username:
from_secret: DRONE_REGISTRY_USERNAME
password:
from_secret: DRONE_REGISTRY_TOKEN
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
when:
event:
- push
depends_on:
- check-fast
---
kind: pipeline
type: docker
name: build-and-release
trigger:
event:
- tag
steps:
- name: build-and-publish-container
image: plugins/docker
settings:
registry: git.local-it.org
repo: git.local-it.org/local-it/mitgliederverwaltung
username:
from_secret: DRONE_REGISTRY_USERNAME
password:
from_secret: DRONE_REGISTRY_TOKEN
auto_tag: true
when:
event:
- tag
depends_on:
- check-fast
--- ---
kind: pipeline kind: pipeline
type: docker type: docker
@ -284,7 +117,7 @@ environment:
steps: steps:
- name: renovate - name: renovate
image: renovate/renovate:43.165 image: renovate/renovate:41.72
environment: environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN: RENOVATE_TOKEN:

View file

@ -1,56 +1 @@
# Production Environment Variables for docker-compose.prod.yml OIDC_CLIENT_SECRET=
# Copy this file to .env and fill in the actual values
# Required: Phoenix secrets (generate with: mix phx.gen.secret)
SECRET_KEY_BASE=changeme-run-mix-phx.gen.secret
TOKEN_SIGNING_SECRET=changeme-run-mix-phx.gen.secret
# Required: Hostname for URL generation
PHX_HOST=localhost
# Recommended: Association settings
ASSOCIATION_NAME="Sportsclub XYZ"
# Optional: Admin user (created/updated on container start via Release.seed_admin)
# In production, set these so the first admin can log in. Change password without redeploy:
# bin/mv eval "Mv.Release.seed_admin()" (with new ADMIN_PASSWORD or ADMIN_PASSWORD_FILE)
# FORCE_SEEDS=true re-runs bootstrap seeds even when admin user exists (e.g. after changing roles/custom fields).
# ADMIN_EMAIL=admin@example.com
# ADMIN_PASSWORD=secure-password
# ADMIN_PASSWORD_FILE=/run/secrets/admin_password
# Optional: OIDC Configuration
# These have defaults in docker-compose.prod.yml, only override if needed
# OIDC_CLIENT_ID=mv
# OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/oidc/callback
# OIDC_CLIENT_SECRET=your-oidc-client-secret
# Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope)
# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in.
# OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list).
# OIDC_ADMIN_GROUP_NAME=admin
# OIDC_GROUPS_CLAIM=groups
# Optional: Show only OIDC sign-in on login page (hide password form).
# When set to true and OIDC is configured, users see only the Single Sign-On button.
# OIDC_ONLY=true
# Optional: Vereinfacht accounting integration (finance-contacts sync)
# If set, these override values from Settings UI; those fields become read-only.
# VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1
# VEREINFACHT_API_KEY=your-api-key
# VEREINFACHT_CLUB_ID=2
# VEREINFACHT_APP_URL=https://app.verein.visuel.dev
# Optional: Mail / SMTP (transactional emails). If set, overrides Settings UI.
# Export current UI settings to .env: mix mv.export_smtp_to_env
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USERNAME=user
# SMTP_PASSWORD=secret
# SMTP_PASSWORD_FILE=/run/secrets/smtp_password
# SMTP_SSL=tls
# SMTP_VERIFY_PEER=false
# MAIL_FROM_EMAIL=noreply@example.com
# MAIL_FROM_NAME=Mila

View file

@ -1,48 +0,0 @@
# Forgejo Configuration
This directory contains configuration files for Forgejo (self-hosted Git service).
## Pull Request Template
The `pull_request_template.md` is automatically loaded when creating a new Pull Request. It provides a checklist and instructions for the PR workflow, including how to run the full test suite before merging.
## Branch Protection Setup
To enforce the full test suite before merging to `main`, configure branch protection in Forgejo:
### Steps:
1. Go to **Repository Settings****Branches** → **Protected Branches**
2. Add a new rule for branch: `main`
3. Configure the following settings:
- ☑️ **Enable Branch Protection**
- ☑️ **Require status checks to pass before merging**
- Add required check: `check-full`
- ☐ **Require approvals** (optional, based on team preference)
- ☑️ **Block if there are outstanding requests** (optional)
### What this does:
- The **"Merge"** button in PRs will only be enabled after `check-full` passes
- `check-full` is triggered by **promoting** a build in Drone CI (see PR template)
- This ensures all tests (including slow and UI tests) run before merging
## Workflow
1. **Create PR** → Fast test suite (`check-fast`) runs automatically
2. **Development** → Fast tests run on every push for quick feedback
3. **Ready to merge:**
- Remove `WIP:` from PR title
- Go to Drone CI and **promote** the build to `production`
- This triggers `check-full` (full test suite)
4. **After full tests pass** → Merge button becomes available
5. **Merge to main** → Container is built and published
## Secrets Required
Make sure the following secrets are configured in Drone CI:
- `DRONE_REGISTRY_USERNAME` - For container registry
- `DRONE_REGISTRY_TOKEN` - For container registry
- `RENOVATE_TOKEN` - For Renovate bot
- `GITHUB_COM_TOKEN` - For Renovate bot (GitHub dependencies)

8
.gitignore vendored
View file

@ -41,11 +41,3 @@ npm-debug.log
.env .env
.elixir_ls/ .elixir_ls/
# Docker secrets directory (generated by `just init-secrets`)
/secrets/
notes.md
# Do NOT commit these — they are local to the dev machine
.pipeline/
.claude/

View file

@ -1,3 +1,3 @@
elixir 1.18.3-otp-27 elixir 1.18.3-otp-27
erlang 27.3.4 erlang 27.3.4
just 1.50.0 just 1.42.4

View file

@ -1,122 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0] - 2026-05-08
### Changed
- **Clickable table row highlights** The new hover/focus-visible row highlight behavior is now the CoreComponents default across clickable tables. Sticky-first-column tables keep zebra striping and show selection through the sticky-column accent stripe (checkboxes keep their default style).
- **Members overview scrolling** The members table scrollbar now scrolls inside the table container instead of moving with the full page.
- **Join request display and settings workflow** Improved join request rendering and related settings behavior in one cohesive update:
- Join request fields now respect their configured field types in the details view.
- Custom field labels in join request views were standardized.
- Join request field formatting was corrected for more consistent output.
- Join link settings now include a direct "Open" action in addition to copy/share workflows.
### Fixed
- **Runtime ENV handling** Empty or invalid environment variables (e.g. `SMTP_PORT=`, `PORT=`, `POOL_SIZE=`, `DATABASE_PORT=`) no longer cause `ArgumentError` at boot. Instead raises clear errors for required vars set but empty (e.g. DATABASE_HOST, PHX_HOST/DOMAIN, SECRET_KEY_BASE).
- **PostgreSQL 18 Docker volume path** Corrected the database volume path to match PostgreSQL 18 expectations.
- **Association name ENV handling** `ASSOCIATION_NAME` is now treated as source of truth; the field is read-only in Global Settings when managed via ENV.
- **Association name consistency after updates** Layout now prefers explicitly assigned `club_name` values to avoid stale cached values right after settings changes.
- **SMTP ENV/UI source selection** SMTP now follows a strict single-source policy: ENV-only when `SMTP_HOST` is set, otherwise Settings-only.
- **SMTP settings UI in ENV mode** SMTP fields are read-only, save action is hidden, and missing required ENV keys are shown as a warning.
### Dependency updates
- Mix dependencies were updated.
- Renovate Docker image was updated to `v43.165`.
- Rauthy Docker image was updated to `v0.35.1`.
- `just` was updated to `v1.50.0`.
## [1.1.1] - 2026-03-16
### Added
- **FORCE_SEEDS** Environment variable. When set to `"true"`, bootstrap (and optionally dev) seeds are run even when the admin user already exists, so you can re-apply changed seed data (e.g. new roles or custom fields) without deleting the admin user.
- **Improved OIDC-only mode** Admin can enable “Only OIDC sign-in” in settings; when enabled, direct registration is disabled and sign-in page redirects to OIDC when configured.
- **Success toast auto-dismiss** Success flash messages (e.g. “Settings saved”) hide automatically after 5 seconds instead of requiring the user to close them.
### Changed
- **Seeds run only when needed** Bootstrap and dev seeds are skipped on application start when the admin user already exists (`Mv.Release.bootstrap_seeds_applied?/0`). This avoids duplicate data and speeds up startup in dev and production after the first run. Set `FORCE_SEEDS=true` to override and re-run.
- **Unauthenticated access** Users who are not logged in are redirected to sign-in without showing a “no permission” message; the message is only shown to logged-in users who lack access.
### Fixed
- **SMTP configuration** Repaired so that both port 587 (TLS/STARTTLS) and 465 (SSL) work correctly.
## [1.1.0] - 2026-03-13
### Added
- **Browser timezone for datetime display** Date/time values (e.g. join request submitted at, approved at, rejected at) are shown in the users local timezone.
- **Registration toggle** New global setting to disable direct registration (`/register`). When disabled, visitors are redirected to sign-in and the register link is hidden; join form remains available.
- **Configurable SMTP in global settings** SMTP host, port, user, password, and TLS options configurable via Admin → Global Settings. Test-email action to verify delivery. Join confirmation and other transactional emails use this configuration.
- **Theme and language selector on unauthenticated pages** Sign-in and join pages now offer theme (light/dark) and locale (e.g. German/English) controls in the header.
- **Duplicate-email handling for join form** If an applicants email is already a member or already has a pending join request, the system sends a clarifying email (already-member or already-pending) and shows the same success message (anti-enumeration).
- **Reviewed-by display for join requests** Approval UI shows who reviewed a request via a dedicated display field, without loading the User record.
- **Improved field order and seeds for join request approval** Approval screen field order improved; seed data updated for join-form and approval flows.
- **Tests for SMTP mailer configuration** Tests for SMTP config and for join confirmation email delivery failure (domain and LiveView).
### Changed
- **SMTP settings layout** SMTP options reordered and grouped in global settings for clearer configuration.
- **Join confirmation mail** Uses configurable SMTP from settings; on delivery failure the join form shows an error and no success message.
- **i18n** Gettext catalogs updated for new and changed strings.
### Fixed
- **Login page translation** Corrected translation/locale handling on the sign-in page.
---
## [1.0.0] and earlier
### Added
- **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08)
- Four hardcoded permission sets: `own_data`, `read_only`, `normal_user`, `admin`
- Database-backed roles with permission set references
- Member resource policies with scope filtering (`:own`, `:linked`, `:all`)
- Authorization checks via `Mv.Authorization.Checks.HasPermission`
- System role protection (critical roles cannot be deleted)
- Role management UI at `/admin/roles`
- **Membership Fees System** - Full implementation
- Membership fee types with intervals (monthly, quarterly, half_yearly, yearly)
- Individual billing cycles per member with payment status tracking
- Cycle generation and regeneration
- Global membership fee settings
- UI components for fee management
- **Global Settings Management** - Singleton settings resource
- Club name configuration (with environment variable support)
- Member field visibility settings
- Membership fee default settings
- **Sidebar Navigation** - Replaced navbar with standard-compliant sidebar (#260, 2026-01-12)
- **CSV Import Templates** - German and English templates (#329, 2026-01-13)
- Template files in `priv/static/templates/`
- CSV specification documented
- User-Member linking with fuzzy search autocomplete (#168)
- PostgreSQL trigram-based member search with typo tolerance
- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support
- 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 <email>" 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)
### Changed
- **Actor Handling Refactoring** (2026-01-09)
- Standardized actor access with `current_actor/1` helper function
- `ash_actor_opts/1` helper for consistent authorization options
- `submit_form/3` wrapper for form submissions with actor
- All Ash operations now properly pass `actor` parameter
- **Error Handling Improvements** (2026-01-13)
- Replaced `Ash.read!` with proper error handling in LiveViews
- Consistent flash message handling for authorization errors
- Early return patterns for unauthenticated users
### Fixed
- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
- Relationship data extraction from Ash manage_relationship during validation
- Copy button count now shows only visible selected members when filtering
- Language headers in German `.po` files (corrected from "en" to "de")
- Critical deny-filter bug in authorization system (2026-01-08)
- HasPermission auto_filter and strict_check implementation (2026-01-08)

File diff suppressed because it is too large Load diff

View file

@ -1,459 +0,0 @@
# UI Design Guidelines (Mila / Phoenix LiveView + DaisyUI)
## Purpose
This document defines Milas **UI system** to ensure **UX consistency**, **accessibility**, and **maintainability** across Phoenix LiveView pages:
- consistent DaisyUI usage
- typography & spacing
- button intent & labeling
- list/search/filter UX
- tables behavior (row click, tooltips, alignment)
- flash/toast UX (position, stacking, auto-dismiss, tones)
- standard page skeletons (index/detail/form)
- microcopy conventions (German “du” tone)
> Engineering practices (LiveView load budget, testing, security, etc.) are defined in `docs/CODE_GUIDELINES.md`.
> This document focuses on **visual + UX** consistency and references engineering rules where needed.
---
## 1) Principles
### 1.1 Components first (no raw DaisyUI classes in views)
- **MUST:** Use `MvWeb.CoreComponents` (e.g. `<.button>`, `<.header>`, `<.table>`, `<.input>`, `<.flash_group>`, `<.form_section>`).
- **MUST NOT:** Write DaisyUI component classes directly in LiveViews/HEEX (e.g. `btn`, `alert`, `table`, `input`, `select`, `tooltip`) unless you are implementing them **inside** CoreComponents.
- **MAY:** Use Tailwind for layout only: `flex`, `grid`, `gap-*`, `p-*`, `max-w-*`, `sm:*`, etc.
### 1.2 DaisyUI for look, Tailwind for layout
- DaisyUI: component visuals + semantic variants (`btn-primary`, `alert-error`, `badge`, `tooltip`).
- Tailwind: spacing, alignment, responsiveness.
### 1.3 Semantics over hard-coded colors
- **MUST NOT:** Use “status colors” in views (`bg-green-500`, `text-blue-500`, …).
- **MUST:** Express intent via component props / DaisyUI semantic variants.
---
## 2) Page Skeleton & “Chrome” (mandatory)
### 2.1 Standard page layout
Every authenticated page should follow the same structure:
1) `<.header>` (title + optional subtitle + actions)
2) content area with consistent vertical rhythm (`mt-6 space-y-6`)
3) optional footer actions for forms
**MUST:** Use `<.header>` on every page (except login/public pages).
**SHOULD:** Put short explanations into `<:subtitle>` rather than sprinkling random text blocks.
### 2.2 Edit/New form header: Back button left (mandatory)
For LiveViews that render an edit or new form (e.g. member, group, role, user, custom field, membership fee type):
- **MUST:** Provide a **Back** button on the **left** side of the header using the `<:leading>` slot (same as data fields: Back left, title next, primary action on the right).
- **MUST:** Use the same pattern everywhere: Back button with `variant="neutral"`, arrow-left icon, and label “Back”. It navigates to the previous context (e.g. detail page or index) via a `return_path`-style helper.
- **SHOULD:** Place the primary action (e.g. “Save”) in `<:actions>` on the right.
- **Rationale:** Users expect a consistent way to leave the form without submitting; Back left matches the data fields edit view and keeps primary actions on the right.
**Template for form pages:**
```heex
<.header>
<:leading>
<.button navigate={return_path(@return_to, @resource)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
Page title (e.g. “Edit Member” or “New User”)
<:subtitle>Short explanation.</:subtitle>
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
```
If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`).
### 2.3 Public / unauthenticated pages (Join, Sign-in, Join Confirm)
Pages that do not require authentication (e.g. `/join`, `/sign-in`, `/confirm_join/:token`) use a unified layout via the **`Layouts.public_page`** component:
- **Component:** `Layouts.public_page` renders:
- **Header:** Logo + "Mitgliederverwaltung" (left) | Club name centered via absolute positioning | Language selector + theme swap (sun/moon, DaisyUI swap with rotate) (right)
- Main content slot, Flash group. No sidebar, no authenticated-layout logic.
- **Content:** DaisyUI **hero** section (`hero`, `hero-content`) for the main message or form, so all public pages share the same visual structure. The hero is constrained in width (`max-w-4xl mx-auto`) and content is left-aligned (`hero-content flex-col items-start text-left`).
- **Locale handling:** The language selector uses `Gettext.get_locale(MvWeb.Gettext)` (backend-specific) to correctly reflect the active locale. `SignInLive` sets both `Gettext.put_locale(MvWeb.Gettext, locale)` and `Gettext.put_locale(locale)` to keep global and backend locales in sync.
- **Translations for AshAuthentication components:** AshAuthentications `_gettext` mechanism translates button labels (e.g. “Sign in” → “Anmelden”, “Register” → “Registrieren”) at runtime via `gettext_fn: {MvWeb.Gettext, "auth"}`. Components that do NOT use `_gettext` (e.g. `HorizontalRule`) receive static German overrides via **`MvWeb.AuthOverridesDE`**, which is prepended to the overrides list in `SignInLive` when the locale is `"de"`.
- **Implementation:**
- **Sign-in** (`SignInLive`): Uses `use Phoenix.LiveView` (not `use MvWeb, :live_view`) so AshAuthentications sign_in_route live_session on_mount chain is not mixed with LiveHelpers hooks. Renders `<Layouts.public_page flash={@flash}>` with the SignIn component inside a hero. Displays a locale-aware `<h1>` title (“Anmelden” / “Registrieren”) above the AshAuthentication component (the librarys Banner is hidden via `show_banner: false`).
- **Join** (`JoinLive`): Uses `use MvWeb, :live_view` and wraps content in `<Layouts.public_page flash={@flash}>` with a hero for the form.
- **Join Confirm** (controller): Uses `JoinConfirmHTML` with a template that wraps content in `<Layouts.public_page flash={@flash}>` and a hero block for the result, so the confirm page shares the same header and chrome as Join and Sign-in.
## 3) Typography (system)
Use these standard roles:
| Role | Use | Class |
|---|---|---|
| Page title (H1) | main page title | `text-xl font-semibold leading-8` |
| Subtitle | helper under title | `text-sm text-base-content/85` |
| Section title (H2) | section headings | `text-lg font-semibold` |
| Helper text | under inputs | `text-sm text-base-content/85` |
| Fine print | small hints | `text-xs text-base-content/80` |
| Empty state | no data | `text-base-content/80 italic` |
| Destructive text | danger | `text-error` |
**MUST:** Page titles via `<.header>`.
**MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later).
**Form labels (WCAG 2.2 AA):** DaisyUI `.label` defaults to 60% opacity and fails contrast. We override it in `app.css` to 85% of `base-content` so labels stay slightly deemphasised vs body text but meet the 4.5:1 minimum. Use `class="label"` and `<span class="label-text">` as usual; no extra classes needed.
---
## 4) States: Loading, Empty, Error (mandatory consistency)
### 4.1 Loading state
- **MUST:** Show a consistent loading indicator when data is not ready.
- **MUST NOT:** Render empty states while loading (avoid flicker).
- **SHOULD:** Prefer “skeleton rows” for tables or a spinner in content area.
### 4.2 Empty state pattern
Empty states must be consistent:
- short message
- optional primary CTA (“Create …”)
- optional secondary help link
**Example:**
```heex
<div class="space-y-3">
<p class="text-base-content/60 italic">No members yet.</p>
<.button variant="primary" navigate={~p"/members/new"}>Create member</.button>
</div>
### 4.3 Error state pattern
- **MUST:** Use flash/toast for global errors.
- **SHOULD:** Also show inline error state near the relevant content area if the page cannot proceed.
---
## 5) Buttons (intent, labels, variants)
### 5.1 Decision rule: action vs status
- **MUST:** Button labels describe **actions** (verb-first):
- ✅ Save, Create member, Send invite, Import CSV
- ❌ Active, Success, Done (status belongs elsewhere)
- **MUST:** Status belongs in badges/labels or read-only text, not in CTAs.
### 5.2 Standard variants (mandatory set)
Buttons must be rendered via `<.button>` and mapped to DaisyUI internally.
**Supported variants:**
- `primary` (main CTA)
- `secondary` (supporting)
- `neutral` (cancel/back)
- `ghost` (low emphasis; table/toolbars)
- `outline` (alternative CTA)
- `danger` (destructive)
- `link` (inline; rare)
- `icon` (icon-only)
**Sizes:** `sm`, `md` (default), `lg` (rare)
### 5.3 Placement rules
- Header CTA inside `<.header><:actions>`.
- Form footer: primary right; cancel/secondary left.
- Tables: use `ghost`/`icon` for row actions (avoid `primary` inside rows).
### 5.4 Primary vs Secondary (UX consistency rules)
#### One primary action per screen
- MUST: Each screen/section has at most one **primary** action (e.g. Save, Create, Start import).
- SHOULD: Additional actions are secondary/neutral/ghost, not additional primary.
#### Primary vs Secondary meaning
- Primary = the most important/most common action to complete the user task.
- Secondary = supporting actions (Cancel/Back/Edit in tool contexts), lower emphasis.
#### Order and placement (choose and apply consistently)
We follow these ordering rules:
- MUST: Order buttons by priority: **Primary → Secondary → Tertiary**.
- Forms: Decide once (primary-left OR primary-right) and apply everywhere.
- Dialogs/confirmations: Place the confirmation action consistently (e.g. trailing edge, confirmation closest to edge).
#### Cancel/Back consistency
- MUST: Cancel/Back is **never** styled as primary.
- MUST: Cancel/Back placement is consistent across the app (same side, same label).
#### Implementation requirement
- MUST: Use CoreComponents (`<.button>`) with `variant`/`size` props.
- MUST NOT: Use ad-hoc classes like `class="secondary"` on `<.button>`; instead extend CoreComponents to support `secondary`, `neutral`, `ghost`, `danger`, etc.
#### Ghost buttons (accessibility requirements)
Ghost buttons are allowed for low-emphasis actions (toolbars, table actions), but:
- MUST: Focus indicator is clearly visible (do not remove outlines).
- MUST: UI contrast for the control (and meaningful icons) meets WCAG non-text contrast (≥ 3:1).
- MUST: Icon-only ghost buttons provide an accessible name (`aria-label`) and preferably a tooltip.
- SHOULD: Hit target is large enough for touch/motor accessibility (recommend ~44x44px).
If these cannot be met, use `secondary`/`outline` instead of `ghost`.
---
## 6) Forms (structure + interaction rules)
### 6.1 Structure
- **MUST:** Forms are grouped into `<.form_section title="…">`.
- **MUST:** All inputs via `<.input>`.
### 6.2 Validation timing (consistent UX)
- **MUST:** Validate on submit always.
- **SHOULD:** Validate on change only where it helps; use debounce to avoid “error spam”.
- **MUST:** Define a consistent “when errors appear” rule:
- Preferred: show field errors after first submit attempt OR after the field has been touched (pick one and apply everywhere).
> Engineering note (implementation): follow LiveView load budget in `CODE_GUIDELINES.md` (no DB reads on `phx-change` by default).
### 6.3 Required fields
- **MUST:** Required fields are marked consistently (UI indicator + accessible text).
- **SHOULD:** If required-ness is configurable via settings, display it consistently in the form.
### 6.4 Form layout (settings / long forms)
- **SHOULD:** On wide viewports, use a responsive grid so related fields share a row and reduce scrolling (e.g. `grid grid-cols-1 lg:grid-cols-2` or `lg:grid-cols-[2fr_5rem_1fr]` for mixed widths).
- **SHOULD:** Limit the main content width for readability (e.g. Settings page uses `max-w-4xl mx-auto px-4` around the content area below the header).
- **Example:** SMTP settings use three rows on large screens (Host, Port, TLS/SSL | Username, Password | Sender email, Sender name) without subsection labels.
---
## 7) Lists, Search & Filters (mandatory UX consistency)
### 7.1 Standard filter/search bar pattern
- **MUST:** All list pages use the same search/filter placement (choose one layout and apply everywhere).
- Recommended: top area above the table, aligned with page actions.
- **MUST:** Always provide “Clear filters” when filters are active.
- **MUST:** Filter state is reflected in URL params (so reload/back/share works consistently).
### 7.2 URL behavior (UX rule)
- Use `push_patch` for in-page state changes: filters, sorting, pagination, tabs.
- Use `push_navigate` for actual page transitions: details, edit, new.
---
## 8) Tables (mandatory UX)
### 8.1 Default behavior: row click opens details
- **DEFAULT:** Clicking a row navigates to the details page.
- **EXCEPTIONS:** Highly interactive rows may disable row-click (document why).
- **Row highlight (CoreComponents):** When `row_click` is set, rows use a neutral background highlight on `hover` and `tr:has(:focus-visible)` (see `assets/css/app.css`), so keyboard focus is visible while mouse-only focus does not appear "stuck". For non-sticky tables, `selected_row_id` can still add a stronger selected ring. For sticky-first-column tables, selection emphasis is handled by the sticky-column accent stripe.
**IMPORTANT (correctness with our `<.table>` CoreComponent):**
Our table implementation attaches the `phx-click` to the **`<td>`** when `row_click` is set. That means click events bubble from inner elements up to the cell unless we stop propagation.
**LiveStream rows:** Do not enumerate `@rows` with `Enum.with_index` in the table template; streams must be consumed only through `:for`. Sticky-first-column zebra striping for those tables is handled in CSS (`nth-child` under `data-sticky-first-col-rows`), not by assigning odd/even classes from an index.
So, for interactive elements inside a clickable row, you must **stop propagation using `Phoenix.LiveView.JS.stop_propagation/1`**, not a custom attribute.
✅ Correct pattern (one click handler that both stops propagation and triggers an event):
```heex
<.table
id="members"
rows={@members}
row_click={fn m -> JS.navigate(~p"/members/#{m.id}") end}
>
<:col :let={m} label="Name">
<%= m.last_name %>, <%= m.first_name %>
</:col>
<:col :let={m} label="Newsletter">
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={m.newsletter}
phx-click={JS.push("toggle_newsletter", value: %{id: m.id}) |> JS.stop_propagation()}
/>
</:col>
<:action :let={m}>
<.button
variant="ghost"
size="sm"
navigate={~p"/members/#{m.id}/edit"}
phx-click={JS.stop_propagation()}
>
Edit
</.button>
</:action>
</.table>
Notes:
- The checkbox uses `phx-click={JS.push(...) |> JS.stop_propagation()}` so it wont trigger row navigation.
- The Edit button also stops propagation to avoid accidental row navigation when clicked.
### 8.2 Tooltips (mandatory where needed)
- **MUST:** Tooltips for:
- icon-only actions
- truncated content
- status badges that require explanation
- **MUST:** Provide tooltips via a shared wrapper (recommended `<.tooltip>` CoreComponent).
- **MUST NOT:** Scatter ad-hoc tooltip markup in views.
### 8.3 Alignment & density conventions
- **MUST:** Text columns left-aligned.
- **MUST:** Numeric columns right-aligned.
- **MUST:** Action column right-aligned.
- **SHOULD:** Table density is consistent:
- default density for most tables
- a single “dense” option only if needed (via a prop, not per-page random classes)
### 8.4 Truncation standard
- **MUST:** Truncate long values consistently (same max widths for name/email-like fields).
- **MUST:** Tooltip reveals full value when truncated.
### 8.5 Loading/Lists/Tables: keep filters visible on desktop
- On **desktop (lg: breakpoint)** only: list pages with large datasets (e.g. Members overview) keep the page header and filter/search bar visible while the user scrolls. Only the table body scrolls inside a constrained area (`lg:max-h-[calc(100vh-<offset>)] lg:overflow-auto`). This preserves context and avoids losing filters when scrolling.
- On **mobile**, sticky headers are not used; the layout uses normal flow (header and table scroll with the page) to preserve vertical space.
- When the table is inside such a scroll container, use the CoreComponents tables `sticky_header={true}` so the tables `<thead>` stays sticky within the scroll area on desktop (`lg:sticky lg:top-0`, opaque background `bg-base-100`, z-index). Sticky areas must not overlap content at 200% zoom; focus order must remain header → filters → table.
### 8.6 Empty table cells (missing values)
- **MUST:** Missing values in tables are shown as **visually empty cells** (no dash, no "n/a").
- **MUST NOT:** Use dashes ("-", "—", "") or "n/a" as placeholders for empty cells.
- **MUST:** For accessibility, render a screen-reader-only label so the cell is not announced as "blank". Use the CoreComponents `<.empty_cell sr_text="…">` for a cell that has no value, or `<.maybe_value value={…} empty_sr_text="…">` when the cell content is conditional (value present vs. absent).
- **SHOULD:** Use context-specific `sr_text` where it helps (e.g. "No cycle", "No group assignment", "Not specified"). Default for "no value" is "Not specified".
---
## 9) Flash / Toast messages (mandatory UX)
### 9.1 Location + stacking
- **MUST:** Position flash/toasts at the bottom of the viewport (pick bottom-right or bottom-center; be consistent).
- **MUST:** Stack all flash messages with consistent spacing.
- **SHOULD:** Newest appears on top.
### 9.2 Auto-dismiss
- **MUST:** Flash messages disappear automatically:
- info/success: 46s
- warning: 68s
- error: 812s (or manual dismiss for critical errors)
- **MUST:** Keep a dismiss button for accessibility and user control.
- **Status:** Not yet implemented. See [feature-roadmap](docs/feature-roadmap.md) → Flash: Auto-dismiss and consistency.
### 9.3 Variants (unified)
- Supported semantic variants: `info`, `success`, `warning`, `error`.
- **MUST:** Use the same variants for all flash types, : e.g. `success` for copy success, no separate tone or styling. This keeps flash UX consistent across the app.
### 9.4 Accessibility
- Flash must work with screen readers (live region behavior belongs in the flash component implementation).
- See `CODE_GUIDELINES.md` Accessibility → live regions.
---
## 10) Mutations & feedback patterns (create/update/delete/import)
### 10.1 Mutation feedback is always two-part
For create/update/delete:
- **MUST:** Show a toast/flash message
- **MUST:** Show a visible UI update (navigate, row removed, values updated)
No “silent success”.
### 10.2 Destructive actions: one standard confirmation pattern
- **MUST:** All destructive actions use the same confirm style and wording conventions.
- **MUST:** Use the standard modal (see §10.3); do not use `data-confirm` / browser `confirm()` for destructive actions, so that focus and keyboard behaviour are consistent and accessible.
**Recommended copy style:**
- Title/confirm text is clear and specific (what will be deleted, consequences).
- Buttons: `Cancel` (neutral) + `Delete` (danger).
### 10.3 Dialogs and modals (mandatory)
- **MUST:** For every dialog (confirmations, form overlays, delete role/group/data field, edit cycle, etc.) use the **same modal pattern**: `<dialog>` with DaisyUI `modal modal-open`, `role="dialog"`, `aria-labelledby` on the title, and focus moved into the modal when it opens (first focusable element).
- **MUST NOT:** Use browser `confirm()` / `data-confirm` for destructive or important choices; use the LiveView-controlled modal so that keyboard users get focus inside the dialog and can confirm or cancel without the mouse.
- **Reference:** Full structure, focus management, and accessibility rules are in **`CODE_GUIDELINES.md` §8.11 (Modals and Dialogs)**. Follow that section for implementation (e.g. `phx-mounted={JS.focus()}` on the first focusable, consistent `modal-box` / `modal-action` layout).
---
## 11) Detail pages (consistent structure)
Detail pages should not drift into random layouts.
**MUST:** Use consistent structure:
- header with primary action (Edit)
- sections/cards for grouped info
- “Danger zone” section at bottom for destructive actions
---
## 12) Navigation rules (UX consistency)
- **MUST:** `push_patch` for in-page state: sorting, filtering, pagination, tabs.
- **MUST:** `push_navigate` for page transitions: detail/edit/new.
- **SHOULD:** Back button behavior must feel predictable (URL reflects state).
---
## 13) Microcopy conventions (German “du” tone + glossary)
### 13.1 Tone
- **MUST:** All German user-facing text uses informal address (“du”).
- **MUST:** Use consistent verbs for common actions:
- Save: “Speichern”
- Cancel: “Abbrechen”
- Delete: “Löschen”
- Edit: “Bearbeiten”
### 13.2 Preferred terms (starter glossary)
- Member: “Mitglied”
- Fee/Contribution: “Beitrag”
- Settings: “Einstellungen”
- Group: “Gruppe”
- Import/Export: “Import/Export”
- Clear filters: “Filter zurücksetzen” (use when filters are active; button label in list/filter UX)
Add to this glossary when new terminology appears.
---
## 14) Destructive actions: Delete flow (canonical)
This section defines the canonical delete flow for list/detail/form resources (e.g. members). Use it as the single pattern; do not introduce a second pattern elsewhere.
### Tables: no row action buttons
- **MUST NOT:** Show Edit or Delete as row action buttons (or dropdown actions) in list/table views.
- **MUST:** Remove any existing edit/delete row actions from tables so that the only way to edit or delete is via the flow below.
### Navigation: row click → details
- **MUST:** Clicking a table row navigates to the resource details page (e.g. `/members/:id`).
- **MUST NOT:** Use the table for primary edit/delete actions.
### Edit: from details header, not from table
- **MUST:** Provide a clear primary “Edit” CTA in the details page header (e.g. “Edit member”).
- **MUST:** Edit is reached from the details page (e.g. “Edit member” button in header), not from the list/table.
### Delete: only via “Danger zone”
- **MUST:** Delete is available only in a dedicated “Danger zone” section at the bottom of the page.
- **MUST:** Use the same “Danger zone” on both the details page and the edit form when the user is authorized to destroy the resource.
- **MUST NOT:** Place delete in the table, in the header next to Edit, or in any other location outside the Danger zone.
### Danger zone layout and wording (canonical pattern)
- **Heading:** “Danger zone” (H2, `aria-labelledby` for the section, semantic colour e.g. `text-error`).
- **Explanatory text:** One short paragraph stating that the action cannot be undone and mentioning consequences (e.g. related data removed). Use `text-base-content/70` for the text.
- **Layout:** Section with heading outside a bordered box; content inside a single bordered, rounded box (`border border-base-300 rounded-lg p-4 bg-base-100`).
- **Button:** One destructive action only (e.g. “Delete member”). Use CoreComponents `<.button variant="danger">`. No primary or secondary actions mixed inside the Danger zone.
### Confirmation and button semantics
- **MUST:** Use a single confirmation step (e.g. `data-confirm` / browser confirm or one modal). Do not introduce a second confirmation pattern in this flow.
- **Confirm copy:** Message must include the resource name and state that the action “cannot be undone” (e.g. “Are you sure you want to delete %{name}? This action cannot be undone.”).
- **Button:** Accessible label (visible text + `aria-label` that includes the resource name, e.g. “Delete member %{name}”). Icon (e.g. trash) is optional and must not replace the text label for the primary action.
### Accessibility
- **MUST:** Button has an accessible name (`aria-label` when icon-only or in addition to visible text as above).
- **MUST:** Focus and keyboard: button is focusable and activatable via keyboard; focus management must not trap the user.
- **MUST:** Contrast and visibility: Danger zone heading and button use semantic danger styling with sufficient contrast (WCAG AA).
### Authorization visibility
- **MUST:** Show the Danger zone only when the current user is authorized to destroy the resource (e.g. `can?(current_user, :destroy, resource)`).
- **MUST NOT:** Show the Danger zone or the delete button when the user cannot destroy the resource; no “disabled” delete button for unauthorized users.
---

View file

@ -7,25 +7,25 @@
# This file is based on these images: # This file is based on these images:
# #
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=trixie-20260202-slim - for the release image # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250317-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages # - https://pkgs.org/ - resource for finding needed packages
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim # - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim
# #
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim" ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
ARG RUNNER_IMAGE="debian:trixie-20260202-slim" ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
FROM ${BUILDER_IMAGE} AS builder FROM ${BUILDER_IMAGE} as builder
# install build dependencies # install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \ RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_* && apt-get clean && rm -f /var/lib/apt/lists/*_*
# prepare build dir # prepare build dir
WORKDIR /app WORKDIR /app
# install hex + rebar # install hex + rebar
RUN mix local.hex --force && \ RUN mix local.hex --force && \
mix local.rebar --force mix local.rebar --force
# set build ENV # set build ENV
ENV MIX_ENV="prod" ENV MIX_ENV="prod"
@ -64,15 +64,15 @@ RUN mix release
FROM ${RUNNER_IMAGE} FROM ${RUNNER_IMAGE}
RUN apt-get update -y && \ RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \ apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_* && apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set the locale # Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8 ENV LANG en_US.UTF-8
ENV LANGUAGE=en_US:en ENV LANGUAGE en_US:en
ENV LC_ALL=en_US.UTF-8 ENV LC_ALL en_US.UTF-8
WORKDIR "/app" WORKDIR "/app"
RUN chown nobody /app RUN chown nobody /app
@ -90,4 +90,4 @@ USER nobody
# above and adding an entrypoint. See https://github.com/krallin/tini for details # above and adding an entrypoint. See https://github.com/krallin/tini for details
# ENTRYPOINT ["/tini", "--"] # ENTRYPOINT ["/tini", "--"]
ENTRYPOINT ["/app/bin/docker-entrypoint.sh"] CMD ["/app/bin/server"]

View file

@ -1,13 +1,4 @@
set dotenv-load := true set dotenv-load := true
set export := true
# Non-interactive shells do not source .bashrc,
# PATH includes asdf shims so that mix / elixir / iex resolve without per-shell
# `source ~/.asdf/asdf.sh`. Recipes inherit this via `set export := true`.
home := env_var('HOME')
PATH := home + "/.asdf/shims:" + home + "/.asdf:" + home + "/.local/bin:/usr/local/bin:/usr/bin:/bin"
MIX_QUIET := "1"
run: install-dependencies start-database migrate-database seed-database run: install-dependencies start-database migrate-database seed-database
mix phx.server mix phx.server
@ -16,7 +7,6 @@ install-dependencies:
mix deps.get mix deps.get
migrate-database: migrate-database:
mix compile
mix ash.setup mix ash.setup
reset-database: reset-database:
@ -29,7 +19,7 @@ seed-database:
start-database: start-database:
docker compose up -d docker compose up -d
ci-dev: lint audit test-fast ci-dev: lint audit test
gettext: gettext:
mix gettext.extract mix gettext.extract
@ -38,47 +28,22 @@ gettext:
lint: lint:
mix format --check-formatted mix format --check-formatted
mix compile --warnings-as-errors mix compile --warnings-as-errors
mix credo --strict mix credo
# Check that all German translations are filled (UI must be in German)
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
mix gettext.extract --check-up-to-date
audit: audit:
mix sobelow --config mix sobelow --config
mix deps.audit mix deps.audit
mix hex.audit mix hex.audit
# Run all tests # first run unit test and after that run e2e test (especially for accessibility)
test *args: install-dependencies test: install-dependencies start-database
mix test {{args}} mix test.unit
mix test.e2e
# Run only fast tests (excludes slow/performance and UI tests)
test-fast *args: install-dependencies
mix test --exclude slow --exclude ui {{args}}
# Run only UI tests
ui *args: install-dependencies
mix test --only ui {{args}}
# Run only slow/performance tests
slow *args: install-dependencies
mix test --only slow {{args}}
# Run only slow/performance tests (alias for consistency)
test-slow *args: install-dependencies
mix test --only slow {{args}}
# Run all tests (fast + slow + ui)
test-all *args: install-dependencies
mix test {{args}}
format: format:
mix format mix format
# Catch-all wrapper for arbitrary mix commands not exposed as their own recipe.
mix *args:
mix {{args}}
build-docker-container: build-docker-container:
docker build --tag mitgliederverwaltung . docker build --tag mitgliederverwaltung .
@ -122,32 +87,3 @@ clean:
mix clean mix clean
rm -rf .elixir_ls rm -rf .elixir_ls
rm -rf _build 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; /^%%%%%%%/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

662
LICENSE
View file

@ -1,662 +0,0 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) {{ year }} {{ organization }}
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.

276
README.md
View file

@ -1,278 +1,18 @@
# Mila # mitgliederverwaltung
**Mila** — simple, usable, self-hostable membership management for small to mid-sized clubs. ## Testing SSO with rauthy
[![Build Status](https://drone.cicd.local-it.cloud/api/badges/local-it/mitgliederverwaltung/status.svg)](https://drone.cicd.local-it.cloud/local-it/mitgliederverwaltung)
![License](https://img.shields.io/badge/license-AGPL--v3-blue)
## 🚧 Project Status
⚠️ **First Version** — Expect breaking changes.
Contributions and feedback are welcome!
## ✨ Overview
Mila is a free and open-source membership management tool designed for real club needs.
It is **self-hosting friendly**, aims for **accessibility and GDPR compliance**, and focuses on **usability** instead of feature overload.
## 💡 Why Mila?
Most membership tools for clubs are either:
* **Too complex** — overloaded with features small and mid-sized clubs dont need
* **Too expensive** — hidden fees, closed ecosystems, vendor lock-in
* **Too rigid** — no way to adapt fields, processes, or roles to your clubs reality
**Mila** is different:
* **Simple**: Focused on what clubs really need — members, dues, communication.
* **Usable**: Clean, accessible UI, GDPR-compliant, designed with everyday volunteers in mind.
* **Flexible**: Customize what data you collect about members, role-based permissions, and self-service for members.
* **Truly open**: 100% free and open source — no lock-in, transparent code, self-host friendly.
Our philosophy: **software should help people spend less time on administration and more time on their community.**
## User Documentation (German)
You can find our documentation for users here: https://wiki.local-it.org/s/mila-user-dokumentation
## 🔑 Features
- ✅ Manage member data with ease
- ✅ Membership fees & payment status tracking
- ✅ Full-text search with fuzzy matching
- ✅ Sorting & filtering
- ✅ Roles & permissions (RBAC system with 4 permission sets)
- ✅ Custom fields (flexible per club needs)
- ✅ SSO via OIDC (works with Authentik, Rauthy, Keycloak, etc.)
- ✅ Sidebar navigation (standard-compliant, accessible)
- ✅ Global settings management
- ✅ Self-service & online application
- ✅ Accessibility improvements (WCAG 2.1 AA compliant keyboard navigation)
- ✅ Email sending
- ✅ Integration of Accounting-Software ([Vereinfacht](https://github.com/vereinfacht/vereinfacht))
## 🚀 Quick Start (Development)
### Prerequisites
We recommend using **[asdf](https://asdf-vm.com/)** for managing tool versions.
- Tested with: `asdf 0.16.5`
- Required versions are documented in `.tool-versions` in this repo
<details>
<summary>Install system dependencies (Debian/Ubuntu)</summary>
```bash
# Debian 12
apt-get -y install build-essential autoconf m4 libncurses-dev libwxgtk3.2-dev libwxgtk-webview3.2-dev libgl1-mesa-dev libglu1-mesa-dev libpng-dev libssh-dev unixodbc-dev xsltproc fop libxml2-utils openjdk-17-jdk icu-devtools bison flex pkg-config
# Ubuntu 24
apt-get -y install build-essential autoconf m4 libwxgtk3.2-dev libwxgtk-webview3.2-dev libgl1-mesa-dev libglu1-mesa-dev libpng-dev libssh-dev unixodbc-dev xsltproc fop libxml2-utils libncurses-dev openjdk-11-jdk icu-devtools bison flex libreadline-dev
```
</details>
<details>
<summary>Install asdf</summary>
```bash
mkdir ~/.asdf
cd ~/.asdf
wget https://github.com/asdf-vm/asdf/releases/download/v0.16.5/asdf-v0.16.5-linux-amd64.tar.gz
tar -xvf asdf-v0.16.5-linux-amd64.tar.gz
ln -s ~/.asdf/asdf ~/.local/bin/asdf
```
Then follow the official “Shell Configuration” steps in the asdf docs.
*Fish example* (`~/.config/fish/config.fish`):
```fish
asdf completion fish > ~/.config/fish/completions/asdf.fish
set -gx PATH "$HOME/.asdf/shims" $PATH
```
*Bash example* (`~/.bash_profile` and `~/.bashrc`):
```bash
export PATH="${ASDF_DATA_DIR:-$HOME/.asdf}/shims:$PATH"
. <(asdf completion bash)
```
</details>
### Install project dependencies
```bash
git clone https://git.local-it.org/local-it/mitgliederverwaltung.git mila
cd mila
asdf plugin add elixir
asdf plugin add erlang
asdf plugin add just
asdf install
# Inside the repo folder:
mix local.hex
mix archive.install hex phx_new
```
> Note: running `mix local.hex` must be done inside the repo folder,
> because `.tool-versions` defines the Erlang/Elixir versions.
### Run the app
1. Copy env file:
```bash
cp .env.example .env
# Set OIDC_CLIENT_SECRET inside .env
```
2. Start everything (database, Mailcrab, Rauthy, app):
```bash
just run
```
3. Services will be available at:
- App: <http://localhost:4000>
- Mail UI: <http://localhost:1080>
- Postgres: `localhost:5000`
## 🔐 Testing SSO locally
Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance is provided.
1. `just run` 1. `just run`
2. go to [localhost:8080](http://localhost:8080), go to the Admin area 1. go to [localhost:8080](http://localhost:8080), go to the Admin area
3. Login with "admin@localhost" and password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in docker-compose.yml 1. Login with "admin@localhost" and password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in docker-compose.yml
4. add client from the admin panel 1. add client from the admin panel
- Client ID: mv - Client ID: mv
- redirect uris: http://localhost:4000/auth/user/oidc/callback - redirect uris: http://localhost:4000/auth/user/rauthy/callback
- Authorization Flows: authorization_code - Authorization Flows: authorization_code
- allowed origins: http://localhost:4000 - allowed origins: http://localhost:4000
- access/id token algortihm: RS256 (EDDSA did not work for me, found just few infos in the ashauthentication docs) - access/id token algortihm: RS256 (EDDSA did not work for me, found just few infos in the ashauthentication docs)
5. copy client secret to `.env` file 1. copy client secret to `.env` file
6. abort and run `just run` again 1. 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 `:oidc` — it works with any OIDC-compliant provider.
**Important:** The redirect URI must always end with `/auth/user/oidc/callback`.
Example for Authentik:
1. Create an OAuth2/OpenID Provider in Authentik
2. Set the redirect URI to: `https://your-domain.com/auth/user/oidc/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/oidc/callback` if not explicitly set.
## ⚙️ Configuration
- **Env vars:** see `.env.example`
## 🏗️ Architecture
**Tech Stack Overview:**
- **Backend:** Elixir + Phoenix + Ash Framework
- **Frontend:** Phoenix LiveView + Tailwind CSS + DaisyUI
- **Database:** PostgreSQL
- **Auth:** AshAuthentication (OIDC + password)
**Code Structure:**
- `lib/accounts/` & `lib/membership/` & `lib/membership_fees/` & `lib/mv/authorization/` — Ash resources and domains
- `lib/mv_web/` — Phoenix controllers, LiveViews, components
- `lib/mv/` — Shared helpers and business logic
- `assets/` — Tailwind, JavaScript, static files
- `test/` — All tests
📚 **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
**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
### Local Production Testing
For testing the production Docker build locally:
1. **Generate secrets:**
```bash
mix phx.gen.secret # for SECRET_KEY_BASE
mix phx.gen.secret # for TOKEN_SIGNING_SECRET
```
2. **Create `.env` file:**
```bash
# Copy template and edit
cp .env.example .env
nano .env
```
3. **Start production environment:**
```bash
docker compose -f docker-compose.prod.yml up
```
4. **Database migrations run automatically** on app start. For manual migration:
```bash
docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate"
```
5. **Access the production app:**
- Production App: http://localhost:4001
- Uses same Rauthy instance as dev (localhost:8080)
**Note:** The local production setup uses `network_mode: host` to share localhost with the development Rauthy instance. For real production deployment, configure an external OIDC provider and remove `network_mode: host`.
### Real Production Deployment
For actual production deployment:
1. **Use an external OIDC provider** (not the local Rauthy)
2. **Update `docker-compose.prod.yml`:**
- Remove `network_mode: host`
- 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.
5. **Configure database backups**
## 🤝 Contributing
We welcome contributions!
- Open issues and PRs in this repo
- Please follow existing code style and conventions
- Expect breaking changes while the project is in early development
## 📄 License
**License: AGPLv3**
See the [LICENSE](LICENSE) file for details.
## 📬 Contact
- Issues: [GitLab Issues](https://git.local-it.org/local-it/mitgliederverwaltung/-/issues)
- E-Mail: info@local-it.org

View file

@ -24,7 +24,7 @@
@plugin "../vendor/daisyui-theme" { @plugin "../vendor/daisyui-theme" {
name: "dark"; name: "dark";
default: false; default: false;
prefersdark: false; prefersdark: true;
color-scheme: "dark"; color-scheme: "dark";
--color-base-100: oklch(30.33% 0.016 252.42); --color-base-100: oklch(30.33% 0.016 252.42);
--color-base-200: oklch(25.26% 0.014 253.1); --color-base-200: oklch(25.26% 0.014 253.1);
@ -99,677 +99,4 @@
/* Make LiveView wrapper divs transparent for layout */ /* Make LiveView wrapper divs transparent for layout */
[data-phx-session] { display: contents } [data-phx-session] { display: contents }
/* Honeypot: off-screen and minimal size so bots fill it, humans never see it (best practice) */
.join-form-helper {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.join-form-helper .join-form-helper-input {
position: absolute;
left: -9999px;
}
/* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers.
Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline
spacing; use inherited values so custom stylesheets can override. */
[popover] {
line-height: inherit;
letter-spacing: inherit;
word-spacing: inherit;
}
/* WCAG 2 AA: success/error/warning text. Light theme: dark tones on light bg; dark theme: light tones on dark bg. */
.text-success-aa {
color: oklch(0.35 0.12 165);
}
.text-error-aa {
color: oklch(0.45 0.2 25);
}
.text-warning-aa {
color: oklch(0.45 0.14 75);
}
[data-theme="dark"] .text-success-aa {
color: oklch(0.72 0.12 165);
}
[data-theme="dark"] .text-error-aa {
color: oklch(0.75 0.18 25);
}
[data-theme="dark"] .text-warning-aa {
color: oklch(0.78 0.14 75);
}
/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use
Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures
outline badges always have a visible background in both themes. */
[data-theme="light"] .badge.badge-outline,
[data-theme="dark"] .badge.badge-outline {
background-color: var(--color-base-100);
}
/* WCAG 2.2 AA (4.5:1 for normal text): Form labels. DaisyUI .label uses 60% opacity,
which fails contrast. Override to 85% of base-content so labels stay slightly
deemphasised vs body text but meet the minimum ratio. Match .label directly
so the override applies even when data-theme is not yet set (e.g. initial load). */
.label {
color: color-mix(in oklab, var(--color-base-content) 85%, transparent);
}
/* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background.
Theme tokens *-content are often too light on * backgrounds in light theme, and
badge-soft uses variant as text on a light tint (low contrast). We override
--badge-fg (and for soft, color) so badge text meets 4.5:1 in both themes. */
/* Light theme: use dark text on all colored badges (solid, soft, outline). */
[data-theme="light"] .badge.badge-primary {
--badge-fg: oklch(0.25 0.08 47);
}
[data-theme="light"] .badge.badge-primary.badge-soft {
color: oklch(0.38 0.14 47);
}
[data-theme="light"] .badge.badge-success {
--badge-fg: oklch(0.26 0.06 165);
}
[data-theme="light"] .badge.badge-success.badge-soft {
color: oklch(0.35 0.10 165);
}
[data-theme="light"] .badge.badge-error {
--badge-fg: oklch(0.22 0.08 25);
}
[data-theme="light"] .badge.badge-error.badge-soft {
color: oklch(0.38 0.14 25);
}
[data-theme="light"] .badge.badge-warning {
--badge-fg: oklch(0.28 0.06 75);
}
[data-theme="light"] .badge.badge-warning.badge-soft {
color: oklch(0.42 0.12 75);
}
[data-theme="light"] .badge.badge-info {
--badge-fg: oklch(0.26 0.08 250);
}
[data-theme="light"] .badge.badge-info.badge-soft {
color: oklch(0.38 0.12 250);
}
[data-theme="light"] .badge.badge-neutral {
--badge-fg: oklch(0.22 0.01 285);
}
[data-theme="light"] .badge.badge-neutral.badge-soft {
color: oklch(0.32 0.02 285);
}
[data-theme="light"] .badge.badge-outline.badge-primary,
[data-theme="light"] .badge.badge-outline.badge-success,
[data-theme="light"] .badge.badge-outline.badge-error,
[data-theme="light"] .badge.badge-outline.badge-warning,
[data-theme="light"] .badge.badge-outline.badge-info,
[data-theme="light"] .badge.badge-outline.badge-neutral {
--badge-fg: oklch(0.25 0.02 285);
}
/* Dark theme: ensure badge backgrounds are dark enough for light content (4.5:1).
Slightly darken solid variant backgrounds so theme *-content (light) passes. */
[data-theme="dark"] .badge.badge-primary:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.42 0.20 277);
--badge-fg: oklch(0.97 0.02 277);
}
[data-theme="dark"] .badge.badge-success:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.42 0.10 185);
--badge-fg: oklch(0.97 0.01 185);
}
[data-theme="dark"] .badge.badge-error:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.42 0.18 18);
--badge-fg: oklch(0.97 0.02 18);
}
[data-theme="dark"] .badge.badge-warning:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.48 0.14 58);
--badge-fg: oklch(0.22 0.02 58);
}
[data-theme="dark"] .badge.badge-info:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.45 0.14 242);
--badge-fg: oklch(0.97 0.02 242);
}
[data-theme="dark"] .badge.badge-neutral:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.32 0.02 257);
--badge-fg: oklch(0.96 0.01 257);
}
[data-theme="dark"] .badge.badge-soft.badge-primary { color: oklch(0.85 0.12 277); }
[data-theme="dark"] .badge.badge-soft.badge-success { color: oklch(0.82 0.08 165); }
[data-theme="dark"] .badge.badge-soft.badge-error { color: oklch(0.82 0.14 25); }
[data-theme="dark"] .badge.badge-soft.badge-warning { color: oklch(0.88 0.10 75); }
[data-theme="dark"] .badge.badge-soft.badge-info { color: oklch(0.85 0.10 250); }
[data-theme="dark"] .badge.badge-soft.badge-neutral { color: oklch(0.90 0.01 257); }
[data-theme="dark"] .badge.badge-outline.badge-primary,
[data-theme="dark"] .badge.badge-outline.badge-success,
[data-theme="dark"] .badge.badge-outline.badge-error,
[data-theme="dark"] .badge.badge-outline.badge-warning,
[data-theme="dark"] .badge.badge-outline.badge-info,
[data-theme="dark"] .badge.badge-outline.badge-neutral {
--badge-fg: oklch(0.92 0.02 257);
}
/* WCAG 2.2 AA: Member filter join buttons (All / Paid / Unpaid, group, boolean).
Inactive state uses base-content on a light/dark surface; active state ensures
*-content on * background meets 4.5:1. */
.member-filter-dropdown .join .btn {
/* Inactive: ensure readable text (theme base-content may be low contrast on btn default) */
border-color: var(--color-base-300);
}
[data-theme="light"] .member-filter-dropdown .join .btn:not(.btn-active) {
color: oklch(0.25 0.02 285);
background-color: var(--color-base-100);
}
[data-theme="light"] .member-filter-dropdown .join .btn.btn-success.btn-active {
background-color: oklch(0.42 0.12 165);
color: oklch(0.98 0.01 165);
}
[data-theme="light"] .member-filter-dropdown .join .btn.btn-error.btn-active {
background-color: oklch(0.42 0.18 18);
color: oklch(0.98 0.02 18);
}
[data-theme="dark"] .member-filter-dropdown .join .btn:not(.btn-active) {
color: oklch(0.92 0.02 257);
background-color: var(--color-base-200);
}
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-success.btn-active {
background-color: oklch(0.42 0.10 165);
color: oklch(0.97 0.01 165);
}
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-error.btn-active {
background-color: oklch(0.42 0.18 18);
color: oklch(0.97 0.02 18);
}
/* ============================================
Sidebar Base Styles
============================================ */
/* Desktop Sidebar Base */
.sidebar {
@apply flex flex-col bg-base-200 min-h-screen;
@apply transition-[width] duration-300 ease-in-out;
@apply relative;
width: 16rem; /* Expanded: w-64 */
z-index: 40;
}
/* Collapsed State */
[data-sidebar-expanded="false"] .sidebar {
width: 4rem; /* Collapsed: w-16 */
}
/* ============================================
Header - Logo Centering
============================================ */
/* Header container with smooth transition for gap */
.sidebar > div:first-child {
@apply transition-all duration-300;
}
/* ============================================
Text Labels - Hide in Collapsed State
============================================ */
.menu-label {
@apply transition-all duration-200 whitespace-nowrap;
transition-delay: 0ms; /* Expanded: sofort sichtbar */
}
[data-sidebar-expanded="false"] .sidebar .menu-label {
@apply opacity-0 w-0 overflow-hidden pointer-events-none;
transition-delay: 300ms; /* Warte bis Sidebar eingeklappt ist (300ms = duration der Sidebar width transition) */
}
/* ============================================
Toggle Button Icon Swap
============================================ */
.sidebar-collapsed-icon {
@apply hidden;
}
[data-sidebar-expanded="false"] .sidebar .sidebar-expanded-icon {
@apply hidden;
}
[data-sidebar-expanded="false"] .sidebar .sidebar-collapsed-icon {
@apply block;
}
/* ============================================
Menu Groups - Show/Hide Based on State
============================================ */
.expanded-menu-group {
@apply block;
}
.collapsed-menu-group {
@apply hidden;
}
[data-sidebar-expanded="false"] .sidebar .expanded-menu-group {
@apply hidden;
}
[data-sidebar-expanded="false"] .sidebar .collapsed-menu-group {
@apply block;
}
/* Collapsed menu group button: center icon under logo */
.sidebar .collapsed-menu-group button {
padding-left: 14px;
}
/* ============================================
Menu Groups - Disable hover and active on expanded-menu-group header
============================================ */
/* Disable all interactive effects on expanded-menu-group header (no href, not clickable)
Using [role="group"] to increase specificity and avoid !important */
.sidebar .menu > li.expanded-menu-group > div[role="group"]:not(a) {
pointer-events: none;
cursor: default;
}
/* Higher specificity selector to override DaisyUI menu hover styles
DaisyUI uses :where() which has 0 specificity, but the compiled CSS might have higher specificity
Using [role="group"] attribute selector increases specificity without !important */
.sidebar .menu > li.expanded-menu-group > div[role="group"]:not(a):hover,
.sidebar .menu > li.expanded-menu-group > div[role="group"]:not(a):active,
.sidebar .menu > li.expanded-menu-group > div[role="group"]:not(a):focus {
background-color: transparent;
box-shadow: none;
cursor: default;
color: inherit;
}
/* ============================================
Elements Only Visible in Expanded State
============================================ */
.expanded-only {
@apply block transition-opacity duration-200;
}
[data-sidebar-expanded="false"] .sidebar .expanded-only {
@apply hidden;
}
/* ============================================
Tooltip - Only Show in Collapsed State
============================================ */
.sidebar .tooltip::before,
.sidebar .tooltip::after {
@apply opacity-0 pointer-events-none;
}
[data-sidebar-expanded="false"] .sidebar .tooltip:hover::before,
[data-sidebar-expanded="false"] .sidebar .tooltip:hover::after {
@apply opacity-100;
}
/* ============================================
Menu Item Alignment - Icons Centered Under Logo
============================================ */
/* Base alignment: Icons centered under logo (32px from left edge)
- Logo center: 16px padding + 16px (half of 32px) = 32px
- Icon center should be at 32px: 22px start + 10px (half of 20px) = 32px
- Menu has p-2 (8px), so links need 14px additional padding-left */
.sidebar .menu > li > a,
.sidebar .menu > li > button,
.sidebar .menu > li.expanded-menu-group > div,
.sidebar .menu > div.collapsed-menu-group > button {
@apply transition-all duration-300;
padding-left: 14px;
}
/* Collapsed state: same padding to keep icons at same position
- Remove gap so label (which is opacity-0 w-0) doesn't create space
- Keep padding-left at 14px so icons stay centered under logo */
[data-sidebar-expanded="false"] .sidebar .menu > li > a,
[data-sidebar-expanded="false"] .sidebar .menu > li > button,
[data-sidebar-expanded="false"] .sidebar .menu > li.expanded-menu-group > div,
[data-sidebar-expanded="false"] .sidebar .menu > div.collapsed-menu-group > button {
@apply gap-0;
padding-left: 14px;
padding-right: 14px; /* Center icon horizontally in 64px sidebar */
}
/* ============================================
Footer Button Alignment - Left Aligned in Collapsed State
============================================ */
[data-sidebar-expanded="false"] .sidebar .dropdown > button {
@apply px-0;
/* Buttons stay at left position, only label disappears */
}
/* ============================================
User Menu Button - Focus Ring on Avatar
============================================ */
/* Focus ring appears on the avatar when button is focused */
.user-menu-button:focus .avatar > div {
@apply ring-2 ring-primary ring-offset-2 ring-offset-base-200;
}
/* ============================================
User Menu Button - Smooth Centering Transition
============================================ */
/* User menu button transitions smoothly to center */
.user-menu-button {
@apply transition-all duration-300;
}
/* In collapsed state, center avatar under logo
- Avatar is 32px (w-8), center it in 64px sidebar
- (64px - 32px) / 2 = 16px padding avatar center at 32px (same as logo center) */
[data-sidebar-expanded="false"] .sidebar .user-menu-button {
@apply gap-0;
padding-left: 16px;
padding-right: 16px;
justify-content: center;
}
/* ============================================
User Menu Button - Hover Ring on Avatar
============================================ */
/* Smooth transition for avatar ring effects */
.user-menu-button .avatar > div {
@apply transition-all duration-200;
}
/* Hover ring appears on the avatar when button is hovered */
.user-menu-button:hover .avatar > div {
@apply ring-1 ring-neutral ring-offset-1 ring-offset-base-200;
}
/* ============================================
Mobile Drawer Width
============================================ */
/* Auf Mobile (< 1024px) ist die Sidebar immer w-64 (16rem) wenn geöffnet */
@media (max-width: 1023px) {
.drawer-side .sidebar {
width: 16rem; /* w-64 auch auf Mobile */
}
}
/* ============================================
Drawer Side Overflow Fix für Desktop
============================================ */
/* Im Desktop-Modus (lg:drawer-open) overflow auf visible setzen
damit Dropdowns und Tooltips über Main Content erscheinen können */
@media (min-width: 1024px) {
.drawer.lg\:drawer-open .drawer-side {
overflow: visible !important;
overflow-x: visible !important;
overflow-y: visible !important;
}
}
/* ============================================
Collapsed Sidebar: User Menu Dropdown Richtung
============================================ */
/* Bei eingeklappter Sidebar liegt der Avatar-Button am linken Rand.
dropdown-end würde das Menü nach links öffnen (off-screen).
Stattdessen nach rechts öffnen (in den Content-Bereich). */
#app-layout[data-sidebar-expanded="false"] .dropdown.dropdown-top > ul.dropdown-content {
right: auto !important;
left: 0 !important;
}
/* Sign-in: hide SSO button and "or" divider when OIDC is not configured.
Scoped to #sign-in-page to avoid hiding unrelated elements. */
#sign-in-page[data-oidc-configured="false"] [id*="oidc"] {
display: none !important;
}
#sign-in-page[data-oidc-configured="false"] a[href*="oidc"] {
display: none !important;
}
#sign-in-page[data-oidc-configured="false"] .divider {
display: none !important;
}
/* Sign-in: when OIDC-only mode is on, hide password form and "or" divider (show only SSO). */
#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] [id*="password"] {
display: none !important;
}
#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] .divider {
display: none !important;
}
/* ============================================
WCAG 1.4.3: Primary button contrast (AA)
============================================ */
/* Override DaisyUI theme --color-primary-content so text on btn-primary (brand)
meets 4.5:1. In DevTools: inspect .btn-primary, check computed --color-primary
and --color-primary-content; verify contrast at https://webaim.org/resources/contrastchecker/ */
/* Light theme: primary is orange (brand); primary-content must be dark. */
[data-theme="light"] {
--color-primary-content: oklch(0.18 0.02 47);
--color-error: oklch(55% 0.253 17.585);
--color-error-content: oklch(98% 0 0);
}
/* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */
[data-theme="dark"] {
--color-error: oklch(55% 0.253 17.585);
--color-error-content: oklch(98% 0 0);
--color-primary: oklch(72% 0.17 45);
--color-primary-content: oklch(0.18 0.02 47);
--color-secondary: oklch(48% 0.233 277.117);
--color-secondary-content: oklch(98% 0 0);
}
/* ============================================
WCAG 2.2 AA: Tab list inactive tab text contrast (4.5:1)
============================================ */
#member-tablist .tab:not(.tab-active) {
color: oklch(0.35 0.02 285);
}
[data-theme="dark"] #member-tablist .tab:not(.tab-active) {
color: oklch(0.72 0.02 257);
}
/* ============================================
WCAG 2.2 AA: Link contrast - primary and accent
============================================ */
[data-theme="light"] .link.link-primary {
color: oklch(0.45 0.15 35);
}
[data-theme="light"] .link.link-primary:hover {
color: oklch(0.38 0.14 35);
}
[data-theme="dark"] .link.link-primary {
color: oklch(0.82 0.14 45);
}
[data-theme="dark"] .link.link-primary:hover {
color: oklch(0.88 0.12 45);
}
[data-theme="dark"] .link.link-accent {
color: oklch(0.82 0.18 292);
}
[data-theme="dark"] .link.link-accent:hover {
color: oklch(0.88 0.16 292);
}
/* ============================================
WCAG 2.2 AA: Danger zone heading contrast (dark theme)
============================================ */
[data-theme="dark"] #danger-zone-heading.text-error {
color: oklch(0.78 0.18 25);
}
/* ============================================
WCAG 2.2 AA: Blue link contrast in dark theme
============================================ */
[data-theme="dark"] a.text-blue-700,
[data-theme="dark"] a.text-blue-600,
[data-theme="dark"] a.hover\:text-blue-800 {
color: oklch(0.72 0.16 255);
}
[data-theme="dark"] a.text-blue-700:hover,
[data-theme="dark"] a.text-blue-600:hover {
color: oklch(0.82 0.14 255);
}
/* ============================================
WCAG 2.2 AA: Password / form label on light box in dark theme
============================================ */
[data-theme="dark"] .bg-gray-50 {
background-color: var(--color-base-200);
color: var(--color-base-content);
}
[data-theme="dark"] .bg-gray-50 .label,
[data-theme="dark"] .bg-gray-50 .mb-1.label,
[data-theme="dark"] .bg-gray-50 .text-gray-600,
[data-theme="dark"] .bg-gray-50 .text-gray-700,
[data-theme="dark"] .bg-gray-50 strong,
[data-theme="dark"] .bg-gray-50 p,
[data-theme="dark"] .bg-gray-50 li {
color: var(--color-base-content);
}
/* Dark mode: orange/red info boxes (admin note, OIDC warning) dark bg, light text */
[data-theme="dark"] .bg-orange-50 {
background-color: oklch(0.32 0.06 55);
border-color: oklch(0.42 0.08 55);
color: var(--color-base-content);
}
[data-theme="dark"] .bg-orange-50 .text-orange-800,
[data-theme="dark"] .bg-orange-50 p,
[data-theme="dark"] .bg-orange-50 strong {
color: var(--color-base-content);
}
[data-theme="dark"] .bg-red-50 {
background-color: oklch(0.32 0.08 25);
border-color: oklch(0.42 0.12 25);
color: var(--color-base-content);
}
[data-theme="dark"] .bg-red-50 .text-red-800,
[data-theme="dark"] .bg-red-50 .text-red-700,
[data-theme="dark"] .bg-red-50 p,
[data-theme="dark"] .bg-red-50 strong {
color: var(--color-base-content);
}
/* This file is for your main application CSS */ /* This file is for your main application CSS */
/* ============================================
SortableList: drag-and-drop table rows
============================================ */
/* Ghost row: placeholder showing where the dragged item will be dropped.
Background fills the gap; text invisible so layout matches original row. */
.sortable-ghost {
background-color: var(--color-base-300) !important;
opacity: 0.5;
}
.sortable-ghost td {
border-color: transparent !important;
}
/* Chosen row: the row being actively dragged (follows the cursor). */
.sortable-chosen {
background-color: var(--color-base-200);
box-shadow: 0 4px 16px -2px oklch(0 0 0 / 0.18);
cursor: grabbing !important;
}
/* Drag handle button: only grab cursor, no hover effect for mouse users.
Keyboard outline is handled via JS outline style. */
[data-sortable-handle] button {
cursor: grab;
}
[data-sortable-handle] button:hover {
background-color: transparent !important;
color: inherit;
}
/*
* Default interactive table rows: neutral hover/focus-visible fill for clickable rows.
* Uses :has(:focus-visible) so keyboard navigation highlights the row without sticky mouse-focus artifacts.
*/
.table.table-zebra tbody tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible)) > td {
background-color: var(--color-base-300);
}
/*
* Sticky first column in zebra tables: opaque backgrounds per row.
* Use nth-child (not HEEx row index) so LiveStream rows stay iterable only via :for (Phoenix LV requirement).
*/
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr:nth-child(odd) > td.sticky-first-col-cell {
background-color: var(--color-base-100);
}
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr:nth-child(even) > td.sticky-first-col-cell {
background-color: var(--color-base-200);
}
/*
* Checkbox-selected rows: keep zebra backgrounds; only accent the sticky checkbox column.
*/
[data-sticky-first-col-rows="true"]
.table.table-zebra
tbody
tr[data-selected="true"]
> td.sticky-first-col-cell {
box-shadow: inset 2px 0 0 var(--color-primary);
}
[data-sticky-first-col-rows="true"]
.table.table-zebra
tbody
tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible))
> td.sticky-first-col-cell {
background-color: var(--color-base-300);
/* Left accent only; keep the familiar orange primary accent. */
box-shadow: inset 2px 0 0 var(--color-primary);
}
/*
* Sticky member selection table: drop mouse-only focus outlines that read like a thin frame around the row;
* keyboard :focus-visible keeps DaisyUI control outlines (checkbox / tabindex cell).
*/
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr {
outline: none;
}
[data-sticky-first-col-rows="true"]
.table.table-zebra
tbody
tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible)):not(:last-child) {
/* DaisyUI draws a bottom border on each row; hiding it while highlighted avoids a boxy “frame”. */
border-bottom-color: transparent;
}
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr td:focus:not(:focus-visible) {
outline: none;
}
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) {
outline: none;
}

View file

@ -21,338 +21,11 @@ import "phoenix_html"
import {Socket} from "phoenix" import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view" import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar" import topbar from "../vendor/topbar"
import Sortable from "../vendor/sortable"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
function getBrowserTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || null
} catch (_e) {
return null
}
}
// 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() {
this.handleKeyDown = (e) => {
const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true"
if (e.key === "Enter" && isDropdownOpen) {
e.preventDefault()
}
}
this.el.addEventListener("keydown", this.handleKeyDown)
},
destroyed() {
this.el.removeEventListener("keydown", this.handleKeyDown)
}
}
// TableRowKeydown hook: WCAG 2.1.1 — when a table row cell has data-row-clickable,
// Enter and Space trigger a click so row_click tables are keyboard activatable
Hooks.TableRowKeydown = {
mounted() {
this.handleKeydown = (e) => {
if (
e.target.getAttribute("data-row-clickable") === "true" &&
(e.key === "Enter" || e.key === " ")
) {
e.preventDefault()
e.target.click()
}
}
this.el.addEventListener("keydown", this.handleKeydown)
},
destroyed() {
this.el.removeEventListener("keydown", this.handleKeydown)
}
}
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
Hooks.FocusRestore = {
mounted() {
this.handleEvent("focus_restore", ({id}) => {
const el = document.getElementById(id)
if (el) el.focus()
})
}
}
// FlashAutoDismiss: after a delay, clear the flash so the toast hides without user clicking X (e.g. success toasts)
Hooks.FlashAutoDismiss = {
mounted() {
const ms = this.el.dataset.autoClearMs
if (!ms) return
const delay = parseInt(ms, 10)
if (delay > 0) {
this.timer = setTimeout(() => {
const key = this.el.dataset.clearFlashKey || "success"
this.pushEvent("lv:clear-flash", {key})
}, delay)
}
},
destroyed() {
if (this.timer) clearTimeout(this.timer)
}
}
// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
Hooks.TabListKeydown = {
mounted() {
this.handleKeydown = (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
}
}
this.el.addEventListener('keydown', this.handleKeydown)
},
destroyed() {
this.el.removeEventListener('keydown', this.handleKeydown)
}
}
// SortableList hook: Accessible reorderable table/list.
// Mouse drag: SortableJS (smooth animation, ghost row, items push apart).
// Keyboard: Space = grab/drop, Arrow up/down = move, Escape = cancel, matching the Salesforce a11y pattern.
// Container must have data-reorder-event and data-list-id.
// Each row (tr) must have data-row-index; locked rows have data-locked="true".
// Pushes event with { from_index, to_index } (both integers) on reorder.
Hooks.SortableList = {
mounted() {
this.reorderEvent = this.el.dataset.reorderEvent
this.listId = this.el.dataset.listId
// Keyboard state: store grabbed row id so it survives LiveView re-renders
this.grabbedRowId = null
this.announcementEl = this.listId ? document.getElementById(this.listId + "-announcement") : null
const announce = (msg) => {
if (!this.announcementEl) return
// Clear then re-set to force screen reader re-read
this.announcementEl.textContent = ""
setTimeout(() => { if (this.announcementEl) this.announcementEl.textContent = msg }, 50)
}
const tbody = this.el.querySelector("tbody")
if (!tbody) return
this.getRows = () => Array.from(tbody.querySelectorAll("tr"))
this.getRowIndex = (tr) => {
const idx = tr.getAttribute("data-row-index")
return idx != null ? parseInt(idx, 10) : -1
}
this.isLocked = (tr) => tr.getAttribute("data-locked") === "true"
// SortableJS for mouse drag-and-drop with animation
this.sortable = new Sortable(tbody, {
animation: 150,
handle: "[data-sortable-handle]",
// Disable sorting for locked rows (first row = email)
filter: "[data-locked='true']",
preventOnFilter: true,
// Ghost (placeholder showing where the item will land)
ghostClass: "sortable-ghost",
// The item being dragged
chosenClass: "sortable-chosen",
// Cursor while dragging
dragClass: "sortable-drag",
// Don't trigger on handle area clicks (only actual drag)
delay: 0,
onEnd: (e) => {
if (e.oldIndex === e.newIndex) return
this.pushEvent(this.reorderEvent, { from_index: e.oldIndex, to_index: e.newIndex })
announce(`Dropped. Position ${e.newIndex + 1} of ${this.getRows().length}.`)
// LiveView will reconcile the DOM order after re-render
}
})
// Keyboard handler (Salesforce a11y pattern: Space=grab/drop, Arrows=move, Escape=cancel)
this.handleKeyDown = (e) => {
// Don't intercept Space on interactive elements (checkboxes, buttons, inputs)
const tag = e.target.tagName
if (tag === "INPUT" || tag === "BUTTON" || tag === "SELECT" || tag === "TEXTAREA") return
const tr = e.target.closest("tr")
if (!tr || this.isLocked(tr)) return
const rows = this.getRows()
const idx = this.getRowIndex(tr)
if (idx < 0) return
const total = rows.length
if (e.key === " ") {
e.preventDefault()
const rowId = tr.id
if (this.grabbedRowId === rowId) {
// Drop
this.grabbedRowId = null
tr.style.outline = ""
announce(`Dropped. Position ${idx + 1} of ${total}.`)
} else {
// Grab
this.grabbedRowId = rowId
tr.style.outline = "2px solid var(--color-primary)"
tr.style.outlineOffset = "-2px"
announce(`Grabbed. Position ${idx + 1} of ${total}. Use arrow keys to move, Space to drop, Escape to cancel.`)
}
return
}
if (e.key === "Escape") {
if (this.grabbedRowId != null) {
e.preventDefault()
const grabbedTr = document.getElementById(this.grabbedRowId)
if (grabbedTr) { grabbedTr.style.outline = ""; grabbedTr.style.outlineOffset = "" }
this.grabbedRowId = null
announce("Reorder cancelled.")
}
return
}
if (this.grabbedRowId == null) return
// Do not move into a locked row (e.g. email always first)
if (e.key === "ArrowUp" && idx > 0) {
const targetRow = rows[idx - 1]
if (!this.isLocked(targetRow)) {
e.preventDefault()
this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx - 1 })
announce(`Position ${idx} of ${total}.`)
}
} else if (e.key === "ArrowDown" && idx < total - 1) {
const targetRow = rows[idx + 1]
if (!this.isLocked(targetRow)) {
e.preventDefault()
this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx + 1 })
announce(`Position ${idx + 2} of ${total}.`)
}
}
}
this.el.addEventListener("keydown", this.handleKeyDown, true)
},
updated() {
// Re-apply keyboard outline and restore focus after LiveView re-render.
// LiveView DOM patching loses focus; without explicit re-focus the next keypress
// goes to document.body (Space scrolls the page instead of triggering our handler).
if (this.grabbedRowId) {
const tr = document.getElementById(this.grabbedRowId)
if (tr) {
tr.style.outline = "2px solid var(--color-primary)"
tr.style.outlineOffset = "-2px"
tr.focus()
} else {
// Row no longer exists (removed while grabbed), clear state
this.grabbedRowId = null
}
}
},
destroyed() {
if (this.sortable) this.sortable.destroy()
this.el.removeEventListener("keydown", this.handleKeyDown, true)
}
}
// SidebarState hook: Manages sidebar expanded/collapsed state
Hooks.SidebarState = {
mounted() {
// Restore state from localStorage
const expanded = localStorage.getItem('sidebar-expanded') !== 'false'
this.setSidebarState(expanded)
// Expose toggle function globally
window.toggleSidebar = () => {
const current = this.el.dataset.sidebarExpanded === 'true'
this.setSidebarState(!current)
}
},
updated() {
// LiveView patches data-sidebar-expanded back to the template default ("true")
// on every DOM update. Re-apply the stored state from localStorage after each patch.
const expanded = localStorage.getItem('sidebar-expanded') !== 'false'
const current = this.el.dataset.sidebarExpanded === 'true'
if (current !== expanded) {
this.setSidebarState(expanded)
}
},
setSidebarState(expanded) {
// Convert boolean to string for consistency
const expandedStr = expanded ? 'true' : 'false'
// Update data-attribute (CSS reacts to this)
this.el.dataset.sidebarExpanded = expandedStr
// Persist to localStorage
localStorage.setItem('sidebar-expanded', expandedStr)
// Update ARIA for accessibility
const toggleBtn = document.getElementById('sidebar-toggle')
if (toggleBtn) {
toggleBtn.setAttribute('aria-expanded', expandedStr)
}
},
destroyed() {
// Cleanup
delete window.toggleSidebar
}
}
let liveSocket = new LiveSocket("/live", Socket, { let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,
params: { params: {_csrf_token: csrfToken}
_csrf_token: csrfToken,
timezone: getBrowserTimezone()
},
hooks: Hooks
})
// Listen for custom events from LiveView
window.addEventListener("phx:set-input-value", (e) => {
const {id, value} = e.detail
const input = document.getElementById(id)
if (input) {
input.value = value
}
}) })
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits
@ -369,177 +42,3 @@ liveSocket.connect()
// >> liveSocket.disableLatencySim() // >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket window.liveSocket = liveSocket
// Sidebar accessibility improvements
document.addEventListener("DOMContentLoaded", () => {
const drawerToggle = document.getElementById("mobile-drawer")
const sidebarToggle = document.getElementById("sidebar-toggle")
const sidebar = document.getElementById("main-sidebar")
if (!drawerToggle || !sidebarToggle || !sidebar) return
// Manage tabindex for sidebar elements based on open/closed state
const updateSidebarTabIndex = (isOpen) => {
// Find all potentially focusable elements (including those with tabindex="-1")
const allFocusableElements = sidebar.querySelectorAll(
'a[href], button, select, input:not([type="hidden"]), [tabindex]'
)
allFocusableElements.forEach(el => {
// Skip the overlay button
if (el.closest('.drawer-overlay')) return
if (isOpen) {
// Remove tabindex="-1" to make focusable when open
if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') === '-1') {
el.removeAttribute('tabindex')
}
} else {
// Set tabindex="-1" to remove from tab order when closed
if (!el.hasAttribute('tabindex')) {
el.setAttribute('tabindex', '-1')
} else if (el.getAttribute('tabindex') !== '-1') {
// Store original tabindex in data attribute before setting to -1
if (!el.hasAttribute('data-original-tabindex')) {
el.setAttribute('data-original-tabindex', el.getAttribute('tabindex'))
}
el.setAttribute('tabindex', '-1')
}
}
})
}
// Find first focusable element in sidebar
// Priority: first navigation link (menuitem) > other links > other focusable elements
const getFirstFocusableElement = () => {
// First, try to find the first navigation link (menuitem)
const firstNavLink = sidebar.querySelector('a[href][role="menuitem"]:not([tabindex="-1"])')
if (firstNavLink && !firstNavLink.closest('.drawer-overlay')) {
return firstNavLink
}
// Fallback: any navigation link
const firstLink = sidebar.querySelector('a[href]:not([tabindex="-1"])')
if (firstLink && !firstLink.closest('.drawer-overlay')) {
return firstLink
}
// Last resort: any other focusable element
const focusableSelectors = [
'button:not([tabindex="-1"]):not([disabled])',
'select:not([tabindex="-1"]):not([disabled])',
'input:not([tabindex="-1"]):not([disabled]):not([type="hidden"])',
'[tabindex]:not([tabindex="-1"])'
]
for (const selector of focusableSelectors) {
const element = sidebar.querySelector(selector)
if (element && !element.closest('.drawer-overlay')) {
return element
}
}
return null
}
// Update aria-expanded when drawer state changes
const updateAriaExpanded = () => {
const isOpen = drawerToggle.checked
sidebarToggle.setAttribute("aria-expanded", isOpen.toString())
// Update dropdown aria-expanded if present
const userMenuButton = sidebar.querySelector('button[aria-haspopup="true"]')
if (userMenuButton) {
const dropdown = userMenuButton.closest('.dropdown')
const isDropdownOpen = dropdown?.classList.contains('dropdown-open')
if (userMenuButton) {
userMenuButton.setAttribute("aria-expanded", (isDropdownOpen || false).toString())
}
}
}
// Listen for changes to the drawer checkbox
drawerToggle.addEventListener("change", () => {
// On desktop (lg:drawer-open), the mobile drawer must never open.
// The hamburger label is lg:hidden, but guard here as a safety net
// against any accidental toggles (e.g. from overlapping elements or JS).
if (drawerToggle.checked && window.innerWidth >= 1024) {
drawerToggle.checked = false
return
}
const isOpen = drawerToggle.checked
updateAriaExpanded()
updateSidebarTabIndex(isOpen)
if (!isOpen) {
// When closing, return focus to toggle button
sidebarToggle.focus()
}
})
// Update on initial load
updateAriaExpanded()
updateSidebarTabIndex(drawerToggle.checked)
// Close sidebar with ESC key
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && drawerToggle.checked) {
drawerToggle.checked = false
updateAriaExpanded()
updateSidebarTabIndex(false)
// Return focus to toggle button
sidebarToggle.focus()
}
})
// Improve keyboard navigation for sidebar toggle
sidebarToggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
const wasOpen = drawerToggle.checked
drawerToggle.checked = !drawerToggle.checked
updateAriaExpanded()
// If opening, move focus to first element in sidebar
if (!wasOpen && drawerToggle.checked) {
updateSidebarTabIndex(true)
// Use setTimeout to ensure DOM is updated
setTimeout(() => {
const firstElement = getFirstFocusableElement()
if (firstElement) {
firstElement.focus()
}
}, 50)
} else if (wasOpen && !drawerToggle.checked) {
updateSidebarTabIndex(false)
}
}
})
// Also handle click events to update tabindex and focus
sidebarToggle.addEventListener("click", () => {
setTimeout(() => {
const isOpen = drawerToggle.checked
updateSidebarTabIndex(isOpen)
if (isOpen) {
const firstElement = getFirstFocusableElement()
if (firstElement) {
firstElement.focus()
}
}
}, 50)
})
// Handle dropdown keyboard navigation
const userMenuButton = sidebar?.querySelector('button[aria-haspopup="true"]')
if (userMenuButton) {
userMenuButton.addEventListener("click", () => {
setTimeout(updateAriaExpanded, 0)
})
userMenuButton.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
userMenuButton.click()
}
})
}
})

59
assets/package-lock.json generated Normal file
View file

@ -0,0 +1,59 @@
{
"name": "assets",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"playwright": "^1.55.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

5
assets/package.json Normal file
View file

@ -0,0 +1,5 @@
{
"devDependencies": {
"playwright": "^1.55.0"
}
}

File diff suppressed because one or more lines are too long

View file

@ -46,35 +46,10 @@ config :spark,
] ]
] ]
# IANA timezone database for DateTime.shift_zone (browser timezone display)
config :elixir, :time_zone_database, Tz.TimeZoneDatabase
config :mv, config :mv,
ecto_repos: [Mv.Repo], ecto_repos: [Mv.Repo],
generators: [timestamp_type: :utc_datetime], generators: [timestamp_type: :utc_datetime],
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization] ash_domains: [Mv.Membership, Mv.Accounts]
# Environment (dev/test/prod). Use this instead of Mix.env() at runtime; Mix.env() is
# not available in releases. Set once at compile time via config_env().
config :mv, :environment, config_env()
# CSV Import configuration
config :mv,
csv_import: [
max_file_size_mb: 10,
max_rows: 1000
]
# PDF Export configuration
config :mv,
pdf_export: [
row_limit: 5000
]
# OIDC group → role sync (optional). Overridden in runtime.exs from ENV in production.
config :mv, :oidc_role_sync,
admin_group_name: nil,
groups_claim: "groups"
# Configures the endpoint # Configures the endpoint
config :mv, MvWeb.Endpoint, config :mv, MvWeb.Endpoint,
@ -96,20 +71,6 @@ config :mv, MvWeb.Endpoint,
# at the `config/runtime.exs`. # at the `config/runtime.exs`.
config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local
# SMTP TLS verification: false = allow self-signed/internal certs; true = verify_peer (use for public SMTP).
# Overridden in runtime.exs from SMTP_VERIFY_PEER when SMTP is configured via ENV in prod.
config :mv, :smtp_verify_peer, false
# Default mail "from" address for transactional emails (join confirmation,
# user confirmation, password reset). Override in config/runtime.exs from ENV.
config :mv, :mail_from, {"Mila", "noreply@example.com"}
# Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP.
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10
# Join emails: notifier implementation (domain → web abstraction). Override in test to inject a mock.
config :mv, :join_notifier, MvWeb.JoinNotifierImpl
# Configure esbuild (the version is required) # Configure esbuild (the version is required)
config :esbuild, config :esbuild,
version: "0.17.11", version: "0.17.11",
@ -134,16 +95,7 @@ config :tailwind,
# Configures Elixir's Logger # Configures Elixir's Logger
config :logger, :default_formatter, config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n", format: "$time $metadata[$level] $message\n",
metadata: [ metadata: [:request_id]
:request_id,
:user_id,
:member_id,
:member_email,
:error,
:error_type,
:cycles_count,
:notifications_count
]
# Use Jason for JSON parsing in Phoenix # Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason config :phoenix, :json_library, Jason

View file

@ -93,13 +93,11 @@ config :mv, :secret_key_base, "ryn7D6ssmIHQFWIks2sFiTGATgwwAR1+3bN8p7fy6qVtB8qnx
# Signing Secret for Authentication # Signing Secret for Authentication
config :mv, :token_signing_secret, "IwUwi65TrEeExwBXXFPGm2I7889NsL" config :mv, :token_signing_secret, "IwUwi65TrEeExwBXXFPGm2I7889NsL"
# OIDC: only use when ENV (or Settings) are set. When all OIDC ENVs are commented out, config :mv, :rauthy,
# do not set defaults here so the SSO button stays hidden and no MissingSecret occurs. client_id: "mv",
# config :mv, :oidc, base_url: "http://localhost:8080/auth/v1",
# client_id: "mv", client_secret: System.get_env("OIDC_CLIENT_SECRET"),
# base_url: "http://localhost:8080/auth/v1", redirect_uri: "http://localhost:4000/auth/user/rauthy/callback"
# client_secret: System.get_env("OIDC_CLIENT_SECRET"),
# redirect_uri: "http://localhost:4000/auth/user/oidc/callback"
# AshAuthentication development configuration # AshAuthentication development configuration
config :mv, :session_identifier, :jti config :mv, :session_identifier, :jti

View file

@ -16,16 +16,5 @@ config :swoosh, local: false
# Do not print debug messages in production # Do not print debug messages in production
config :logger, level: :info config :logger, level: :info
# AshAuthentication production configuration
# These must be set at compile-time (not in runtime.exs) because
# Application.compile_env!/3 is used in lib/accounts/user.ex
config :mv, :session_identifier, :jti
config :mv, :require_token_presence_for_authentication, true
# Token signing secret - using a placeholder that MUST be overridden
# at runtime via environment variable in config/runtime.exs
config :mv, :token_signing_secret, "REPLACE_ME_AT_RUNTIME"
# Runtime production configuration, including reading # Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs. # of environment variables, is done on config/runtime.exs.

View file

@ -7,158 +7,6 @@ import Config
# any compile-time configuration in here, as it won't be applied. # any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration. # 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 or empty (after trim).
# Empty values lead to unclear runtime errors; failing at boot with a clear message is preferred.
get_env_or_file! = fn var_name, error_message ->
case get_env_or_file.(var_name, nil) do
nil ->
raise error_message
value when is_binary(value) ->
trimmed = String.trim(value)
if trimmed == "" do
raise """
#{error_message}
(Variable #{var_name} or #{var_name}_FILE is set but the value is empty.)
"""
else
trimmed
end
value ->
value
end
end
# Returns default when env_value is nil, empty after trim, or not a valid positive integer.
# Used for PORT, POOL_SIZE, SMTP_PORT to avoid ArgumentError on empty or invalid values.
parse_positive_integer = fn env_value, default ->
case env_value do
nil ->
default
v when is_binary(v) ->
case String.trim(v) do
"" ->
default
trimmed ->
case Integer.parse(trimmed) do
{n, _} when n > 0 -> n
_ -> default
end
end
_ ->
default
end
end
# Returns default when the key is missing or the value is empty (after trim).
# Use for optional string ENV vars (e.g. DATABASE_PORT) so empty string is treated as "unset".
get_env_non_empty = fn key, default ->
case System.get_env(key) do
nil ->
default
v when is_binary(v) ->
trimmed = String.trim(v)
if trimmed == "", do: default, else: trimmed
v ->
v
end
end
# Returns the trimmed value when set and non-empty; otherwise raises with error_message.
# Use for required vars (DATABASE_HOST, etc.) so "set but empty" fails at boot with a clear message.
get_env_required = fn key, error_message ->
case System.get_env(key) do
nil ->
raise error_message
v when is_binary(v) ->
trimmed = String.trim(v)
if trimmed == "" do
raise """
#{error_message}
(Variable #{key} is set but empty.)
"""
else
trimmed
end
v ->
v
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 =
get_env_required.("DATABASE_HOST", """
DATABASE_HOST is required when DATABASE_URL is not set.
""")
user =
get_env_required.("DATABASE_USER", """
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 =
get_env_required.("DATABASE_NAME", """
DATABASE_NAME is required when DATABASE_URL is not set.
""")
port = get_env_non_empty.("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 # ## Using releases
# #
# If you use `mix release`, you need to explicitly enable the server # If you use `mix release`, you need to explicitly enable the server
@ -172,20 +20,20 @@ if System.get_env("PHX_SERVER") do
config :mv, MvWeb.Endpoint, server: true config :mv, MvWeb.Endpoint, server: true
end end
# OIDC group → Admin role sync: read from ENV in all environments (dev/test/prod)
config :mv, :oidc_role_sync,
admin_group_name: System.get_env("OIDC_ADMIN_GROUP_NAME"),
groups_claim: System.get_env("OIDC_GROUPS_CLAIM") || "groups"
if config_env() == :prod do 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: [] maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :mv, Mv.Repo, config :mv, Mv.Repo,
# ssl: true, # ssl: true,
url: database_url, url: database_url,
pool_size: parse_positive_integer.(System.get_env("POOL_SIZE"), 10), pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: maybe_ipv6 socket_options: maybe_ipv6
# The secret key base is used to sign/encrypt cookies and other secrets. # The secret key base is used to sign/encrypt cookies and other secrets.
@ -193,83 +41,36 @@ if config_env() == :prod do
# want to use a different value for prod and you most likely don't want # 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 # to check this value into version control, so we use an environment
# variable instead. # variable instead.
# Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets.
secret_key_base = secret_key_base =
get_env_or_file!.("SECRET_KEY_BASE", """ System.get_env("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 =
get_env_non_empty.("PHX_HOST", nil) ||
get_env_non_empty.("DOMAIN", nil) ||
raise """ raise """
Please define the PHX_HOST or DOMAIN environment variable. environment variable SECRET_KEY_BASE is missing.
(Variable may be set but empty.) You can generate one by calling: mix phx.gen.secret
""" """
port = parse_positive_integer.(System.get_env("PORT"), 4000) 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") config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
# OIDC configuration (works with any OIDC provider: Authentik, Rauthy, Keycloak, etc.) config :mv, :rauthy, redirect_uri: "http://localhost:4000/auth/user/rauthy/callback"
# The redirect_uri callback path is /auth/user/oidc/callback.
#
# 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 = # AshAuthentication production configuration
if oidc_in_use do config :mv, :session_identifier, :jti
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. config :mv, :require_token_presence_for_authentication, true
# Uses HTTPS since production runs behind TLS termination.
default_redirect_uri = "https://#{host}/auth/user/oidc/callback"
config :mv, :oidc,
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
# 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
""")
config :mv, :token_signing_secret, token_signing_secret
config :mv, MvWeb.Endpoint, config :mv, MvWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"], url: [host: host, port: 443, scheme: "https"],
http: [ http: [
# Bind on all IPv4 interfaces. # Enable IPv6 and bind on all interfaces.
# Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only. # 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 # 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 port: port
], ],
secret_key_base: secret_key_base, secret_key_base: secret_key_base
# Allow connections from localhost and 127.0.0.1
check_origin: [
"//#{host}",
"//localhost:#{port}",
"//127.0.0.1:#{port}"
]
# ## SSL Support # ## SSL Support
# #
@ -303,54 +104,21 @@ if config_env() == :prod do
# #
# Check `Plug.SSL` for all available options in `force_ssl`. # Check `Plug.SSL` for all available options in `force_ssl`.
# Transactional emails use the sender from config :mv, :mail_from (overridable via ENV). # ## Configuring the mailer
config :mv, #
:mail_from, # In production you need to configure the mailer to use a different adapter.
{System.get_env("MAIL_FROM_NAME", "Mila"), # Also, you may need to configure the Swoosh API client of your choice if you
System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")} # are not using SMTP. Here is an example of the configuration:
#
# SMTP configuration from environment variables (overrides base adapter in prod). # config :mv, Mv.Mailer,
# When SMTP_HOST is set, configure Swoosh to use the SMTP adapter at boot time. # adapter: Swoosh.Adapters.Mailgun,
# If SMTP is configured only via Settings (Admin UI), the mailer builds the config # api_key: System.get_env("MAILGUN_API_KEY"),
# per-send at runtime using Mv.Mailer.smtp_config/0 (which uses the same Mv.Smtp.ConfigBuilder). # domain: System.get_env("MAILGUN_DOMAIN")
smtp_host_env = System.get_env("SMTP_HOST") #
# For this example you need include a HTTP client required by Swoosh API client.
if smtp_host_env && String.trim(smtp_host_env) != "" do # Swoosh supports Hackney, Req and Finch out of the box:
smtp_port_env = parse_positive_integer.(System.get_env("SMTP_PORT"), 587) #
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
smtp_password_env = #
case System.get_env("SMTP_PASSWORD") do # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
nil ->
case System.get_env("SMTP_PASSWORD_FILE") do
nil -> nil
path -> path |> File.read!() |> String.trim()
end
v ->
v
end
smtp_ssl_mode = System.get_env("SMTP_SSL", "tls")
# SMTP_VERIFY_PEER: set to true/1/yes to enable TLS certificate verification (recommended
# for public SMTP like Gmail/Mailgun). Default false for self-signed/internal certs.
smtp_verify_peer =
(System.get_env("SMTP_VERIFY_PEER", "false") |> String.downcase()) in ~w(true 1 yes)
config :mv, :smtp_verify_peer, smtp_verify_peer
verify_mode = if smtp_verify_peer, do: :verify_peer, else: :verify_none
smtp_opts =
Mv.Smtp.ConfigBuilder.build_opts(
host: String.trim(smtp_host_env),
port: smtp_port_env,
username: System.get_env("SMTP_USERNAME"),
password: smtp_password_env,
ssl_mode: smtp_ssl_mode,
verify_mode: verify_mode
)
config :mv, Mv.Mailer, smtp_opts
end
end end

View file

@ -12,17 +12,15 @@ config :mv, Mv.Repo,
port: System.get_env("TEST_POSTGRES_PORT", "5000"), port: System.get_env("TEST_POSTGRES_PORT", "5000"),
database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}", database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 8, pool_size: System.schedulers_online() * 2
queue_target: 5000,
queue_interval: 1000,
timeout: 60_000
# We don't run a server during test. If one is required, # We don't run a server during test. If one is required,
# you can enable the server option below. # you can enable the server option below.
config :mv, MvWeb.Endpoint, config :mv, MvWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002], http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "Qbc/hcosiQzgfgMMPVs2slKjY2oqiqhpQHsV3twL9dN5GVDzsmsMWC1L/BZAU3Fd", secret_key_base: "Qbc/hcosiQzgfgMMPVs2slKjY2oqiqhpQHsV3twL9dN5GVDzsmsMWC1L/BZAU3Fd",
server: false # Set to true for playwright
server: true
# In test we don't send emails # In test we don't send emails
config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Test config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Test
@ -49,16 +47,15 @@ config :mv, :session_identifier, :unsafe
config :mv, :require_token_presence_for_authentication, false config :mv, :require_token_presence_for_authentication, false
# Use English as default locale in tests so UI tests can assert on English strings. # Playwright config
config :mv, :default_locale, "en" config :phoenix_test,
endpoint: MvWeb.Endpoint,
# Enable SQL Sandbox for async LiveView tests otp_app: :mv,
# This flag controls sync vs async behavior in CycleGenerator after_action hooks playwright: [
config :mv, :sql_sandbox, true browser: :firefox, #:chromium
headless: System.get_env("PW_HEADLESS", "true") in ~w(t true),
# Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute) js_logger: false,
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2 screenshot: System.get_env("PW_SCREENSHOT", "false") in ~w(t true),
trace: System.get_env("PW_TRACE", "false") in ~w(t true),
# Ash: silence "after_transaction hooks in surrounding transaction" warning when using browser_launch_timeout: 10_000
# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected). ]
config :ash, warn_on_transaction_hooks?: false

View file

@ -1,61 +0,0 @@
services:
app:
image: git.local-it.org/local-it/mitgliederverwaltung:latest
container_name: mv-prod-app
ports:
- "4001:4001"
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}"
PORT: "4001"
PHX_SERVER: "true"
# OIDC config - use host.docker.internal to reach host services
OIDC_CLIENT_ID: "mv"
OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1"
OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret"
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/oidc/callback"
secrets:
- db_password
- secret_key_base
- token_signing_secret
- oidc_client_secret
depends_on:
- db-prod
restart: unless-stopped
db-prod:
image: postgres:18.3-alpine
container_name: mv-prod-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: mv_prod
secrets:
- db_password
volumes:
- postgres_data_prod:/var/lib/postgresql
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:

View file

@ -1,16 +1,21 @@
networks: networks:
local: local:
rauthy-dev: rauthy-dev:
driver: bridge
services: services:
db: db:
image: postgres:18.3-alpine image: postgres:17.5-alpine
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_DB: mv_dev POSTGRES_DB: mv_dev
volumes: volumes:
- postgres-data:/var/lib/postgresql - type: volume
source: postgres-data
target: /var/lib/postgresql/data
volume:
nocopy: true
ports: ports:
- "5000:5432" - "5000:5432"
networks: networks:
@ -25,7 +30,7 @@ services:
rauthy: rauthy:
container_name: rauthy-dev container_name: rauthy-dev
image: ghcr.io/sebadob/rauthy:0.35.1 image: ghcr.io/sebadob/rauthy:0.32.0
environment: environment:
- LOCAL_TEST=true - LOCAL_TEST=true
- SMTP_URL=mailcrab - SMTP_URL=mailcrab
@ -34,8 +39,12 @@ services:
- LISTEN_SCHEME=http - LISTEN_SCHEME=http
- PUB_URL=localhost:8080 - PUB_URL=localhost:8080
- BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345 - BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345
# Disable strict IP validation to allow access from multiple Docker networks #- HIQLITE=false
- SESSION_VALIDATE_IP=false #- PG_HOST=db
#- PG_PORT=5432
#- PG_USER=postgres
#- PG_PASSWORD=postgres
#- PG_DB_NAME=mv_dev
ports: ports:
- "8080:8080" - "8080:8080"
depends_on: depends_on:
@ -45,7 +54,9 @@ services:
- rauthy-dev - rauthy-dev
- local - local
volumes: volumes:
- rauthy-data:/app/data - type: volume
source: rauthy-data
target: /app/data
volumes: volumes:
postgres-data: postgres-data:

View file

@ -1,62 +0,0 @@
# Admin Bootstrap and OIDC Role Sync
## Overview
- **Admin bootstrap:** In production, the Docker entrypoint runs migrate, then `Mv.Release.run_seeds/0` (skips if admin user already exists unless `FORCE_SEEDS=true`; set `RUN_DEV_SEEDS=true` to also run dev seeds), then `seed_admin/0` from ENV, then the server. Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`.
- **OIDC role sync:** Optional mapping from OIDC groups (e.g. from Authentik profile scope) to the Admin role. Users in the configured admin group get the Admin role on registration and on each sign-in.
## Admin Bootstrap (Part A)
### Environment Variables
- `RUN_DEV_SEEDS` If set to `"true"`, `run_seeds/0` also runs dev seeds (members, groups, sample data). Otherwise only bootstrap seeds run.
- `FORCE_SEEDS` If set to `"true"`, seeds are run even when the admin user already exists (e.g. after changing bootstrap data such as roles or custom fields). Otherwise seeds are skipped when bootstrap was already applied.
- `ADMIN_EMAIL` Email of the admin user to create/update. If unset, seed_admin/0 does nothing.
- `ADMIN_PASSWORD` Password for the admin user. If unset (and no file), no new user is created; if a user with ADMIN_EMAIL already exists (e.g. OIDC-only), their role is set to Admin (no password change).
- `ADMIN_PASSWORD_FILE` Path to a file containing the password (e.g. Docker secret).
### Release Tasks
- `Mv.Release.run_seeds/0` If the admin user already exists (bootstrap already applied), skips unless `FORCE_SEEDS=true`; otherwise runs bootstrap seeds (fee types, custom fields, roles, settings). If `RUN_DEV_SEEDS` env is `"true"`, also runs dev seeds (members, groups, sample data). Safe to call on every start.
- `Mv.Release.seed_admin/0` Reads ADMIN_EMAIL and password from ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. If both email and password are set: creates or updates the user with the Admin role. If only ADMIN_EMAIL is set: sets the Admin role on an existing user with that email (for OIDC-only admins); does not create a user. Idempotent.
### Entrypoint
- rel/overlays/bin/docker-entrypoint.sh After migrate, runs run_seeds(), then seed_admin(), then starts the server.
### Seeds (Dev/Test)
- priv/repo/seeds.exs Uses ADMIN_PASSWORD or ADMIN_PASSWORD_FILE when set; otherwise fallback "testpassword" only in dev/test.
## OIDC Role Sync (Part B)
### Configuration
- `OIDC_ADMIN_GROUP_NAME` OIDC group name that maps to the Admin role. If unset, no role sync.
- `OIDC_GROUPS_CLAIM` JWT claim name for group list (default "groups").
- Module: Mv.OidcRoleSyncConfig (oidc_admin_group_name/0, oidc_groups_claim/0).
### Sign-in page (OIDC-only mode)
- `OIDC_ONLY` (or Settings → OIDC → "Only OIDC sign-in") When set to true/1/yes and OIDC is configured, the sign-in page shows only the Single Sign-On button (password login is hidden). ENV takes precedence over Settings.
- **Redirect loop fix:** After an OIDC failure (e.g. provider down), the app redirects to `/sign-in?oidc_failed=1`. The plug `OidcOnlySignInRedirect` does not redirect that request back to OIDC, so the sign-in page is shown with the error (no endless redirect).
### Sync Logic
- Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info) If admin group configured, sets user role to Admin or Mitglied based on user_info groups.
### Where It Runs
1. Registration: register_with_oidc after_action calls OidcRoleSync.
2. Sign-in: sign_in_with_oidc prepare after_action calls OidcRoleSync for each user.
### Internal Action
- User.set_role_from_oidc_sync Internal update (role_id only). Used by OidcRoleSync; not exposed.
## See Also
- .env.example Admin and OIDC group env vars.
- lib/mv/release.ex seed_admin/0.
- lib/mv/oidc_role_sync.ex Sync implementation.
- docs/oidc-account-linking.md OIDC account linking.

View file

@ -1,88 +0,0 @@
# Phase 1 — Badge WCAG Analysis & Migration
## 1) Repo-Analyse (Stand vor Änderungen)
### Badge-Verwendungen (alle Fundstellen)
| Datei | Kontext | Markup |
|-------|---------|--------|
| `lib/mv_web/live/member_field_live/index_component.ex` | Tabelle (show_in_overview) | `<span class="badge badge-success">` / `<span class="badge badge-ghost">` |
| `lib/mv_web/live/components/member_filter_component.ex` | Filter-Chips (Anzahl) | `<span class="badge badge-primary badge-sm">` (2×) |
| `lib/mv_web/live/role_live/index.html.heex` | Tabelle (System Role, Permission Set, Custom) | `badge-warning`, `permission_set_badge_class()`, `badge-ghost` (User Count) |
| `lib/mv_web/helpers/membership_fee_helpers.ex` | Helper | `status_color/1` → "badge-success" \| "badge-error" \| "badge-ghost" |
| `lib/mv_web/live/member_live/show.ex` | Mitgliedsdetail (Beiträge) | `<span class={["badge", status_color(status)]}>`, `badge-ghost` (No cycles) |
| `lib/mv_web/live/membership_fee_settings_live.ex` | Settings (Fee Types) | `badge-outline`, `badge-ghost` (member count) |
| `lib/mv_web/live/membership_fee_type_live/index.ex` | Index (Fee Types) | `badge-outline`, `badge-ghost` (member count) |
| `lib/mv_web/live/role_live/index.ex` | (Helper-Import) | `permission_set_badge_class/1` |
| `lib/mv_web/live/member_live/show/membership_fees_component.ex` | Mitgliedsbeiträge | `badge-outline`, `["badge", status_color]` |
| `lib/mv_web/live/custom_field_live/index_component.ex` | Tabelle (show_in_overview) | `badge-success`, `badge-ghost` |
| `lib/mv_web/member_live/index/membership_fee_status.ex` | Helper | `format_cycle_status_badge/1` → map mit `color`, `icon`, `label` |
| `lib/mv_web/live/global_settings_live.ex` | Form (label-text-alt) | `badge badge-ghost` "(set)" (2×) |
| `lib/mv_web/live/member_live/index.html.heex` | Tabelle (Status) | `format_cycle_status_badge` + `<span class={["badge", badge.color]}>`, `badge-ghost` (No cycle), `badge-outline badge-primary` (Filter-Chip) |
| `lib/mv_web/live/role_live/helpers.ex` | Helper | `permission_set_badge_class/1` → "badge badge-* badge-sm" |
| `lib/mv_web/live/group_live/show.ex` | Card | `badge badge-outline badge` |
| `lib/mv_web/live/role_live/show.ex` | Detail | `permission_set_badge_class`, `badge-warning` (System), `badge-ghost` (No) |
### DaisyUI/Tailwind Config
- **Tailwind:** `assets/tailwind.config.js` — erweitert nur `theme.extend.colors.brand`; kein DaisyUI hier.
- **DaisyUI:** wird in `assets/css/app.css` per `@plugin "../vendor/daisyui"` mit `themes: false` geladen.
- **Themes:** Zwei Custom-Themes in `app.css`:
- `@plugin "../vendor/daisyui-theme"` mit `name: "dark"` (default: false)
- `@plugin "../vendor/daisyui-theme"` mit `name: "light"` (default: true)
- **Theme-Umschaltung:** `lib/mv_web/components/layouts/root.html.heex` — Inline-Script setzt `document.documentElement.setAttribute("data-theme", "light"|"dark")` aus `localStorage["phx:theme"]` oder `prefers-color-scheme`. Sidebar enthält Theme-Toggle (`<.theme_toggle />`).
### Core Components
- **Modul:** `lib/mv_web/components/core_components.ex` (MvWeb.CoreComponents).
- **Vorhanden:** flash, button, dropdown_menu, form_section, input, header, table, icon, link, etc.
- **Badge:** Bisher keine zentrale `<.badge>`-Komponente.
### DaisyUI Badge (Vendor)
- **Default:** `--badge-bg: var(--badge-color, var(--color-base-100))`, `--badge-fg: var(--color-base-content)`.
- **badge-outline:** `--badge-bg: "#0000"` (transparent) → Kontrastproblem auf base-200/base-300.
- **badge-ghost:** `background-color: var(--color-base-200)`, `color: var(--color-base-content)` → auf base-200-Flächen kaum sichtbar.
- **badge-soft:** color-mix 8% Variante mit base-100 → sichtbar; Text ist Variantenfarbe (Kontrast prüfen).
---
## 2) Core Component <.badge> API (geplant)
- **attr :variant**`:neutral | :primary | :info | :success | :warning | :error`
- **attr :style**`:soft | :solid | :outline` (Default: `:soft`)
- **attr :size**`:sm | :md` (Default: `:md`)
- **slot :inner_block** — Badge-Text
- **attr :sr_label** — optional, für Icon-only (Screen Reader)
- **slot :icon** — optional
Regeln:
- `:soft` und `:solid` nutzen sichtbaren Hintergrund (kein transparenter Ghost als Default).
- `:outline` setzt immer einen Hintergrund (z. B. `bg-base-100`), damit der Rand auf grauen Flächen sichtbar bleibt.
- Ghost nur als explizites Opt-in; dann mit `bg-base-100` für Sichtbarkeit.
---
## 3) Theme-Overrides (WCAG)
- In `app.css` sind bereits Custom-Themes für `light` und `dark` mit eigenen Tokens.
- **Badge-Kontrast (WCAG 2.2 AA 4.5:1):** Zusätzliche Overrides in `app.css`:
- **Light theme:** Dunkle `--badge-fg` für alle Varianten (primary, success, error, warning, info, neutral); für `badge-soft` dunklere Textfarbe (`color`) auf getöntem Hintergrund; für `badge-outline` einheitlich dunkle Schrift auf base-100.
- **Dark theme:** Leicht abgedunkelte Badge-Hintergründe für Solid-Badges, damit die hellen *-content-Farben 4.5:1 erreichen; für `badge-soft` hellere, gut lesbare Variantentöne; für `badge-outline` heller Text (`--badge-fg`) auf base-100.
---
## 4) Migration (erledigt)
- Alle `<span class="badge ...">` durch `<.badge variant="..." style="...">...</.badge>` ersetzt.
- Klickbare Chips (z. B. Group Show „Remove“) bleiben als <.badge> mit Button im inner_block (Badge ist nur Container).
- **Neue Helper:** `MembershipFeeHelpers.status_variant/1` (→ :success | :error | :warning; suspended = :warning wie Edit-Button), `RoleLive.Helpers.permission_set_badge_variant/1` (→ :neutral | :info | :success | :error).
- **Angepasst:** `MembershipFeeStatus.format_cycle_status_badge/1` liefert zusätzlich `:variant` für <.badge>.
- **Migrierte Stellen:** member_field_live, member_filter_component, role_live (index + show), member_live (show, index, membership_fees_component), membership_fee_settings_live, membership_fee_type_live, custom_field_live, global_settings_live, group_live/show.
## 5) Weitere Anpassungen (nach Phase 1)
- **Filter Join-Buttons (WCAG):** In `app.css` Kontrast-Overrides für `.member-filter-dropdown .join .btn` (inaktiv: base-100/base-200 + dunkle/helle Schrift; aktiv: success/error mit 4.5:1).
- **Badge „Pausiert“ (suspended):** `status_variant(:suspended)``:warning` (gelb), damit Badge dieselbe Farbe wie der Edit-Button (btn-warning) hat.
- **Filter-Dropdown schließen:** `phx-click-away` vom inneren Panel auf den äußeren Wrapper (`member-filter-dropdown`) verschoben; Klick auf den Filter-Button schließt das Dropdown (konsistent mit Spalten/Ausblenden).

View file

@ -1,796 +0,0 @@
# CSV Member Import v1 - Implementation Plan
**Version:** 1.0
**Last Updated:** 2026-01-13
**Status:** In Progress (Backend Complete, UI Complete, Tests Pending)
**Related Documents:**
- [Feature Roadmap](./feature-roadmap.md) - Overall feature planning
## Implementation Status
**Completed Issues:**
- ✅ Issue #1: CSV Specification & Static Template Files
- ✅ Issue #2: Import Service Module Skeleton
- ✅ Issue #3: CSV Parsing + Delimiter Auto-Detection + BOM Handling
- ✅ Issue #4: Header Normalization + Per-Header Mapping
- ✅ Issue #5: Validation (Required Fields) + Error Formatting
- ✅ Issue #6: Persistence via Ash Create + Per-Row Error Capture (with Error-Capping)
- ✅ Issue #7: Admin Global Settings LiveView UI (Upload + Start Import + Results + Template Links)
- ✅ Issue #8: Authorization + Limits
- ✅ Issue #11: Custom Field Import (Backend + UI)
**In Progress / Pending:**
- ⏳ Issue #9: End-to-End LiveView Tests + Fixtures
- ⏳ Issue #10: Documentation Polish
**Latest Update:** CSV Import UI fully implemented in GlobalSettingsLive with chunk processing, progress tracking, error display, and custom field support (2026-01-13)
---
## Table of Contents
- [Overview & Scope](#overview--scope)
- [UX Flow](#ux-flow)
- [CSV Specification](#csv-specification)
- [Technical Design Notes](#technical-design-notes)
- [Implementation Issues](#implementation-issues)
- [Rollout & Risks](#rollout--risks)
---
## Overview & Scope
### What We're Building
A **basic CSV member import feature** that allows administrators to upload a CSV file and import new members into the system. This is a **v1 minimal implementation** focused on establishing the import structure without advanced features.
**Core Functionality (v1 Minimal):**
- Upload CSV file via LiveView file upload
- Parse CSV with bilingual header support for core member fields (English/German)
- Auto-detect delimiter (`;` or `,`) using header recognition
- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`, `country`)
- **Import custom field values** - Map CSV columns to existing custom fields by name (unknown custom field columns will be ignored with a warning)
- Validate each row (required field: `email`)
- Create members via Ash resource (one-by-one, **no background jobs**, processed in chunks of 200 rows via LiveView messages)
- Display import results: success count, error count, and error details
- Provide static CSV templates (EN/DE)
**Key Constraints (v1):**
- ✅ **Admin-only feature**
- ✅ **No upsert** (create only)
- ✅ **No deduplication** (duplicate emails fail and show as errors)
- ✅ **No mapping wizard** (fixed header mapping via bilingual variants)
- ✅ **No background jobs** (progress via LiveView `handle_info`)
- ✅ **Best-effort import** (row-by-row, no rollback)
- ✅ **UI-only error display** (no error CSV export)
- ✅ **Safety limits** (10 MB, 1,000 rows, chunks of 200)
### Out of Scope (v1)
**Deferred to Future Versions:**
- ❌ Upsert/update existing members
- ❌ Advanced deduplication strategies
- ❌ Column mapping wizard UI
- ❌ Background job processing (Oban/GenStage)
- ❌ Transactional all-or-nothing import
- ❌ Error CSV export/download
- ❌ Batch validation preview before import
- ❌ Dynamic template generation
- ❌ Import history/audit log
- ❌ Import templates for other entities
---
## UX Flow
### Access & Location
**Entry Point:**
- **Location:** Global Settings page (`/settings`)
- **UI Element:** New section "Import Members (CSV)" below "Custom Fields" section
- **Access Control:** Admin-only (enforced at LiveView event level, not entire `/settings` route)
### User Journey
1. **Navigate to Global Settings**
2. **Access Import Section**
- **Important notice:** Custom fields should be created in Mila before importing CSV files with custom field columns (unknown columns will be ignored with a warning)
- Upload area (drag & drop or file picker)
- Template download links (English / German)
- Help text explaining CSV format and custom field requirements
3. **Ensure Custom Fields Exist (if importing custom fields)**
- Navigate to Custom Fields section and create required custom fields
- Note the name/identifier for each custom field (used as CSV header)
4. **Download Template (Optional)**
5. **Prepare CSV File**
- Include custom field columns using the custom field name as header (e.g., `membership_number`, `birth_date`)
6. **Upload CSV**
7. **Start Import**
- Runs server-side via LiveView messages (may take up to ~30 seconds for large files)
- Warning messages if custom field columns reference non-existent custom fields (columns will be ignored)
8. **View Results**
- Success count
- Error count
- First 50 errors, each with:
- **CSV line number** (header is line 1, first data record begins at line 2)
- Error message
- Field name (if applicable)
### Error Handling
- **File too large:** Flash error before upload starts
- **Too many rows:** Flash error before import starts
- **Invalid CSV format:** Error shown in results
- **Partial success:** Results show both success and error counts
---
## CSV Specification
### Delimiter
**Recommended:** Semicolon (`;`)
**Supported:** `;` and `,`
**Auto-Detection (Header Recognition):**
- Remove UTF-8 BOM *first*
- Extract header record and try parsing with both delimiters
- For each delimiter, count how many recognized headers are present (via normalized variants)
- Choose delimiter with higher recognition; prefer `;` if tied
- If neither yields recognized headers, default to `;`
### Quoting Rules
- Fields may be quoted with double quotes (`"`)
- Escaped quotes: `""` inside quoted field represents a single `"`
- **v1 assumption:** CSV records do **not** contain embedded newlines inside quoted fields. (If they do, parsing may fail or line numbers may be inaccurate.)
### Column Headers
**v1 Supported Fields:**
**Core Member Fields (all importable):**
- `email` / `E-Mail` (required)
- `first_name` / `Vorname` (optional)
- `last_name` / `Nachname` (optional)
- `join_date` / `Beitrittsdatum` (optional, ISO-8601 date)
- `exit_date` / `Austrittsdatum` (optional, ISO-8601 date)
- `notes` / `Notizen` (optional)
- `country` / `Land` / `Staat` (optional)
- `city` / `Stadt` (optional)
- `street` / `Straße` (optional)
- `house_number` / `Hausnummer` / `Nr.` (optional)
- `postal_code` / `PLZ` / `Postleitzahl` (optional)
- `membership_fee_start_date` / `Beitragsbeginn` (optional, ISO-8601 date)
Address column order in import/export matches the members overview: country, city, street, house number, postal code.
**Not supported for import (by design):**
- **membership_fee_status** Computed field (from fee cycles). Not stored; export-only.
- **groups** Many-to-many relationship. Would require resolving group names to IDs; not in current scope.
- **membership_fee_type_id** Foreign key; could be added later (e.g. resolve type name to ID).
**Custom Fields:**
- Any custom field column using the custom field's **name** as the header (e.g., `membership_number`, `birth_date`)
- **Important:** Custom fields must be created in Mila before importing. The CSV header must match the custom field name exactly (same normalization as member fields).
- **Behavior:** If the CSV contains custom field columns that don't exist in Mila, a warning message will be shown and those columns will be ignored during import.
- **Value Validation:** Custom field values are validated according to the custom field type:
- **string**: Any text value (trimmed)
- **integer**: Must be a valid integer (e.g., `42`, `-10`). Invalid values will cause a row error with the custom field name and reason.
- **boolean**: Accepts `true`, `false`, `1`, `0`, `yes`, `no`, `ja`, `nein` (case-insensitive). Invalid values will cause a row error.
- **date**: Must be in ISO-8601 format (YYYY-MM-DD, e.g., `2024-01-15`). Invalid values will cause a row error.
- **email**: Must be a valid email format (contains `@`, 5-254 characters, valid format). Invalid values will cause a row error.
- **Error Messages:** Custom field validation errors are included in the import error list with format: `custom_field: <name> <reason>` (e.g., `custom_field: Alter expected integer, got: abc`)
**Member Field Header Mapping:**
| Canonical Field | English Variants | German Variants |
|---|---|---|
| `first_name` | `first_name`, `firstname` | `Vorname`, `vorname` |
| `last_name` | `last_name`, `lastname`, `surname` | `Nachname`, `nachname`, `Familienname` |
| `email` | `email`, `e-mail`, `e_mail` | `E-Mail`, `e-mail`, `e_mail` |
| `join_date` | `join date`, `join_date` | `Beitrittsdatum`, `beitritts-datum` |
| `exit_date` | `exit date`, `exit_date` | `Austrittsdatum`, `austritts-datum` |
| `notes` | `notes` | `Notizen`, `bemerkungen` |
| `street` | `street`, `address` | `Straße`, `strasse`, `Strasse` |
| `house_number` | `house number`, `house_number`, `house no` | `Hausnummer`, `Nr`, `Nr.`, `Nummer` |
| `postal_code` | `postal_code`, `zip`, `postcode` | `PLZ`, `plz`, `Postleitzahl`, `postleitzahl` |
| `city` | `city`, `town` | `Stadt`, `stadt`, `Ort` |
| `country` | `country` | `Land`, `land`, `Staat`, `staat` |
| `membership_fee_start_date` | `membership fee start date`, `membership_fee_start_date`, `fee start` | `Beitragsbeginn`, `beitrags-beginn` |
**Header Normalization (used consistently for both input headers AND mapping variants):**
- Trim whitespace
- Convert to lowercase
- Normalize Unicode: `ß``ss` (e.g., `Straße``strasse`)
- Replace hyphens/whitespace with underscores: `E-Mail``e_mail`, `phone number``phone_number`
- Collapse multiple underscores: `e__mail``e_mail`
- Case-insensitive matching
**Unknown columns:** ignored (no error)
**Required fields:** `email`
**Custom Field Columns:**
- Custom field columns are identified by matching the normalized CSV header to the custom field `name` (not slug)
- Same normalization rules apply as for member fields (trim, lowercase, Unicode normalization, underscore replacement)
- Unknown custom field columns (non-existent names) will be ignored with a warning message
### CSV Template Files
**Location:**
- `priv/static/templates/member_import_en.csv`
- `priv/static/templates/member_import_de.csv`
**Content:**
- Header row with required + common optional fields
- **Note:** Custom field columns are not included in templates by default (users add them based on their custom field configuration)
- One example row
- Uses semicolon delimiter (`;`)
- UTF-8 encoding **with BOM** (Excel compatibility)
**Template Access:**
- Templates are static files in `priv/static/templates/`
- Served at:
- `/templates/member_import_en.csv`
- `/templates/member_import_de.csv`
- In LiveView, link them using Phoenix static path helpers (e.g. `~p` or `Routes.static_path/2`, depending on Phoenix version).
**Example Usage in LiveView Templates:**
```heex
<!-- Using ~p sigil (Phoenix 1.7+) -->
<.link href={~p"/templates/member_import_en.csv"} download>
<%= gettext("Download English Template") %>
</.link>
<.link href={~p"/templates/member_import_de.csv"} download>
<%= gettext("Download German Template") %>
</.link>
<!-- Alternative: Using Routes.static_path/2 -->
<.link href={Routes.static_path(MvWeb.Endpoint, "/templates/member_import_en.csv")} download>
<%= gettext("Download English Template") %>
</.link>
```
**Note:** The `templates` directory must be included in `MvWeb.static_paths()` (configured in `lib/mv_web.ex`) for the files to be served.
### File Limits
- **Max file size:** 10 MB
- **Max rows:** 1,000 rows (excluding header)
- **Processing:** chunks of 200 (via LiveView messages)
- **Encoding:** UTF-8 (BOM handled)
---
## Technical Design Notes
### Architecture Overview
```
┌─────────────────┐
│ LiveView UI │ (GlobalSettingsLive or component)
│ - Upload area │
│ - Progress │
│ - Results │
└────────┬────────┘
│ prepare
┌─────────────────────────────┐
│ Import Service │ (Mv.Membership.Import.MemberCSV)
│ - parse + map + limit checks│ -> returns import_state
│ - process_chunk(chunk) │ -> returns chunk results
└────────┬────────────────────┘
│ create
┌─────────────────┐
│ Ash Resource │ (Mv.Membership.Member)
│ - Create │
└─────────────────┘
```
### Technology Stack
- **Phoenix LiveView:** file upload via `allow_upload/3`
- **NimbleCSV:** CSV parsing (add explicit dependency if missing)
- **Ash Resource:** member creation via `Membership.create_member/1`
- **Gettext:** bilingual UI/error messages
### Module Structure
**New Modules:**
- `lib/mv/membership/import/member_csv.ex` - import orchestration + chunk processing + custom field handling
- `lib/mv/membership/import/csv_parser.ex` - delimiter detection + parsing + BOM handling
- `lib/mv/membership/import/header_mapper.ex` - normalization + header mapping (core fields + custom fields)
**Modified Modules:**
- `lib/mv_web/live/global_settings_live.ex` - render import section, handle upload/events/messages
### Data Flow
1. **Upload:** LiveView receives file via `allow_upload`
2. **Consume:** `consume_uploaded_entries/3` reads file content
3. **Prepare:** `MemberCSV.prepare/2`
- Strip BOM
- Detect delimiter (header recognition)
- Parse header + rows
- Map headers to canonical fields (core member fields)
- **Query existing custom fields and map custom field columns by name** (using same normalization as member fields)
- **Warn about unknown custom field columns** (non-existent names will be ignored with warning)
- Early abort if required headers missing
- Row count check
- Return `import_state` containing chunks, column_map, and custom_field_map
4. **Process:** LiveView drives chunk processing via `handle_info`
- For each chunk: validate + create member + create custom field values + collect errors
5. **Results:** LiveView shows progress + final summary
### Types & Key Consistency
- **Raw CSV parsing:** returns headers as list of strings, and rows **with csv line numbers**
- **Header mapping:** operates on normalized strings; mapping table variants are normalized once
- **Ash attrs:** built as atom-keyed map (`%{first_name: ..., ...}`)
### Error Model
```elixir
%{
csv_line_number: 5, # physical line number in the CSV file
field: :email, # optional
message: "is not a valid email"
}
```
### CSV Line Numbers (Important)
To keep error reporting user-friendly and accurate, **row errors must reference the physical line number in the original file**, even if empty lines are skipped.
**Design decision:** the parser returns rows as:
```elixir
rows :: [{csv_line_number :: pos_integer(), row_map :: map()}]
```
Downstream logic must **not** recompute line numbers from row indexes.
### Authorization
**Enforcement points:**
1. **LiveView event level:** check admin permission in `handle_event("start_import", ...)`
2. **UI level:** render import section only for admin users
3. **Static templates:** public assets (no authorization needed)
Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string checks where possible.
### Safety Limits
- File size enforced by `allow_upload` (`max_file_size`)
- Row count enforced in `MemberCSV.prepare/2` before processing starts
- Chunking is done via **LiveView `handle_info` loop** (sequential, cooperative scheduling)
---
## Implementation Issues
### Issue #1: CSV Specification & Static Template Files
**Dependencies:** None
**Status:** ✅ **COMPLETED**
**Goal:** Define CSV contract and add static templates.
**Tasks:**
- [x] Finalize header mapping variants
- [x] Document normalization rules
- [x] Document delimiter detection strategy
- [x] Create templates in `priv/static/templates/` (UTF-8 with BOM)
- `member_import_en.csv` with English headers
- `member_import_de.csv` with German headers
- [x] Document template URLs and how to link them from LiveView
- [x] Document line number semantics (physical CSV line numbers)
- [x] Templates included in `MvWeb.static_paths()` configuration
**Definition of Done:**
- [x] Templates open cleanly in Excel/LibreOffice
- [x] CSV spec section complete
---
### Issue #2: Import Service Module Skeleton
**Dependencies:** None
**Status:** ✅ **COMPLETED**
**Goal:** Create service API and error types.
**API (recommended):**
- `prepare/2` — parse + map + limit checks, returns import_state
- `process_chunk/4` — process one chunk (pure-ish), returns per-chunk results
**Tasks:**
- [x] Create `lib/mv/membership/import/member_csv.ex`
- [x] Define public function: `prepare/2 (file_content, opts \\ [])`
- [x] Define public function: `process_chunk/4 (chunk_rows_with_lines, column_map, custom_field_map, opts \\ [])`
- [x] Define error struct: `%MemberCSV.Error{csv_line_number: integer, field: atom | nil, message: String.t}`
- [x] Document module + API
---
### Issue #3: CSV Parsing + Delimiter Auto-Detection + BOM Handling
**Dependencies:** Issue #2
**Status:** ✅ **COMPLETED**
**Goal:** Parse CSV robustly with correct delimiter detection and BOM handling.
**Tasks:**
- [x] Verify/add NimbleCSV dependency (`{:nimble_csv, "~> 1.0"}`)
- [x] Create `lib/mv/membership/import/csv_parser.ex`
- [x] Implement `strip_bom/1` and apply it **before** any header handling
- [x] Handle `\r\n` and `\n` line endings (trim `\r` on header record)
- [x] Detect delimiter via header recognition (try `;` and `,`)
- [x] Parse CSV and return:
- `headers :: [String.t()]`
- `rows :: [{csv_line_number, [String.t()]}]` with correct physical line numbers
- [x] Skip completely empty records (but preserve correct physical line numbers)
- [x] Return `{:ok, headers, rows}` or `{:error, reason}`
**Definition of Done:**
- [x] BOM handling works (Excel exports)
- [x] Delimiter detection works reliably
- [x] Rows carry correct `csv_line_number`
---
### Issue #4: Header Normalization + Per-Header Mapping (No Language Detection)
**Dependencies:** Issue #3
**Status:** ✅ **COMPLETED**
**Goal:** Map each header individually to canonical fields (normalized comparison).
**Tasks:**
- [x] Create `lib/mv/membership/import/header_mapper.ex`
- [x] Implement `normalize_header/1`
- [x] Normalize mapping variants once and compare normalized strings
- [x] Build `column_map` (canonical field -> column index)
- [x] **Early abort if required headers missing** (`email`)
- [x] Ignore unknown columns (member fields only)
- [x] **Separate custom field column detection** (by name, with normalization)
**Definition of Done:**
- [x] English/German headers map correctly
- [x] Missing required columns fails fast
---
### Issue #5: Validation (Required Fields) + Error Formatting
**Dependencies:** Issue #4
**Status:** ✅ **COMPLETED**
**Goal:** Validate each row and return structured, translatable errors.
**Tasks:**
- [x] Implement `validate_row/3 (row_map, csv_line_number, opts)`
- [x] Required field presence (`email`)
- [x] Email format validation (EctoCommons.EmailValidator)
- [x] Trim values before validation
- [x] Gettext-backed error messages
---
### Issue #6: Persistence via Ash Create + Per-Row Error Capture (Chunked Processing)
**Dependencies:** Issue #5
**Status:** ✅ **COMPLETED**
**Goal:** Create members and capture errors per row with correct CSV line numbers.
**Tasks:**
- [x] Implement `process_chunk/4` in service:
- Input: `[{csv_line_number, row_map}]`
- Validate + create sequentially
- Collect counts + first 50 errors (per import overall; LiveView enforces cap across chunks)
- **Error-Capping:** Supports `existing_error_count` and `max_errors` in opts (default: 50)
- **Error-Capping:** Only collects errors if under limit, but continues processing all rows
- **Error-Capping:** `failed` count is always accurate, even when errors are capped
- [x] Implement Ash error formatter helper:
- Convert `Ash.Error.Invalid` into `%MemberCSV.Error{}`
- Prefer field-level errors where possible (attach `field` atom)
- Handle unique email constraint error as user-friendly message
- [x] Map row_map to Ash attrs (`%{first_name: ..., ...}`)
- [x] Custom field value processing and creation
**Important:** **Do not recompute line numbers** in this layer—use the ones provided by the parser.
**Implementation Notes:**
- `process_chunk/4` accepts `opts` with `existing_error_count` and `max_errors` for error capping across chunks
- Error capping respects the limit per import overall (not per chunk)
- Processing continues even after error limit is reached (for accurate counts)
---
### Issue #7: Admin Global Settings LiveView UI (Upload + Start Import + Results + Template Links)
**Dependencies:** Issue #6
**Status:** ✅ **COMPLETED**
**Goal:** UI section with upload, progress, results, and template links.
**Tasks:**
- [x] Render import section only for admins
- [x] **Add prominent UI notice about custom fields:**
- Display alert/info box: "Custom fields must be created in Mila before importing CSV files with custom field columns"
- Explain: "Use the custom field name as the CSV column header (same normalization as member fields applies)"
- Add link to custom fields management section
- [x] Configure `allow_upload/3`:
- `.csv` only, `max_entries: 1`, `max_file_size: 10MB`, `auto_upload: true` (auto-upload enabled for better UX)
- [x] `handle_event("start_import", ...)`:
- Admin permission check
- Consume upload -> read file content
- Call `MemberCSV.prepare/2`
- Store `import_state` in assigns (chunks + column_map + metadata)
- Initialize progress assigns
- `send(self(), {:process_chunk, 0})`
- [x] `handle_info({:process_chunk, idx}, socket)`:
- Fetch chunk from `import_state`
- Call `MemberCSV.process_chunk/4` with error capping support
- Merge counts/errors into progress assigns (cap errors at 50 overall)
- Schedule next chunk (or finish and show results)
- Async task processing with SQL sandbox support for tests
- [x] Results UI:
- Success count
- Failure count
- Error list (line number + message + field)
- **Warning messages for unknown custom field columns** (non-existent names) shown in results
- Progress indicator during import
- Error truncation notice when errors exceed limit
**Template links:**
- [x] Link `/templates/member_import_en.csv` and `/templates/member_import_de.csv` via Phoenix static path helpers.
**Definition of Done:**
- [x] Upload area with drag & drop support
- [x] Template download links (EN/DE)
- [x] Progress tracking during import
- [x] Results display with success/error counts
- [x] Error list with line numbers and field information
- [x] Warning display for unknown custom field columns
- [x] Admin-only access control
- [x] Async chunk processing with proper error handling
---
### Issue #8: Authorization + Limits
**Dependencies:** None (can be parallelized)
**Status:** ✅ **COMPLETED**
**Goal:** Ensure admin-only access and enforce limits.
**Tasks:**
- [x] Admin check in start import event handler (via `Authorization.can?/3`)
- [x] File size enforced in upload config (`max_file_size: 10MB`)
- [x] Row limit enforced in `MemberCSV.prepare/2` (max_rows: 1000, configurable via opts)
- [x] Chunk size limit (200 rows per chunk)
- [x] Error limit (50 errors per import)
- [x] UI-level authorization check (import section only visible to admins)
- [x] Event-level authorization check (prevents unauthorized import attempts)
**Implementation Notes:**
- File size limit: 10 MB (10,485,760 bytes) enforced via `allow_upload/3`
- Row limit: 1,000 rows (excluding header) enforced in `MemberCSV.prepare/2`
- Chunk size: 200 rows per chunk (configurable via opts)
- Error limit: 50 errors per import (configurable via `@max_errors`)
- Authorization uses `MvWeb.Authorization.can?/3` with `:create` permission on `Mv.Membership.Member`
**Definition of Done:**
- [x] Admin-only access enforced at UI and event level
- [x] File size limit enforced
- [x] Row count limit enforced
- [x] Chunk processing with size limits
- [x] Error capping implemented
---
### Issue #9: End-to-End LiveView Tests + Fixtures
**Dependencies:** Issue #7 and #8
**Tasks:**
- [ ] Fixtures:
- valid EN/DE (core fields only)
- valid with custom fields
- invalid
- unknown custom field name (non-existent, should show warning)
- too many rows (1,001)
- BOM + `;` delimiter fixture
- fixture with empty line(s) to validate correct line numbers
- [ ] LiveView tests:
- admin sees section, non-admin does not
- upload + start import
- success + error rendering
- row limit + file size errors
- custom field import success
- custom field import warning (non-existent name, column ignored)
---
### Issue #10: Documentation Polish (Inline Help Text + Docs)
**Dependencies:** Issue #9
**Tasks:**
- [ ] UI help text + translations
- [ ] CHANGELOG entry
- [ ] Ensure moduledocs/docs
---
### Issue #11: Custom Field Import
**Dependencies:** Issue #6 (Persistence)
**Priority:** High (Core v1 Feature)
**Status:** ✅ **COMPLETED** (Backend + UI Implementation)
**Goal:** Support importing custom field values from CSV columns. Custom fields should exist in Mila before import for best results.
**Important Requirements:**
- **Custom fields should be created in Mila first** - Unknown custom field columns will be ignored with a warning message
- CSV headers for custom fields must match the custom field **name** exactly (same normalization as member fields applies)
- Custom field values are validated according to the custom field type (string, integer, boolean, date, email)
- Unknown custom field columns (non-existent names) will be ignored with a warning - import continues
**Tasks:**
- [x] Extend `header_mapper.ex` to detect custom field columns by name (using same normalization as member fields)
- [x] Query existing custom fields during `prepare/2` to map custom field columns
- [x] Collect unknown custom field columns and add warning messages (don't fail import)
- [x] Map custom field CSV values to `CustomFieldValue` creation in `process_chunk/4`
- [x] Handle custom field type validation (string, integer, boolean, date, email) with proper error messages
- [x] Create `CustomFieldValue` records linked to members during import
- [x] Validate custom field values and return structured errors with custom field name and reason
- [x] UI help text and link to custom field management (implemented in Issue #7)
- [x] Update error messages to include custom field validation errors (format: `custom_field: <name> expected <type>, got: <value>`)
- [x] Add UI help text explaining custom field requirements (completed in Issue #7):
- "Custom fields must be created in Mila before importing"
- "Use the custom field name as the CSV column header (same normalization as member fields)"
- Link to custom fields management section
- [x] Update CSV templates documentation to explain custom field columns (documented in Issue #1)
- [x] Add tests for custom field import (valid, invalid name, type validation, warning for unknown)
**Definition of Done:**
- [x] Custom field columns are recognized by name (with normalization)
- [x] Warning messages shown for unknown custom field columns (import continues)
- [x] Custom field values are created and linked to members
- [x] Type validation works for all custom field types (string, integer, boolean, date, email)
- [x] UI clearly explains custom field requirements (completed in Issue #7)
- [x] Tests cover custom field import scenarios (including warning for unknown names)
- [x] Error messages include custom field validation errors with proper formatting
**Implementation Notes:**
- Custom field lookup is built in `prepare/2` and passed via `custom_field_lookup` in opts
- Custom field values are formatted according to type in `format_custom_field_value/2`
- Unknown custom field columns generate warnings in `import_state.warnings`
---
## Rollout & Risks
### Rollout Strategy
- Dev → Staging → Production (with anonymized real-world CSV tests)
### Risks & Mitigations
| Risk | Impact | Likelihood | Mitigation |
|---|---:|---:|---|
| Large import timeout | High | Medium | 10 MB + 1,000 rows, chunking via `handle_info` |
| Encoding issues | Medium | Medium | BOM stripping, templates with BOM |
| Invalid CSV format | Medium | High | Clear errors + templates |
| Duplicate emails | Low | High | Ash constraint error -> user-friendly message |
| Performance (no background jobs) | Medium | Low | Small limits, sequential chunk processing |
| Admin access bypass | High | Low | Event-level auth + UI hiding |
| Data corruption | High | Low | Per-row validation + best-effort |
---
## Appendix
### Module File Structure
```
lib/
├── mv/
│ └── membership/
│ └── import/
│ ├── member_csv.ex # prepare + process_chunk
│ ├── import_runner.ex # orchestration: file read, progress merge, chunk process, error format
│ ├── csv_parser.ex # delimiter detection + parsing + BOM handling
│ └── header_mapper.ex # normalization + header mapping
└── mv_web/
└── live/
├── import_export_live.ex # mount / handle_event / handle_info + glue only
└── import_export_live/
└── components.ex # UI: custom_fields_notice, template_links, import_form, import_progress, import_results
priv/
└── static/
└── templates/
├── member_import_en.csv
└── member_import_de.csv
test/
├── mv/
│ └── membership/
│ └── import/
│ ├── member_csv_test.exs
│ ├── csv_parser_test.exs
│ └── header_mapper_test.exs
└── fixtures/
├── member_import_en.csv
├── member_import_de.csv
├── member_import_invalid.csv
├── member_import_large.csv
└── member_import_empty_lines.csv
```
### Example Usage (LiveView)
```elixir
def handle_event("start_import", _params, socket) do
assert_admin!(socket.assigns.current_user)
[{_name, content}] =
consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry ->
{:ok, File.read!(path)}
end)
case Mv.Membership.Import.MemberCSV.prepare(content) do
{:ok, import_state} ->
socket =
socket
|> assign(:import_state, import_state)
|> assign(:import_progress, %{processed: 0, inserted: 0, failed: 0, errors: []})
|> assign(:importing?, true)
send(self(), {:process_chunk, 0})
{:noreply, socket}
{:error, reason} ->
{:noreply, put_flash(socket, :error, reason)}
end
end
def handle_info({:process_chunk, idx}, socket) do
%{chunks: chunks, column_map: column_map} = socket.assigns.import_state
case Enum.at(chunks, idx) do
nil ->
{:noreply, assign(socket, importing?: false)}
chunk_rows_with_lines ->
{:ok, chunk_result} =
Mv.Membership.Import.MemberCSV.process_chunk(chunk_rows_with_lines, column_map)
socket = merge_progress(socket, chunk_result) # caps errors at 50 overall
send(self(), {:process_chunk, idx + 1})
{:noreply, socket}
end
end
```
---
**End of Implementation Plan**

View file

@ -1,243 +0,0 @@
# Performance Analysis: Custom Fields in Search Vector
## Current Implementation
The search vector includes custom field values via database triggers that:
1. Aggregate all custom field values for a member
2. Extract values from JSONB format
3. Add them to the search_vector with weight 'C'
## Performance Considerations
### 1. Trigger Performance on Member Updates
**Current Implementation:**
- `members_search_vector_trigger()` executes a subquery on every INSERT/UPDATE:
```sql
SELECT string_agg(...) FROM custom_field_values WHERE member_id = NEW.id
```
**Performance Impact:**
- ✅ **Good:** Index on `member_id` exists (`custom_field_values_member_id_idx`)
- ✅ **Good:** Subquery only runs for the affected member
- ⚠️ **Potential Issue:** With many custom fields per member (e.g., 50+), aggregation could be slower
- ⚠️ **Potential Issue:** JSONB extraction (`value->>'_union_value'`) is relatively fast but adds overhead
**Expected Performance:**
- **Small scale (< 10 custom fields per member):** Negligible impact (< 5ms per operation)
- **Medium scale (10-30 custom fields):** Minor impact (5-20ms per operation)
- **Large scale (30+ custom fields):** Noticeable impact (20-50ms+ per operation)
### 2. Trigger Performance on Custom Field Value Changes
**Current Implementation:**
- `update_member_search_vector_from_custom_field_value()` executes on every INSERT/UPDATE/DELETE on `custom_field_values`
- **Optimized:** Only fetches required member fields (not full record) to reduce overhead
- **Optimized:** Skips re-aggregation on UPDATE if value hasn't actually changed
- Aggregates all custom field values, then updates member search_vector
**Performance Impact:**
- ✅ **Good:** Index on `member_id` ensures fast lookup
- ✅ **Optimized:** Only required fields are fetched (first_name, last_name, email, etc.) instead of full record
- ✅ **Optimized:** UPDATE operations that don't change the value skip expensive re-aggregation (early return)
- ⚠️ **Note:** Re-aggregation is still necessary when values change (required for search_vector consistency)
- ⚠️ **Critical:** Bulk operations (e.g., importing 1000 members with custom fields) will trigger this for each row
**Expected Performance:**
- **Single operation (value changed):** 3-10ms per custom field value change (improved from 5-15ms)
- **Single operation (value unchanged):** <1ms (early return, no aggregation)
- **Bulk operations:** Could be slow (consider disabling trigger temporarily)
### 3. Search Vector Size
**Current Constraints:**
- String values: max 10,000 characters per custom field
- No limit on number of custom fields per member
- tsvector has no explicit size limit, but very large vectors can cause issues
**Potential Issues:**
- **Theoretical maximum:** If a member has 100 custom fields with 10,000 char strings each, the aggregated text could be ~1MB
- **Practical concern:** Very large search vectors (> 100KB) can slow down:
- Index updates (GIN index maintenance)
- Search queries (tsvector operations)
- Trigger execution time
**Recommendation:**
- Monitor search_vector size in production
- Consider limiting total custom field content per member if needed
- PostgreSQL can handle large tsvectors, but performance degrades gradually
### 4. Initial Migration Performance
**Current Implementation:**
- Updates ALL members in a single transaction:
```sql
UPDATE members m SET search_vector = ... (subquery for each member)
```
**Performance Impact:**
- ⚠️ **Potential Issue:** With 10,000+ members, this could take minutes
- ⚠️ **Potential Issue:** Single transaction locks the members table
- ⚠️ **Potential Issue:** If migration fails, entire rollback required
**Recommendation:**
- For large datasets (> 10,000 members), consider:
- Batch updates (e.g., 1000 members at a time)
- Run during maintenance window
- Monitor progress
### 5. Search Query Performance
**Current Implementation:**
- Full-text search uses GIN index on `search_vector` (fast)
- Additional LIKE queries on `custom_field_values` for substring matching:
```sql
EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = id AND ... LIKE ...)
```
**Performance Impact:**
- ✅ **Good:** GIN index on `search_vector` is very fast
- ⚠️ **Potential Issue:** LIKE queries on JSONB are not indexed (sequential scan)
- ⚠️ **Potential Issue:** EXISTS subquery runs for every search, even if search_vector match is found
- ⚠️ **Potential Issue:** With many custom fields, the LIKE queries could be slow
**Expected Performance:**
- **With GIN index match:** Very fast (< 10ms for typical queries)
- **Without GIN index match (fallback to LIKE):** Slower (10-100ms depending on data size)
- **Worst case:** Sequential scan of all custom_field_values for all members
## Recommendations
### Short-term (Current Implementation)
1. **Monitor Performance:**
- Add logging for trigger execution time
- Monitor search_vector size distribution
- Track search query performance
2. **Index Verification:**
- Ensure `custom_field_values_member_id_idx` exists and is used
- Verify GIN index on `search_vector` is maintained
3. **Bulk Operations:**
- For bulk imports, consider temporarily disabling the custom_field_values trigger
- Re-enable and update search_vectors in batch after import
### Medium-term Optimizations
1. **✅ Optimize Trigger Function (FULLY IMPLEMENTED):**
- ✅ Only fetch required member fields instead of full record (reduces overhead)
- ✅ Skip re-aggregation on UPDATE if value hasn't actually changed (early return optimization)
2. **Limit Search Vector Size:**
- Truncate very long custom field values (e.g., first 1000 chars)
- Add warning if aggregated text exceeds threshold
3. **Optimize LIKE Queries:**
- Consider adding a generated column for searchable text
- Or use a materialized view for custom field search
### Long-term Considerations
1. **Alternative Approaches:**
- Separate search index table for custom fields
- Use Elasticsearch or similar for advanced search
- Materialized view for search optimization
2. **Scaling Strategy:**
- If performance becomes an issue with 100+ custom fields per member:
- Consider limiting which custom fields are searchable
- Use a separate search service
- Implement search result caching
## Performance Benchmarks (Estimated)
Based on typical PostgreSQL performance:
| Scenario | Members | Custom Fields/Member | Expected Impact |
|----------|---------|---------------------|-----------------|
| Small | < 1,000 | < 10 | Negligible (< 5ms per operation) |
| Medium | 1,000-10,000 | 10-30 | Minor (5-20ms per operation) |
| Large | 10,000-100,000 | 30-50 | Noticeable (20-50ms per operation) |
| Very Large | > 100,000 | 50+ | Significant (50-200ms+ per operation) |
## Monitoring Queries
```sql
-- Check search_vector size distribution
SELECT
pg_size_pretty(octet_length(search_vector::text)) as size,
COUNT(*) as member_count
FROM members
WHERE search_vector IS NOT NULL
GROUP BY octet_length(search_vector::text)
ORDER BY octet_length(search_vector::text) DESC
LIMIT 20;
-- Check average custom fields per member
SELECT
AVG(custom_field_count) as avg_custom_fields,
MAX(custom_field_count) as max_custom_fields
FROM (
SELECT member_id, COUNT(*) as custom_field_count
FROM custom_field_values
GROUP BY member_id
) subq;
-- Check trigger execution time (requires pg_stat_statements)
SELECT
mean_exec_time,
calls,
query
FROM pg_stat_statements
WHERE query LIKE '%members_search_vector_trigger%'
ORDER BY mean_exec_time DESC;
```
## Code Quality Improvements (Post-Review)
### Refactored Search Implementation
The search query has been refactored for better maintainability and clarity:
**Before:** Single large OR-chain with mixed search types (hard to maintain)
**After:** Modular functions grouped by search type:
- `build_fts_filter/1` - Full-text search (highest priority, fastest)
- `build_substring_filter/2` - Substring matching on structured fields
- `build_custom_field_filter/1` - Custom field value search (JSONB LIKE)
- `build_fuzzy_filter/2` - Trigram/fuzzy matching for names and streets
**Benefits:**
- ✅ Clear separation of concerns
- ✅ Easier to maintain and test
- ✅ Better documentation of search priority
- ✅ Easier to optimize individual search types
**Search Priority Order:**
1. **FTS (Full-Text Search)** - Fastest, uses GIN index on search_vector
2. **Substring** - For structured fields (postal_code, phone_number, etc.)
3. **Custom Fields** - JSONB LIKE queries (fallback for substring matching)
4. **Fuzzy Matching** - Trigram similarity for names and streets
## Conclusion
The current implementation is **well-optimized for typical use cases** (< 30 custom fields per member, < 10,000 members). For larger scales, monitoring and potential optimizations may be needed.
**Key Strengths:**
- Indexed lookups (member_id index)
- Efficient GIN index for search
- Trigger-based automatic updates
- Modular, maintainable search code structure
**Key Weaknesses:**
- LIKE queries on JSONB (not indexed)
- Re-aggregation on every custom field change (necessary for consistency)
- Potential size issues with many/large custom fields
- Substring searches (contains/ILIKE) not index-optimized
**Recent Optimizations:**
- ✅ Trigger function optimized to fetch only required fields (reduces overhead by ~30-50%)
- ✅ Early return on UPDATE when value hasn't changed (skips expensive re-aggregation, <1ms vs 3-10ms)
- ✅ Improved performance for custom field value updates (3-10ms vs 5-15ms when value changes)

View file

@ -1,533 +0,0 @@
# DaisyUI Drawer Pattern - Standard Implementation
This document describes the standard DaisyUI drawer pattern for implementing responsive sidebars. It covers mobile overlay drawers, desktop persistent sidebars, and their combination.
## Core Concept
DaisyUI's drawer component uses a **checkbox-based toggle mechanism** combined with CSS to create accessible, responsive sidebars without custom JavaScript.
### Key Components
1. **`drawer`** - Container element
2. **`drawer-toggle`** - Hidden checkbox that controls open/close state
3. **`drawer-content`** - Main content area
4. **`drawer-side`** - Sidebar content (menu, navigation)
5. **`drawer-overlay`** - Optional overlay for mobile (closes drawer on click)
## HTML Structure
```html
<div class="drawer">
<!-- Hidden checkbox controls the drawer state -->
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
<!-- Main content area -->
<div class="drawer-content">
<!-- Page content goes here -->
<label for="my-drawer" class="btn btn-primary">Open drawer</label>
</div>
<!-- Sidebar content -->
<div class="drawer-side">
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu p-4 w-80 min-h-full bg-base-200 text-base-content">
<!-- Sidebar content goes here -->
<li><a>Sidebar Item 1</a></li>
<li><a>Sidebar Item 2</a></li>
</ul>
</div>
</div>
```
## How drawer-toggle Works
### Mechanism
The `drawer-toggle` is a **hidden checkbox** that serves as the state controller:
```html
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
```
### Toggle Behavior
1. **Label Connection**: Any `<label for="my-drawer">` element can toggle the drawer
2. **Checkbox State**:
- `checked` → drawer is open
- `unchecked` → drawer is closed
3. **CSS Targeting**: DaisyUI uses CSS sibling selectors to show/hide the drawer based on checkbox state
4. **Accessibility**: Native checkbox provides keyboard accessibility (Space/Enter to toggle)
### Toggle Examples
```html
<!-- Button to open drawer -->
<label for="my-drawer" class="btn btn-primary drawer-button">
Open Menu
</label>
<!-- Close button inside drawer -->
<label for="my-drawer" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></label>
<!-- Overlay to close (click outside) -->
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
```
## Mobile Drawer (Overlay)
### Characteristics
- Drawer slides in from the side (usually left)
- Overlays the main content
- Dark overlay (drawer-overlay) behind drawer
- Clicking overlay closes the drawer
- Typically used on mobile/tablet screens
### Implementation
```html
<div class="drawer">
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<!-- Toggle button in header -->
<div class="navbar bg-base-100">
<div class="flex-none">
<label for="mobile-drawer" class="btn btn-square btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</label>
</div>
<div class="flex-1">
<a class="btn btn-ghost text-xl">My App</a>
</div>
</div>
<!-- Main content -->
<div class="p-4">
<h1>Main Content</h1>
</div>
</div>
<div class="drawer-side">
<!-- Overlay - clicking it closes the drawer -->
<label for="mobile-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<!-- Sidebar menu -->
<ul class="menu p-4 w-80 min-h-full bg-base-200">
<li><a>Home</a></li>
<li><a>About</a></li>
<li><a>Contact</a></li>
</ul>
</div>
</div>
```
### Styling Notes
- **Width**: Default `w-80` (320px), adjust with Tailwind width utilities
- **Background**: Use DaisyUI color classes like `bg-base-200`
- **Height**: Always use `min-h-full` to ensure full height
- **Padding**: Add `p-4` or similar for inner spacing
## Desktop Sidebar (Persistent)
### Characteristics
- Always visible (no overlay)
- Does not overlay main content
- Main content adjusts to sidebar width
- No toggle button needed
- Used on desktop screens
### Implementation with drawer-open
```html
<div class="drawer lg:drawer-open">
<input id="desktop-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<!-- Main content -->
<div class="p-4">
<h1>Main Content</h1>
<p>The sidebar is always visible on desktop (lg and above)</p>
</div>
</div>
<div class="drawer-side">
<!-- No overlay needed for persistent sidebar -->
<label for="desktop-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<!-- Sidebar menu -->
<ul class="menu p-4 w-80 min-h-full bg-base-200">
<li><a>Dashboard</a></li>
<li><a>Settings</a></li>
<li><a>Profile</a></li>
</ul>
</div>
</div>
```
### How drawer-open Works
The `drawer-open` class forces the drawer to be **permanently open**:
```html
<div class="drawer drawer-open">
```
- Drawer is always visible
- Cannot be toggled closed
- `drawer-toggle` checkbox is ignored
- `drawer-overlay` is not shown
- Main content automatically shifts to accommodate sidebar width
### Responsive Usage
Use Tailwind breakpoint modifiers for responsive behavior:
```html
<!-- Open on large screens and above -->
<div class="drawer lg:drawer-open">
<!-- Open on medium screens and above -->
<div class="drawer md:drawer-open">
<!-- Open on extra-large screens and above -->
<div class="drawer xl:drawer-open">
```
## Combined Mobile + Desktop Pattern (Recommended)
This is the **most common pattern** for responsive applications: mobile overlay + desktop persistent.
### Complete Implementation
```html
<div class="drawer lg:drawer-open">
<!-- Checkbox for mobile toggle -->
<input id="app-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col">
<!-- Navbar with mobile menu button -->
<div class="navbar bg-base-100 lg:hidden">
<div class="flex-none">
<label for="app-drawer" class="btn btn-square btn-ghost">
<!-- Hamburger icon -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</label>
</div>
<div class="flex-1">
<a class="btn btn-ghost text-xl">My App</a>
</div>
</div>
<!-- Main content -->
<div class="flex-1 p-6">
<h1 class="text-3xl font-bold mb-4">Welcome</h1>
<p>This is the main content area.</p>
<p>On mobile (< lg): sidebar is hidden, hamburger menu visible</p>
<p>On desktop (≥ lg): sidebar is persistent, hamburger menu hidden</p>
</div>
</div>
<div class="drawer-side">
<!-- Overlay only shows on mobile -->
<label for="app-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<!-- Sidebar navigation -->
<aside class="bg-base-200 w-80 min-h-full">
<!-- Logo/Header area -->
<div class="p-4 font-bold text-xl border-b border-base-300">
My App Logo
</div>
<!-- Navigation menu -->
<ul class="menu p-4">
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Dashboard
</a></li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Documents
</a></li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a></li>
</ul>
</aside>
</div>
</div>
```
### Behavior Breakdown
#### On Mobile (< 1024px / < lg)
1. Sidebar is hidden by default
2. Hamburger button visible in navbar
3. Clicking hamburger opens sidebar as overlay
4. Clicking overlay or close button closes sidebar
5. Sidebar slides in from left with animation
#### On Desktop (≥ 1024px / ≥ lg)
1. `lg:drawer-open` keeps sidebar permanently visible
2. Hamburger button hidden via `lg:hidden`
3. Sidebar takes up fixed width (320px)
4. Main content area adjusts automatically
5. No overlay, no toggle needed
## Tailwind Breakpoints Reference
```css
/* Default (mobile-first) */
/* < 640px */
sm: /* ≥ 640px */
md: /* ≥ 768px */
lg: /* ≥ 1024px */ ← Common desktop breakpoint
xl: /* ≥ 1280px */
2xl: /* ≥ 1536px */
```
## Key Classes Summary
| Class | Purpose |
|-------|---------|
| `drawer` | Main container |
| `drawer-toggle` | Hidden checkbox for state control |
| `drawer-content` | Main content area |
| `drawer-side` | Sidebar container |
| `drawer-overlay` | Clickable overlay (closes drawer) |
| `drawer-open` | Forces drawer to stay open |
| `drawer-end` | Positions drawer on the right side |
| `lg:drawer-open` | Opens drawer on large screens only |
## Positioning Variants
### Left Side Drawer (Default)
```html
<div class="drawer">
<!-- Drawer appears on the left -->
</div>
```
### Right Side Drawer
```html
<div class="drawer drawer-end">
<!-- Drawer appears on the right -->
</div>
```
## Best Practices
### 1. Accessibility
- Always include `aria-label` on overlay: `<label for="drawer" aria-label="close sidebar" class="drawer-overlay"></label>`
- Use semantic HTML (`<nav>`, `<aside>`)
- Ensure keyboard navigation works (native checkbox provides this)
### 2. Responsive Design
- Use `lg:drawer-open` for desktop persistence
- Hide mobile toggle button on desktop: `lg:hidden`
- Adjust sidebar width for mobile if needed: `w-64 md:w-80`
### 3. Performance
- DaisyUI drawer is pure CSS (no JavaScript needed)
- Animations are handled by CSS transitions
- No performance overhead
### 4. Styling
- Use DaisyUI theme colors: `bg-base-200`, `text-base-content`
- Maintain consistent spacing: `p-4`, `gap-2`
- Use DaisyUI menu component for navigation: `<ul class="menu">`
### 5. Content Structure
```html
<div class="drawer-content flex flex-col">
<!-- Navbar (if needed) -->
<div class="navbar">...</div>
<!-- Main content with flex-1 to fill space -->
<div class="flex-1 p-6">
<!-- Your content -->
</div>
<!-- Footer (if needed) -->
<footer>...</footer>
</div>
```
## Common Patterns
### Pattern 1: Drawer with Close Button
```html
<div class="drawer-side">
<label for="drawer" class="drawer-overlay"></label>
<aside class="bg-base-200 w-80 min-h-full relative">
<!-- Close button (mobile only) -->
<label for="drawer" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 lg:hidden"></label>
<!-- Sidebar content -->
<ul class="menu p-4 pt-12">
<li><a>Item 1</a></li>
</ul>
</aside>
</div>
```
### Pattern 2: Drawer with User Profile
```html
<aside class="bg-base-200 w-80 min-h-full flex flex-col">
<!-- Logo -->
<div class="p-4 font-bold text-xl">My App</div>
<!-- Navigation (flex-1 to push footer down) -->
<ul class="menu flex-1 p-4">
<li><a>Dashboard</a></li>
<li><a>Settings</a></li>
</ul>
<!-- User profile footer -->
<div class="p-4 border-t border-base-300">
<div class="flex items-center gap-2">
<div class="avatar">
<div class="w-10 rounded-full">
<img src="/avatar.jpg" alt="User" />
</div>
</div>
<div>
<div class="font-semibold">John Doe</div>
<div class="text-sm opacity-70">john@example.com</div>
</div>
</div>
</div>
</aside>
```
### Pattern 3: Nested Menu with Submenu
```html
<ul class="menu p-4 w-80 min-h-full bg-base-200">
<li><a>Dashboard</a></li>
<!-- Submenu -->
<li>
<details>
<summary>Products</summary>
<ul>
<li><a>Electronics</a></li>
<li><a>Clothing</a></li>
<li><a>Books</a></li>
</ul>
</details>
</li>
<li><a>Settings</a></li>
</ul>
```
## Troubleshooting
### Issue: Drawer doesn't open on mobile
**Solution**: Check that:
1. Checkbox `id` matches label `for` attribute
2. Checkbox has class `drawer-toggle`
3. You're not using `drawer-open` on mobile breakpoints
### Issue: Drawer overlaps content on desktop
**Solution**:
- Remove `drawer-open` or use responsive variant `lg:drawer-open`
- Ensure you want overlay behavior, not persistent sidebar
### Issue: Overlay not clickable
**Solution**:
- Ensure overlay label has correct `for` attribute
- Check that overlay is not behind other elements (z-index)
### Issue: Content jumps when drawer opens
**Solution**:
- Add `flex flex-col` to `drawer-content`
- Ensure drawer-side width is fixed (e.g., `w-80`)
## Migration from Custom Solutions
If migrating from a custom sidebar implementation:
### Replace custom JavaScript
❌ Before:
```javascript
function toggleDrawer() {
document.getElementById('sidebar').classList.toggle('open');
}
```
✅ After:
```html
<input id="drawer" type="checkbox" class="drawer-toggle" />
<label for="drawer">Toggle</label>
```
### Replace custom CSS
❌ Before:
```css
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s;
}
.sidebar.open {
transform: translateX(0);
}
```
✅ After:
```html
<div class="drawer">
<!-- DaisyUI handles all transitions -->
</div>
```
### Replace media query logic
❌ Before:
```css
@media (min-width: 1024px) {
.sidebar { display: block; }
.toggle-button { display: none; }
}
```
✅ After:
```html
<div class="drawer lg:drawer-open">
<label for="drawer" class="lg:hidden">Toggle</label>
</div>
```
## Summary
The DaisyUI drawer pattern provides:
**Zero JavaScript** - Pure CSS solution
**Accessible** - Built-in keyboard support via checkbox
**Responsive** - Easy mobile/desktop variants with Tailwind
**Themeable** - Uses DaisyUI theme colors
**Flexible** - Supports left/right positioning
**Standard** - No custom CSS needed
**Recommended approach**: Use `lg:drawer-open` for desktop with hidden mobile toggle for best responsive experience.

View file

@ -1,548 +0,0 @@
# 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** | 11 |
| **Domains** | 4 (Accounts, Membership, MembershipFees, Authorization) |
| **Relationships** | 9 |
| **Indexes** | 25+ |
| **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
#### `custom_field_values`
- **Purpose:** Dynamic custom member attributes
- **Rows (Estimated):** Variable (N per member)
- **Key Features:**
- Union type value storage (JSONB)
- Multiple data types supported
- One custom field value per custom field per member
#### `custom_fields`
- **Purpose:** Schema definitions for custom_field_values
- **Rows (Estimated):** Low (admin-defined)
- **Key Features:**
- Type definitions
- Immutable and required flags
- Centralized custom field management
#### `settings`
- **Purpose:** Global application settings (singleton resource)
- **Rows (Estimated):** 1 (singleton pattern)
- **Key Features:**
- Club name configuration
- Member field visibility settings
- Membership fee default settings
- Environment variable support for club name
#### `groups`
- **Purpose:** Group definitions for organizing members
- **Rows (Estimated):** Low (typically 5-20 groups per club)
- **Key Features:**
- Unique group names (case-insensitive)
- URL-friendly slugs (auto-generated, immutable)
- Optional descriptions
- Many-to-many relationship with members
#### `member_groups`
- **Purpose:** Join table for many-to-many relationship between members and groups
- **Rows (Estimated):** Medium to High (multiple groups per member)
- **Key Features:**
- Unique constraint on (member_id, group_id)
- CASCADE delete on both sides
- Efficient indexes for queries
### Authorization Domain
#### `roles`
- **Purpose:** Role-based access control (RBAC)
- **Rows (Estimated):** Low (typically 3-10 roles)
- **Key Features:**
- Links users to permission sets
- System role protection
- Four hardcoded permission sets: own_data, read_only, normal_user, admin
## Key Relationships
```
User (0..1) ←→ (0..1) Member
↓ ↓
Tokens (N) CustomFieldValues (N)
↓ ↓
Role (N:1) CustomField (1)
Member (1) → (N) MembershipFeeCycles
MembershipFeeType (1)
Member (N) ←→ (N) Group
↓ ↓
MemberGroups (N) MemberGroups (N)
Settings (1) → MembershipFeeType (0..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. **User → Role (N:1)**
- Many users can be assigned to one role
- `ON DELETE RESTRICT` - cannot delete role if users are assigned
- Role links user to permission set for authorization
3. **Member → CustomFieldValues (1:N)**
- One member, many custom_field_values
- `ON DELETE CASCADE` - custom_field_values deleted with member
- Composite unique constraint (member_id, custom_field_id)
4. **CustomFieldValue → CustomField (N:1)**
- Custom field values reference type definition
- `ON DELETE RESTRICT` - cannot delete type if in use
- Type defines data structure
5. **Member → MembershipFeeType (N:1, optional)**
- Many members can be assigned to one fee type
- `ON DELETE RESTRICT` - cannot delete fee type if members are assigned
- Optional relationship (member can have no fee type)
6. **Member → MembershipFeeCycles (1:N)**
- One member, many billing cycles
- `ON DELETE CASCADE` - cycles deleted when member deleted
- Unique constraint (member_id, cycle_start)
7. **MembershipFeeCycle → MembershipFeeType (N:1)**
- Many cycles reference one fee type
- `ON DELETE RESTRICT` - cannot delete fee type if cycles exist
8. **Settings → MembershipFeeType (N:1, optional)**
- Settings can reference a default fee type
- `ON DELETE SET NULL` - if fee type is deleted, setting is cleared
9. **Member ↔ Group (N:N via MemberGroup)**
- Many-to-many relationship through `member_groups` join table
- `ON DELETE CASCADE` on both sides - removing member/group removes associations
- Unique constraint on (member_id, group_id) prevents duplicates
- Groups searchable via member search vector
## 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)
- Exit date must be after join date
- Phone: `+?[0-9\- ]{6,20}`
- Postal code: optional (no format validation)
- Country: optional
### CustomFieldValue System
- Maximum one custom field value per custom field 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 (tsvector)
- `first_name` (GIN trgm) - Fuzzy search on first name
- `last_name` (GIN trgm) - Fuzzy search on last name
- `email` (GIN trgm) - Fuzzy search on email
- `city` (GIN trgm) - Fuzzy search on city
- `street` (GIN trgm) - Fuzzy search on street
- `notes` (GIN trgm) - Fuzzy search on notes
- `email` (B-tree) - Exact email lookups
- `last_name` (B-tree) - Name sorting
- `join_date` (B-tree) - Date filtering
**custom_field_values:**
- `member_id` - Member custom field value lookups
- `custom_field_id` - Type-based queries
- Composite `(member_id, custom_field_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** on `members` (INSERT/UPDATE): runs function `members_search_vector_trigger()`
- **Trigger** on `member_groups` (INSERT/UPDATE/DELETE): `update_member_search_vector_on_member_groups_change` runs function `update_member_search_vector_from_member_groups()`
- **Index Type:** GIN (Generalized Inverted Index)
### Weighted Fields
- **Weight A (highest):** first_name, last_name
- **Weight B:** email, notes, group names (from member_groups → groups)
- **Weight C:** city, street, house_number, postal_code, country, custom_field_values
- **Weight D (lowest):** join_date, exit_date
### Group Names in Search
Group names are included in the member search vector so that searching for a group name (e.g. "Vorstand") finds all members in that group:
- Group names are aggregated from `member_groups` joined with `groups` and receive weight 'B'
- The trigger `update_member_search_vector_on_member_groups_change` runs on INSERT/UPDATE/DELETE on `member_groups` and refreshes the affected member's `search_vector`
- See migration `20260217120000_add_group_names_to_member_search_vector.exs` (Issue #375)
### Custom Field Values in Search
Custom field values are automatically included in the search vector:
- All custom field values (string, integer, boolean, date, email) are aggregated and added to the search vector
- Values are converted to text format for indexing
- Custom field values receive weight 'C' (same as city, etc.)
- The search vector is automatically updated when custom field values are created, updated, or deleted via database triggers
### Usage Example
```sql
SELECT * FROM members
WHERE search_vector @@ to_tsquery('simple', 'john & doe');
```
## Fuzzy Search (Trigram-based)
### Implementation
- **Extension:** `pg_trgm` (PostgreSQL Trigram)
- **Index Type:** GIN with `gin_trgm_ops` operator class
- **Similarity Threshold:** 0.2 (default, configurable)
- **Added:** November 2025 (PR #187, closes #162)
### How It Works
Fuzzy search combines multiple search strategies:
1. **Full-text search** - Primary filter using tsvector
2. **Trigram similarity** - `similarity(field, query) > threshold`
3. **Word similarity** - `word_similarity(query, field) > threshold`
4. **Substring matching** - `LIKE` and `ILIKE` for exact substrings
5. **Modulo operator** - `query % field` for quick similarity check
### Indexed Fields for Fuzzy Search
- `first_name` - GIN trigram index
- `last_name` - GIN trigram index
- `email` - GIN trigram index
- `city` - GIN trigram index
- `street` - GIN trigram index
- `notes` - GIN trigram index
### Usage Example (Ash Action)
```elixir
# In LiveView or context
Member.fuzzy_search(Member, query: "john", similarity_threshold: 0.2)
# Or using Ash Query directly
Member
|> Ash.Query.for_read(:search, %{query: "john", similarity_threshold: 0.2})
|> Mv.Membership.read!()
```
### Usage Example (SQL)
```sql
-- Trigram similarity search
SELECT * FROM members
WHERE similarity(first_name, 'john') > 0.2
OR similarity(last_name, 'doe') > 0.2
ORDER BY similarity(first_name, 'john') DESC;
-- Word similarity (better for partial matches)
SELECT * FROM members
WHERE word_similarity('john', first_name) > 0.2;
-- Quick similarity check with % operator
SELECT * FROM members
WHERE 'john' % first_name;
```
### Performance Considerations
- **GIN indexes** speed up trigram operations significantly
- **Similarity threshold** of 0.2 balances precision and recall
- **Combined approach** (FTS + trigram) provides best results
- Lower threshold = more results but less specific
## 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)
3. **pg_trgm**
- Purpose: Trigram-based fuzzy text search and similarity matching
- Used for: Fuzzy member search with similarity scoring
- Operators: `%` (similarity), `word_similarity()`, `similarity()`
- Added in: Migration `20251001141005_add_trigram_to_members.exs`
### Installation
```sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "citext";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
```
## 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
├── 20251001141005_add_trigram_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 |
| `custom_field_values.member_id → members.id` | CASCADE | Delete custom_field_values with member |
| `custom_field_values.custom_field_id → custom_fields.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, membership_fee_type_id)
- User authentication (uses unique index on email/oidc_id)
- CustomFieldValue lookups by member (uses index on member_id)
**Medium Frequency:**
- Member CRUD operations
- CustomFieldValue updates
- Token validation
**Low Frequency:**
- CustomField 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. **GIN indexes:** Full-text search and fuzzy search on multiple fields
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, 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
- `custom_fields` - Schema definitions
### Important Tables (Priority 2)
- `custom_field_values` - 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:** 2026-01-27
**Schema Version:** 1.5
**Database:** PostgreSQL 17.6 (dev) / 16 (prod)

View file

@ -1,635 +0,0 @@
// 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.4
// Last Updated: 2026-01-13
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 with secure account linking)
- Member management with flexible custom fields
- Bidirectional email synchronization between users and members
- Full-text search capabilities (tsvector)
- Fuzzy search with trigram matching (pg_trgm)
- GDPR-compliant data management
## Domains:
- **Accounts**: User authentication and session management
- **Membership**: Club member data and custom fields
- **MembershipFees**: Membership fee types and billing cycles
- **Authorization**: Role-based access control (RBAC)
## Required PostgreSQL Extensions:
- uuid-ossp (UUID generation)
- citext (case-insensitive text)
- pg_trgm (trigram-based fuzzy search)
'''
}
// ============================================
// 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 [null, note: 'Member first name (min length: 1 if present)']
last_name text [null, note: 'Member last name (min length: 1 if present)']
email text [not null, unique, note: 'Member email address (5-254 chars, validated)']
join_date date [null, note: 'Date when member joined club']
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']
country text [null, note: 'Country of residence']
search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type']
membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated']
indexes {
email [unique, name: 'members_unique_email_index']
search_vector [type: gin, name: 'members_search_vector_idx', note: 'GIN index for full-text search (tsvector)']
first_name [type: gin, name: 'members_first_name_trgm_idx', note: 'GIN trigram index for fuzzy search']
last_name [type: gin, name: 'members_last_name_trgm_idx', note: 'GIN trigram index for fuzzy search']
email [type: gin, name: 'members_email_trgm_idx', note: 'GIN trigram index for fuzzy search']
city [type: gin, name: 'members_city_trgm_idx', note: 'GIN trigram index for fuzzy search']
street [type: gin, name: 'members_street_trgm_idx', note: 'GIN trigram index for fuzzy search']
notes [type: gin, name: 'members_notes_trgm_idx', note: 'GIN trigram index for fuzzy search']
email [name: 'members_email_idx', note: 'B-tree index for exact lookups']
last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting']
join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters']
membership_fee_type_id [name: 'members_membership_fee_type_id_index', note: 'B-tree index for fee type lookups']
}
Note: '''
**Club Member Master Data**
Core entity for membership management containing:
- Personal information (name, email)
- Contact details (address)
- Membership status (join/exit dates, membership fee cycles)
- 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
**Search Capabilities:**
1. Full-Text Search (tsvector):
- `search_vector` is auto-updated via trigger
- Weighted fields: first_name (A), last_name (A), email (B), notes (B)
- GIN index for fast text search
2. Fuzzy Search (pg_trgm):
- Trigram-based similarity matching
- 6 GIN trigram indexes on searchable fields
- Configurable similarity threshold (default 0.2)
- Supports typos and partial matches
**Relationships:**
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
- 1:N with custom_field_values (custom dynamic fields)
- Optional N:1 with membership_fee_types - assigned fee type
- 1:N with membership_fee_cycles - billing history
**Validation Rules:**
- first_name, last_name: optional, but if present min 1 character
- email: 5-254 characters, valid email format (required)
- exit_date: must be after join_date (if both present)
- postal_code: optional (no format validation)
- country: optional
'''
}
Table custom_field_values {
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']
custom_field_id uuid [not null, note: 'Link to custom field definition']
indexes {
(member_id, custom_field_id) [unique, name: 'custom_field_values_unique_custom_field_per_member_index', note: 'One custom field value per custom field per member']
member_id [name: 'custom_field_values_member_id_idx']
custom_field_id [name: 'custom_field_values_custom_field_id_idx']
}
Note: '''
**Dynamic Custom Member Field Values**
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 custom field value per custom field
- Custom field values are deleted when member is deleted (CASCADE)
- Custom field cannot be deleted if custom field values exist (RESTRICT)
**Use Cases:**
- Custom membership numbers
- Additional contact methods
- Club-specific attributes
- Flexible data model without schema migrations
'''
}
Table custom_fields {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
name text [not null, unique, note: 'CustomFieldValue name/identifier (e.g., "membership_number")']
slug text [not null, unique, note: 'URL-friendly, immutable identifier (e.g., "membership-number"). Auto-generated from name.']
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 custom field']
indexes {
name [unique, name: 'custom_fields_unique_name_index']
slug [unique, name: 'custom_fields_unique_slug_index']
}
Note: '''
**CustomFieldValue Type Definitions**
Defines the schema and behavior for custom member custom_field_values.
**Attributes:**
- `name`: Unique identifier for the custom field
- `slug`: URL-friendly, human-readable identifier (auto-generated, immutable)
- `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 custom field
**Slug Generation:**
- Automatically generated from `name` on creation
- Immutable after creation (does not change when name is updated)
- Lowercase, spaces replaced with hyphens, special characters removed
- UTF-8 support (ä → a, ß → ss, etc.)
- Used for human-readable identifiers (CSV export/import, API, etc.)
- Examples: "Mobile Phone" → "mobile-phone", "Café Müller" → "cafe-muller"
**Constraints:**
- `value_type` must be one of: string, integer, boolean, date, email
- `name` must be unique across all custom fields
- `slug` must be unique across all custom fields
- `slug` cannot be empty (validated on creation)
- Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT)
**Examples:**
- Membership Number (string, immutable, required) → slug: "membership-number"
- Emergency Contact (string, mutable, optional) → slug: "emergency-contact"
- Certified Trainer (boolean, mutable, optional) → slug: "certified-trainer"
- Certification Date (date, immutable, optional) → slug: "certification-date"
'''
}
// ============================================
// MEMBERSHIP_FEES DOMAIN
// ============================================
Table membership_fee_types {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
name text [not null, unique, note: 'Unique name for the fee type (e.g., "Standard", "Reduced")']
amount numeric(10,2) [not null, note: 'Fee amount in default currency (CHECK: >= 0)']
interval text [not null, note: 'Billing interval (CHECK: IN monthly, quarterly, half_yearly, yearly) - immutable']
description text [null, note: 'Optional description for the fee type']
indexes {
name [unique, name: 'membership_fee_types_unique_name_index']
}
Note: '''
**Membership Fee Type Definitions**
Defines the different types of membership fees with fixed billing intervals.
**Attributes:**
- `name`: Unique identifier for the fee type
- `amount`: Default fee amount (stored per cycle for audit trail)
- `interval`: Billing cycle - immutable after creation
- `description`: Optional documentation
**Interval Values:**
- `monthly`: 1st to last day of month
- `quarterly`: 1st of Jan/Apr/Jul/Oct to last day of quarter
- `half_yearly`: 1st of Jan/Jul to last day of half
- `yearly`: Jan 1 to Dec 31
**Immutability:**
The `interval` field cannot be changed after creation to prevent
complex migration scenarios. Create a new fee type to change intervals.
**Relationships:**
- 1:N with members - members assigned to this fee type
- 1:N with membership_fee_cycles - all cycles using this fee type
**Deletion Behavior:**
- ON DELETE RESTRICT: Cannot delete if members or cycles reference it
'''
}
Table membership_fee_cycles {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
cycle_start date [not null, note: 'Start date of the billing cycle']
amount numeric(10,2) [not null, note: 'Fee amount for this cycle (CHECK: >= 0)']
status text [not null, default: 'unpaid', note: 'Payment status (CHECK: IN unpaid, paid, suspended)']
notes text [null, note: 'Optional notes for this cycle']
member_id uuid [not null, note: 'FK to members - the member this cycle belongs to']
membership_fee_type_id uuid [not null, note: 'FK to membership_fee_types - fee type for this cycle']
indexes {
member_id [name: 'membership_fee_cycles_member_id_index']
membership_fee_type_id [name: 'membership_fee_cycles_membership_fee_type_id_index']
status [name: 'membership_fee_cycles_status_index']
cycle_start [name: 'membership_fee_cycles_cycle_start_index']
(member_id, cycle_start) [unique, name: 'membership_fee_cycles_unique_cycle_per_member_index', note: 'One cycle per member per cycle_start']
}
Note: '''
**Individual Membership Fee Cycles**
Represents a single billing cycle for a member with payment tracking.
**Design Decisions:**
- `cycle_end` is NOT stored - calculated from cycle_start + interval
- `amount` is stored per cycle to preserve historical values when fee type amount changes
- Cycles are aligned to calendar boundaries
**Status Values:**
- `unpaid`: Payment pending (default)
- `paid`: Payment received
- `suspended`: Payment suspended (e.g., hardship case)
**Constraints:**
- Unique: One cycle per member per cycle_start date
- member_id: Required (belongs_to)
- membership_fee_type_id: Required (belongs_to)
**Relationships:**
- N:1 with members - the member this cycle belongs to
- N:1 with membership_fee_types - the fee type for this cycle
**Deletion Behavior:**
- ON DELETE CASCADE (member_id): Cycles deleted when member deleted
- ON DELETE RESTRICT (membership_fee_type_id): Cannot delete fee type if cycles exist
'''
}
// ============================================
// 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 custom_field_values
// - Each custom field value belongs to exactly one member
// - ON DELETE CASCADE: Properties deleted when member deleted
// - UNIQUE constraint: One custom field value per custom field per member
Ref: custom_field_values.member_id > members.id [delete: cascade]
// CustomFieldValue → CustomField (N:1)
// - Many custom_field_values can reference one custom field
// - CustomFieldValue type defines the schema/behavior
// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict]
// Member → MembershipFeeType (N:1)
// - Many members can be assigned to one fee type
// - Optional relationship (member can have no fee type)
// - ON DELETE RESTRICT: Cannot delete fee type if members are assigned
Ref: members.membership_fee_type_id > membership_fee_types.id [delete: restrict]
// MembershipFeeCycle → Member (N:1)
// - Many cycles belong to one member
// - ON DELETE CASCADE: Cycles deleted when member deleted
Ref: membership_fee_cycles.member_id > members.id [delete: cascade]
// MembershipFeeCycle → MembershipFeeType (N:1)
// - Many cycles reference one fee type
// - ON DELETE RESTRICT: Cannot delete fee type if cycles reference it
Ref: membership_fee_cycles.membership_fee_type_id > membership_fee_types.id [delete: restrict]
// ============================================
// ENUMS
// ============================================
// Valid data types for custom field values
// Determines how CustomFieldValue.value is interpreted
Enum custom_field_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']
}
// Billing interval for membership fee types
Enum membership_fee_interval {
monthly [note: '1st to last day of month']
quarterly [note: '1st of Jan/Apr/Jul/Oct to last day of quarter']
half_yearly [note: '1st of Jan/Jul to last day of half']
yearly [note: 'Jan 1 to Dec 31']
}
// Payment status for membership fee cycles
Enum membership_fee_status {
unpaid [note: 'Payment pending (default)']
paid [note: 'Payment received']
suspended [note: 'Payment suspended']
}
// ============================================
// 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
custom_field_values
custom_fields
Note: '''
**Membership Domain**
Core business logic for club membership management.
Supports flexible, extensible member data model.
'''
}
TableGroup membership_fees_domain {
membership_fee_types
membership_fee_cycles
Note: '''
**Membership Fees Domain**
Handles membership fee management including:
- Fee type definitions with intervals
- Individual billing cycles per member
- Payment status tracking
'''
}
// ============================================
// AUTHORIZATION DOMAIN
// ============================================
Table roles {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
name text [not null, unique, note: 'Unique role name (e.g., "Vorstand", "Admin", "Mitglied")']
description text [null, note: 'Human-readable description of the role']
permission_set_name text [not null, note: 'Permission set name: "own_data", "read_only", "normal_user", or "admin"']
is_system_role boolean [not null, default: false, note: 'If true, role cannot be deleted (protects critical roles)']
inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
indexes {
name [unique, name: 'roles_unique_name_index']
}
Note: '''
**Role-Based Access Control (RBAC)**
Roles link users to permission sets. Each role references one of four hardcoded
permission sets defined in the application code.
**Permission Sets:**
- `own_data`: Users can only access their own linked member data
- `read_only`: Users can read all data but cannot modify
- `normal_user`: Users can read and modify most data (standard permissions)
- `admin`: Full access to all features and settings
**System Roles:**
- System roles (is_system_role = true) cannot be deleted
- Protects critical roles like "Mitglied" (member) from accidental deletion
- Only set via seed scripts or internal actions
**Relationships:**
- 1:N with users - users assigned to this role
- ON DELETE RESTRICT: Cannot delete role if users are assigned
**Constraints:**
- `name` must be unique
- `permission_set_name` must be a valid permission set (validated in application)
- System roles cannot be deleted (enforced via validation)
'''
}
// ============================================
// MEMBERSHIP DOMAIN (Additional Tables)
// ============================================
Table settings {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
club_name text [not null, note: 'The name of the association/club (min length: 1)']
member_field_visibility jsonb [null, note: 'Visibility configuration for member fields in overview (JSONB map)']
include_joining_cycle boolean [not null, default: true, note: 'Whether to include the joining cycle in membership fee generation']
default_membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - default fee type for new members']
inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
indexes {
default_membership_fee_type_id [name: 'settings_default_membership_fee_type_id_index', note: 'B-tree index for fee type lookups']
}
Note: '''
**Global Application Settings (Singleton Resource)**
Stores global configuration for the association/club. There should only ever
be one settings record in the database (singleton pattern).
**Attributes:**
- `club_name`: The name of the association/club (required, can be set via ASSOCIATION_NAME env var)
- `member_field_visibility`: JSONB map storing visibility configuration for member fields
(e.g., `{"street": false, "house_number": false}`). Fields not in the map default to `true`.
- `include_joining_cycle`: When true, members pay from their joining cycle. When false,
they pay from the next full cycle after joining.
- `default_membership_fee_type_id`: The membership fee type automatically assigned to
new members. Can be nil if no default is set.
**Singleton Pattern:**
- Only one settings record should exist
- Designed to be read and updated, not created/destroyed via normal CRUD
- Initial settings should be seeded
**Environment Variable Support:**
- `club_name` can be set via `ASSOCIATION_NAME` environment variable
- Database values always take precedence over environment variables
**Relationships:**
- Optional N:1 with membership_fee_types - default fee type for new members
- ON DELETE SET NULL: If default fee type is deleted, setting is cleared
'''
}
// ============================================
// RELATIONSHIPS (Additional)
// ============================================
// User → Role (N:1)
// - Many users can be assigned to one role
// - ON DELETE RESTRICT: Cannot delete role if users are assigned
Ref: users.role_id > roles.id [delete: restrict]
// Settings → MembershipFeeType (N:1, optional)
// - Settings can reference a default membership fee type
// - ON DELETE SET NULL: If fee type is deleted, setting is cleared
Ref: settings.default_membership_fee_type_id > membership_fee_types.id [delete: set null]
// ============================================
// TABLE GROUPS (Updated)
// ============================================
TableGroup authorization_domain {
roles
Note: '''
**Authorization Domain**
Handles role-based access control (RBAC) with hardcoded permission sets.
Roles link users to permission sets for authorization.
'''
}
TableGroup membership_domain {
members
custom_field_values
custom_fields
settings
Note: '''
**Membership Domain**
Core business logic for club membership management.
Supports flexible, extensible member data model.
Includes global application settings (singleton).
'''
}

File diff suppressed because it is too large Load diff

View file

@ -1,26 +0,0 @@
# Unified Email Layout ASCII Mockup
All transactional emails (join confirmation, user confirmation, password reset) use the same layout.
```
+------------------------------------------------------------------+
| [Logo or app name e.g. "Mila" or club name] |
+------------------------------------------------------------------+
| |
| [Subject / heading line e.g. "Confirm your email address"] |
| |
| [Body content paragraph and CTA link] |
| e.g. "Please click the link below to confirm your request." |
| "Confirm my request" (button or link) |
| |
| [Optional: short note e.g. "If you didn't request this, |
| you can ignore this email."] |
| |
+------------------------------------------------------------------+
| [Footer one line, e.g. "© 2025 Mila · Mitgliederverwaltung"] |
+------------------------------------------------------------------+
```
- **Header:** Single line (app/club name), subtle.
- **Main:** Heading + body text + primary CTA (link/button).
- **Footer:** Single line, small text (copyright / product name).

View file

@ -1,50 +0,0 @@
## Core Rules
1. **User.email is source of truth** - Always overrides member email when linking
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
4. **Sync is bidirectional**: User ↔ Member (but User always wins on link)
5. **Linked member email change** - When a member is linked, only administrators or the linked user may change that member's email (Member resource validation `EmailChangePermission`). Because User.email wins on link and changes sync Member → User, allowing anyone to change a linked member's email would overwrite that user's account email; this rule keeps sync under control.
---
## Decision Tree
```
Action: Create/Update/Link Entity with Email X
├─ Does Email X violate DB constraint (same table)?
│ └─ YES → ❌ FAIL (two users or two members with same email)
├─ Is Entity currently linked? (or being linked?)
│ │
│ ├─ NO (unlinked entity)
│ │ └─ ✅ SUCCESS (no custom validation)
│ │
│ └─ YES (linked or linking)
│ │
│ ├─ Action: Update Linked User Email
│ │ ├─ Email used by other member? → ❌ FAIL (validation)
│ │ └─ Email unique? → ✅ SUCCESS + sync to member
│ │
│ ├─ Action: Update Linked Member Email
│ │ ├─ Email used by other user? → ❌ FAIL (validation)
│ │ └─ Email unique? → ✅ SUCCESS + sync to user
│ │
│ ├─ Action: Link User to Member (both directions)
│ │ ├─ User email used by other member? → ❌ FAIL (validation)
│ │ └─ Otherwise → ✅ SUCCESS + override member email
```
## Sync Triggers
| Action | Sync Direction | When |
|--------|---------------|------|
| Update linked user email | User → Member | Email changed |
| Update linked member email | Member → User | Email changed |
| Link user to member | User → Member | Always (override) |
| Link member to user | User → Member | Always (override) |
| Unlink | None | Emails stay as-is |

View file

@ -1,62 +0,0 @@
# Email Validation Strategy
We use `EctoCommons.EmailValidator` with both `:html_input` and `:pow` checks, defined centrally in `Mv.Constants.email_validator_checks/0`.
## Checks Used
- `:html_input` - Pragmatic validation matching browser `<input type="email">` behavior
- `:pow` - Stricter validation following email spec, supports internationalization (Unicode)
## Rationale
Using both checks ensures:
- **Compatibility with common email providers** (`:html_input`) - Matches what users expect from web forms
- **Compliance with email standards** (`:pow`) - Follows RFC 5322 and related specifications
- **Support for international email addresses** (`:pow`) - Allows Unicode characters in email addresses
This dual approach provides a balance between user experience (accepting common email formats) and technical correctness (validating against email standards).
## Usage
The checks are used consistently across all email validation points:
- `Mv.Membership.Import.MemberCSV.validate_row/3` - CSV import validation
- `Mv.Membership.Member` validations - Member resource validation
- `Mv.Accounts.User` validations - User resource validation
All three locations use `Mv.Constants.email_validator_checks()` to ensure consistency.
## Implementation Details
### CSV Import Validation
The CSV import uses a schemaless changeset for email validation:
```elixir
changeset =
{%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: Map.get(member_attrs, :email)}, [:email])
|> Ecto.Changeset.update_change(:email, &String.trim/1)
|> Ecto.Changeset.validate_required([:email])
|> EctoCommons.EmailValidator.validate_email(:email, checks: Mv.Constants.email_validator_checks())
```
This approach:
- Trims whitespace before validation
- Validates email is required
- Validates email format using the centralized checks
- Provides consistent error messages via Gettext
### Resource Validations
Both `Member` and `User` resources use similar schemaless changesets within their Ash validations, ensuring consistent validation behavior across the application.
## Changing the Validation Strategy
To change the email validation checks, update the `@email_validator_checks` constant in `Mv.Constants`. This will automatically apply to all validation points.
**Note:** Changing the validation strategy may affect existing data. Consider:
- Whether existing emails will still be valid
- Migration strategy for invalid emails
- User communication if validation becomes stricter

View file

@ -1,853 +0,0 @@
# Feature Roadmap & Implementation Plan
**Project:** Mila - Membership Management System
**Last Updated:** 2026-03-03
**Status:** Active Development
---
## 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
- ✅ **OIDC account linking with password verification** (PR #192, closes #171)
- ✅ **Secure OIDC email collision handling** (PR #192)
- ✅ **Automatic linking for passwordless users** (PR #192)
- ✅ **Page Permission Router Plug** - Page-level authorization (PR #390, closes #388, 2026-01-27)
- Route-based permission checking
- Automatic redirects for unauthorized access
- Integration with permission sets
**Closed Issues:**
- ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13)
- ✅ [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen — fixed via `MvWeb.AuthOverridesDE` locale-specific module (2026-03-13)
- ✅ [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen — fixed locale selector bug with `Gettext.get_locale(MvWeb.Gettext)` (2026-03-13)
**Open Issues:** (none remaining for Authentication UI)
**Current State:**
- ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345)
- ✅ **Permission system** - Four hardcoded permission sets (`own_data`, `read_only`, `normal_user`, `admin`)
- ✅ **Database-backed roles** - Roles table with permission set references
- ✅ **Resource policies** - Member resource policies with scope filtering
- ✅ **Page-level authorization** - LiveView page access control
- ✅ **System role protection** - Critical roles cannot be deleted
**Planned: OIDC-only mode (TDD, tests first):**
- Admin Settings: When OIDC-only is enabled, disable "Allow direct registration" toggle and show hint (tests in `GlobalSettingsLiveTest`).
- Backend: Reject password sign-in and `register_with_password` when OIDC-only (tests in `AuthControllerTest`, `Accounts`).
- GET `/sign-in` redirect to OIDC when OIDC-only and OIDC configured (tests in `AuthControllerTest`). Implementation to follow after tests.
**Missing Features:**
- ❌ Password reset flow
- ❌ Email verification
- ❌ Two-factor authentication (future)
**Related Issues:**
- ✅ [#345](https://git.local-it.org/local-it/mitgliederverwaltung/issues/345) - Member Resource Policies (closed 2026-01-13)
- ✅ [#191](https://git.local-it.org/local-it/mitgliederverwaltung/issues/191) - Implement Roles in Ash (M) - Completed
- ✅ [#190](https://git.local-it.org/local-it/mitgliederverwaltung/issues/190) - Implement Permissions in Ash (M) - Completed
- ✅ [#151](https://git.local-it.org/local-it/mitgliederverwaltung/issues/151) - Define implementation plan for roles and permissions (M) - Completed
- ✅ [#388](https://git.local-it.org/local-it/mitgliederverwaltung/issues/388) - Page Permission Router Plug (closed 2026-01-27)
- ✅ [#386](https://git.local-it.org/local-it/mitgliederverwaltung/issues/386) - CustomField Resource Policies (closed 2026-01-27)
- ✅ [#369](https://git.local-it.org/local-it/mitgliederverwaltung/issues/369) - CustomFieldValue Resource Policies (closed 2026-01-27)
- ✅ [#363](https://git.local-it.org/local-it/mitgliederverwaltung/issues/363) - User Resource Policies (closed 2026-01-27)
---
#### 2. **Member Management** 👥
**Current State:**
- ✅ Member CRUD operations
- ✅ Member profile with personal data
- ✅ Address management
- ✅ Membership status tracking
- ✅ Full-text search (PostgreSQL tsvector)
- ✅ **Fuzzy search with trigram matching** (PR #187, closes #162)
- ✅ **Combined FTS + trigram search** (PR #187)
- ✅ **6 GIN trigram indexes** for fuzzy matching (PR #187)
- ✅ 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)
- ✅ **Groups** - Organize members into groups (PR #378, #382, #423, closes #371, #372, #374, #375, 2026-01/02)
- Many-to-many relationship with groups
- Groups management UI (`/groups`)
- Filter and sort by groups in member list
- Per-group filter in member list: one row per group with All / Yes / No (All/Alle); URL params `group_<uuid>=in|not_in`
- Groups displayed in member overview and detail views
- Member search includes group names (search by group name finds members in that group; search_vector + trigger on member_groups)
- ✅ **CSV Import** - Import members from CSV files (PR #359, #394, #395, closes #335, #336, #338, 2026-01-27)
- Member field import
- Custom field value import
- Real-time progress tracking
- Error reporting
**Closed Issues:**
- ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12)
- ✅ [#371](https://git.local-it.org/local-it/mitgliederverwaltung/issues/371) - Add groups resource (closed 2026-01-27)
- ✅ [#372](https://git.local-it.org/local-it/mitgliederverwaltung/issues/372) - Groups Admin UI (closed 2026-01-27)
- ✅ [#375](https://git.local-it.org/local-it/mitgliederverwaltung/issues/375) - Search Integration (group names in member search) (implemented 2026-02-17)
- ✅ [#335](https://git.local-it.org/local-it/mitgliederverwaltung/issues/335) - CSV Import UI (closed 2026-01-27)
- ✅ [#336](https://git.local-it.org/local-it/mitgliederverwaltung/issues/336) - Config for import limits (closed 2026-01-27)
- ✅ [#338](https://git.local-it.org/local-it/mitgliederverwaltung/issues/338) - Custom field CSV import (closed 2026-01-27)
**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)
- [#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:**
- ❌ Advanced filters (date ranges, multiple criteria)
- ❌ Pagination (currently all members loaded)
- ❌ Bulk operations (bulk delete, bulk update)
- ❌ Excel import for members
- ❌ Member profile photos/avatars
- ❌ Member history/audit log
- ❌ Duplicate detection
---
#### 3. **Custom Fields (CustomFieldValue System)** 🔧
**Current State:**
- ✅ CustomFieldValue types (string, integer, boolean, date, email)
- ✅ CustomFieldValue type management
- ✅ Dynamic custom field value assignment to members
- ✅ Union type storage (JSONB)
- ✅ Default field visibility configuration
**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]
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
**Missing Features:**
- ❌ 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
- ❌ **Flash: Auto-dismiss and consistency** (Design Guidelines §9)
- Auto-dismiss: info/success 46s, warning 68s, error 812s; dismiss button kept for accessibility.
- Implement via JS hook (e.g. `FlashAutoDismiss`) + `data-dismiss-ms` (or `data-kind`) on flash component; on timeout push `lv:clear-flash` and hide element.
- LiveView: add shared `handle_event("lv:clear-flash", %{"key" => key}, socket)` (e.g. in `MvWeb` live_view quote) calling `clear_flash(socket, key)`.
- All flashes (including “Email copied”) use the same variants (info, success, warning, error); no special tone. See `DESIGN_GUIDELINES.md` §9.
---
#### 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
- ✅ **Membership Fee Types Management** - Full CRUD implementation
- ✅ **Membership Fee Cycles** - Individual billing cycles per member
- ✅ **Membership Fee Settings** - Global settings (include_joining_cycle, default_fee_type)
- ✅ **Cycle Generation** - Automatic cycle generation for members
- ✅ **Payment Status Tracking** - Status per cycle (unpaid, paid, suspended)
- ✅ **Member Fee Assignment** - Members can be assigned to fee types
- ✅ **Cycle Regeneration** - Regenerate cycles when fee type changes
- ✅ **UI Components** - Membership fee status in member list and detail views
**Open Issues:**
- [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority)
- ✅ [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Membership Fee Mockup Pages (Preview) - Implemented
**Implemented Pages:**
- `/membership_fee_types` - Membership Fee Types Management (fully functional)
- `/membership_fee_settings` - Global Membership Fee Settings (fully functional)
- `/members/:id` - Member detail view with membership fee cycles
**Missing Features:**
- ❌ Payment records/transactions (external payment tracking)
- ❌ Payment reminders
- ❌ Invoice generation
- ✅ Memberfinance-contact sync with vereinfacht.digital API (see `docs/vereinfacht-api.md`); ❌ transaction import / full API integration
- ❌ SEPA direct debit support
- ❌ Payment reports
**Related Milestones:**
- Import transactions via vereinfacht API
---
#### 8. **Admin Panel & Configuration** ⚙️
**Current State:**
- ✅ AshAdmin integration (basic)
- ✅ **Global Settings Management** - `/settings` page (singleton resource)
- ✅ **Club/Organization profile** - Club name configuration
- ✅ **Member Field Visibility Settings** - Configure which fields show in overview
- ✅ **CustomFieldValue type management UI** - Full CRUD for custom fields
- ✅ **Role Management UI** - Full CRUD for roles (`/admin/roles`)
- ✅ **Membership Fee Settings** - Global fee settings management
**Open Issues:**
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
**Implemented Features:**
- ✅ **SMTP configuration** Configure mail server via ENV (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) and Admin Settings (UI), with ENV taking priority. Test email from Settings SMTP section. Production warning when SMTP is not configured. See [`docs/smtp-configuration-concept.md`](smtp-configuration-concept.md).
**Missing Features:**
- ❌ Email templates configuration
- ❌ 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)
- ✅ **SMTP configuration** via ENV and Admin Settings (see Admin Panel section)
- ⚠️ No member communication features
**Missing Features:**
- ❌ Email broadcast to members
- ❌ Email templates (customizable)
- ❌ Email to member groups/filters
---
#### 10. **Reporting & Analytics** 📊
**Current State:**
- ✅ **Statistics page (MVP)** `/statistics` with active/inactive member counts, joins/exits by year, cycle totals, open amount (2026-02-10)
**Missing Features:**
- ❌ Extended 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
- ✅ **CSV Import Templates** - German and English templates (#329, 2026-01-13)
- Template files in `priv/static/templates/member_import_de.csv` and `member_import_en.csv`
- CSV specification documented in `docs/csv-member-import-v1.md`
- ✅ **CSV Import Implementation** - Full CSV import feature (#335, #336, #338, 2026-01-27)
- Import/Export LiveView (`/import_export`)
- Member field import (email, first_name, last_name, etc.)
- Custom field value import (all types: string, integer, boolean, date, email)
- Real-time progress tracking
- Error and warning reporting with line numbers
- Configurable limits (max file size, max rows)
- Chunked processing (200 rows per chunk)
- Admin-only access
**Closed Issues:**
- ✅ [#335](https://git.local-it.org/local-it/mitgliederverwaltung/issues/335) - CSV Import UI (closed 2026-01-27)
- ✅ [#336](https://git.local-it.org/local-it/mitgliederverwaltung/issues/336) - Config for import limits (closed 2026-01-27)
- ✅ [#338](https://git.local-it.org/local-it/mitgliederverwaltung/issues/338) - Custom field CSV import (closed 2026-01-27)
**Missing Features:**
- ❌ Excel import for members
- ❌ Import validation preview (before import)
- ❌ 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
- ✅ Database seeds split into bootstrap (all envs) and dev-only seeds (20 members, groups; 2026-03-03)
- ⚠️ 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** | 60% complete | **High** | Medium |
| **Member Management** | 85% 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** (Closed) - #162 implemented (Fuzzy Search), #154 needs refinement
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** (Partially closed) - #171 implemented (OIDC linking), #169/#168 still open
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/oidc` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy |
| `GET` | `/auth/user/oidc/callback` | Handle OIDC callback | 🔓 | `{code, state}` | Redirect + session cookie |
| `POST` | `/auth/user/sign_out` | Sign out user | 🔐 | - | Redirect to login |
| `GET` | `/auth/link-oidc-account` | OIDC account linking (password verification) | 🔓 | - | LiveView form | ✅ Implemented |
| `GET` | `/auth/user/password/reset` | Show password reset form | 🔓 | - | HTML form |
| `POST` | `/auth/user/password/reset` | Request password reset | 🔓 | `{email}` | Success message + email sent |
| `GET` | `/auth/user/password/reset/:token` | Show reset password form | 🔓 | - | HTML form |
| `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_oidc` | 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_oidc` | 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_custom_field_value` |
| `/members/:id` | Member detail view | 🔐 | - | `edit`, `delete`, `link_user` |
| `/members/:id/edit` | Edit member form | 🔐 | - | `save`, `cancel`, `add_custom_field_value`, `remove_custom_field_value` |
#### 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_custom_field_value` | Add custom field value | `%{"custom_field_id" => id, "value" => val}` | Update form |
| `remove_custom_field_value` | Remove custom field value | `%{"custom_field_value_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 custom field | 🔐 | `{custom_field_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 (CustomFieldValue System) Endpoints
#### LiveView Endpoints (✅ Implemented)
| Mount | Purpose | Auth | Events | Status |
|-------|---------|------|--------|--------|
| `/settings` | Global settings (includes custom fields management) | 🔐 | `save`, `validate` | ✅ Implemented |
| `/custom_field_values` | List all custom field values | 🔐 | `new`, `edit`, `delete` | ✅ Implemented |
| `/custom_field_values/new` | Create custom field value | 🔐 | `save`, `cancel` | ✅ Implemented |
| `/custom_field_values/:id` | Custom field value detail | 🔐 | `edit` | ✅ Implemented |
| `/custom_field_values/:id/edit` | Edit custom field value | 🔐 | `save`, `cancel` | ✅ Implemented |
| `/custom_field_values/:id/show/edit` | Edit from show page | 🔐 | `save`, `cancel` | ✅ Implemented |
**Note:** Custom fields (definitions) are managed via LiveComponent in `/settings` page, not as separate routes.
#### Ash Resource Actions
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `CustomField` | `:create` | Create custom field | 🛡️ | `{name, value_type, description, ...}` | `{:ok, custom_field}` |
| `CustomField` | `:read` | List custom fields | 🔐 | - | `[%CustomField{}]` |
| `CustomField` | `:update` | Update custom field | 🛡️ | `{id, attrs}` | `{:ok, custom_field}` |
| `CustomField` | `:destroy` | Delete custom field | 🛡️ | `{id}` | `{:ok, custom_field}` |
| `CustomFieldValue` | `:create` | Add custom field value to member | 🔐 | `{member_id, custom_field_id, value}` | `{:ok, custom_field_value}` |
| `CustomFieldValue` | `:update` | Update custom field value | 🔐 | `{id, value}` | `{:ok, custom_field_value}` |
| `CustomFieldValue` | `:destroy` | Remove custom field value | 🔐 | `{id}` | `{:ok, custom_field_value}` |
#### **NEW: Enhanced Custom Fields** (Issue #194, #157, #161, #153)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `CustomField` | `:set_default_visibility` | Show/hide by default | 🛡️ | `{id, visible}` | `{:ok, custom_field}` |
| `CustomField` | `:set_required` | Mark as required | 🛡️ | `{id, required}` | `{:ok, custom_field}` |
| `CustomField` | `:add_validation` | Add validation rule | 🛡️ | `{id, rule_type, params}` | `{:ok, custom_field}` |
| `CustomField` | `:create_group` | Create field group | 🛡️ | `{name, custom_field_ids}` | `{:ok, group}` |
| `CustomFieldValue` | `:validate_value` | Validate custom field value | 🔐 | `{custom_field_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 (✅ Implemented)
| Method | Route | Purpose | Auth | Request | Response | Status |
|--------|-------|---------|------|---------|----------|--------|
| `POST` | `/set_locale` | Set user locale | 🔐 | `{locale: "de"}` | Redirect with cookie | ✅ Implemented |
| `GET` | `/locales` | List available locales | 🔓 | - | `["de", "en"]` | ❌ Not implemented |
**Note:** Locale is set via `/set_locale` POST endpoint and persisted in session/cookie. Supported locales: `de` (default), `en`.
---
### 7. Payment & Fees Management Endpoints
#### LiveView Endpoints (✅ Implemented)
| Mount | Purpose | Auth | Events | Status |
|-------|---------|------|--------|--------|
| `/membership_fee_types` | Membership fee type list | 🔐 | `new`, `edit`, `delete` | ✅ Implemented |
| `/membership_fee_types/new` | Create membership fee type | 🔐 | `save`, `cancel` | ✅ Implemented |
| `/membership_fee_types/:id/edit` | Edit membership fee type | 🔐 | `save`, `cancel` | ✅ Implemented |
| `/membership_fee_settings` | Global membership fee settings | 🔐 | `save` | ✅ Implemented |
| `/contributions/member/:id` | Member contribution periods (mock-up) | 🔐 | - | ⚠️ Mock-up only |
| `/contribution_types` | Contribution types (mock-up) | 🔐 | - | ⚠️ Mock-up only |
#### Ash Resource Actions (✅ Partially Implemented)
| Resource | Action | Purpose | Auth | Input | Output | Status |
|----------|--------|---------|------|-------|--------|--------|
| `MembershipFeeType` | `:create` | Create fee type | 🔐 | `{name, amount, interval, ...}` | `{:ok, fee_type}` | ✅ Implemented |
| `MembershipFeeType` | `:read` | List fee types | 🔐 | - | `[%MembershipFeeType{}]` | ✅ Implemented |
| `MembershipFeeType` | `:update` | Update fee type (name, amount, description) | 🔐 | `{id, attrs}` | `{:ok, fee_type}` | ✅ Implemented |
| `MembershipFeeType` | `:destroy` | Delete fee type (if no cycles) | 🔐 | `{id}` | `{:ok, fee_type}` | ✅ Implemented |
| `MembershipFeeCycle` | `:read` | List cycles for member | 🔐 | `{member_id}` | `[%MembershipFeeCycle{}]` | ✅ Implemented |
| `MembershipFeeCycle` | `:update` | Update cycle status | 🔐 | `{id, status}` | `{:ok, cycle}` | ✅ Implemented |
| `Payment` | `:create` | Record payment | 🔐 | `{member_id, fee_id, amount, date}` | `{:ok, payment}` | ❌ Not implemented |
| `Payment` | `:list_by_member` | Member payment history | 🔐 | `{member_id}` | `[%Payment{}]` | ❌ Not implemented |
| `Payment` | `:mark_paid` | Mark as paid | 🔐 | `{id}` | `{:ok, payment}` | ❌ Not implemented |
| `Invoice` | `:generate` | Generate invoice | 🔐 | `{member_id, fee_id, period}` | `{:ok, invoice}` | ❌ Not implemented |
| `Invoice` | `:send` | Send invoice via email | 🔐 | `{id}` | `{:ok, sent}` | ❌ Not implemented |
| `Payment` | `:import_vereinfacht` | Import from vereinfacht.digital | 🛡️ | `{transactions}` | `{:ok, count}` | ❌ Not implemented |
---
### 8. Admin Panel & Configuration Endpoints
#### LiveView Endpoints (✅ Partially Implemented)
| Mount | Purpose | Auth | Events | Status |
|-------|---------|------|--------|--------|
| `/settings` | Global settings (club name, member fields, custom fields) | 🔐 | `save`, `validate` | ✅ Implemented |
| `/admin/roles` | Role management | 🛡️ | `new`, `edit`, `delete` | ✅ Implemented |
| `/admin/roles/new` | Create role | 🛡️ | `save`, `cancel` | ✅ Implemented |
| `/admin/roles/:id` | Role detail view | 🛡️ | `edit` | ✅ Implemented |
| `/admin/roles/:id/edit` | Edit role | 🛡️ | `save`, `cancel` | ✅ Implemented |
| `/admin` | Admin dashboard | 🛡️ | - | ❌ Not implemented |
| `/admin/organization` | Organization profile | 🛡️ | `save` | ❌ Not implemented |
| `/admin/email-templates` | Email template editor | 🛡️ | `create`, `edit`, `preview` | ❌ Not implemented |
| `/admin/audit-log` | System audit log | 🛡️ | `filter`, `export` | ❌ Not implemented |
#### Ash Resource Actions (✅ Partially Implemented)
| Resource | Action | Purpose | Auth | Input | Output | Status |
|----------|--------|---------|------|-------|--------|--------|
| `Setting` | `:read` | Get settings (singleton) | 🔐 | - | `{:ok, settings}` | ✅ Implemented |
| `Setting` | `:update` | Update settings | 🔐 | `{club_name, member_field_visibility, ...}` | `{:ok, settings}` | ✅ Implemented |
| `Setting` | `:update_member_field_visibility` | Update field visibility | 🔐 | `{member_field_visibility}` | `{:ok, settings}` | ✅ Implemented |
| `Setting` | `:update_single_member_field_visibility` | Atomic field visibility update | 🔐 | `{field, show_in_overview}` | `{:ok, settings}` | ✅ Implemented |
| `Setting` | `:update_membership_fee_settings` | Update fee settings | 🔐 | `{include_joining_cycle, default_membership_fee_type_id}` | `{:ok, settings}` | ✅ Implemented |
| `Role` | `:read` | List roles | 🛡️ | - | `[%Role{}]` | ✅ Implemented |
| `Role` | `:create` | Create role | 🛡️ | `{name, permission_set_name, ...}` | `{:ok, role}` | ✅ Implemented |
| `Role` | `:update` | Update role | 🛡️ | `{id, attrs}` | `{:ok, role}` | ✅ Implemented |
| `Role` | `:destroy` | Delete role (if not system role) | 🛡️ | `{id}` | `{:ok, role}` | ✅ Implemented |
| `Organization` | `:read` | Get organization info | 🔐 | - | `%Organization{}` | ❌ Not implemented |
| `Organization` | `:update` | Update organization | 🛡️ | `{name, logo, ...}` | `{:ok, org}` | ❌ Not implemented |
| `AuditLog` | `:list` | List audit entries | 🛡️ | `{filters, pagination}` | `[%AuditLog{}]` | ❌ Not implemented |
---
### 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

View file

@ -1,731 +0,0 @@
# Membership Fees - Technical Architecture
**Project:** Mila - Membership Management System
**Feature:** Membership Fee Management
**Version:** 1.0
**Last Updated:** 2026-01-13
**Status:** ✅ Implemented
---
## Purpose
This document defines the technical architecture for the Membership Fees system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details.
**Related Documents:**
- [membership-fee-overview.md](./membership-fee-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
- Cycle generation separated from status management
- Calendar logic isolated in dedicated module
2. **No Redundancy:**
- No `cycle_end` field (calculated from `cycle_start` + `interval`)
- No `interval_type` field (read from `membership_fee_type.interval`)
- Eliminates data inconsistencies
3. **Immutability Where Important:**
- `membership_fee_type.interval` cannot be changed after creation
- Prevents complex migration scenarios
- Enforced via Ash change validation
4. **Historical Accuracy:**
- `amount` stored per cycle for audit trail
- Enables tracking of membership fee changes over time
- Old cycles retain original amounts
5. **Calendar-Based Cycles:**
- All cycles aligned to calendar boundaries
- Simplifies date calculations
- Predictable cycle generation
---
## Domain Structure
### Ash Domain: `Mv.MembershipFees`
**Purpose:** Encapsulates all membership fee-related resources and logic
**Resources:**
- `MembershipFeeType` - Membership fee type definitions (admin-managed)
- `MembershipFeeCycle` - Individual membership fee cycles per member
**Public API:**
The domain exposes code interface functions:
- `create_membership_fee_type/1`, `list_membership_fee_types/0`, `update_membership_fee_type/2`, `destroy_membership_fee_type/1`
- `create_membership_fee_cycle/1`, `list_membership_fee_cycles/0`, `update_membership_fee_cycle/2`, `destroy_membership_fee_cycle/1`
**Note:** In LiveViews, direct `Ash.read`, `Ash.create`, `Ash.update`, `Ash.destroy` calls are used with `domain: Mv.MembershipFees` instead of code interface functions. This is acceptable for LiveView forms that use `AshPhoenix.Form`.
**Extensions:**
- Member resource extended with membership fee fields
### Module Organization
```
lib/
├── membership_fees/
│ ├── membership_fees.ex # Ash domain definition
│ ├── membership_fee_type.ex # MembershipFeeType resource
│ ├── membership_fee_cycle.ex # MembershipFeeCycle resource
│ └── changes/
│ ├── prevent_interval_change.ex # Validates interval immutability
│ ├── set_membership_fee_start_date.ex # Auto-sets start date
│ └── validate_same_interval.ex # Validates interval match on type change
├── mv/
│ └── membership_fees/
│ ├── cycle_generator.ex # Cycle generation algorithm
│ └── calendar_cycles.ex # Calendar cycle calculations
└── membership/
└── member.ex # Extended with membership fee relationships
```
### Separation of Concerns
**Domain Layer (Ash Resources):**
- Data validation
- Relationship management
- Policy enforcement
- Action definitions
**Business Logic Layer (`Mv.MembershipFees`):**
- Cycle 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. **`membership_fee_types`**
- Purpose: Define membership fee types with fixed intervals
- Key Constraint: `interval` field immutable after creation
- Relationships: has_many members, has_many membership_fee_cycles
2. **`membership_fee_cycles`**
- Purpose: Individual membership fee cycles for members
- Key Design: NO `cycle_end` or `interval_type` fields (calculated)
- Relationships: belongs_to member, belongs_to membership_fee_type
- Composite uniqueness: One cycle per member per cycle_start
### Member Table Extensions
**Fields Added:**
- `membership_fee_type_id` (FK, NOT NULL with default from settings)
- `membership_fee_start_date` (Date, nullable)
**Existing Fields Used:**
- `join_date` - For calculating membership fee start
- `exit_date` - For limiting cycle generation
- These fields must remain member fields and should not be replaced by custom fields in the future
### Settings Integration
**Global Settings:**
- `membership_fees.include_joining_cycle` (Boolean)
- `membership_fees.default_membership_fee_type_id` (UUID)
**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource)
### Foreign Key Behaviors
| Relationship | On Delete | Rationale |
|--------------|-----------|-----------|
| `membership_fee_cycles.member_id → members.id` | CASCADE | Remove membership fee cycles when member deleted |
| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if cycles exist |
| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if assigned to members |
---
## Business Logic Architecture
### Cycle Generation System
**Component:** `Mv.MembershipFees.CycleGenerator`
**Responsibilities:**
- Calculate which cycles should exist for a member
- Generate missing cycles
- Respect membership_fee_start_date and exit_date boundaries
- Skip existing cycles (idempotent)
- Use PostgreSQL advisory locks per member to prevent race conditions
**Triggers:**
1. Member membership fee type assigned (via Ash change)
2. Member created with membership fee type (via Ash change)
3. Scheduled job runs (daily/weekly cron)
4. Admin manual regeneration (UI action)
**Algorithm Steps:**
1. Retrieve member with membership fee type and dates
2. Determine generation start point:
- If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
- If cycles exist: Start from the cycle AFTER the last existing one
3. Generate all cycle starts from the determined start point to today (or `exit_date`)
4. Create new cycles with current membership fee type's amount
5. Use PostgreSQL advisory locks per member to prevent race conditions
**Edge Case Handling:**
- If membership_fee_start_date is NULL: Calculate from join_date + global setting
- If exit_date is set: Stop generation at exit_date
- If membership fee type changes: Handled separately by regeneration logic
- **Gap Handling:** If cycles were explicitly deleted (gaps exist), they are NOT recreated.
The generator always continues from the cycle AFTER the last existing cycle, regardless of gaps.
### Calendar Cycle Calculations
**Component:** `Mv.MembershipFees.CalendarCycles`
**Responsibilities:**
- Calculate cycle boundaries based on interval type
- Determine current cycle
- Determine last completed cycle
- Calculate cycle_end from cycle_start + interval
**Functions (high-level):**
- `calculate_cycle_start/3` - Given date and interval, find cycle start
- `calculate_cycle_end/2` - Given cycle_start and interval, calculate end
- `next_cycle_start/2` - Given cycle_start and interval, find next
- `is_current_cycle?/2` - Check if cycle contains today
- `is_last_completed_cycle?/2` - Check if cycle 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 `MembershipFeeCycle`
**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 cycles as paid (efficiency)
- low priority, can be a future issue
### Membership Fee Type Change Handling
**Component:** Ash change on `Member.membership_fee_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 cycles unchanged
2. Find future unpaid cycles
3. Delete future unpaid cycles
4. Regenerate cycles with new membership_fee_type_id and amount
**Implementation Pattern:**
- Use Ash change module to validate
- Use after_action hook to trigger regeneration synchronously
- Regeneration runs in the same transaction as the member update to ensure atomicity
- CycleGenerator uses advisory locks and transactions internally to prevent race conditions
**Validation Behavior:**
- Fail-closed: If membership fee types cannot be loaded during validation, the change is rejected with a validation error
- Nil assignment prevention: Attempts to set membership_fee_type_id to nil are rejected when a current type exists
---
## Integration Points
### Member Resource Integration
**Extension Points:**
1. Add fields via migration
2. Add relationships (belongs_to, has_many)
3. Add calculations (current_cycle_status, overdue_count)
4. Add changes (auto-set membership_fee_start_date, validate interval)
**Backward Compatibility:**
- New fields nullable or with defaults
- Existing members get default membership fee 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 membership fee type must exist)
**Access Pattern:**
- Read settings during cycle generation
- Read settings during member creation
- Write settings only via admin UI
### Permission System Integration
**Status:** ✅ Implemented. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns.
**PermissionSets (lib/mv/authorization/permission_sets.ex):**
- **MembershipFeeType:** All permission sets can read (:all); only admin has create/update/destroy (:all).
- **MembershipFeeCycle:** All can read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all).
- **Manual "Regenerate Cycles" (UI + server):** The "Regenerate Cycles" button in the member detail view is shown to users who have MembershipFeeCycle create permission (normal_user and admin). UI access is gated by `can_create_cycle`. The LiveView handler also enforces `can?(:create, MembershipFeeCycle)` server-side before running regeneration (so e.g. a read_only user cannot trigger it via DevTools). Regeneration runs with system actor.
**Resource Policies:**
- **MembershipFeeType** (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy.
- **MembershipFeeCycle** (`lib/membership_fees/membership_fee_cycle.ex`): Same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid.
### LiveView Integration
**New LiveViews Required:**
1. MembershipFeeType index/form (admin)
2. MembershipFeeCycle table component (member detail view)
- Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent`
- Displays all cycles in a table with status management
- Allows changing cycle status, editing amounts, and manually regenerating cycles (normal_user and admin)
3. Settings form section (admin)
4. Member list column (membership fee status)
**Existing LiveViews to Extend:**
- Member detail view: Add membership fees section
- Member list view: Add status column
- Settings page: Add membership fees section
**Authorization Helpers:**
- Use existing `can?/3` helper for UI conditionals
- Check permissions before showing actions
---
## Acceptance Criteria
### MembershipFeeType Resource
**AC-MFT-1:** Admin can create membership fee type with name, amount, interval, description
**AC-MFT-2:** Interval field is immutable after creation (validation error on change attempt)
**AC-MFT-3:** Admin can update name, amount, description (but not interval)
**AC-MFT-4:** Cannot delete membership fee type if assigned to members
**AC-MFT-5:** Cannot delete membership fee type if cycles exist referencing it
**AC-MFT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly
### MembershipFeeCycle Resource
**AC-MFC-1:** Cycle has cycle_start, status, amount, notes, member_id, membership_fee_type_id
**AC-MFC-2:** cycle_end is calculated, not stored
**AC-MFC-3:** Status defaults to :unpaid
**AC-MFC-4:** One cycle per member per cycle_start (uniqueness constraint)
**AC-MFC-5:** Amount is set at generation time from membership_fee_type.amount
**AC-MFC-6:** Cycles cascade delete when member deleted
**AC-MFC-7:** Admin/Treasurer can change status
**AC-MFC-8:** Member can read own cycles
### Member Extensions
**AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default)
**AC-M-2:** Member has membership_fee_start_date field (nullable)
**AC-M-3:** New members get default membership fee type from global setting
**AC-M-4:** membership_fee_start_date auto-set based on join_date and global setting
**AC-M-5:** Admin can manually override membership_fee_start_date
**AC-M-6:** Cannot change to membership fee type with different interval (MVP)
### Cycle Generation
**AC-CG-1:** Cycles generated when member gets membership fee type
**AC-CG-2:** Cycles generated when member created (via change hook)
**AC-CG-3:** Scheduled job generates missing cycles daily
**AC-CG-4:** Generation respects membership_fee_start_date
**AC-CG-5:** Generation stops at exit_date if member exited
**AC-CG-6:** Generation is idempotent (skips existing cycles)
**AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year)
**AC-CG-8:** Amount comes from membership_fee_type at generation time
### Calendar Logic
**AC-CL-1:** Monthly cycles: 1st to last day of month
**AC-CL-2:** Quarterly cycles: 1st of Jan/Apr/Jul/Oct to last day of quarter
**AC-CL-3:** Half-yearly cycles: 1st of Jan/Jul to last day of half
**AC-CL-4:** Yearly cycles: Jan 1 to Dec 31
**AC-CL-5:** cycle_end calculated correctly for all interval types
**AC-CL-6:** Current cycle determined correctly based on today's date
**AC-CL-7:** Last completed cycle determined correctly
### Membership Fee 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 cycles regenerated
**AC-TC-4:** On allowed change: paid/suspended cycles unchanged
**AC-TC-5:** On allowed change: amount updated to new type's amount
**AC-TC-6:** Change is atomic (transaction) - ✅ Implemented: Regeneration runs synchronously in the same transaction as the member update
### Settings
**AC-S-1:** Global setting: include_joining_cycle (boolean, default true)
**AC-S-2:** Global setting: default_membership_fee_type_id (UUID, required)
**AC-S-3:** Admin can modify settings via UI
**AC-S-4:** Settings validated (e.g., default membership fee type must exist)
**AC-S-5:** Settings applied to new members immediately
### UI - Member List
**AC-UI-ML-1:** New column shows membership fee status
**AC-UI-ML-2:** Default: Shows last completed cycle status
**AC-UI-ML-3:** Optional: Toggle to show current cycle status
**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended)
**AC-UI-ML-5:** Filter: Unpaid in last cycle
**AC-UI-ML-6:** Filter: Unpaid in current cycle
### UI - Member Detail
**AC-UI-MD-1:** Membership fees section shows all cycles
**AC-UI-MD-2:** Table columns: Cycle, Interval, Amount, Status, Actions
**AC-UI-MD-3:** Checkbox per cycle for bulk marking (low prio)
**AC-UI-MD-4:** "Mark selected as paid" button
**AC-UI-MD-5:** Dropdown to change membership fee 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 - Membership Fee Types Admin
**AC-UI-CTA-1:** List all membership fee types
**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count
**AC-UI-CTA-3:** Create new membership fee 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:** Membership fees section in settings
**AC-UI-SA-2:** Dropdown to select default membership fee type
**AC-UI-SA-3:** Checkbox: Include joining cycle
**AC-UI-SA-4:** Explanatory text with examples
**AC-UI-SA-5:** Save button with validation
---
## Testing Strategy
### Unit Testing
**Cycle Generator Tests:**
- Correct cycle_start calculation for all interval types
- Correct cycle count from start to end date
- Respects membership_fee_start_date boundary
- Respects exit_date boundary
- Skips existing cycles (idempotent)
- Does not fill gaps when cycles were deleted
- Handles edge dates (year boundaries, leap years)
**Calendar Cycles Tests:**
- Cycle boundaries correct for all intervals
- cycle_end calculation correct
- Current cycle detection
- Last completed cycle detection
- Next cycle calculation
**Validation Tests:**
- Interval immutability enforced
- Same interval validation on type change
- Status transitions allowed
- Uniqueness constraints enforced
### Integration Testing
**Cycle Generation Flow:**
- Member creation triggers generation
- Type assignment triggers generation
- Type change regenerates future cycles
- Scheduled job generates missing cycles
- Left member stops generation
**Status Management Flow:**
- Mark single cycle as paid
- Bulk mark multiple cycles (low prio)
- Status transitions work
- Permissions enforced
**Membership Fee 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:**
- Cycles table displays all cycles
- Checkboxes work
- Bulk marking works (low prio)
- Membership fee 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 cycles unchanged
**Date Boundaries:**
- Today = cycle start handled
- Today = cycle end handled
- Leap year handled
### Performance Testing
**Cycle Generation:**
- Generate 10 years of monthly cycles: < 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:**
- Membership fee type management: Admin only
- Membership fee cycle status changes: Admin + Treasurer
- View all cycles: Admin + Treasurer + Board
- View own cycles: 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
- Cycle amounts immutable (audit trail)
- Settings changes logged (future)
### Audit Trail
**Tracked Information:**
- Cycle status changes (who, when) - future enhancement
- Membership fee type amount changes (implicit via cycle amounts)
---
## Performance Considerations
### Database Indexes
**Required Indexes:**
- `membership_fee_cycles(member_id)` - For member cycle lookups
- `membership_fee_cycles(membership_fee_type_id)` - For type queries
- `membership_fee_cycles(status)` - For unpaid filters
- `membership_fee_cycles(cycle_start)` - For date range queries
- `membership_fee_cycles(member_id, cycle_start)` - Composite unique index
- `members(membership_fee_type_id)` - For type membership count
### Query Optimization
**Preloading:**
- Load membership_fee_type with cycles (avoid N+1)
- Load cycles when displaying member detail
- Use Ash's load for efficient preloading
**Calculated Fields:**
- cycle_end calculated on-demand (not stored)
- current_cycle_status calculated when needed
- Use Ash calculations for lazy evaluation
**Pagination:**
- Cycle list paginated if > 50 cycles
- Member list already paginated
### Caching Strategy
**No caching needed in MVP:**
- Membership fee types rarely change
- Cycle queries are fast
- Settings read infrequently
**Future caching if needed:**
- Cache settings in application memory
- Cache membership fee types list
- Invalidate on change
### Scheduled Job Performance
**Cycle 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 cycle overlaps
- Calculate prorata amounts if needed
- More complex validation
- Migration path for existing cycles
### Phase 3: Payment Details
**Architecture Changes:**
- Add PaymentTransaction resource
- Link transactions to cycles
- Support multiple payments per cycle
- 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**

View file

@ -1,531 +0,0 @@
# Membership Fees - Overview
**Project:** Mila - Membership Management System
**Feature:** Membership Fee Management
**Version:** 1.0
**Last Updated:** 2026-01-13
**Status:** ✅ Implemented
---
## Purpose
This document provides a comprehensive overview of the Membership Fees system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format.
**For detailed implementation:** See [membership-fee-implementation-plan.md](./membership-fee-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 cycle-based (Month/Quarter/Half-Year/Year)
---
## Terminology
### German ↔ English
**Core Entities:**
- Beitragsart ↔ Membership Fee Type
- Beitragszyklus ↔ Membership Fee Cycle
- Mitgliedsbeitrag ↔ Membership Fee
**Status:**
- bezahlt ↔ paid
- unbezahlt ↔ unpaid
- ausgesetzt ↔ suspended / waived
**Intervals (Frequenz / Payment Frequency):**
- monatlich ↔ monthly
- quartalsweise ↔ quarterly
- halbjährlich ↔ half-yearly / semi-annually
- jährlich ↔ yearly / annually
**UI Elements:**
- "Letzter Zyklus" ↔ "Last Cycle" (e.g., 2023 when in 2024)
- "Aktueller Zyklus" ↔ "Current Cycle" (e.g., 2024)
- "Als bezahlt markieren" ↔ "Mark as paid"
- "Aussetzen" ↔ "Suspend" / "Waive"
---
## Data Model
### Membership Fee Type (MembershipFeeType)
```
- id (UUID)
- name (String) - e.g., "Regular", "Reduced", "Student"
- amount (Decimal) - Membership fee amount in Euro
- interval (Enum) - :monthly, :quarterly, :half_yearly, :yearly
- description (Text, optional)
```
**Important:**
- `interval` is **IMMUTABLE** after creation!
- Admin can only change `name`, `amount`, `description`
- On change: Future unpaid cycles regenerated with new amount
### Membership Fee Cycle (MembershipFeeCycle)
```
- id (UUID)
- member_id (FK → members.id)
- membership_fee_type_id (FK → membership_fee_types.id)
- cycle_start (Date) - Calendar cycle start (01.01., 01.04., 01.07., 01.10., etc.)
- status (Enum) - :unpaid (default), :paid, :suspended
- amount (Decimal) - Membership fee amount at generation time (history when type changes)
- notes (Text, optional) - Admin notes
```
**Important:**
- **NO** `cycle_end` - calculated from `cycle_start` + `interval`
- **NO** `interval_type` - read from `membership_fee_type.interval`
- Avoids redundancy and inconsistencies!
**Calendar Cycle 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)
```
- membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings)
- membership_fee_start_date (Date, nullable) - When to start generating membership fees
- exit_date (Date, nullable) - Exit date (existing)
```
**Logic for membership_fee_start_date:**
- Auto-set based on global setting `include_joining_cycle`
- If `include_joining_cycle = true`: First day of joining month/quarter/year
- If `include_joining_cycle = false`: First day of NEXT cycle after joining
- Can be manually overridden by admin
**NO** `include_joining_cycle` field on Member - unnecessary due to `membership_fee_start_date`!
### Global Settings
```
key: "membership_fees.include_joining_cycle"
value: Boolean (Default: true)
key: "membership_fees.default_membership_fee_type_id"
value: UUID (Required) - Default membership fee type for new members
```
**Meaning include_joining_cycle:**
- `true`: Joining cycle is included (member pays from joining cycle)
- `false`: Only from next full cycle after joining
**Meaning of default membership fee type setting:**
- Every new member automatically gets this membership fee type
- Must be configured in admin settings
- Prevents: Members without membership fee type
---
## Business Logic
### Cycle Generation
**Triggers:**
- Member gets membership fee type assigned (also during member creation)
- New cycle begins (Cron job daily/weekly)
- Admin requests manual regeneration
**Algorithm:**
Use PostgreSQL advisory locks per member to prevent race conditions
1. Get `member.membership_fee_start_date` and member's membership fee type
2. Determine generation start point:
- If NO cycles exist: Start from `membership_fee_start_date`
- If cycles exist: Start from the cycle AFTER the last existing one
3. Generate cycles until today (or `exit_date` if present):
- Use the interval to generate the cycles
- **Note:** If cycles were explicitly deleted (gaps exist), they are NOT recreated.
The generator always continues from the cycle AFTER the last existing cycle.
4. Set `amount` to current membership fee type's amount
**Example (Yearly):**
```
Joining date: 15.03.2023
include_joining_cycle: true
→ membership_fee_start_date: 01.01.2023
Generated cycles:
- 01.01.2023 - 31.12.2023 (joining cycle)
- 01.01.2024 - 31.12.2024
- 01.01.2025 - 31.12.2025 (current year)
```
**Example (Quarterly):**
```
Joining date: 15.03.2023
include_joining_cycle: false
→ membership_fee_start_date: 01.04.2023
Generated cycles:
- 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
### Membership Fee Type Change
**MVP - Same Cycle Only:**
- Member can only choose membership fee type with **same cycle**
- Example: From "Regular (yearly)" to "Reduced (yearly)" ✓
- Example: From "Regular (yearly)" to "Reduced (monthly)" ✗
**Logic on Change:**
1. Check: New membership fee type has same interval
2. If yes: Set `member.membership_fee_type_id`
3. Future **unpaid** cycles: Delete and regenerate with new amount
4. Paid/suspended cycles: Remain unchanged (historical amount)
**Future - Different Intervals:**
- Enable interval switching (e.g., yearly → monthly)
- More complex logic for cycle overlaps
- Needs additional validation
### Member Exit
**Logic:**
- Cycles only generated until `member.exit_date`
- Existing cycles remain visible
- Unpaid exit cycle can be marked as "suspended"
**Example:**
```
Exit: 15.08.2024
Yearly cycle: 01.01.2024 - 31.12.2024
→ Cycle 2024 is shown (Status: unpaid)
→ Admin can set to "suspended"
→ No cycles for 2025+ generated
```
---
## UI/UX Design
### Member List View
**New Column: "Membership Fee Status"**
**Default Display (Last Cycle):**
- Shows status of **last completed** cycle
- Example in 2024: Shows membership fee for 2023
- Color coding:
- Green: paid ✓
- Red: unpaid ✗
- Gray: suspended ⊘
**Optional: Show Current Cycle**
- Toggle: "Show current cycle" (2024)
- Admin decides what to display
**Filters:**
- "Unpaid membership fees in last cycle"
- "Unpaid membership fees in current cycle"
### Member Detail View
**Section: "Membership Fees"**
**Membership Fee Type Assignment:**
```
┌─────────────────────────────────────┐
│ Membership Fee Type: [Dropdown] │
│ ⚠ Only types with same interval │
│ can be selected │
└─────────────────────────────────────┘
```
**Cycle Table:**
```
┌───────────────┬──────────┬────────┬──────────┬─────────┐
│ Cycle │ 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 cycles
### Admin: Membership Fee 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 cycles will be generated with 65 €
- Already paid cycles remain with old amount
[Cancel] [Confirm]
```
### Admin: Settings
**Membership Fee Configuration:**
```
Default Membership Fee Type: [Dropdown: Membership Fee Types]
Selected: "Regular (60 €, Yearly)"
This membership fee type is automatically assigned to all new members.
Can be changed individually per member.
---
☐ Include joining cycle
When active:
Members pay from the cycle of their joining.
Example (Yearly):
Joining: 15.03.2023
→ Pays from 2023
When inactive:
Members pay from the next full cycle.
Example (Yearly):
Joining: 15.03.2023
→ Pays from 2024
```
---
## Edge Cases
### 1. Membership Fee Type Change with Different Interval
**MVP:** Blocked (only same interval allowed)
**UI:**
```
Error: Interval change not possible
Current membership fee type: "Regular (Yearly)"
Selected membership fee type: "Student (Monthly)"
Changing the interval is currently not possible.
Please select a membership fee type with interval "Yearly".
[OK]
```
**Future:**
- Allow interval switching
- Calculate overlaps
- Generate new cycles without duplicates
### 2. Exit with Unpaid Membership Fees
**Scenario:**
```
Member exits: 15.08.2024
Yearly cycle 2024: unpaid
```
**UI Notice on Exit: (Low Prio)**
```
⚠ Unpaid membership fees present
This member has 1 unpaid cycle(s):
- 2024: 60 € (unpaid)
Do you want to continue?
[ ] Mark membership fee as "suspended"
[Cancel] [Confirm Exit]
```
### 3. Multiple Unpaid Cycles
**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:**
- Cycle 2023: Saved with 50 € (history)
- Cycle 2024: Generated with 60 € (current)
- Both cycles show correct historical amount
### 5. Date Boundaries
**Problem:** What if today = 01.01.2025?
**Solution:**
- Current cycle (2025) is generated
- Status: unpaid (open)
- Shown in overview
---
## Implementation Scope
### MVP (Phase 1)
**Included:**
- ✓ Membership fee types (CRUD)
- ✓ Automatic cycle generation
- ✓ Status management (paid/unpaid/suspended)
- ✓ Member overview with membership fee status
- ✓ Cycle view per member
- ✓ Quick checkbox marking
- ✓ Bulk actions
- ✓ Amount history
- ✓ Same-interval type change
- ✓ Default membership fee type
- ✓ Joining cycle 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 cycles
- Manual vereinfacht.digital links per member
- Extended filter options
**Phase 3:**
- Automated vereinfacht.digital integration
- Automatic payment matching
- SEPA integration
- Advanced reports

View file

@ -1,207 +0,0 @@
# OIDC Account Linking Implementation
## Overview
This feature implements secure account linking between password-based accounts and OIDC authentication. When a user attempts to log in via OIDC with an email that already exists as a password-only account, the system requires password verification before linking the accounts.
## Architecture
### Key Components
#### 1. Security Fix: `lib/accounts/user.ex`
**Change**: The `sign_in_with_oidc` action now filters by `oidc_id` instead of `email`.
```elixir
read :sign_in_with_oidc do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
# SECURITY: Filter by oidc_id, NOT by email!
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
end
```
**Why**: Prevents OIDC users from bypassing password authentication and taking over existing accounts.
#### 2. Custom Error: `lib/accounts/user/errors/password_verification_required.ex`
Custom error raised when OIDC login conflicts with existing password account.
**Fields**:
- `user_id`: ID of the existing user
- `oidc_user_info`: OIDC user information for account linking
#### 3. Validation: `lib/accounts/user/validations/oidc_email_collision.ex`
Validates email uniqueness during OIDC registration.
**Scenarios**:
1. **User exists with matching `oidc_id`**: Allow (upsert)
2. **User exists without `oidc_id`** (password-protected OR passwordless): Raise `PasswordVerificationRequired`
- The `LinkOidcAccountLive` will auto-link passwordless users without password prompt
- Password-protected users must verify their password
3. **User exists with different `oidc_id`**: Hard error (cannot link multiple OIDC providers)
4. **No user exists**: Allow (new user creation)
#### 4. Account Linking Action: `lib/accounts/user.ex`
```elixir
update :link_oidc_id do
description "Links an OIDC ID to an existing user after password verification"
accept []
argument :oidc_id, :string, allow_nil?: false
argument :oidc_user_info, :map, allow_nil?: false
# ... implementation
end
```
**Features**:
- Links `oidc_id` to existing user
- Updates email if it differs from OIDC provider
- Syncs email changes to linked member
#### 5. Controller: `lib/mv_web/controllers/auth_controller.ex`
Refactored for better complexity and maintainability.
**Key improvements**:
- Reduced cyclomatic complexity from 11 to below 9
- Better separation of concerns with helper functions
- Comprehensive documentation
**Flow**:
1. Detects `PasswordVerificationRequired` error
2. Stores OIDC info in session
3. Redirects to account linking page
#### 6. LiveView: `lib/mv_web/live/auth/link_oidc_account_live.ex`
Interactive UI for password verification and account linking.
**Flow**:
1. Retrieves OIDC info from session
2. **Auto-links passwordless users** immediately (no password prompt)
3. Displays password verification form for password-protected users
4. Verifies password using AshAuthentication
5. Links OIDC account on success
6. Redirects to complete OIDC login
7. **Logs all security-relevant events** (successful/failed linking attempts)
### Locale Persistence
**Problem**: Locale was lost on logout (session cleared).
**Solution**: Store locale in persistent cookie (1 year TTL) with security flags.
**Changes**:
- `lib/mv_web/locale_controller.ex`: Sets locale cookie with `http_only` and `secure` flags
- `lib/mv_web/router.ex`: Reads locale from cookie if session empty
**Security Features**:
- `http_only: true` - Cookie not accessible via JavaScript (XSS protection)
- `secure: true` - Cookie only transmitted over HTTPS in production
- `same_site: "Lax"` - CSRF protection
## Security Considerations
### 1. OIDC ID Matching
- **Before**: Matched by email (vulnerable to account takeover)
- **After**: Matched by `oidc_id` (secure)
### 2. Account Linking Flow
- Password verification required before linking (for password-protected users)
- Passwordless users are auto-linked immediately (secure, as they have no password)
- OIDC info stored in session (not in URL/query params)
- CSRF protection on all forms
- All linking attempts logged for audit trail
### 3. Email Updates
- Email updates from OIDC provider are applied during linking
- Email changes sync to linked member (if exists)
### 4. Error Handling
- Internal errors are logged but not exposed to users (prevents information disclosure)
- User-friendly error messages shown in UI
- Security-relevant events logged with appropriate levels:
- `Logger.info` for successful operations
- `Logger.warning` for failed authentication attempts
- `Logger.error` for system errors
## Usage Examples
### Scenario 1: New OIDC User
```elixir
# User signs in with OIDC for the first time
# → New user created with oidc_id
```
### Scenario 2: Existing OIDC User
```elixir
# User with oidc_id signs in via OIDC
# → Matched by oidc_id, email updated if changed
```
### Scenario 3: Password User + OIDC Login
```elixir
# User with password account tries OIDC login
# → PasswordVerificationRequired raised
# → Redirected to /auth/link-oidc-account
# → User enters password
# → Password verified and logged
# → oidc_id linked to account
# → Successful linking logged
# → Redirected to complete OIDC login
```
### Scenario 4: Passwordless User + OIDC Login
```elixir
# User without password (invited user) tries OIDC login
# → PasswordVerificationRequired raised
# → Redirected to /auth/link-oidc-account
# → System detects passwordless user
# → oidc_id automatically linked (no password prompt)
# → Auto-linking logged
# → Redirected to complete OIDC login
```
## API
### Custom Actions
#### `link_oidc_id`
Links an OIDC ID to existing user after password verification.
**Arguments**:
- `oidc_id` (required): OIDC sub/id from provider
- `oidc_user_info` (required): Full OIDC user info map
**Returns**: Updated user with linked `oidc_id`
**Side Effects**:
- Updates email if different from OIDC provider
- Syncs email to linked member (if exists)
## References
- [AshAuthentication Documentation](https://hexdocs.pm/ash_authentication)
- [OIDC Specification](https://openid.net/specs/openid-connect-core-1_0.html)
- [Security Best Practices for Account Linking](https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html)

View file

@ -1,280 +0,0 @@
# Onboarding & Join High-Level Concept
**Status:** Draft for design decisions and implementation specs. **Prio 1 (Subtasks 14) implemented.**
**Scope:** Prio 1 = public Join form; Step 2 = Vorstand approval. Invite-Link and OIDC JIT are out of scope and documented only as future entry paths.
**Related:** Issue #308, roles-and-permissions-architecture, page-permission-route-coverage.
---
## 1. Focus and Goals
- **Focus:** Onboarding and **initial data capture**, not self-service editing of existing members.
- **Entry paths (vision):**
- **Public Join form** (Prio 1) unauthenticated submission.
- **Invite link** (tokenized) later.
- **OIDC first-login** (Just-in-Time Provisioning) later.
- **Admin control:** All entry paths and their behaviour (e.g. which fields, approval required) shall be configurable by admins; MVP can start with sensible defaults.
- **Approval:** A Vorstand (board) approval step is a direct follow-up (Step 2) after the public Join; the data model and flow must support it.
---
## 2. Prio 1: Public Join Page
### 2.1 Intent
- **Public** page (e.g. `/join`): no login; anyone can open and submit.
- Result is **not** a User or Member. Result is an **onboarding / join request**: the JoinRequest record is **created in the database on form submit** in status `pending_confirmation`, then **updated to** `submitted` after the user clicks the confirmation link.
- This keeps:
- **Public intake** (abuse-prone) separate from **identity and account creation** (after approval / invite / OIDC).
- Existing policies (e.g. UserMember linking, admin-only link) untouched until a defined "promotion" flow (e.g. after approval) creates User/Member.
- **Elixir/Phoenix/Ash standard:** Data is persisted in the database from the start (one Ash resource, status-driven flow). No ETS or stateless token for pre-confirmation storage; confirm flow only updates the existing record.
### 2.2 User Flow (Prio 1)
1. Unauthenticated user opens `/join`.
2. Short explanation + form (what happens next: "We will review … you will hear from us").
3. **Submit** → A **JoinRequest is created** in the database with status `pending_confirmation`; confirmation email is sent; user sees: "We have saved your details. To complete your request, please click the link we sent to your email."
4. **User clicks confirmation link** → The existing JoinRequest is **updated** to status `submitted` (`submitted_at` set, confirmation token invalidated); user sees: "Thank you, we have received your request."
**Rationale (double opt-in with DB-first):** Email confirmation remains best practice (we only treat the request as "submitted" after the link is clicked). The record exists in the DB from submit time so we use standard Phoenix/Ash persistence, multi-node safety, and a simple status transition (`pending_confirmation``submitted`) on confirm. This aligns with patterns like AshAuthentication (resource exists before confirm; confirm updates state).
**Out of scope for Prio 1:** Approval UI, account creation, OIDC, invite links.
### 2.3 Data Flow
- **Input:** Only data explicitly allowed for the public form; field set is admin-configured (see §2.6). No internal or sensitive fields. **Server-side allowlist:** The set of accepted fields is enforced in the LiveView (`build_submit_attrs`) and in the resource via **`JoinRequest.Changes.FilterFormDataByAllowlist`** so that even direct API/submit_join_request calls only persist allowlisted form_data keys.
- **On form submit:** **Create** a JoinRequest with status `pending_confirmation`, store confirmation token **hash** in the DB (raw token only in the email link), set `confirmation_token_expires_at` (e.g. 24h), store all allowlisted form data (see §2.3.2), then send confirmation email.
- **On confirmation link click:** **Update** the JoinRequest (find by token hash): set status to `submitted`, set `submitted_at`, clear/invalidate token fields. If the record is already `submitted`, return success without changing it (idempotent).
- **No creation** of Member or User in Prio 1; promotion to Member/User happens in a later step (e.g. after approval).
#### 2.3.1 Pre-Confirmation Store (Decided)
**Decision:** Store in the **database** only. Use the **same** JoinRequest resource and table from the start.
- On submit: **create** one JoinRequest row with status `pending_confirmation`, confirmation token **hash**, and expiry.
- On confirm: **update** that row to status `submitted` (no second table, no ETS, no stateless token).
- **Retention and cleanup:** JoinRequests that remain in `pending_confirmation` past the token expiry (e.g. 24 hours) are **hard-deleted** by a scheduled job (e.g. Oban cron). Retention period: **24 hours**; document in DSGVO/retention as needed.
- **Rationale:** Elixir/Phoenix/Ash standard is persistence in DB, one resource, status machine. Multi-node safe, restart safe, and cleanup is a standard cron task.
#### 2.3.2 JoinRequest: Data Model and Schema
- **Status:** `pending_confirmation` (initial, after form submit) → `submitted` (after link click) → later `approved` / `rejected`. Include **approved_at**, **rejected_at**, **reviewed_by_user_id** for audit.
- **Confirmation:** Store **confirmation_token_hash** (not the raw token); **confirmation_token_expires_at**; optional **confirmation_sent_at**. Raw token appears only in the email link; on confirm, hash the incoming token and find the record by hash.
- **Payload vs typed columns (recommendation):**
- **Typed columns** for **email** (required, dedicated field for index, search, dedup, audit) and for **first_name** and **last_name** (optional). These align with `Mv.Constants.member_fields()` and with the existing Member resource; they support approval-list display and straightforward promotion to Member without parsing JSON.
- **Remaining form data** (other member fields + custom field values) in a **jsonb** attribute (e.g. `form_data`) plus a **schema_version** (e.g. tied to join-form or member_fields evolution) so future changes do not break existing records.
- **What it depends on:** (1) Whether the join form field set is fixed or often extended if fixed, more typed columns are feasible; if very dynamic, keeping the rest in jsonb avoids migrations. (2) Whether the approval UI or reporting needs to filter/sort by other fields (e.g. city) if yes, consider adding those as typed columns later. For MVP, email + first_name + last_name typed and rest in jsonb is a good balance with the current codebase (Member has typed attributes; export/import use allowlists of field names).
- **Logger hygiene:** Do not log the full payload/form_data; follow CODE_GUIDELINES on log sanitization.
- **Idempotency:** Confirm action finds the JoinRequest by token hash; if status is already `submitted`, return success without updating. Optionally enforce **unique_index on confirmation_token_hash** so the same token cannot apply to more than one record.
- **Abuse metadata:** If stored (e.g. IP hash), classify as **security telemetry** or **personally identifiable** (DSGVO). Prefer hashed/aggregated values only (e.g. /24 prefix hash or keyed-hash), not raw IP; document classification and retention. Out of scope for Prio 1 unless explicitly added.
### 2.4 Security
- **Public paths:** `/join` and the confirmation route must be public (unauthenticated access returns 200).
- **Explicit public path for `/join`:** Add **`/join`** (and if needed `/join/*`) to the page-permission plugs **`public_path?/1`** so that the join page is reachable without login. Do not rely on the confirm path alone.
- **Confirmation route:** Use **`/confirm_join/:token`** so that the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it; no extra plug change for confirm.
- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Use Phoenix/Elixir standard options (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Client IP for rate limiting: prefer **X-Forwarded-For** / **X-Real-IP** when behind a reverse proxy (see Endpoint `connect_info: [:x_headers]` and `JoinLive.client_ip_from_socket/1`); fallback to peer_data with correct IPv4/IPv6 string via `:inet.ntoa/1`. Verify library version and multi-node behaviour before or during implementation.
- **Data:** Minimal PII; no sensitive data on the public form; consider DSGVO when extending. If abuse signals are stored: only hashed or aggregated values; document classification and retention.
- **Approval-only:** No automatic User/Member creation from the join form; approval (Step 2) or other trusted path creates identity.
- **Ash policies and actor:** JoinRequest has **explicit public actions** allowed with `actor: nil` (e.g. `submit` for create, `confirm` for update). Model via **policies** that permit these actions when actor is nil; do **not** use `authorize?: false` unless documented and clearly not a privilege-escalation path.
- **No system-actor fallback:** Join and confirmation run without an authenticated user. Do **not** use the system actor as a fallback for "missing actor". Use explicit unauthenticated context; see CODE_GUIDELINES §5.0.
### 2.5 Usability and UX
- **After submit:** Communicate clearly: e.g. "We have saved your details. To complete your request, please click the link we sent to your email." (Exact copy in implementation spec.)
- Clear heading and short copy (e.g. "Become a member / Submit request" and "What happens next").
- Form only as simple as needed (conversion vs. data hunger).
- Success message after confirm: neutral, no promise of an account ("We will get in touch").
- **Expired confirmation link:** If the user clicks after the token has expired, show a clear message (e.g. "This link has expired") and instruct them to submit the form again. Specify exact copy and behaviour in the implementation spec.
- **Re-send confirmation link:** Out of scope for Prio 1. If not implemented in Prio 1, **create a separate ticket immediately**. Example UX: "Request new confirmation email" on the "Please confirm your email" or expired-link page.
- Accessibility and i18n: same standards as rest of the app (labels, errors, Gettext).
### 2.6 Admin Configurability: Join Form Settings
- **Placement:** Own section **"Onboarding / Join"** in global settings, **above** "Custom fields", **below** "Vereinsdaten" (club data).
- **Join form enabled:** Checkbox (e.g. `join_form_enabled`). When set, the public `/join` page is active and the following config applies.
- **Copyable join link:** When the join form is enabled, a copyable full URL to the `/join` page is shown below the checkbox (above the field list), with a short hint so admins can share it with applicants.
- **Field selection:** From **all existing** member fields (from `Mv.Constants.member_fields()`) and **custom fields**, the admin selects which fields appear on the join form. Stored as a list/set of field identifiers (no separate table); display in settings as a simple list, e.g. **badges with X to remove** (similar to the groups overview). Adding fields: e.g. dropdown or modal to pick from remaining fields. Detailed UX for this subsection is to be specified in a **separate subtask**.
- **Technically required fields:** The only field that must always be required for the join flow is **email**. All other fields can be optional or marked as required per admin choice; implementation should support a "required" flag per selected join-form field.
- **Other:** Which entry paths are enabled, approval workflow (who can approve) to be detailed in Step 2 and later specs.
---
## 3. Step 2: Vorstand Approval
- **Goal:** Board (Vorstand) can review join requests (e.g. list status "submitted") and approve or reject.
- **Route:** **`/join_requests`** for the approval UI (list and detail). See §3.1 for full specification.
- **Outcome of approval (admin-configurable):**
- **Default:** Approval creates **Member only**; no User is created. An admin can link a User later if needed.
- **Optional (configurable):** If an option is set, approval may also create a **User** (e.g. invite-to-set-password). This is **open for later**; implementation concepts will be detailed when that option is implemented.
- **Permissions:** Approval uses the existing permission set **normal_user** (e.g. role "Kassenwart"). JoinRequest gets read and update (or dedicated approve/reject actions) for scope :all in normal_user, and **`/join_requests`** (and **`/join_requests/:id`** for detail) are added to normal_users allowed pages.
### 3.1 Step 2 Approval (detail)
Implementation spec for Subtask 5.
#### Route and pages
- **List:** **`/join_requests`** list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit.
- **Detail:** **`/join_requests/:id`** single join request. **Two blocks:** (1) **Applicant data** all form fields (typed + `form_data`) merged and shown in join-form order; (2) **Status and review** submitted_at, status, and when decided: approved_at/rejected_at, reviewed by. Actions Approve / Reject when status is `submitted`.
#### Backend (JoinRequest)
- **New actions (authenticated only):**
- **`approve`** (update): allowed only when status is `submitted`. Sets status `approved`, `approved_at`, `reviewed_by_user_id` (actor). Triggers promotion to Member (see Promotion below).
- **`reject`** (update): allowed only when status is `submitted`. Sets status `rejected`, `rejected_at`, `reviewed_by_user_id`. No reason field in MVP.
- **Policies:** `approve` and `reject` permitted via **HasPermission** for permission set **normal_user** (read/update or explicit approve/reject on JoinRequest, scope :all). Not allowed for `actor: nil`.
- **Domain:** Expose `list_join_requests/1` (e.g. filter by status, with actor), `approve_join_request/2` (id, actor), `reject_join_request/2` (id, actor). Read action for JoinRequest for normal_user scope :all so list/detail can load data.
#### Promotion: JoinRequest → Member
- **When:** On successful `approve` only (status was `submitted`).
- **Mapping:**
- JoinRequest typed fields → Member: **email**, **first_name**, **last_name** copied to Member attributes.
- **form_data** (jsonb): keys that match `Mv.Constants.member_fields()` (atom names or string keys) → corresponding Member attributes. Keys that are custom field IDs (UUID format) → create **CustomFieldValue** records linked to the new Member.
- **Defaults:** e.g. `join_date` = today if not in form_data; `membership_fee_type_id` = default from settings (or first available) if not provided. Handle required Member validations (e.g. email already present from JoinRequest).
- **Implementation:** Prefer a single Ash change (e.g. `JoinRequest.Changes.PromoteToMember`) or a domain function that builds member attributes + custom_field_values from the approved JoinRequest and calls Member `create_member` (actor: reviewer or system actor as per CODE_GUIDELINES; document choice). No User created in MVP.
- **Atomicity:** The approve flow (get JoinRequest → update to approved → promote to Member) runs inside **`Ash.transact(JoinRequest, fn -> ... end)`** so that if Member creation fails (e.g. validation, unique constraint), the JoinRequest status is rolled back and remains consistent.
- **Idempotency:** If approve is called again by mistake (e.g. race), either reject transition when status is already `approved` or ensure Member creation is idempotent (e.g. do not create duplicate Member for same JoinRequest).
#### Permission sets and routing
- **PermissionSets (normal_user):** Add JoinRequest **read** :all and **update** :all (or **approve** / **reject** if using dedicated actions). Add pages **`/join_requests`** and **`/join_requests/:id`** to the normal_user pages list.
- **Router:** Register live routes for list and detail; add entries to **page-permission-route-coverage.md** and extend plug tests so normal_user is allowed, read_only/own_data denied.
#### UI/UX (approval)
- **List:** Table or card list with columns e.g. submitted_at, first_name, last_name, email, status. Primary filter or default filter: status = `submitted`. Buttons or links to open detail. Follow existing list patterns (e.g. Members/Groups): header, back link, CoreComponents table.
- **Detail:** Show all request data (typed + form_data rendered by field). Buttons: **Approve** (primary), **Reject** (secondary). Reject in MVP: no reason field; just set status to rejected and audit fields.
- **Accessibility and i18n:** Same standards as rest of app (labels, Gettext, keyboard, ARIA where needed).
#### Tests
- JoinRequest: policy tests approve/reject allowed for normal_user (and admin), forbidden for nil/own_data/read_only.
- Domain: approve creates one Member with correct mapped data; reject only updates status and audit fields; approve when already approved is no-op or error.
- Page permission: normal_user can GET `/join_requests` and `/join_requests/:id`; read_only/own_data cannot.
- Optional: LiveView smoke test list loads, approve/reject from detail updates state.
---
## 4. Future Entry Paths (Out of Scope Here)
- **Invite link (tokenized):** Unique link per invitee; submission or account creation tied to token.
- **OIDC first-login (JIT):** First login via OIDC creates/links User and optionally Member from IdP data.
- Both must be design-ready so they can attach to the same approval or creation pipeline later.
---
## 5. Evaluation of the Proposed Concept Draft
**Adopted and reflected above:**
- **Naming:** Resource name **JoinRequest** (one resource, status + audit timestamps).
- **No User/Member from `/join`:** Only a JoinRequest; record is **created on form submit** (status `pending_confirmation`) and **updated to** `submitted` on confirmation. Abuse surface and policy complexity stay low.
- **Dedicated resource and actions:** New resource `JoinRequest` with public actions: **submit** (create with `pending_confirmation` + send email) and **confirm** (update to `submitted`). Member/User domain unchanged.
- **Public paths:** `/join` is **explicitly** added to the page-permission plugs public path list; confirmation route `/confirm_join/:token` is covered by existing `/confirm*` rule.
- **Minimal data:** Email is technically required; other fields from admin-configured join-form field set, with optional "required" per field.
- **Security:** Honeypot + rate limiting in MVP; email confirmation before treating request as submitted; token stored as hash; 24h retention and hard-delete for expired pending.
- **Tests:** Unauthenticated GET `/join` → 200; submit creates one JoinRequest (`pending_confirmation`); confirm updates it to `submitted`; idempotent confirm; honeypot and rate limiting covered; public-path tests updated.
**Refinements in this document:**
- Approval as Step 2; User creation after approval left open for later.
- Admin configurability: join form settings as own section; detailed UX in a subtask.
- Three entry paths (public, invite, OIDC) and their place in the roadmap made explicit.
- Pre-confirmation store: DB only, one resource, 24h retention, hard-delete.
- Payload: typed email (required), first_name, last_name; rest in jsonb with schema_version; rationale and what it depends on documented.
---
## 6. Decisions and Open Points
**Decided:**
- **Email confirmation (double opt-in):** JoinRequest is **created on form submit** with status `pending_confirmation` and **updated to** `submitted` when the user clicks the confirmation link. Double opt-in is preserved (we only treat as "submitted" after the link is clicked). Existing confirmation pattern (AshAuthentication) is reused for token + email sender + route.
- **Naming:** **JoinRequest**.
- **Pre-confirmation store:** **DB only.** Same JoinRequest resource; no ETS, no stateless token. Confirmation token stored as **hash** in DB; raw token only in email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (e.g. Oban cron).
- **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it.
- **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plugs `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page.
- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id**, **reviewed_by_display** (denormalized reviewer email for "Geprüft von" without loading User) for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`).
- **Approval outcome:** Admin-configurable. Default: approval creates Member only (no User). Optional "create User on approval" is **left for later**.
- **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug).
- **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**.
- **Approval permission:** normal_user; JoinRequest read/update and approval page added to normal_user; no new permission set.
- **Approval route:** **`/join_requests`** (list) and **`/join_requests/:id`** (detail).
- **Resend confirmation:** If not in Prio 1, create a separate ticket immediately.
**Open for later:**
- Abuse metadata (IP hash etc.): classification and whether to store in Prio 1.
- "Create User on approval" option: to be specified when implemented.
- Invite link and OIDC JIT entry paths.
---
## 7. Definition of Done (Prio 1)
- Public `/join` page and confirmation route reachable without login; **`/join` explicitly** in public paths (plug and tests).
- Flow: form submit → **JoinRequest created** in status `pending_confirmation` → confirmation email sent → user clicks link → **JoinRequest updated** to status `submitted`; no User or Member created by this flow.
- Anti-abuse: honeypot and rate limiting implemented and tested.
- Cleanup: scheduled job hard-deletes JoinRequests in `pending_confirmation` older than 24h (or configured retention).
- Page-permission and routing tests updated (including public-path coverage for `/join` and `/confirm_join/:token`).
- Concept and decisions (§6) documented for use in implementation specs.
---
## 8. Implementation Plan (Subtasks)
**Resend confirmation** remains a separate ticket (see §2.5, §6).
### Prio 1 Public Join (4 subtasks)
#### 1. JoinRequest resource and public policies **(done)**
- **Scope:** Ash resource `JoinRequest` per §2.3.2: status (`pending_confirmation`, `submitted`, `approved`, `rejected`), email (required), first_name, last_name (optional), form_data (jsonb), schema_version; confirmation_token_hash, confirmation_token_expires_at; submitted_at, approved_at, rejected_at, reviewed_by_user_id, source. Migration; unique_index on confirmation_token_hash (or equivalent for idempotency).
- **Policies:** Public actions **submit** (create) and **confirm** (update) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`.
- **Boundary:** No UI, no emails only resource, persistence, and actions callable with nil actor.
- **Done:** Resource and migration in place; tests for create/update with `actor: nil` and for idempotent confirm (same token twice → no second update).
#### 2. Submit and confirm flow **(done)**
- **Scope:** Form submit → **create** JoinRequest (status `pending_confirmation`, token hash + expiry, form data) → send confirmation email (reuse AshAuthentication sender pattern). Route **`/confirm_join/:token`** → verify token (hash and lookup) → **update** JoinRequest to status `submitted`, set submitted_at, invalidate token (idempotent if already submitted). Optional: Oban (or similar) job to **hard-delete** JoinRequests in `pending_confirmation` with confirmation_token_expires_at older than 24h.
- **Boundary:** No join-form UI, no admin settings only backend create/update and email/route.
- **Done:** Submit creates one JoinRequest; confirm updates it to submitted; double-click idempotent; expired token shows clear message; cleanup job implemented and documented. Tests for these cases.
#### 3. Admin: Join form settings **(done)**
- **Scope:** Section "Onboarding / Join" in global settings (§2.6): `join_form_enabled`, selection of join-form fields (from member_fields + custom fields), "required" per field. Persist (e.g. Setting or existing config). UI e.g. badges with remove + dropdown/modal to add (details in sub-subtask if needed).
- **Boundary:** No public form only save/load of config and **server-side allowlist** for use in subtask 4.
- **Done:** Settings save/load; allowlist available in backend for join form; tests.
#### 4. Public join page and anti-abuse **(done)**
- **Scope:** Route **`/join`** (public). **Add `/join` to the page-permission plugs public path list** so unauthenticated access is allowed. LiveView (or controller + form). Form fields from allowlist (subtask 3); copy per §2.5. **Honeypot** and **rate limiting** (e.g. Hammer.Plug) on join/submit. After submit: show "We have saved your details … click the link …". Expired-link page: clear message + "submit form again". Public-path tests updated to include `/join`.
- **Boundary:** No approval UI, no User/Member creation only public page, form, anti-abuse, and wiring to submit/confirm flow (subtask 2).
- **Done:** Unauthenticated GET `/join` → 200; submit creates JoinRequest (pending_confirmation) and sends email; confirm updates to submitted; honeypot and rate limit tested; public-path tests updated.
### Order and dependencies
- **1 → 2:** Submit/confirm flow uses JoinRequest resource.
- **3 before or in parallel with 4:** Form reads allowlist from settings; for MVP, subtask 4 can use a default allowlist and 3 can follow shortly after.
- **Recommended order:** **1****2****3****4** (or 3 in parallel with 2 if two people work on it).
### Step 2 Approval (1 subtask, later)
#### 5. Approval UI (Vorstand)
- **Route:** **`/join_requests`** (list), **`/join_requests/:id`** (detail). Full specification: §3.1.
- **Scope:** List JoinRequests (status "submitted"), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add JoinRequest read/update (or approve/reject) and pages `/join_requests`, `/join_requests/:id` to PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id). Promotion: JoinRequest → Member per §3.1 (mapping, defaults, idempotency).
- **Boundary:** Separate ticket; builds on JoinRequest and existing Member creation.
---
## 9. References
- `docs/roles-and-permissions-architecture.md` Permission sets, roles, page permissions.
- `docs/page-permission-route-coverage.md` Public paths, plug behaviour, tests; add `/join_requests` and `/join_requests/:id` for Step 2 (normal_user).
- `lib/mv_web/plugs/check_page_permission.ex` Public path list; **add `/join`** in `public_path?/1`.
- `lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex` Existing confirmation-email pattern (token, link, Mailer).
- Hammer / Hammer.Plug (e.g. hexdocs.pm/hammer) Rate limiting for Phoenix/Plug.
- Issue #308 Original feature/planning context.

View file

@ -1,100 +0,0 @@
# Page Permission Route and Test Coverage
This document lists all protected routes, which permission set may access them, and how they are covered by tests.
## Protected Routes (Router scope with CheckPagePermission in :browser)
| Route | own_data | read_only | normal_user | admin |
|-------|----------|-----------|-------------|-------|
| `/` | ✗ | ✓ | ✓ | ✓ |
| `/members` | ✗ | ✓ | ✓ | ✓ |
| `/members/new` | ✗ | ✗ | ✓ | ✓ |
| `/members/:id` | ✓ (linked only) | ✓ | ✓ | ✓ |
| `/members/:id/edit` | ✓ (linked only) | ✗ | ✓ | ✓ |
| `/members/:id/show/edit` | ✓ (linked only) | ✗ | ✓ | ✓ |
| `/users` | ✗ | ✗ | ✗ | ✓ |
| `/users/new` | ✗ | ✗ | ✗ | ✓ |
| `/users/:id` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
| `/users/:id/edit` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
| `/users/:id/show/edit` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
| `/settings` | ✗ | ✗ | ✗ | ✓ |
| `/membership_fee_settings` | ✗ | ✗ | ✗ | ✓ |
| `/membership_fee_types` | ✗ | ✗ | ✗ | ✓ |
| `/membership_fee_types/new` | ✗ | ✗ | ✗ | ✓ |
| `/membership_fee_types/:id/edit` | ✗ | ✗ | ✗ | ✓ |
| `/groups` | ✗ | ✓ | ✓ | ✓ |
| `/groups/new` | ✗ | ✗ | ✗ | ✓ |
| `/groups/:slug` | ✗ | ✓ | ✓ | ✓ |
| `/groups/:slug/edit` | ✗ | ✗ | ✗ | ✓ |
| `/statistics` | ✗ | ✓ | ✓ | ✓ |
| `/admin/roles` | ✗ | ✗ | ✗ | ✓ |
| `/admin/roles/new` | ✗ | ✗ | ✗ | ✓ |
| `/admin/roles/:id` | ✗ | ✗ | ✗ | ✓ |
| `/admin/roles/:id/edit` | ✗ | ✗ | ✗ | ✓ |
| `/join_requests` (Step 2) | ✗ | ✗ | ✓ | ✓ |
| `/join_requests/:id` (Step 2) | ✗ | ✗ | ✓ | ✓ |
**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use. Step 2 (Approval UI) adds `/join_requests` and `/join_requests/:id` for normal_user and admin; routes and permission set entries are not yet implemented; tests exist in `check_page_permission_test.exs` (describe "join_requests routes" and integration blocks).
## Public Paths (no permission check)
- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`, **`/join`**
The public join page `GET /join` is explicitly public (Subtask 4); unauthenticated access returns 200 when join form is enabled, 404 when disabled. Unit test: `test/mv_web/plugs/check_page_permission_test.exs` (plug allows /join); integration: `test/mv_web/live/join_live_test.exs`.
The join confirmation route `GET /confirm_join/:token` is public (matched by `/confirm*`). Unit tests: `test/mv_web/controllers/join_confirm_controller_test.exs` (stubbed callback, no integration).
## Test Coverage
**File:** `test/mv_web/plugs/check_page_permission_test.exs`
### Unit tests (plug called directly with mock conn)
- Static: own_data denied `/members`; read_only allowed `/members`; flash on denial.
- Dynamic: read_only allowed `/members/123`; normal_user allowed `/members/456/edit`; read_only denied `/members/123/edit`.
- read_only / normal_user: denied `/admin/roles`; read_only denied `/members/new`.
- Wildcard: admin allowed `/admin/roles`, `/members/999/edit`.
- Unauthenticated: nil user denied, redirect `/sign-in`.
- Public: unauthenticated allowed `/auth/sign-in`, `/register`.
- Error: no role, invalid permission_set_name → denied.
- **Join requests (Step 2):** normal_user and admin allowed `/join_requests`, `/join_requests/:id`; read_only and own_data denied. Tests fail (red) until routes and permission set are added.
### Integration tests (full router, Mitglied = own_data)
**Denied (Mitglied gets 302 → `/users/:id`):**
- `/members`, `/members/new`, `/users`, `/users/new`, `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/membership_fee_types/new`, `/groups`, `/groups/new`, `/admin/roles`, `/admin/roles/new`
- `/members/:id/edit`, `/members/:id/show/edit`, `/users/:id` (other user), `/users/:id/edit` (other), `/users/:id/show/edit` (other), `/membership_fee_types/:id/edit`, `/groups/:slug`, `/admin/roles/:id`, `/admin/roles/:id/edit`
**Allowed (Mitglied gets 200):**
- `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`
- `/members/:id`, `/members/:id/edit`, `/members/:id/show/edit` for linked member (plug unit tests; full-router tests for linked member skipped: session/LiveView constraints)
**Root:** `GET /` redirects Mitglied to profile (root not allowed for own_data).
All protected routes above are either covered by integration “denied” tests for Mitglied or by unit tests for the relevant permission set.
### Integration tests (full router, read_only = Vorstand/Buchhaltung)
**Allowed (200):** `/`, `/members`, `/members/:id`, `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`, `/groups`, `/groups/:slug`.
**Denied (302 → `/users/:id`):** `/members/new`, `/members/:id/edit`, `/members/:id/show/edit`, `/users`, `/users/new`, `/users/:id` (other user), `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/groups/new`, `/groups/:slug/edit`, `/admin/roles`, `/admin/roles/:id`.
### Integration tests (full router, normal_user = Kassenwart)
**Allowed (200):** `/`, `/members`, `/members/new`, `/members/:id`, `/members/:id/edit`, `/members/:id/show/edit`, `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`, `/groups`, `/groups/:slug`.
**Denied (302 → `/users/:id`):** `/users`, `/users/new`, `/users/:id` (other user), `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/groups/new`, `/groups/:slug/edit`, `/admin/roles`, `/admin/roles/:id`.
### Integration tests (full router, admin)
**Allowed (200):** All protected routes (sample covered: `/`, `/members`, `/users`, `/settings`, `/membership_fee_settings`, `/admin/roles`, `/members/:id`, `/admin/roles/:id`, `/groups/:slug`).
## Plug behaviour: reserved segments
The plug treats `"new"` as a reserved path segment so that patterns like `/members/:id` and `/groups/:slug` do not match `/members/new` or `/groups/new`. Thus `/groups/new` is only allowed when the permission set explicitly lists `/groups/new` (currently only admin).
## Role and member_id loading
The plug may reload the user's role (and optionally `member_id`) before checking page permission. Session/`load_from_session` can leave the role unloaded; the plug uses `Mv.Authorization.Actor.ensure_loaded/1` (and, when needed, loads `member_id`) so that permission checks always have the required data. No change to session loading is required; this is documented for clarity.

View file

@ -1,71 +0,0 @@
# PDF Generation: Imprintor statt Chromium
## Übersicht
Für die PDF-Generierung in der Mitgliederverwaltung verwenden wir **Imprintor** (`~> 0.5.0`) anstelle von Chromium-basierten Lösungen (wie z.B. Puppeteer, Chrome Headless, oder ähnliche).
## Warum Imprintor statt Chromium?
### 1. Ressourceneffizienz
- **Geringerer Speicherverbrauch**: Imprintor benötigt keine vollständige Browser-Instanz im Speicher
- **Niedrigere CPU-Last**: Native PDF-Generierung ohne Browser-Rendering-Pipeline
- **Kleinere Docker-Images**: Keine Chromium-Installation erforderlich (spart mehrere hundert MB)
### 2. Performance
- **Schnellere Generierung**: Direkte PDF-Generierung ohne HTML-Rendering-Overhead
- **Bessere Skalierbarkeit**: Kann mehrere PDFs parallel generieren ohne Browser-Instanzen zu verwalten
- **Niedrigere Latenz**: Keine Browser-Startup-Zeit
### 3. Deployment & Wartung
- **Einfacheres Deployment**: Keine System-Abhängigkeiten (Chromium, ChromeDriver, etc.)
- **Weniger Wartungsaufwand**: Keine Browser-Version-Updates zu verwalten
- **Bessere Container-Kompatibilität**: Funktioniert in minimalen Docker-Images (z.B. Alpine)
### 4. Sicherheit
- **Kleinere Angriffsfläche**: Keine Browser-Engine mit bekannten Sicherheitslücken
- **Isolation**: Weniger System-Calls und externe Prozesse
### 5. Elixir-Native Lösung
- **Erlang/OTP-Integration**: Nutzt die Vorteile der BEAM-VM (Concurrency, Fault Tolerance)
- **Type-Safety**: Bessere Integration mit Elixir-Typen und Pattern Matching
- **Einfachere Fehlerbehandlung**: Elixir-native Error-Handling statt externer Prozesse
## Wann Chromium trotzdem sinnvoll wäre
Chromium-basierte Lösungen sind sinnvoll, wenn:
- Komplexe JavaScript-Ausführung im HTML nötig ist
- Moderne CSS-Features (Grid, Flexbox, etc.) kritisch sind
- Screenshots von Web-Seiten generiert werden sollen
- Dynamische Inhalte gerendert werden müssen, die JavaScript erfordern
## Verwendung in diesem Projekt
Imprintor wird für folgende Anwendungsfälle verwendet:
- **Member-Export als PDF**: Generierung von Mitgliederlisten und -reports
- **Statische Reports**: PDF-Generierung für vordefinierte Report-Formate
- **Dokumente**: Generierung von Mitgliedschaftsbescheinigungen, Rechnungen, etc.
## Technische Details
- **Dependency**: `{:imprintor, "~> 0.5.0"}`
- **Typ**: Native Elixir-Bibliothek (vermutlich basierend auf Rust-NIFs oder ähnlichen Technologien)
- **Format**: Generiert PDF direkt aus HTML/Templates ohne Browser-Engine
## Migration von Chromium (falls vorhanden)
Falls zuvor eine Chromium-basierte Lösung verwendet wurde:
1. HTML-Templates müssen ggf. angepasst werden (kein JavaScript-Support)
2. CSS muss statisch sein (keine dynamischen Styles)
3. Komplexe Layouts sollten vorher getestet werden
## Weitere Ressourcen
- [Imprintor auf Hex.pm](https://hex.pm/packages/imprintor)
- [GitHub Repository](https://github.com/[imprintor-repo]) (falls verfügbar)

View file

@ -1,330 +0,0 @@
# Policy Pattern: Bypass vs. HasPermission
**Date:** 2026-01-22
**Status:** Implemented and Tested
**Applies to:** User Resource, Member Resource
---
## Summary
For filter-based permissions (`scope :own`, `scope :linked`), we use a **two-tier authorization pattern**:
1. **Bypass with `expr()` for READ operations** - Handles list queries via auto_filter
2. **HasPermission for UPDATE/CREATE/DESTROY** - Uses scope from PermissionSets when record is present
This pattern ensures that the scope concept in PermissionSets is actually used and not redundant.
---
## The Problem
### Initial Assumption (INCORRECT)
> "No separate Own Credentials Bypass needed, as all permission sets already have User read/update :own. HasPermission with scope :own handles this correctly."
This assumption was based on the idea that `HasPermission` returning `{:filter, expr(...)}` would automatically trigger Ash's `auto_filter` for list queries.
### Reality
**When HasPermission returns `{:filter, expr(...)}`:**
1. `strict_check` is called first
2. For list queries (no record yet), `strict_check` returns `{:ok, false}`
3. Ash **STOPS** evaluation and does **NOT** call `auto_filter`
4. Result: List queries fail with empty results ❌
**Example:**
```elixir
# This FAILS for list queries:
policy action_type([:read, :update]) do
authorize_if Mv.Authorization.Checks.HasPermission
end
# User tries to list all users:
Ash.read(User, actor: user)
# Expected: Returns [user] (filtered to own record)
# Actual: Returns [] (empty list)
```
---
## The Solution
### Pattern: Bypass for READ, HasPermission for UPDATE
**User Resource Example:**
```elixir
policies do
# Bypass for READ (handles list queries via auto_filter)
bypass action_type(:read) do
description "Users can always read their own account"
authorize_if expr(id == ^actor(:id))
end
# HasPermission for UPDATE (scope :own works with changesets)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role and permission set"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
```
**Why This Works:**
| Operation | Record Available? | Method | Result |
|-----------|-------------------|--------|--------|
| **READ (list)** | ❌ No | `bypass` with `expr()` | Ash applies expr as SQL WHERE → ✅ Filtered list |
| **READ (single)** | ✅ Yes | `bypass` with `expr()` | Ash evaluates expr → ✅ true/false |
| **UPDATE** | ✅ Yes (changeset) | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized |
| **CREATE** | ✅ Yes (changeset) | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized |
| **DESTROY** | ✅ Yes | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized |
**Important: UPDATE Strategy**
UPDATE is **NOT** a hardcoded bypass. It is controlled by **PermissionSets**:
- All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) explicitly grant `User.update :own`
- `HasPermission` evaluates `scope :own` when a changeset with record is present
- If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials
- This is intentional - UPDATE is controlled by PermissionSets, not hardcoded
**Example:** The `read_only` permission set grants `User.update :own` even though it's "read-only" for member data. This allows password changes while keeping member data read-only.
---
## Why `scope :own` Is NOT Redundant
### The Question
> "If we use a bypass with `expr(id == ^actor(:id))` for READ, isn't `scope :own` in PermissionSets redundant?"
### The Answer: NO! ✅
**`scope :own` is ONLY used for operations where a record is present:**
```elixir
# PermissionSets.ex
%{resource: "User", action: :read, scope: :own, granted: true}, # Not used (bypass handles it)
%{resource: "User", action: :update, scope: :own, granted: true}, # USED by HasPermission ✅
```
**Test Proof:**
```elixir
# test/mv/accounts/user_policies_test.exs:82
test "can update own email", %{user: user} do
new_email = "updated@example.com"
# This works via HasPermission with scope :own (NOT via bypass)
{:ok, updated_user} =
user
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|> Ash.update(actor: user)
assert updated_user.email == Ash.CiString.new(new_email)
end
# ✅ Test passes - proves scope :own is used!
```
---
## Consistency Across Resources
### User Resource
```elixir
# Bypass for READ list queries
bypass action_type(:read) do
authorize_if expr(id == ^actor(:id))
end
# HasPermission for UPDATE (uses scope :own from PermissionSets)
policy action_type([:read, :create, :update, :destroy]) do
authorize_if Mv.Authorization.Checks.HasPermission
end
```
**PermissionSets:**
- `own_data`, `read_only`, `normal_user`: `scope :own` for read/update
- `admin`: `scope :all` for all operations
### Member Resource
```elixir
# Bypass for READ list queries
bypass action_type(:read) do
authorize_if expr(id == ^actor(:member_id))
end
# HasPermission for UPDATE (uses scope :linked from PermissionSets)
policy action_type([:read, :create, :update, :destroy]) do
authorize_if Mv.Authorization.Checks.HasPermission
end
```
**PermissionSets:**
- `own_data`: `scope :linked` for read/update
- `read_only`: `scope :all` for read (no update permission)
- `normal_user`, `admin`: `scope :all` for all operations
---
## Technical Deep Dive
### Why Does `expr()` in Bypass Work?
**Ash treats `expr()` natively in two contexts:**
1. **strict_check** (single record):
- Ash evaluates the expression against the record
- Returns true/false based on match
2. **auto_filter** (list queries):
- Ash compiles the expression to SQL WHERE clause
- Applies filter directly in database query
**Example:**
```elixir
bypass action_type(:read) do
authorize_if expr(id == ^actor(:id))
end
# For list query: Ash.read(User, actor: user)
# Compiled SQL: SELECT * FROM users WHERE id = $1 (user.id)
# Result: [user] ✅
```
### Why Doesn't HasPermission Trigger auto_filter?
**HasPermission.strict_check logic:**
```elixir
def strict_check(actor, authorizer, _opts) do
# ...
case check_permission(...) do
{:filter, filter_expr} ->
if record do
# Evaluate filter against record
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
else
# No record (list query) - return false
# Ash STOPS here, does NOT call auto_filter
{:ok, false}
end
end
end
```
**Why return false instead of :unknown?**
We tested returning `:unknown`, but Ash's policy evaluation still didn't reliably call `auto_filter`. The `bypass` with `expr()` is the only consistent solution.
---
## Design Principles
### 1. Consistency
Both User and Member follow the same pattern:
- Bypass for READ (list queries)
- HasPermission for UPDATE/CREATE/DESTROY (with scope)
### 2. Scope Concept Is Essential
PermissionSets define scopes for all operations:
- `:own` - User can access their own records
- `:linked` - User can access linked records (e.g., their member)
- `:all` - User can access all records (admin)
**These scopes are NOT redundant** - they are used for UPDATE/CREATE/DESTROY.
### 3. Bypass Is a Technical Workaround
The bypass is not a design choice but a **technical necessity** due to Ash's policy evaluation behavior:
- Ash doesn't call `auto_filter` when `strict_check` returns `false`
- `expr()` in bypass is handled natively by Ash for both contexts
- This is consistent with Ash's documentation and best practices
---
## Test Coverage
### User Resource Tests
**File:** `test/mv/accounts/user_policies_test.exs`
**Coverage:**
- ✅ 31 tests: 30 passing, 1 skipped
- ✅ All 4 permission sets: `own_data`, `read_only`, `normal_user`, `admin`
- ✅ READ operations (list and single) via bypass
- ✅ UPDATE operations via HasPermission with `scope :own`
- ✅ Admin operations via HasPermission with `scope :all`
- ✅ AshAuthentication bypass (registration/login)
- ✅ Tests use system_actor for authorization
**Key Tests Proving Pattern:**
```elixir
# Test 1: READ list uses bypass (returns filtered list)
test "list users returns only own user", %{user: user} do
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
assert length(users) == 1 # Filtered to own user ✅
assert hd(users).id == user.id
end
# Test 2: UPDATE uses HasPermission with scope :own
test "can update own email", %{user: user} do
{:ok, updated_user} =
user
|> Ash.Changeset.for_update(:update_user, %{email: "new@example.com"})
|> Ash.update(actor: user)
assert updated_user.email # Uses scope :own from PermissionSets ✅
end
# Test 3: Admin uses HasPermission with scope :all
test "admin can update other users", %{admin: admin, other_user: other_user} do
{:ok, updated_user} =
other_user
|> Ash.Changeset.for_update(:update_user, %{email: "admin-changed@example.com"})
|> Ash.update(actor: admin)
assert updated_user.email # Uses scope :all from PermissionSets ✅
end
```
---
## Lessons Learned
1. **Don't assume** that returning a filter from `strict_check` will trigger `auto_filter` - test it!
2. **Bypass with `expr()` is necessary** for list queries with filter-based permissions
3. **Scope concept is NOT redundant** - it's used for operations with records (UPDATE/CREATE/DESTROY)
4. **Consistency matters** - following the same pattern across resources improves maintainability
5. **Documentation is key** - explaining WHY the pattern exists prevents future confusion
---
## Future Considerations
### If Ash Changes Policy Evaluation
If a future version of Ash reliably calls `auto_filter` when `strict_check` returns `:unknown` or `{:filter, expr}`:
1. We could **remove** the bypass for READ
2. Keep only the HasPermission policy for all operations
3. Update tests to verify the new behavior
**However, for now (Ash 3.13.1), the bypass pattern is necessary and correct.**
---
## References
- **Ash Policy Documentation**: [https://hexdocs.pm/ash/policies.html](https://hexdocs.pm/ash/policies.html)
- **Implementation**: `lib/accounts/user.ex` (lines 271-315)
- **Tests**: `test/mv/accounts/user_policies_test.exs`
- **Architecture Doc**: `docs/roles-and-permissions-architecture.md`
- **Permission Sets**: `lib/mv/authorization/permission_sets.ex`

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,508 +0,0 @@
# 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:** 2026-01-13
**Status:** ✅ Implemented (2026-01-08, PR #346, closes #345)
---
## 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, CustomFieldValue, CustomField, 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", "CustomFieldValue", 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 linked to actor via relationships
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
- CustomFieldValue: `member_id == actor.member_id` (traverses Member → User relationship)
- 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,44 +0,0 @@
# Settings page Authentication section (ASCII mockup)
Structure after renaming "OIDC" to "Authentication" and adding the registration toggle.
Subsections use their own headings (h3) inside the main "Authentication" form_section.
+------------------------------------------------------------------+
| Settings |
| Manage global settings for the association. |
+------------------------------------------------------------------+
+-- Club Settings -------------------------------------------------+
| Association Name: [________________] [Save Name] |
+------------------------------------------------------------------+
+-- Join Form -----------------------------------------------------+
| ... (unchanged) |
+------------------------------------------------------------------+
+-- SMTP / E-Mail -------------------------------------------------+
| ... |
+------------------------------------------------------------------+
+-- Accounting-Software (Vereinfacht) Integration -----------------+
| ... |
+------------------------------------------------------------------+
+-- Authentication ------------------------------------------------+ <-- main section (renamed from "OIDC (Single Sign-On)")
| |
| Direct registration | <-- subsection heading (h3)
| [x] Allow direct registration (/register) |
| If disabled, users cannot sign up via /register; sign-in |
| and the join form remain available. |
| |
| OIDC (Single Sign-On) | <-- subsection heading (h3)
| (Some values are set via environment variables...) |
| Client ID: [________________] |
| Base URL: [________________] |
| Redirect URI: [________________] |
| Client Secret: [________________] (set) |
| Admin group name: [________________] |
| Groups claim: [________________] |
| [ ] Only OIDC sign-in (hide password login) |
| [Save OIDC Settings] |
+------------------------------------------------------------------+

View file

@ -1,149 +0,0 @@
# SMTP Configuration Concept
**Status:** Implemented
**Last updated:** 2026-03-12
---
## 1. Goal
Enable configurable SMTP for sending transactional emails (join confirmation, user confirmation, password reset). Configuration via **environment variables** and **Admin Settings** (database), with the same precedence pattern as OIDC and Vereinfacht: **ENV overrides Settings**. Include a **test email** action in Settings (button + recipient field) with clear success/error feedback.
---
## 2. Scope
- **In scope:** SMTP server configuration (host, port, credentials, TLS/SSL), sender identity (from-name, from-email), test email from Settings UI, warning when SMTP is not configured in production, specific error messages per failure category, graceful delivery errors in AshAuthentication senders.
- **Out of scope:** Separate adapters per email type; retry queues.
---
## 3. Configuration Sources
| Source | Priority | Use case |
|----------|----------|-----------------------------------|
| ENV | 1 | Production, Docker, 12-factor |
| Settings | 2 | Admin UI, dev without ENV |
When `SMTP_HOST` is set, SMTP runs in **ENV-only mode**:
- all SMTP fields in Settings are read-only,
- saving SMTP settings in the UI is disabled,
- and the UI shows a warning block if required SMTP ENV values are missing.
- the UI displays the effective ENV-driven SMTP values in disabled fields so admins can verify what is active.
---
## 4. SMTP Parameters
| Parameter | ENV | Settings attribute | Notes |
|----------------|------------------------|---------------------|---------------------------------------------|
| Host | `SMTP_HOST` | `smtp_host` | e.g. `smtp.example.com` |
| Port | `SMTP_PORT` | `smtp_port` | Default 587 (TLS), 465 (SSL), 25 (plain) |
| Username | `SMTP_USERNAME` | `smtp_username` | Optional if no auth |
| Password | `SMTP_PASSWORD` | `smtp_password` | Sensitive, not shown when set |
| Password file | `SMTP_PASSWORD_FILE` | — | Docker/Secrets: path to file with password |
| TLS/SSL | `SMTP_SSL` | `smtp_ssl` | `tls` / `ssl` / `none` (default: tls) |
| Sender name | `MAIL_FROM_NAME` | `smtp_from_name` | Display name in "From" header (default: Mila)|
| Sender email | `MAIL_FROM_EMAIL` | `smtp_from_email` | Address in "From" header; must match SMTP user on most servers |
**Boot-time ENV handling:** In `config/runtime.exs`, if `SMTP_PORT` is set but empty or invalid, it is treated as unset and default 587 is used. This avoids startup crashes (e.g. `ArgumentError` from `String.to_integer("")`) when variables are misconfigured in deployment.
**Important:** On most SMTP servers (e.g. Postfix with strict relay policies) the sender email (`smtp_from_email`) must be the same address as `smtp_username` or an alias that is owned by that account.
**Settings UI:** The form uses three rows on wide viewports: host, port, TLS/SSL | username, password | sender email, sender name. Content width is limited by the global settings wrapper (see `DESIGN_GUIDELINES.md` §6.4).
---
## 5. Password from File
Support **SMTP_PASSWORD_FILE** (path to file containing the password), same pattern as `OIDC_CLIENT_SECRET_FILE` in `runtime.exs`. Read once at runtime; `SMTP_PASSWORD` ENV overrides file if both are set.
---
## 6. Behaviour When SMTP Is Not Configured
- **Dev/Test:** Keep current adapters (`Swoosh.Adapters.Local`, `Swoosh.Adapters.Test`). No change.
- **Production:** If neither ENV nor Settings provide SMTP (no host):
- Show a warning in the Settings UI.
- Delivery attempts silently fall back to the Local adapter (no crash).
### 6.1 Behaviour in ENV-only mode (`SMTP_HOST` set)
- The SMTP source of truth is environment variables only.
- The UI does not allow editing SMTP fields in this mode.
- The Settings page shows a warning block when required values are missing:
- `SMTP_USERNAME`
- `SMTP_PASSWORD` or `SMTP_PASSWORD_FILE`
---
## 7. Test Email (Settings UI)
- **Location:** SMTP / E-Mail section in Global Settings.
- **Elements:** Input for recipient, submit button inside a `phx-submit` form.
- **Behaviour:** Sends one email using current SMTP config and `mail_from/0`. Returns `{:ok, _}` or `{:error, classified_reason}`.
- **Error categories:** `:sender_rejected`, `:auth_failed`, `:recipient_rejected`, `:tls_failed`, `:connection_failed`, `{:smtp_error, message}` — each shows a specific human-readable message in the UI.
- **Permission:** Reuses existing Settings page authorization (admin).
---
## 8. Sender Identity (`mail_from`)
`Mv.Mailer.mail_from/0` returns `{name, email}`. Priority:
1. `MAIL_FROM_NAME` / `MAIL_FROM_EMAIL` ENV variables
2. `smtp_from_name` / `smtp_from_email` in Settings (DB)
3. Hardcoded defaults: `{"Mila", "noreply@example.com"}`
Provided by `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`.
---
## 9. Join Confirmation Email
`MvWeb.Emails.JoinConfirmationEmail` uses the same SMTP configuration as the test email: `Mailer.deliver(email, Mailer.smtp_config())`. This ensures Settings-based SMTP is used when not configured via ENV at boot. On delivery failure the domain returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI.
---
## 10. AshAuthentication Senders
Both `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Delivery failures are logged (`Logger.error`) and not re-raised, so they never crash the caller process. AshAuthentication ignores the return value of `send/3`.
---
## 11. TLS / SSL in OTP 27
OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates.
By default, TLS certificate verification is relaxed (`verify_none`) so self-signed or internal SMTP servers work. For public SMTP providers (Gmail, Mailgun, etc.) you can enable verification:
- **ENV (prod):** Set `SMTP_VERIFY_PEER=true` (or `1`/`yes`) when configuring SMTP via environment variables in `config/runtime.exs`. This sets `config :mv, :smtp_verify_peer` and is used for both boot-time and per-send config.
- **Default:** `false` (verify_none) for backward compatibility and internal/self-signed certs.
Verify mode is set in `tls_options` for port 587 (STARTTLS). For port 465 (implicit SSL), the initial connection is `ssl:connect`, so we also pass `sockopts: [verify: verify_mode]` so the SSL handshake uses the same mode. For 587 we must not pass `verify` in sockopts—gen_tcp is used first and rejects it (ArgumentError). The logic lives in `Mv.Smtp.ConfigBuilder.build_opts/1` (single source of truth), used by `config/runtime.exs` (boot) and `Mv.Mailer.smtp_config/0` (Settings-only).
**Tests:** `Mv.Smtp.ConfigBuilderTest` asserts sockopts/TLS shape. `Mv.Mailer.smtp_config/0` returns `[]` when the mailer adapter is `Swoosh.Adapters.Test`; `test/mv/mailer_smtp_config_test.exs` asserts that guard and, with the adapter temporarily set to `Swoosh.Adapters.Local`, wiring from ENV. Those mailer tests use `Mv.DataCase` so Settings fallbacks in `Mv.Config` (e.g. SMTP username/password when ENV is unset) stay under the SQL sandbox.
---
## 12. Summary Checklist
- [x] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`.
- [x] ENV: `MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` for sender identity.
- [x] Settings: attributes and UI for host, port, username, password, TLS/SSL, from-name, from-email.
- [x] Password from file: `SMTP_PASSWORD_FILE` supported in `runtime.exs`.
- [x] Mailer: Swoosh SMTP adapter configured from merged ENV + Settings when SMTP is configured.
- [x] Per-request SMTP config via `Mv.Mailer.smtp_config/0` for Settings-only scenarios.
- [x] TLS certificate validation relaxed for OTP 27 (tls_options for 587; sockopts with verify only for 465).
- [x] Prod warning: clear message in Settings when SMTP is not configured.
- [x] Test email: form with recipient field, translatable content, classified success/error messages.
- [x] Join confirmation email: uses `Mailer.smtp_config/0` (same as test mail); on failure returns `{:error, :email_delivery_failed}`, error shown in JoinLive, logged for admin.
- [x] AshAuthentication senders: graceful error handling (no crash on delivery failure).
- [x] Gettext for all new UI strings, translated to German.
- [x] Docs and code guidelines updated.
---
## 13. Follow-up / Future Work
- **SMTP password at-rest encryption:** The `smtp_password` attribute is currently stored in plaintext in the `settings` table. It is excluded from default reads (same pattern as `oidc_client_secret`); both are read only via explicit select when needed. For production systems at-rest encryption (e.g. with [Cloak](https://hexdocs.pm/cloak)) should be considered and tracked as a follow-up issue.
- **Error classification:** SMTP error categorization currently uses substring matching on server messages (e.g. "535", "authentication"). A more robust approach would be to pattern-match on `gen_smtp` error tuples first where possible, and fall back to string analysis only when needed. Server wording varies; consider extending patterns as new providers are used.

View file

@ -1,163 +0,0 @@
# Statistics Page Implementation Plan
**Project:** Mila Membership Management System
**Feature:** Statistics page at `/statistics`
**Scope:** MVP only (no export, no optional extensions)
**Last updated:** 2026-02-10
---
## Decisions (from clarification)
| Topic | Decision |
|-------|----------|
| Route | `/statistics` |
| Navigation | Top-level menu (next to Members, Fee Types) |
| Permission | read_only, normal_user, admin (same as member list) |
| Charts | HTML/CSS and SVG only (no Contex, no Chart.js) |
| MVP scope | Minimal: active/inactive, joins/exits per year, contribution sums per year, open amount |
| Open amount | Total unpaid only (no overdue vs. not-yet-due split in MVP) |
Excluded from this plan: Export (CSV/PDF), caching, month/quarter filters, “members per fee type”, “members per group”, and overdue split.
---
## 1. Statistics module (`Mv.Statistics`)
**Goal:** Central module for all statistics; LiveView only calls this API. Uses Ash reads with actor so policies apply.
**Location:** `lib/mv/statistics.ex` (new).
**Functions to implement:**
| Function | Purpose | Data source |
|----------|---------|-------------|
| `active_member_count(opts)` | Count members with `exit_date == nil` | `Member` read with filter |
| `inactive_member_count(opts)` | Count members with `exit_date != nil` | `Member` read with filter |
| `joins_by_year(year, opts)` | Count members with `join_date` in given year | `Member` read, filter by year, count |
| `exits_by_year(year, opts)` | Count members with `exit_date` in given year | `Member` read, filter by year, count |
| `cycle_totals_by_year(year, opts)` | For cycles with `cycle_start` in year: total sum, and sums/counts by status (paid, unpaid, suspended) | `MembershipFeeCycle` read (filter by year via `cycle_start`), aggregate sum(amount) and count per status in Elixir or via Ash aggregates |
| `open_amount_total(opts)` | Sum of `amount` for all cycles with `status == :unpaid` | `MembershipFeeCycle` read with filter `status == :unpaid`, sum(amount) |
All functions accept `opts` (keyword list) and pass `actor: opts[:actor]` (and `domain:` where needed) to Ash calls. No new resources; only read actions on existing `Member` and `MembershipFeeCycle`.
**Implementation notes:**
- Use `Ash.Query.filter(Member, expr(...))` for date filters; for “year”, filter `join_date >= first_day_of_year` and `join_date <= last_day_of_year` (same for `exit_date` and for `MembershipFeeCycle.cycle_start`).
- For `cycle_totals_by_year`: either multiple Ash reads (one per status) with sum aggregate, or one read of cycles in that year and `Enum.group_by(..., :status)` then sum amounts in Elixir.
- Use `Mv.MembershipFees.CalendarCycles` only if needed for interval (e.g. cycle_end); for “cycle in year” the `cycle_start` year is enough.
**Tests:** Unit tests in `test/mv/statistics_test.exs` for each function (with fixtures: members with join_date/exit_date, cycles with cycle_start/amount/status). Use `Mv.Helpers.SystemActor.get_system_actor()` in tests for Ash read authorization where appropriate.
---
## 2. Route and authorization
**Router** ([lib/mv_web/router.ex](lib/mv_web/router.ex)):
- In the same `ash_authentication_live_session` block where `/members` and `/membership_fee_types` live, add:
- `live "/statistics", StatisticsLive, :index`
**PagePaths** ([lib/mv_web/page_paths.ex](lib/mv_web/page_paths.ex)):
- Add module attribute `@statistics "/statistics"`.
- Add `def statistics, do: @statistics`.
- No change to `@admin_page_paths` (statistics is top-level).
**Page permission** (route matrix is driven by [lib/mv/authorization/permission_sets.ex](lib/mv/authorization/permission_sets.ex)):
- Add `"/statistics"` to the `pages` list of **read_only** (e.g. after `"/groups/:slug"`) and to the `pages` list of **normal_user** (e.g. after groups entries). **admin** already has `"*"` so no change.
- **own_data** must not list `/statistics` (so they cannot access it).
- Update [docs/page-permission-route-coverage.md](docs/page-permission-route-coverage.md): add row for `| /statistics | ✗ | ✓ | ✓ | ✓ |`.
- Add test in `test/mv_web/plugs/check_page_permission_test.exs`: read_only and normal_user and admin can access `/statistics`; own_data cannot.
---
## 3. Sidebar
**File:** [lib/mv_web/components/layouts/sidebar.ex](lib/mv_web/components/layouts/sidebar.ex).
- In `sidebar_menu`, after the “Fee Types” menu item and before the “Administration” block, add a conditional menu item for Statistics:
- `can_access_page?(@current_user, PagePaths.statistics())` → show link.
- `href={~p"/statistics"}`, `icon="hero-chart-bar"` (or similar), `label={gettext("Statistics")}`.
---
## 4. Statistics LiveView
**Module:** `MvWeb.StatisticsLive`
**File:** `lib/mv_web/live/statistics_live.ex`
**Mount:** `:index` only.
**Behaviour:**
- `on_mount`: use `MvWeb.LiveUserAuth, :live_user_required` and ensure role/permission check (same as other protected LiveViews). In `mount` or `handle_params`, set default selected year to current year (e.g. `Date.utc_today().year`).
- **Assigns:** `:year` (integer), `:active_count`, `:inactive_count`, `:joins_this_year`, `:exits_this_year`, `:cycle_totals` (map with keys e.g. `:total`, `:paid`, `:unpaid`, `:suspended` for the selected year), `:open_amount_total`, and any extra needed for the bar data (e.g. list of `%{year: y, joins: j, exits: e}` for a small range of years if you show a minimal bar chart).
- **Year filter:** A single select or dropdown for year (e.g. from “first year with data” to current year). On change, send event (e.g. `"set_year"`) with `%{"year" => year}`; in `handle_event` update `assigns.year` and reload data by calling `Mv.Statistics` again and re-assigning.
**Data loading:**
- In `mount` and whenever year changes, call `Mv.Statistics` with `actor: current_actor(socket)` (and optionally `year: @year` where needed). Assign results to socket. Handle errors (e.g. redirect or flash) if a call fails.
**Layout (sections):**
1. **Page title:** e.g. “Statistics” (gettext).
2. **Year filter:** One control to select year; applies to “joins/exits” and “contribution sums” for that year.
3. **Cards (top row):**
- Active members (count)
- Inactive members (count)
- Joins in selected year
- Exits in selected year
- Open amount total (sum of all unpaid cycles; format with `MvWeb.Helpers.MembershipFeeHelpers.format_currency/1`)
- Optionally: “Paid this year” (from `cycle_totals_by_year` for selected year)
4. **Contributions for selected year:** One section showing for the chosen year: total (Soll), paid, unpaid, suspended (sums and optionally counts). Use simple table or key-value list; no chart required for MVP.
5. **Joins / Exits by year (simple bar chart):** Data: e.g. last 5 or 10 years. For each year, show joins and exits as horizontal bars (HTML/CSS: e.g. `div` with `width: #{percent}%`). Pure HTML/SVG; no external chart library. Use Tailwind/DaisyUI for layout and cards.
**Accessibility:** Semantic HTML; headings (e.g. `h2`) for each section; ensure year filter has a label; format numbers in a screen-reader-friendly way (e.g. no purely visual abbreviations without aria-label).
**i18n:** All user-visible strings via gettext (e.g. “Statistics”, “Active members”, “Inactive members”, “Joins (year)”, “Exits (year)”, “Open amount”, “Contributions for year”, “Total”, “Paid”, “Unpaid”, “Suspended”). Add keys to `priv/gettext` as needed.
---
## 5. Implementation order (tasks)
Execute in this order so that each step is testable:
1. **Statistics module**
- Add `lib/mv/statistics.ex` with the six functions above and `@moduledoc`.
- Add `test/mv/statistics_test.exs` with tests for each function (use fixtures for members and cycles; pass actor in opts).
- Run tests and fix until green.
2. **Route and permission**
- Add `live "/statistics", StatisticsLive, :index` in router.
- Add `statistics/0` and `@statistics` in PagePaths.
- Add `/statistics` to page permission logic so read_only, normal_user, admin are allowed and own_data is denied.
- Update `docs/page-permission-route-coverage.md` and add/update plug tests for `/statistics`.
3. **Sidebar**
- Add Statistics link in sidebar (top-level) with `can_access_page?` and `PagePaths.statistics()`.
4. **StatisticsLive**
- Create `lib/mv_web/live/statistics_live.ex` with mount, assigns, year param, and data loading from `Mv.Statistics`.
- Implement UI: title, year filter, cards, contribution section, simple joins/exits bar (HTML).
- Add gettext keys and use them in the template.
- Optionally add a simple LiveView test (e.g. authenticated user sees statistics page and key labels).
5. **CI and docs**
- Run `just ci-dev` (or project equivalent); fix formatting, Credo, and tests.
- In [docs/feature-roadmap.md](docs/feature-roadmap.md), update “Reporting & Analytics” to reflect that a basic statistics page is implemented (MVP).
- In [CODE_GUIDELINES.md](CODE_GUIDELINES.md), add a short note under a suitable section (e.g. “Reporting” or “LiveView”) that statistics are provided by `Mv.Statistics` and displayed in `StatisticsLive`, if desired.
---
## 6. Out of scope (not in this plan)
- Export (CSV/PDF).
- Caching (ETS/GenServer/HTTP).
- Month or quarter filters.
- “Members per fee type” or “members per group” statistics.
- Overdue vs. not-yet-due split for open amount.
- Contex or Chart.js.
- New database tables or Ash resources.
These can be added later as separate tasks or follow-up plans.

View file

@ -1,877 +0,0 @@
# Test Performance Optimization
**Last Updated:** 2026-01-28
**Status:** ✅ Active optimization program
---
## Executive Summary
This document provides a comprehensive overview of test performance optimizations, risk assessments, and future opportunities. The test suite execution time has been reduced through systematic analysis and targeted optimizations.
### Current Performance Metrics
| Metric | Value |
|--------|-------|
| **Total Execution Time** (without `:slow` tests) | ~368 seconds (~6.1 minutes) |
| **Total Tests** | 1,336 tests (+ 25 doctests) |
| **Async Execution** | 163.5 seconds |
| **Sync Execution** | 281.5 seconds |
| **Slow Tests Excluded** | 25 tests (tagged with `@tag :slow`) |
| **Top 50 Slowest Tests** | 121.9 seconds (27.4% of total time) |
### Optimization Impact Summary
| Optimization | Tests Affected | Time Saved | Status |
|--------------|----------------|------------|--------|
| Seeds tests reduction | 13 → 4 tests | ~10-16s | ✅ Completed |
| Performance tests tagging | 9 tests | ~3-4s per run | ✅ Completed |
| Critical test query filtering | 1 test | ~8-10s | ✅ Completed |
| Full test suite via promotion | 25 tests | ~77s per run | ✅ Completed |
| **Total Saved** | | **~98-107s** | |
---
## Completed Optimizations
### 1. Seeds Test Suite Optimization
**Date:** 2026-01-28
**Status:** ✅ Completed
#### What Changed
- **Reduced test count:** From 13 tests to 4 tests (69% reduction)
- **Reduced seeds executions:** From 8-10 times to 5 times per test run
- **Execution time:** From 24-30 seconds to 13-17 seconds
- **Time saved:** ~10-16 seconds per test run (40-50% faster)
#### Removed Tests (9 tests)
Tests were removed because their functionality is covered by domain-specific test suites:
1. `"at least one member has no membership fee type assigned"` → Covered by `membership_fees/*_test.exs`
2. `"each membership fee type has at least one member"` → Covered by `membership_fees/*_test.exs`
3. `"members with fee types have cycles with various statuses"` → Covered by `cycle_generator_test.exs`
4. `"creates all 5 authorization roles with correct permission sets"` → Covered by `authorization/*_test.exs`
5. `"all roles have valid permission_set_names"` → Covered by `authorization/permission_sets_test.exs`
6. `"does not change role of users who already have a role"` → Merged into idempotency test
7. `"role creation is idempotent"` (detailed) → Merged into general idempotency test
#### Retained Tests (4 tests)
Critical deployment requirements are still covered:
1. ✅ **Smoke Test:** Seeds run successfully and create basic data
2. ✅ **Idempotency Test:** Seeds can be run multiple times without duplicating data
3. ✅ **Admin Bootstrap:** Admin user exists with Admin role (critical for initial access)
4. ✅ **System Role Bootstrap:** Mitglied system role exists (critical for user registration)
#### Risk Assessment
| Removed Test Category | Alternative Coverage | Risk Level |
|----------------------|---------------------|------------|
| Member/fee type distribution | `membership_fees/*_test.exs` | ⚠️ Low |
| Cycle status variations | `cycle_generator_test.exs` | ⚠️ Low |
| Detailed role configs | `authorization/*_test.exs` | ⚠️ Very Low |
| Permission set validation | `permission_sets_test.exs` | ⚠️ Very Low |
**Overall Risk:** ⚠️ **Low** - All removed tests have equivalent or better coverage in domain-specific test suites.
---
### 2. Full Test Suite via Promotion (`@tag :slow`)
**Date:** 2026-01-28
**Status:** ✅ Completed
#### What Changed
Tests with **low risk** and **execution time >1 second** are now tagged with `@tag :slow` and excluded from standard test runs. These tests are important but not critical for every commit and are run via promotion before merging to `main`.
#### Tagging Criteria
**Tagged as `@tag :slow` when:**
- ✅ Test execution time >1 second
- ✅ Low risk (not critical for catching regressions in core business logic)
- ✅ UI/Display tests (formatting, rendering)
- ✅ Workflow detail tests (not core functionality)
- ✅ Edge cases with large datasets
**NOT tagged when:**
- ❌ Core CRUD operations (Member/User Create/Update/Destroy)
- ❌ Basic Authentication/Authorization
- ❌ Critical Bootstrap (Admin user, system roles)
- ❌ Email Synchronization
- ❌ Representative tests per Permission Set + Action
#### Identified Tests for Full Test Suite (25 tests)
**1. Seeds Tests (2 tests) - 18.1s**
- `"runs successfully and creates basic data"` (9.0s)
- `"is idempotent when run multiple times"` (9.1s)
- **Note:** Critical bootstrap tests remain in fast suite
**2. UserLive.ShowTest (3 tests) - 10.8s**
- `"mounts successfully with valid user ID"` (4.2s)
- `"displays linked member when present"` (2.4s)
- `"redirects to user list when viewing system actor user"` (4.2s)
**3. UserLive.IndexTest (5 tests) - 25.0s**
- `"displays users in a table"` (1.0s)
- `"initially sorts by email ascending"` (2.2s)
- `"can sort email descending by clicking sort button"` (3.4s)
- `"select all automatically checks when all individual users are selected"` (2.0s)
- `"displays linked member name in user list"` (1.9s)
**4. MemberLive.IndexCustomFieldsDisplayTest (3 tests) - 4.9s**
- `"displays custom field with show_in_overview: true"` (1.6s)
- `"formats date custom field values correctly"` (1.5s)
- `"formats email custom field values correctly"` (1.8s)
**5. MemberLive.IndexCustomFieldsEdgeCasesTest (3 tests) - 3.6s**
- `"displays custom field column even when no members have values"` (1.1s)
- `"displays very long custom field values correctly"` (1.4s)
- `"handles multiple custom fields with show_in_overview correctly"` (1.2s)
**6. RoleLive Tests (7 tests) - 7.7s**
- `role_live_test.exs`: `"mounts successfully"` (1.5s), `"deletes non-system role"` (2.1s)
- `role_live/show_test.exs`: 5 tests >1s (mount, display, navigation)
**7. MemberAvailableForLinkingTest (1 test) - 1.5s**
- `"limits results to 10 members even when more exist"` (1.5s)
**8. Performance Tests (1 test) - 3.8s**
- `"boolean filter performance with 150 members"` (3.8s)
**Total:** 25 tests, ~77 seconds saved
#### Execution Commands
**Fast Tests (Default):**
```bash
just test-fast
# or
mix test --exclude slow
```
**Slow Tests Only:**
```bash
just test-slow
# or
mix test --only slow
```
**All Tests:**
```bash
just test
# or
mix test
```
#### CI/CD Integration
- **Standard CI (`check-fast`):** Runs `mix test --exclude slow --exclude ui` for faster feedback loops (~6 minutes)
- **Full Test Suite (`check-full`):** Triggered via promotion before merge, executes `mix test` (all tests, including slow and UI) for comprehensive coverage (~7.4 minutes)
- **Pre-Merge:** Full test suite (`mix test`) runs via promotion before merging to main
- **Manual Execution:** Promote build to `production` in Drone CI to trigger full test suite
#### Risk Assessment
**Risk Level:** ✅ **Very Low**
- All tagged tests have **low risk** - they don't catch critical regressions
- Core functionality remains tested (CRUD, Auth, Bootstrap)
- Standard test runs are faster (~6 minutes vs ~7.4 minutes)
- Full test suite runs via promotion before merge ensures comprehensive coverage
- No functionality is lost, only execution timing changed
**Critical Tests Remain in Fast Suite:**
- Core CRUD operations (Member/User Create/Update/Destroy)
- Basic Authentication/Authorization
- Critical Bootstrap (Admin user, system roles)
- Email Synchronization
- Representative Policy tests (one per Permission Set + Action)
---
### 3. Critical Test Optimization
**Date:** 2026-01-28
**Status:** ✅ Completed
#### Problem Identified
The test `test respects show_in_overview config` was the slowest test in the suite:
- **Isolated execution:** 4.8 seconds
- **In full test run:** 14.7 seconds
- **Difference:** 9.9 seconds (test isolation issue)
#### Root Cause
The test loaded **all members** from the database, not just the 2 members from the test setup. In full test runs, many members from other tests were present in the database, significantly slowing down the query.
#### Solution Implemented
**Query Filtering:** Added search query parameter to filter to only the expected member.
**Code Change:**
```elixir
# Before:
{:ok, _view, html} = live(conn, "/members")
# After:
{:ok, _view, html} = live(conn, "/members?query=Alice")
```
#### Results
| Execution | Before | After | Improvement |
|-----------|--------|-------|-------------|
| **Isolated** | 4.8s | 1.1s | **-77%** (3.7s saved) |
| **In Module** | 4.2s | 0.4s | **-90%** (3.8s saved) |
| **Expected in Full Run** | 14.7s | ~4-6s | **-65% to -73%** (8-10s saved) |
#### Risk Assessment
**Risk Level:** ✅ **Very Low**
- Test functionality unchanged - only loads expected data
- All assertions still pass
- Test is now faster and more isolated
- No impact on test coverage
---
### 3. Full Test Suite Analysis and Categorization
**Date:** 2026-01-28
**Status:** ✅ Completed
#### Analysis Methodology
A comprehensive analysis was performed to identify tests suitable for the full test suite (via promotion) based on:
- **Execution time:** Tests taking >1 second
- **Risk assessment:** Tests that don't catch critical regressions
- **Test category:** UI/Display, workflow details, edge cases
#### Test Categorization
**🔴 CRITICAL - Must Stay in Fast Suite:**
- Core Business Logic (Member/User CRUD)
- Authentication & Authorization Basics
- Critical Bootstrap (Admin user, system roles)
- Email Synchronization
- Representative Policy Tests (one per Permission Set + Action)
**🟡 LOW RISK - Moved to Full Test Suite (via Promotion):**
- Seeds Tests (non-critical: smoke test, idempotency)
- LiveView Display/Formatting Tests
- UserLive.ShowTest (core functionality covered by Index/Form)
- UserLive.IndexTest UI Features (sorting, checkboxes, navigation)
- RoleLive Tests (role management, not core authorization)
- MemberLive Custom Fields Display Tests
- Edge Cases with Large Datasets
#### Risk Assessment Summary
| Category | Tests | Time Saved | Risk Level | Rationale |
|----------|-------|------------|------------|-----------|
| Seeds (non-critical) | 2 | 18.1s | ⚠️ Low | Critical bootstrap tests remain |
| UserLive.ShowTest | 3 | 10.8s | ⚠️ Low | Core CRUD covered by Index/Form |
| UserLive.IndexTest (UI) | 5 | 25.0s | ⚠️ Low | UI features, not core functionality |
| Custom Fields Display | 6 | 8.5s | ⚠️ Low | Formatting tests, visible in code review |
| RoleLive Tests | 7 | 7.7s | ⚠️ Low | Role management, not authorization |
| Edge Cases | 1 | 1.5s | ⚠️ Low | Edge case, not critical path |
| Performance Tests | 1 | 3.8s | ✅ Very Low | Explicit performance validation |
| **Total** | **25** | **~77s** | **⚠️ Low** | |
**Overall Risk:** ⚠️ **Low** - All moved tests have low risk and don't catch critical regressions. Core functionality remains fully tested.
#### Tests Excluded from Full Test Suite
The following tests were **NOT** moved to full test suite (via promotion) despite being slow:
- **Policy Tests:** Medium risk - kept in fast suite (representative tests remain)
- **UserLive.FormTest:** Medium risk - core CRUD functionality
- **Tests <1s:** Don't meet execution time threshold
- **Critical Bootstrap Tests:** High risk - deployment critical
---
## Current Performance Analysis
### Top 20 Slowest Tests (without `:slow`)
After implementing the full test suite via promotion, the remaining slowest tests are:
| Rank | Test | File | Time | Category |
|------|------|------|------|----------|
| 1 | `test Critical bootstrap invariants Mitglied system role exists` | `seeds_test.exs` | 6.7s | Critical Bootstrap |
| 2 | `test Critical bootstrap invariants Admin user has Admin role` | `seeds_test.exs` | 5.0s | Critical Bootstrap |
| 3 | `test normal_user permission set can read own user record` | `user_policies_test.exs` | 2.6s | Policy Test |
| 4 | `test normal_user permission set can create member` | `member_policies_test.exs` | 2.5s | Policy Test |
| 5-20 | Various Policy and LiveView tests | Multiple files | 1.5-2.4s each | Policy/LiveView |
**Total Top 20:** ~44 seconds (12% of total time without `:slow`)
**Note:** Many previously slow tests (UserLive.IndexTest, UserLive.ShowTest, Display/Formatting tests) are now tagged with `@tag :slow` and excluded from standard runs.
### Performance Hotspots Identified
#### 1. Seeds Tests (~16.2s for 4 tests)
**Status:** ✅ Optimized (reduced from 13 tests)
**Remaining Optimization Potential:** 3-5 seconds
**Opportunities:**
- Settings update could potentially be moved to `setup_all` (if sandbox allows)
- Seeds execution could be further optimized (less data in test mode)
- Idempotency test could be optimized (only 1x seeds instead of 2x)
#### 2. User LiveView Tests (~35.5s for 10 tests)
**Status:** ⏳ Identified for optimization
**Optimization Potential:** 15-20 seconds
**Files:**
- `test/mv_web/user_live/index_test.exs` (3 tests, ~10.2s)
- `test/mv_web/user_live/form_test.exs` (4 tests, ~15.0s)
- `test/mv_web/user_live/show_test.exs` (3 tests, ~10.3s)
**Patterns:**
- Many tests create user/member data
- LiveView mounts are expensive
- Form submissions with validations are slow
**Recommended Actions:**
- Move shared fixtures to `setup_all`
- Reduce test data volume (3-5 users instead of 10+)
- Optimize setup patterns for recurring patterns
#### 3. Policy Tests (~8.7s for 3 tests)
**Status:** ⏳ Identified for optimization
**Optimization Potential:** 5-8 seconds
**Files:**
- `test/mv/membership/member_policies_test.exs` (2 tests, ~6.1s)
- `test/mv/accounts/user_policies_test.exs` (1 test, ~2.6s)
**Pattern:**
- Each test creates new roles/users/members
- Roles are identical across tests
**Recommended Actions:**
- Create roles in `setup_all` (shared across tests)
- Reuse common fixtures
- Maintain test isolation while optimizing setup
---
## Future Optimization Opportunities
### Priority 1: User LiveView Tests Optimization
**Estimated Savings:** 14-22 seconds
**Status:** 📋 Analysis Complete - Ready for Implementation
#### Analysis Summary
Analysis of User LiveView tests identified significant optimization opportunities:
- **Framework functionality over-testing:** ~30 tests test Phoenix/Ash/Gettext core features
- **Redundant test data creation:** Each test creates users/members independently
- **Missing shared fixtures:** No `setup_all` usage for common data
#### Current Performance
**Top 20 Slowest Tests (User LiveView):**
- `index_test.exs`: ~10.2s for 3 tests in Top 20
- `form_test.exs`: ~15.0s for 4 tests in Top 20
- `show_test.exs`: ~10.3s for 3 tests in Top 20
- **Total:** ~35.5 seconds for User LiveView tests
#### Optimization Opportunities
**1. Remove Framework Functionality Tests (~30 tests, 8-12s saved)**
- Remove translation tests (Gettext framework)
- Remove navigation tests (Phoenix LiveView framework)
- Remove validation tests (Ash framework)
- Remove basic HTML rendering tests (consolidate into smoke test)
- Remove password storage tests (AshAuthentication framework)
**2. Implement Shared Fixtures (3-5s saved)**
- Use `setup_all` for common test data in `index_test.exs` and `show_test.exs`
- Share users for sorting/checkbox tests
- Share common users/members across tests
- **Note:** `form_test.exs` uses `async: false`, preventing `setup_all` usage
**3. Consolidate Redundant Tests (~10 tests → 3-4 tests, 2-3s saved)**
- Merge basic display tests into smoke test
- Merge navigation tests into integration test
- Reduce sorting tests to 1 integration test
**4. Optimize Test Data Volume (1-2s saved)**
- Use minimum required data (2 users for sorting, 2 for checkboxes)
- Share data across tests via `setup_all`
#### Tests to Keep (Business Logic)
**Index Tests:**
- `initially sorts by email ascending` - Tests default sort
- `can sort email descending by clicking sort button` - Tests sort functionality
- `select all automatically checks when all individual users are selected` - Business logic
- `does not show system actor user in list` - Business rule
- `displays linked member name in user list` - Business logic
- Edge case tests
**Form Tests:**
- `creates user without password` - Business logic
- `creates user with password when enabled` - Business logic
- `admin sets new password for user` - Business logic
- `selecting member and saving links member to user` - Business logic
- Member linking/unlinking workflow tests
**Show Tests:**
- `displays password authentication status` - Business logic
- `displays linked member when present` - Business logic
- `redirects to user list when viewing system actor user` - Business rule
#### Implementation Plan
**Phase 1: Remove Framework Tests (1-2 hours, ⚠️ Very Low Risk)**
- Remove translation, navigation, validation, and basic HTML rendering tests
- Consolidate remaining display tests into smoke test
**Phase 2: Implement Shared Fixtures (2-3 hours, ⚠️ Low Risk)**
- Add `setup_all` to `index_test.exs` and `show_test.exs`
- Update tests to use shared fixtures
- Verify test isolation maintained
**Phase 3: Consolidate Tests (1-2 hours, ⚠️ Very Low Risk)**
- Merge basic display tests into smoke test
- Merge navigation tests into integration test
- Reduce sorting tests to 1 integration test
**Risk Assessment:** ⚠️ **Low**
- Framework functionality is tested by framework maintainers
- Business logic tests remain intact
- Shared fixtures maintain test isolation
- Consolidation preserves coverage
### Priority 2: Policy Tests Optimization
**Estimated Savings:** 5.5-9 seconds
**Status:** 📋 Analysis Complete - Ready for Decision
#### Analysis Summary
Analysis of policy tests identified significant optimization opportunities:
- **Redundant fixture creation:** Roles and users created repeatedly across tests
- **Framework functionality over-testing:** Many tests verify Ash policy framework behavior
- **Test duplication:** Similar tests across different permission sets
#### Current Performance
**Policy Test Files Performance:**
- `member_policies_test.exs`: 24 tests, ~66s (top 20)
- `user_policies_test.exs`: 30 tests, ~66s (top 20)
- `custom_field_value_policies_test.exs`: 20 tests, ~66s (top 20)
- **Total:** 74 tests, ~152s total
**Top 20 Slowest Policy Tests:** ~66 seconds
#### Framework vs. Business Logic Analysis
**Framework Functionality (Should NOT Test):**
- Policy evaluation (how Ash evaluates policies)
- Permission lookup (how Ash looks up permissions)
- Scope filtering (how Ash applies scope filters)
- Auto-filter behavior (how Ash auto-filters queries)
- Forbidden vs NotFound (how Ash returns errors)
**Business Logic (Should Test):**
- Permission set definitions (what permissions each role has)
- Scope definitions (what scopes each permission set uses)
- Special cases (custom business rules)
- Permission set behavior (how our permission sets differ)
#### Optimization Opportunities
**1. Remove Framework Functionality Tests (~22-34 tests, 3-4s saved)**
- Remove "cannot" tests that verify error types (Forbidden, NotFound)
- Remove tests that verify auto-filter behavior (framework)
- Remove tests that verify permission evaluation (framework)
- **Risk:** ⚠️ Very Low - Framework functionality is tested by Ash maintainers
**2. Consolidate Redundant Tests (~6-8 tests → 2-3 tests, 1-2s saved)**
- Merge similar tests across permission sets
- Create integration tests that cover multiple permission sets
- **Risk:** ⚠️ Low - Same coverage, fewer tests
**3. Share Admin User Across Describe Blocks (1-2s saved)**
- Create admin user once in module-level `setup`
- Reuse admin user in helper functions
- **Note:** `async: false` prevents `setup_all`, but module-level `setup` works
- **Risk:** ⚠️ Low - Admin user is read-only in tests, safe to share
**4. Reduce Test Data Volume (0.5-1s saved)**
- Use minimum required data
- Share fixtures where possible
- **Risk:** ⚠️ Very Low - Still tests same functionality
#### Test Classification Summary
**Tests to Remove (Framework):**
- `member_policies_test.exs`: ~10 tests (cannot create/destroy/update, auto-filter tests)
- `user_policies_test.exs`: ~16 tests (cannot read/update/create/destroy, auto-filter tests)
- `custom_field_value_policies_test.exs`: ~8 tests (similar "cannot" tests)
**Tests to Consolidate (Redundant):**
- `user_policies_test.exs`: 6 tests → 2 tests (can read/update own user record)
**Tests to Keep (Business Logic):**
- All "can" tests that verify permission set behavior
- Special case tests (e.g., "user can always READ linked member")
- AshAuthentication bypass tests (our integration)
#### Implementation Plan
**Phase 1: Remove Framework Tests (1-2 hours, ⚠️ Very Low Risk)**
- Identify all "cannot" tests that verify error types
- Remove tests that verify Ash auto-filter behavior
- Remove tests that verify permission evaluation (framework)
**Phase 2: Consolidate Redundant Tests (1-2 hours, ⚠️ Low Risk)**
- Identify similar tests across permission sets
- Create integration tests that cover multiple permission sets
- Remove redundant individual tests
**Phase 3: Share Admin User (1-2 hours, ⚠️ Low Risk)**
- Add module-level `setup` to create admin user
- Update helper functions to accept admin user parameter
- Update all `setup` blocks to use shared admin user
**Risk Assessment:** ⚠️ **Low**
- Framework functionality is tested by Ash maintainers
- Business logic tests remain intact
- Admin user sharing maintains test isolation (read-only)
- Consolidation preserves coverage
### Priority 3: Seeds Tests Further Optimization
**Estimated Savings:** 3-5 seconds
**Actions:**
1. Investigate if settings update can be moved to `setup_all`
2. Introduce seeds mode for tests (less data in test mode)
3. Optimize idempotency test (only 1x seeds instead of 2x)
**Risk Assessment:** ⚠️ **Low to Medium**
- Sandbox limitations may prevent `setup_all` usage
- Seeds mode would require careful implementation
- Idempotency test optimization needs to maintain test validity
### Priority 4: Additional Test Isolation Improvements
**Estimated Savings:** Variable (depends on specific tests)
**Actions:**
1. Review tests that load all records (similar to the critical test fix)
2. Add query filters where appropriate
3. Ensure proper test isolation in async tests
**Risk Assessment:** ⚠️ **Very Low**
- Similar to the critical test optimization (proven approach)
- Improves test isolation and reliability
---
## Estimated Total Optimization Potential
| Priority | Optimization | Estimated Savings |
|----------|-------------|-------------------|
| 1 | User LiveView Tests | 14-22s |
| 2 | Policy Tests | 5.5-9s |
| 3 | Seeds Tests Further | 3-5s |
| 4 | Additional Isolation | Variable |
| **Total Potential** | | **22.5-36 seconds** |
**Projected Final Time:** From ~368 seconds (fast suite) to **~332-345 seconds** (~5.5-5.8 minutes) with remaining optimizations
**Note:** Detailed analysis documents available:
- User LiveView Tests: See "Priority 1: User LiveView Tests Optimization" section above
- Policy Tests: See "Priority 2: Policy Tests Optimization" section above
---
## Risk Assessment Summary
### Overall Risk Level: ⚠️ **Low**
All optimizations maintain test coverage while improving performance:
| Optimization | Risk Level | Mitigation |
|-------------|------------|------------|
| Seeds tests reduction | ⚠️ Low | Coverage mapped to domain tests |
| Performance tests tagging | ✅ Very Low | Tests still executed, just separately |
| Critical test optimization | ✅ Very Low | Functionality unchanged, better isolation |
| Future optimizations | ⚠️ Low | Careful implementation with verification |
### Monitoring Plan
#### Success Criteria
- ✅ Seeds tests execute in <20 seconds consistently
- ✅ No increase in seeds-related deployment failures
- ✅ No regression in authorization or membership fee bugs
- ✅ Top 20 slowest tests: < 60 seconds (currently ~44s)
- ✅ Total execution time (without `:slow`): < 10 minutes (currently 6.1 min)
- ⏳ Slow tests execution time: < 2 minutes (currently ~1.3 min)
#### What to Watch For
1. **Production Seeds Failures:**
- Monitor deployment logs for seeds errors
- If failures increase, consider restoring detailed tests
2. **Authorization Bugs After Seeds Changes:**
- If role/permission bugs appear after seeds modifications
- May indicate need for more seeds-specific role validation
3. **Test Performance Regression:**
- Monitor test execution times in CI
- Alert if times increase significantly
4. **Developer Feedback:**
- If developers report missing test coverage
- Adjust based on real-world experience
---
## Benchmarking and Analysis
### How to Benchmark Tests
**ExUnit Built-in Benchmarking:**
The test suite is configured to show the slowest tests automatically:
```elixir
# test/test_helper.exs
ExUnit.start(
slowest: 10 # Shows 10 slowest tests at the end of test run
)
```
**Run Benchmark Analysis:**
```bash
# Show slowest tests
mix test --slowest 20
# Exclude slow tests for faster feedback
mix test --exclude slow --slowest 20
# Run only slow tests
mix test --only slow --slowest 10
# Benchmark specific test file
mix test test/mv_web/member_live/index_member_fields_display_test.exs --slowest 5
```
### Benchmarking Best Practices
1. **Run benchmarks regularly** (e.g., monthly) to catch performance regressions
2. **Compare isolated vs. full runs** to identify test isolation issues
3. **Monitor CI execution times** to track trends over time
4. **Document significant changes** in test performance
---
## Test Suite Structure
### Test Execution Modes
**Fast Tests (Default):**
- Excludes slow tests (`@tag :slow`)
- Used for standard development workflow
- Execution time: ~6 minutes
- Command: `mix test --exclude slow` or `just test-fast`
**Slow Tests:**
- Tests tagged with `@tag :slow` or `@describetag :slow` (25 tests)
- Low risk, >1 second execution time
- UI/Display tests, workflow details, edge cases, performance tests
- Execution time: ~1.3 minutes
- Command: `mix test --only slow` or `just test-slow`
- Excluded from standard CI runs
**Full Test Suite (via Promotion):**
- Triggered by promoting a build to `production` in Drone CI
- Runs all tests (`mix test`) for comprehensive coverage
- Execution time: ~7.4 minutes
- Required before merging to `main` (enforced via branch protection)
**All Tests:**
- Includes both fast and slow tests
- Used for comprehensive validation (pre-merge)
- Execution time: ~7.4 minutes
- Command: `mix test` or `just test`
### Test Organization
Tests are organized to mirror the `lib/` directory structure:
```
test/
├── accounts/ # Accounts domain tests
├── membership/ # Membership domain tests
├── membership_fees/ # Membership fees domain tests
├── mv/ # Core application tests
│ ├── accounts/ # User-related tests
│ ├── membership/ # Member-related tests
│ └── authorization/ # Authorization tests
├── mv_web/ # Web layer tests
│ ├── controllers/ # Controller tests
│ ├── live/ # LiveView tests
│ └── components/ # Component tests
└── support/ # Test helpers
├── conn_case.ex # Controller test setup
└── data_case.ex # Database test setup
```
---
## Best Practices for Test Performance
### When Writing New Tests
1. **Use `async: true`** when possible (for parallel execution)
2. **Filter queries** to only load necessary data
3. **Share fixtures** in `setup_all` when appropriate
4. **Tag performance tests** with `@tag :slow` if they use large datasets
5. **Keep test data minimal** - only create what's needed for the test
### When Optimizing Existing Tests
1. **Measure first** - Use `mix test --slowest` to identify bottlenecks
2. **Compare isolated vs. full runs** - Identify test isolation issues
3. **Optimize setup** - Move shared data to `setup_all` where possible
4. **Filter queries** - Only load data needed for the test
5. **Verify coverage** - Ensure optimizations don't reduce test coverage
### Test Tagging Guidelines
#### Tag as `@tag :slow` when:
1. **Performance Tests:**
- Explicitly testing performance characteristics
- Using large datasets (50+ records)
- Testing scalability or query optimization
- Validating N+1 query prevention
2. **Low-Risk Tests (>1s):**
- UI/Display/Formatting tests (not critical for every commit)
- Workflow detail tests (not core functionality)
- Edge cases with large datasets
- Show page tests (core functionality covered by Index/Form tests)
- Non-critical seeds tests (smoke tests, idempotency)
#### Do NOT tag as `@tag :slow` when:
- ❌ Test is slow due to inefficient setup (fix the setup instead)
- ❌ Test is slow due to bugs (fix the bug instead)
- ❌ Core CRUD operations (Member/User Create/Update/Destroy)
- ❌ Basic Authentication/Authorization
- ❌ Critical Bootstrap (Admin user, system roles)
- ❌ Email Synchronization
- ❌ Representative Policy tests (one per Permission Set + Action)
- ❌ It's an integration test (use `@tag :integration` instead)
---
## Changelog
### 2026-01-28: Initial Optimization Phase
**Completed:**
- ✅ Reduced seeds tests from 13 to 4 tests
- ✅ Tagged 9 performance tests with `@tag :slow`
- ✅ Optimized critical test with query filtering
- ✅ Created slow test suite infrastructure
- ✅ Updated CI/CD to exclude slow tests from standard runs
- ✅ Added promotion-based full test suite pipeline (`check-full`)
**Time Saved:** ~21-30 seconds per test run
### 2026-01-28: Full Test Suite via Promotion Implementation
**Completed:**
- ✅ Analyzed all tests for full test suite candidates
- ✅ Identified 36 tests with low risk and >1s execution time
- ✅ Tagged 25 tests with `@tag :slow` for full test suite (via promotion)
- ✅ Categorized tests by risk level and execution time
- ✅ Documented tagging criteria and guidelines
**Tests Tagged:**
- 2 Seeds tests (non-critical) - 18.1s
- 3 UserLive.ShowTest tests - 10.8s
- 5 UserLive.IndexTest tests - 25.0s
- 3 MemberLive.IndexCustomFieldsDisplayTest tests - 4.9s
- 3 MemberLive.IndexCustomFieldsEdgeCasesTest tests - 3.6s
- 7 RoleLive tests - 7.7s
- 1 MemberAvailableForLinkingTest - 1.5s
- 1 Performance test (already tagged) - 3.8s
**Time Saved:** ~77 seconds per test run
**Total Optimization Impact:**
- **Before:** ~445 seconds (7.4 minutes)
- **After (fast suite):** ~368 seconds (6.1 minutes)
- **Time saved:** ~77 seconds (17% reduction)
**Next Steps:**
- ⏳ Monitor full test suite execution via promotion in CI
- ⏳ Optimize remaining slow tests (Policy tests, etc.)
- ⏳ Further optimize Seeds tests (Priority 3)
---
## References
- **Testing Standards:** `CODE_GUIDELINES.md` - Section 4 (Testing Standards)
- **CI/CD Configuration:** `.drone.yml`
- **Test Helper:** `test/test_helper.exs`
- **Justfile Commands:** `Justfile` (test-fast, test-slow, test-all)
---
## Questions & Answers
**Q: What if seeds create wrong data and break the system?**
A: The smoke test will fail if seeds raise errors. Domain tests ensure business logic is correct regardless of seeds content.
**Q: What if we add a new critical bootstrap requirement?**
A: Add a new test to the "Critical bootstrap invariants" section in `test/seeds_test.exs`.
**Q: How do we know the removed tests aren't needed?**
A: Monitor for 2-3 months. If no seeds-related bugs appear that would have been caught by removed tests, they were redundant.
**Q: Should we restore the tests for important releases?**
A: Consider running the full test suite (including slow tests) before major releases. Daily development uses the optimized suite.
**Q: How do I add a new performance test?**
A: Tag it with `@tag :slow` for individual tests or `@describetag :slow` for describe blocks. Use `@describetag` instead of `@moduletag` to avoid tagging unrelated tests. Include measurable performance assertions (query counts, timing with tolerance, etc.). See "Performance Test Guidelines" section above.
**Q: Can I run slow tests locally?**
A: Yes, use `just test-slow` or `mix test --only slow`. They're excluded from standard runs for faster feedback.
**Q: What is the "full test suite"?**
A: The full test suite runs **all tests** (`mix test`), including slow and UI tests. Tests tagged with `@tag :slow` or `@describetag :slow` are excluded from standard CI runs (`check-fast`) for faster feedback, but are included when promoting a build to `production` (`check-full`) before merging to `main`.
**Q: Which tests should I tag as `:slow`?**
A: Tag tests with `@tag :slow` if they: (1) take >1 second, (2) have low risk (not critical for catching regressions), and (3) test UI/Display/Formatting or workflow details. See "Test Tagging Guidelines" section for details.
**Q: What if a slow test fails in the full test suite?**
A: If a test in the full test suite fails, investigate the failure. If it indicates a critical regression, consider moving it back to the fast suite. If it's a flaky test, fix the test itself. The merge will be blocked until all tests pass.

View file

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

View file

@ -1,47 +0,0 @@
# Vereinfacht API Integration
This document describes the current integration with the Vereinfacht (verein.visuel.dev) accounting API for syncing members as finance contacts.
## Overview
- **Purpose:** Create and update external finance contacts in Vereinfacht when members are created or updated; support bulk sync for members without a contact ID.
- **Configuration:** ENV or Settings: `VEREINFACHT_API_URL`, `VEREINFACHT_API_KEY`, `VEREINFACHT_CLUB_ID`, optional `VEREINFACHT_APP_URL` for contact view links.
- **Modules:** `Mv.Vereinfacht` (business logic), `Mv.Vereinfacht.Client` (HTTP client), `Mv.Vereinfacht.Changes.SyncContact` (Ash after_transaction change).
## API Usage
### Finding an existing contact by email
The API supports filtered list requests. Use a single GET instead of paginating:
- **Endpoint:** `GET /api/v1/finance-contacts?filter[isExternal]=true&filter[email]=<email>`
- **Client:** `Mv.Vereinfacht.Client.find_contact_by_email/1` builds this URL (with encoded email) and returns `{:ok, contact_id}` if the first match exists, `{:error, :not_found}` otherwise.
- No member fields are required in the app solely for this lookup.
### Creating a contact
When creating an external finance contact, the API only requires:
- **Attributes:** `contactType` (e.g. `"person"`), `isExternal: true`
- **Relationship:** `club` (club ID from config)
Additional attributes (firstName, lastName, email, address, zipCode, city, country) are optional and are sent when present on the member so the contact is filled in. The app does **not** enforce extra required member fields for Vereinfacht; only Settings-based required fields and email apply.
- **Client:** `Mv.Vereinfacht.Client.create_contact/1` builds the JSON:API body from the member; `Mv.Constants.vereinfacht_required_member_fields/0` is an empty list.
### Updating a contact
- **Endpoint:** `PATCH /api/v1/finance-contacts/:id`
- **Client:** `Mv.Vereinfacht.Client.update_contact/2` sends current member attributes. The API may still validate presence/format of fields on update.
## Flow
1. **Member create/update:** `SyncContact` runs after the transaction. If the member has no `vereinfacht_contact_id`, the client tries `find_contact_by_email(email)`; if found, it updates that contact and stores the ID on the member; otherwise it creates a contact and stores the new ID. If the member already has a contact ID, the client updates the contact.
2. **Bulk sync:** “Sync all members without Vereinfacht contact” calls `Vereinfacht.sync_members_without_contact/0`, which loads members with nil/blank `vereinfacht_contact_id` and runs the same create/update flow per member.
## References
- **Config:** `Mv.Config` (`vereinfacht_api_url`, `vereinfacht_api_key`, `vereinfacht_club_id`, `vereinfacht_app_url`, `vereinfacht_configured?/0`).
- **Constants:** `Mv.Constants.vereinfacht_required_member_fields/0` (empty), `vereinfacht_required_field?/1` (legacy; currently unused in UI or validation).
- **Tests:** `test/mv/vereinfacht/`, `test/mv/config_vereinfacht_test.exs`; see `test/mv/vereinfacht/vereinfacht_test_README.md` for scope.
- **Roadmap:** Payment/transaction import and deeper integration are tracked in `docs/feature-roadmap.md` and `docs/membership-fee-architecture.md`.

View file

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

View file

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

View file

@ -5,46 +5,30 @@ defmodule Mv.Accounts.User do
use Ash.Resource, use Ash.Resource,
domain: Mv.Accounts, domain: Mv.Accounts,
data_layer: AshPostgres.DataLayer, data_layer: AshPostgres.DataLayer,
extensions: [AshAuthentication], extensions: [AshAuthentication]
authorizers: [Ash.Policy.Authorizer]
require Ash.Query # authorizers: [Ash.Policy.Authorizer]
import Ash.Expr
alias Ash.Resource.Preparation.Builtins
alias Mv.Authorization.Role, as: RoleResource
alias Mv.Helpers.SystemActor
alias Mv.OidcRoleSync
postgres do postgres do
table "users" table "users"
repo Mv.Repo repo Mv.Repo
references do
# When a member is deleted, set the user's member_id to NULL
# This allows users to continue existing even if their linked member is removed
reference :member, on_delete: :nilify
# When a role is deleted, prevent deletion if users are assigned to it
# This protects critical roles from accidental deletion
reference :role, on_delete: :restrict
end
end end
@doc """ @doc """
AshAuthentication specific: Defines the strategies we want to use for authentication. AshAuthentication specific: Defines the strategies we want to use for authentication.
Currently password and SSO via OIDC (supports any provider: Authentik, Rauthy, Keycloak, etc.) Currently password and SSO with Rauthy as OIDC provider
""" """
authentication do authentication do
session_identifier Application.compile_env!(:mv, :session_identifier) session_identifier Application.compile_env(:mv, :session_identifier, :jti)
tokens do tokens do
enabled? true enabled? true
token_resource Mv.Accounts.Token token_resource Mv.Accounts.Token
require_token_presence_for_authentication? Application.compile_env!( require_token_presence_for_authentication? Application.compile_env(
:mv, :mv,
:require_token_presence_for_authentication :require_token_presence_for_authentication,
false
) )
store_all_tokens? true store_all_tokens? true
@ -57,7 +41,7 @@ defmodule Mv.Accounts.User do
end end
strategies do strategies do
oidc :oidc do oidc :rauthy do
client_id Mv.Secrets client_id Mv.Secrets
base_url Mv.Secrets base_url Mv.Secrets
redirect_uri Mv.Secrets redirect_uri Mv.Secrets
@ -65,9 +49,6 @@ defmodule Mv.Accounts.User do
auth_method :client_secret_jwt auth_method :client_secret_jwt
code_verifier true 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 # id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87
end end
@ -75,134 +56,20 @@ defmodule Mv.Accounts.User do
identity_field :email identity_field :email
hash_provider AshAuthentication.BcryptProvider hash_provider AshAuthentication.BcryptProvider
confirmation_required? false confirmation_required? false
resettable do
sender Mv.Accounts.User.Senders.SendPasswordResetEmail
end
end end
end end
end end
actions do actions do
# Default actions for framework/tooling integration: defaults [:read, :create, :destroy, :update]
# - :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:
# - :create_user (for manual user creation with optional member link)
# - :register_with_password (for password-based registration)
# - :register_with_oidc (for OIDC-based registration)
defaults [:read]
destroy :destroy do
primary? true
# Required because custom validation (system actor protection) cannot run atomically
require_atomic? false
end
# Primary generic update action:
# - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
# helpers that assume a default update action.
# - Intended for simple attribute updates (e.g., :email) and scenarios
# that do NOT need to manage the :member relationship.
# - For linking/unlinking a member (and the related validations), prefer
# the specialized :update_user action below.
update :update do
primary? true
accept [:email]
# Required because custom validation functions (email validation, member relationship validation)
# cannot be executed atomically. These validations need to query the database and perform
# complex checks that are not supported in atomic operations.
require_atomic? false
# Sync email changes to linked member (User → Member)
# Only runs when email is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end
create :create_user do create :create_user do
description "Creates a new user with optional member relationship. The member relationship is managed through the :member argument."
# Only accept email directly - member_id is NOT in accept list
# This prevents direct foreign key manipulation, forcing use of manage_relationship
accept [:email] accept [:email]
# Allow member to be passed as argument for relationship management
argument :member, :map, allow_nil?: true
upsert? true upsert? true
# Note: Default role is automatically assigned via attribute default (see attributes block)
# Manage the member relationship during user creation
change manage_relationship(:member, :member,
# Look up existing member and relate to it
on_lookup: :relate,
# Error if member doesn't exist in database
on_no_match: :error,
# If member already linked to this user, ignore (shouldn't happen in create)
on_match: :ignore,
# If no member provided, that's fine (optional relationship)
on_missing: :ignore
)
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end end
update :update_user do update :update_user do
description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one." accept [:email]
# Accept email and role_id (role_id only used by admins; policy restricts update_user to admins).
# member_id is NOT in accept list - use argument :member for relationship management.
accept [:email, :role_id]
# Allow member to be passed as argument for relationship management
argument :member, :map, allow_nil?: true
# Required because custom validation functions (email validation, member relationship validation)
# cannot be executed atomically. These validations need to query the database and perform
# complex checks that are not supported in atomic operations.
require_atomic? false
# Manage the member relationship during user update
change manage_relationship(:member, :member,
# Look up existing member and relate to it
on_lookup: :relate,
# Error if member doesn't exist in database
on_no_match: :error,
# If same member provided, that's fine (allows updates with same member)
on_match: :ignore,
# If no member provided, remove existing relationship (allows member removal)
on_missing: :unrelate
)
# Sync email changes and handle linking (User → Member)
# Runs when email OR member relationship changes
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where any([changing(:email), changing(:member)])
end
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end
# Internal update used only by SystemActor/bootstrap and tests to assign role to system user.
# Not protected by system-user validation so bootstrap can run.
update :update_internal do
accept []
require_atomic? false
end
# Internal: set role from OIDC group sync (Mv.OidcRoleSync). Bypass policy when context.private.oidc_role_sync.
# Same "at least one admin" validation as update_user (see validations where action_is).
update :set_role_from_oidc_sync do
accept [:role_id]
require_atomic? false
end end
# Admin action for direct password changes in admin panel # Admin action for direct password changes in admin panel
@ -210,59 +77,12 @@ defmodule Mv.Accounts.User do
update :admin_set_password do update :admin_set_password do
accept [:email] accept [:email]
argument :password, :string, allow_nil?: false, sensitive?: true argument :password, :string, allow_nil?: false, sensitive?: true
require_atomic? false
# Set the strategy context that HashPasswordChange expects # Set the strategy context that HashPasswordChange expects
change set_context(%{strategy_name: :password}) change set_context(%{strategy_name: :password})
# Use the official Ash Authentication password change # Use the official Ash Authentication password change
change AshAuthentication.Strategy.Password.HashPasswordChange change AshAuthentication.Strategy.Password.HashPasswordChange
# Sync email changes to linked member when email is changed (e.g. form changes both)
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end
# Action to link an OIDC account to an existing password-only user
# This is called after the user has verified their password
update :link_oidc_id do
description "Links an OIDC ID to an existing user after password verification"
accept []
argument :oidc_id, :string, allow_nil?: false
argument :oidc_user_info, :map, allow_nil?: false
require_atomic? false
change fn changeset, _ctx ->
oidc_id = Ash.Changeset.get_argument(changeset, :oidc_id)
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")
changeset
|> Ash.Changeset.change_attribute(:oidc_id, oidc_id)
# Update email if it differs from OIDC provider
# change_attribute/3 already checks if value matches existing value
|> then(fn cs ->
if new_email do
Ash.Changeset.change_attribute(cs, :email, new_email)
else
cs
end
end)
end
# Sync email changes to member if email was updated
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end end
read :get_by_subject do read :get_by_subject do
@ -272,49 +92,19 @@ defmodule Mv.Accounts.User do
prepare AshAuthentication.Preparations.FilterBySubject prepare AshAuthentication.Preparations.FilterBySubject
end end
read :sign_in_with_oidc do read :sign_in_with_rauthy do
# Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1).
get? true
argument :user_info, :map, allow_nil?: false argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false argument :oauth_tokens, :map, allow_nil?: false
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
# SECURITY: Filter by oidc_id, NOT by email! filter expr(email == get_path(^arg(:user_info), [:preferred_username]))
# This ensures that OIDC sign-in only works for users who have already
# linked their account via OIDC. Password-only users (oidc_id = nil)
# cannot be accessed via OIDC login without password verification.
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
# Sync role from OIDC groups after sign-in (e.g. admin group → Admin role)
# get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each
prepare Builtins.after_action(fn query, result, _context ->
user_info = Ash.Query.get_argument(query, :user_info) || %{}
oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{}
users =
case result do
nil -> []
u when is_struct(u, __MODULE__) -> [u]
list when is_list(list) -> list
_ -> []
end
Enum.each(users, fn user ->
OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
end)
{:ok, result}
end)
end end
create :register_with_oidc do create :register_with_rauthy do
argument :user_info, :map, allow_nil?: false argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false argument :oauth_tokens, :map, allow_nil?: false
upsert? true upsert? true
# Upsert based on oidc_id (primary match for existing OIDC users)
upsert_identity :unique_oidc_id upsert_identity :unique_oidc_id
# On upsert, only update email - preserve existing role_id
upsert_fields [:email]
validate &__MODULE__.validate_oidc_id_present/2 validate &__MODULE__.validate_oidc_id_present/2
@ -323,225 +113,19 @@ defmodule Mv.Accounts.User do
change fn changeset, _ctx -> change fn changeset, _ctx ->
user_info = Ash.Changeset.get_argument(changeset, :user_info) 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 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"]) |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
end end
# Check for email collisions with existing accounts
# This validation must run AFTER email and oidc_id are set above
# - Raises PasswordVerificationRequired for password-protected OR passwordless users
# - The LinkOidcAccountLive will auto-link passwordless users without password prompt
validate Mv.Accounts.User.Validations.OidcEmailCollision
# Note: Default role is automatically assigned via attribute default (see attributes block)
# upsert_fields [:email] ensures existing users' roles are preserved during upserts
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
# Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated
change fn changeset, _ctx ->
user_info = Ash.Changeset.get_argument(changeset, :user_info)
oauth_tokens = Ash.Changeset.get_argument(changeset, :oauth_tokens) || %{}
Ash.Changeset.after_action(changeset, fn _cs, record ->
Mv.OidcRoleSync.apply_admin_role_from_user_info(record, user_info, oauth_tokens)
# Return original record so __metadata__.token (from GenerateTokenChange) is preserved
{:ok, record}
end)
end
end end
end end
# Authorization Policies
# Order matters: Most specific policies first, then general permission check
policies do
# When OIDC-only is active, password sign-in is forbidden (SSO only).
policy action(:sign_in_with_password) do
forbid_if Mv.Authorization.Checks.OidcOnlyActive
authorize_if always()
end
# AshAuthentication bypass (registration/login without actor)
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
description "Allow AshAuthentication internal operations (registration, login)"
authorize_if always()
end
# READ bypass for list queries (scope :own via expr)
bypass action_type(:read) do
description "Users can always read their own account"
authorize_if expr(id == ^actor(:id))
end
# update_user allows :member argument (link/unlink). Only admins may use it to prevent
# privilege escalation (own_data could otherwise link to any member and get :linked scope).
policy action(:update_user) do
description "Only admins can update user with member link/unlink"
forbid_unless Mv.Authorization.Checks.ActorIsAdmin
authorize_if Mv.Authorization.Checks.ActorIsAdmin
end
# set_role_from_oidc_sync: internal only (called from Mv.OidcRoleSync on registration/sign-in).
# Not exposed in code_interface; only allowed when context.private.oidc_role_sync is set.
bypass action(:set_role_from_oidc_sync) do
description "Internal: OIDC role sync (server-side only)"
authorize_if Mv.Authorization.Checks.OidcRoleSyncContext
end
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role and permission set"
authorize_if Mv.Authorization.Checks.HasPermission
end
# Default: Ash implicitly forbids if no policy authorizes (fail-closed)
end
# Global validations - applied to all relevant actions # Global validations - applied to all relevant actions
validations do validations do
# Password strength policy: minimum 8 characters for all password-related actions # Password strength policy: minimum 8 characters for all password-related actions
validate string_length(:password, min: 8), validate string_length(:password, min: 8) do
where: [action_is([:register_with_password, :admin_set_password])], where action_is([:register_with_password, :admin_set_password])
message: "must have length of at least 8"
# Block direct registration when disabled in global settings
validate {Mv.Accounts.User.Validations.RegistrationEnabled, []},
where: [action_is(:register_with_password)]
# Block password registration when OIDC-only mode is active
validate {Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration, []},
where: [action_is(:register_with_password)]
# Email uniqueness check for all actions that change the email attribute
# Validates that user email is not already used by another (unlinked) member
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
# Email validation with EctoCommons.EmailValidator (same as Member)
# This ensures consistency between User and Member email validation
validate fn changeset, _ ->
# Get email from attribute (Ash.CiString) and convert to string
email = Ash.Changeset.get_attribute(changeset, :email)
email_string = if email, do: to_string(email), else: nil
# Only validate if email is present
if email_string do
changeset2 =
{%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: email_string}, [:email])
|> EctoCommons.EmailValidator.validate_email(:email,
checks: Mv.Constants.email_validator_checks()
)
if changeset2.valid? do
:ok
else
{:error, field: :email, message: "is not a valid email"}
end
else
:ok
end
end end
# Prevent overwriting existing member relationship
# This validation ensures race condition safety by requiring explicit two-step process:
# 1. Remove existing member (set member to nil)
# 2. Add new member
# This prevents accidental overwrites when multiple admins work simultaneously
validate fn changeset, _context ->
member_arg = Ash.Changeset.get_argument(changeset, :member)
current_member_id = changeset.data.member_id
# Only trigger if:
# - member argument is provided AND has an ID
# - user currently has a member
# - the new member ID is different from current member ID
if member_arg && member_arg[:id] && current_member_id &&
member_arg[:id] != current_member_id do
{:error,
field: :member, message: "User already has a member. Remove existing member first."}
else
:ok
end
end
# Last-admin: prevent the only admin from leaving the admin role (at least one admin required).
# Only block when the user is leaving admin (target role is not admin). Switching between
# two admin roles (e.g. "Admin" and "Superadmin" both with permission_set_name "admin") is allowed.
validate fn changeset, _context ->
if Ash.Changeset.changing_attribute?(changeset, :role_id) do
new_role_id = Ash.Changeset.get_attribute(changeset, :role_id)
if is_nil(new_role_id) do
:ok
else
current_role_id = changeset.data.role_id
current_role =
Mv.Authorization.Role
|> Ash.get!(current_role_id, authorize?: false)
new_role =
Mv.Authorization.Role
|> Ash.get!(new_role_id, authorize?: false)
# Only block when current user is admin and target role is not admin (leaving admin)
if current_role.permission_set_name == "admin" and
new_role.permission_set_name != "admin" do
admin_role_ids =
Mv.Authorization.Role
|> Ash.Query.for_read(:read)
|> Ash.Query.filter(expr(permission_set_name == "admin"))
|> Ash.read!(authorize?: false)
|> Enum.map(& &1.id)
# Count only non-system users with admin role (system user is for internal ops)
system_email = SystemActor.system_user_email()
count =
__MODULE__
|> Ash.Query.for_read(:read)
|> Ash.Query.filter(expr(role_id in ^admin_role_ids))
|> Ash.Query.filter(expr(email != ^system_email))
|> Ash.count!(authorize?: false)
if count <= 1 do
{:error,
field: :role_id, message: "At least one user must keep the Admin role."}
else
:ok
end
else
:ok
end
end
else
:ok
end
end,
on: [:update],
where: [action_is([:update_user, :set_role_from_oidc_sync])]
# Prevent modification of the system actor user (required for internal operations).
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
validate fn changeset, _context ->
if SystemActor.system_user?(changeset.data) do
{:error,
field: :email,
message:
"Cannot modify system actor user. This user is required for internal operations."}
else
:ok
end
end,
on: [:update, :destroy],
where: [action_is([:update, :update_user, :admin_set_password, :destroy])]
end end
def validate_oidc_id_present(changeset, _context) do def validate_oidc_id_present(changeset, _context) do
@ -557,53 +141,18 @@ defmodule Mv.Accounts.User do
attributes do attributes do
uuid_primary_key :id uuid_primary_key :id
# IMPORTANT: Email Synchronization attribute :email, :ci_string, allow_nil?: false, public?: true
# When user and member are linked, emails are automatically synced bidirectionally.
# User.email is the source of truth - when a link is established, member.email
# is overridden to match user.email. Subsequent changes to either email will
# sync to the other resource.
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
# Mv.EmailSync.Changes.SyncMemberEmailToUser
attribute :email, :ci_string do
allow_nil? false
public? true
# Same constraints as Member email for consistency
constraints min_length: 5, max_length: 254
end
attribute :hashed_password, :string, sensitive?: true, allow_nil?: true attribute :hashed_password, :string, sensitive?: true, allow_nil?: true
attribute :oidc_id, :string, allow_nil?: true attribute :oidc_id, :string, allow_nil?: true
# Role assignment: Explicitly defined to enforce default value
# This ensures every user has a role, regardless of creation path
# (register_with_password, create_user, seeds, etc.)
attribute :role_id, :uuid do
allow_nil? false
default &__MODULE__.default_role_id/0
public? false
end
end end
relationships do relationships do
# 1:1 relationship - User can optionally belong to one Member
# This automatically creates a `member_id` attribute in the User table
# The relationship is optional (allow_nil? true by default)
belongs_to :member, Mv.Membership.Member belongs_to :member, Mv.Membership.Member
# 1:1 relationship - User belongs to a Role
# We define role_id ourselves (above in attributes) to control default value
# Foreign key constraint: on_delete: :restrict (prevents deleting roles assigned to users)
belongs_to :role, Mv.Authorization.Role do
define_attribute? false
source_attribute :role_id
allow_nil? false
end
end end
identities do identities do
identity :unique_email, [:email] identity :unique_email, [:email]
identity :unique_oidc_id, [:oidc_id] identity :unique_oidc_id, [:oidc_id]
identity :unique_member, [:member_id]
end end
# You can customize this if you wish, but this is a safe default that # You can customize this if you wish, but this is a safe default that
@ -617,60 +166,4 @@ defmodule Mv.Accounts.User do
# forbid_if(always()) # forbid_if(always())
# end # end
# end # end
@doc """
Returns the default role ID for new users.
This function is called automatically when creating a user without an explicit role_id.
It fetches the "Mitglied" role from the database without authorization checks
(safe during user creation bootstrap phase).
The result is cached in the process dictionary to avoid repeated database queries
during high-volume user creation. The cache is invalidated on application restart.
## Bootstrap Safety
Only non-nil values are cached. If the role doesn't exist yet (e.g., before seeds run),
`nil` is not cached, allowing subsequent calls to retry after the role is created.
This prevents bootstrap issues where a process would be permanently stuck with `nil`
if the first call happens before the role exists.
## Performance Note
This function makes one database query per process (cached in process dictionary).
For very high-volume scenarios, consider using a fixed UUID from Application config
instead of querying the database.
## Returns
- UUID of the "Mitglied" role if it exists
- `nil` if the role doesn't exist (will cause validation error due to `allow_nil? false`)
## Examples
iex> Mv.Accounts.User.default_role_id()
"019bf2e2-873a-7712-a7ce-a5a1f90c5f4f"
"""
@spec default_role_id() :: Ecto.UUID.t() | nil
def default_role_id do
# Cache in process dictionary to avoid repeated queries
# IMPORTANT: Only cache non-nil values to avoid bootstrap issues.
# If the role doesn't exist yet (e.g., before seeds run), we don't cache nil
# so that subsequent calls can retry after the role is created.
case Process.get({__MODULE__, :default_role_id}) do
nil ->
role_id =
case RoleResource.get_mitglied_role() do
{:ok, %RoleResource{id: id}} -> id
_ -> nil
end
# Only cache non-nil values to allow retry if role is created later
if role_id, do: Process.put({__MODULE__, :default_role_id}, role_id)
role_id
cached_role_id ->
cached_role_id
end
end
end end

View file

@ -1,33 +0,0 @@
defmodule Mv.Accounts.User.Errors.PasswordVerificationRequired do
@moduledoc """
Custom error raised when an OIDC login attempts to use an email that already exists
in the system with a password-only account (no oidc_id set).
This error indicates that the user must verify their password before the OIDC account
can be linked to the existing password account.
"""
use Splode.Error,
fields: [:user_id, :oidc_user_info],
class: :invalid
@type t :: %__MODULE__{
user_id: String.t(),
oidc_user_info: map()
}
@doc """
Returns a human-readable error message.
## Parameters
- error: The error struct containing user_id and oidc_user_info
"""
def message(%{user_id: user_id, oidc_user_info: user_info}) do
email = Map.get(user_info, "preferred_username", "unknown")
oidc_id = Map.get(user_info, "sub") || Map.get(user_info, "id", "unknown")
"""
Password verification required: An account with email '#{email}' already exists (user_id: #{user_id}).
To link your OIDC account (oidc_id: #{oidc_id}) to this existing account, please verify your password.
"""
end
end

View file

@ -1,178 +0,0 @@
defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
@moduledoc """
Validation that checks for email collisions during OIDC registration.
This validation prevents unauthorized account takeovers and enforces proper
account linking flows based on user state.
## Scenarios:
1. **User exists with matching oidc_id**:
- Allow (upsert will update the existing user)
2. **User exists with different oidc_id**:
- Hard error: Cannot link multiple OIDC providers to same account
- No linking possible - user must use original OIDC provider
3. **User exists without oidc_id** (password-protected OR passwordless):
- Raise PasswordVerificationRequired error
- User is redirected to LinkOidcAccountLive which will:
- Show password form if user has password
- Auto-link immediately if user is passwordless
4. **No user exists with this email**:
- Allow (new user will be created)
"""
use Ash.Resource.Validation
require Logger
alias Mv.Accounts.User
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
alias Mv.Helpers.SystemActor
@impl true
def init(opts), do: {:ok, opts}
@impl true
def validate(changeset, _opts, _context) do
# Get the email and oidc_id from the changeset
email = Ash.Changeset.get_attribute(changeset, :email)
oidc_id = Ash.Changeset.get_attribute(changeset, :oidc_id)
user_info = Ash.Changeset.get_argument(changeset, :user_info)
# Only validate if we have both email and oidc_id (from OIDC registration)
if email && oidc_id && user_info do
# Check if a user with this oidc_id already exists
# If yes, this will be an upsert (email update), not a new registration
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
system_actor = SystemActor.get_system_actor()
existing_oidc_user =
case User
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|> Ash.read_one(actor: system_actor) do
{:ok, user} -> user
_ -> nil
end
check_email_collision(email, oidc_id, user_info, existing_oidc_user, system_actor)
else
:ok
end
end
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user, system_actor) do
# Find existing user with this email
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
case User
|> Ash.Query.filter(email == ^to_string(email))
|> Ash.read_one(actor: system_actor) do
{:ok, nil} ->
# No user exists with this email - OK to create new user
:ok
{:ok, user_with_email} ->
# User exists with this email - check if it's an upsert or registration
is_upsert = not is_nil(existing_oidc_user)
if is_upsert do
handle_upsert_scenario(user_with_email, user_info, existing_oidc_user)
else
handle_create_scenario(user_with_email, new_oidc_id, user_info)
end
{:error, error} ->
# Database error - log for debugging but don't expose internals to user
Logger.error("Email uniqueness check failed during OIDC registration: #{inspect(error)}")
{:error, field: :email, message: "Could not verify email uniqueness. Please try again."}
end
end
# Handle email update for existing OIDC user
defp handle_upsert_scenario(user_with_email, user_info, existing_oidc_user) do
cond do
# Same user updating their own record
not is_nil(existing_oidc_user) and user_with_email.id == existing_oidc_user.id ->
:ok
# Different user exists with target email
not is_nil(existing_oidc_user) and user_with_email.id != existing_oidc_user.id ->
handle_email_conflict(user_with_email, user_info)
# Should not reach here
true ->
{:error, field: :email, message: "Unexpected error during email update"}
end
end
# Handle email conflict during upsert
defp handle_email_conflict(user_with_email, user_info) do
email = Map.get(user_info, "preferred_username", "unknown")
email_user_oidc_id = user_with_email.oidc_id
# Check if target email belongs to another OIDC user
if not is_nil(email_user_oidc_id) and email_user_oidc_id != "" do
different_oidc_error(email)
else
email_taken_error(email)
end
end
# Handle new OIDC user registration scenarios
defp handle_create_scenario(user_with_email, new_oidc_id, user_info) do
email_user_oidc_id = user_with_email.oidc_id
cond do
# Same oidc_id (should not happen in practice, but allow for safety)
email_user_oidc_id == new_oidc_id ->
:ok
# Different oidc_id exists (hard error)
not is_nil(email_user_oidc_id) and email_user_oidc_id != "" and
email_user_oidc_id != new_oidc_id ->
email = Map.get(user_info, "preferred_username", "unknown")
different_oidc_error(email)
# No oidc_id (require account linking)
is_nil(email_user_oidc_id) or email_user_oidc_id == "" ->
{:error,
PasswordVerificationRequired.exception(
user_id: user_with_email.id,
oidc_user_info: user_info
)}
# Should not reach here
true ->
{:error, field: :email, message: "Unexpected error during OIDC registration"}
end
end
# Generate error for different OIDC account conflict
defp different_oidc_error(email) do
{:error,
field: :email,
message:
"Email '#{email}' is already linked to a different OIDC account. " <>
"Cannot link multiple OIDC providers to the same account."}
end
# Generate error for email already taken
defp email_taken_error(email) do
{:error,
field: :email,
message:
"Cannot update email to '#{email}': This email is already registered to another account. " <>
"Please change your email in the identity provider."}
end
@impl true
def atomic?, do: false
@impl true
def describe(_opts) do
[
message: "OIDC email collision detected",
vars: []
]
end
end

View file

@ -1,27 +0,0 @@
defmodule Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration do
@moduledoc """
Validation that blocks direct registration (register_with_password) when
OIDC-only mode is active. In OIDC-only mode, sign-in and registration are
only allowed via OIDC (SSO).
"""
use Ash.Resource.Validation
@impl true
def init(opts), do: {:ok, opts}
@impl true
def validate(_changeset, _opts, _context) do
if Mv.Config.oidc_only?() do
{:error,
field: :base,
message:
Gettext.dgettext(
MvWeb.Gettext,
"default",
"Registration with password is disabled when only OIDC sign-in is active."
)}
else
:ok
end
end
end

View file

@ -1,31 +0,0 @@
defmodule Mv.Accounts.User.Validations.RegistrationEnabled do
@moduledoc """
Validation that blocks direct registration (register_with_password) when
registration is disabled in global settings. Used so that even direct API/form
submissions cannot register when the setting is off.
"""
use Ash.Resource.Validation
alias Mv.Membership
@impl true
def init(opts), do: {:ok, opts}
@impl true
def validate(_changeset, _opts, _context) do
case Membership.get_settings() do
{:ok, %{registration_enabled: true}} ->
:ok
_ ->
{:error,
field: :base,
message:
Gettext.dgettext(
MvWeb.Gettext,
"default",
"Registration is disabled. Please use the join form or contact an administrator."
)}
end
end
end

View file

@ -1,147 +0,0 @@
defmodule Mv.Membership.Changes.GenerateSlug do
@moduledoc """
Ash Change that automatically generates a URL-friendly slug from the `name` attribute.
## Behavior
- **On Create**: Generates a slug from the name attribute using slugify
- **On Update**: Slug remains unchanged (immutable after creation)
- **Slug Generation**: Uses the `slugify` library to convert name to slug
- Converts to lowercase
- Replaces spaces with hyphens
- Removes special characters
- Handles UTF-8 characters (e.g., ä a, ß ss)
- Trims leading/trailing hyphens
- Truncates to max 100 characters
## Usage
Works for any resource with `name` and `slug` attributes.
Used by CustomField and Group resources.
create :create do
accept [:name, :description]
change Mv.Membership.Changes.GenerateSlug
validate string_length(:slug, min: 1)
end
## Examples
# Create with automatic slug generation
CustomField.create!(%{name: "Mobile Phone"})
# => %CustomField{name: "Mobile Phone", slug: "mobile-phone"}
Group.create!(%{name: "Test Group"})
# => %Group{name: "Test Group", slug: "test-group"}
# German umlauts are converted
CustomField.create!(%{name: "Café Müller"})
# => %CustomField{name: "Café Müller", slug: "cafe-muller"}
# Slug is immutable on update
custom_field = CustomField.create!(%{name: "Original"})
CustomField.update!(custom_field, %{name: "New Name"})
# => %CustomField{name: "New Name", slug: "original"} # slug unchanged!
## Implementation Note
This change only runs on `:create` actions. The slug is immutable by design,
as changing slugs would break external references (e.g., CSV imports/exports, URL routes).
"""
use Ash.Resource.Change
@doc """
Generates a slug from the changeset's `name` attribute.
Only runs on create actions. Returns the changeset unchanged if:
- The action is not :create
- The name is not being changed
- The name is nil or empty
## Parameters
- `changeset` - The Ash changeset
- `_opts` - Options passed to the change (unused)
- `_context` - Ash context map (unused)
## Returns
The changeset with the `:slug` attribute set to the generated slug.
"""
@impl true
def change(changeset, _opts, _context) do
# Only generate slug on create, not on update (immutability)
if changeset.action_type == :create do
case Ash.Changeset.get_attribute(changeset, :name) do
nil ->
changeset
name when is_binary(name) ->
slug = generate_slug(name)
Ash.Changeset.force_change_attribute(changeset, :slug, slug)
_ ->
changeset
end
else
# On update, don't touch the slug (immutable)
changeset
end
end
@doc """
Generates a URL-friendly slug from a given string.
Uses the `slugify` library to create a clean, lowercase slug with:
- Spaces replaced by hyphens
- Special characters removed
- UTF-8 characters transliterated (ä a, ß ss, etc.)
- Multiple consecutive hyphens reduced to single hyphen
- Leading/trailing hyphens removed
- Maximum length of 100 characters
## Parameters
- `name` - The string to convert to a slug
## Returns
A URL-friendly slug string, or empty string if input is invalid.
## Examples
iex> generate_slug("Mobile Phone")
"mobile-phone"
iex> generate_slug("Café Müller")
"cafe-muller"
iex> generate_slug("TEST NAME")
"test-name"
iex> generate_slug("E-Mail & Address!")
"e-mail-address"
iex> generate_slug("Multiple Spaces")
"multiple-spaces"
iex> generate_slug("-Test-")
"test"
iex> generate_slug("Straße")
"strasse"
"""
@spec generate_slug(String.t()) :: String.t()
def generate_slug(name) when is_binary(name) do
slug = Slug.slugify(name)
case slug do
nil -> ""
"" -> ""
slug when is_binary(slug) -> String.slice(slug, 0, 100)
end
end
def generate_slug(_), do: ""
end

View file

@ -1,172 +0,0 @@
defmodule Mv.Membership.CustomField do
@moduledoc """
Ash resource defining the schema for custom member fields.
## Overview
CustomFields define the "schema" for custom fields in the membership system.
Each CustomField specifies the name, data type, and behavior of a custom field
that can be attached to members via CustomFieldValue resources.
## Attributes
- `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday")
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation.
- `description` - Optional human-readable description
- `required` - If true, all members must have this custom field (future feature)
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
## Supported Value Types
- `:string` - Text data (max 10,000 characters)
- `:integer` - Numeric data (64-bit integers)
- `:boolean` - True/false flags
- `:date` - Date values (no time component)
- `:email` - Validated email addresses (max 254 characters)
## Relationships
- `has_many :custom_field_values` - All custom field values of this type
## Constraints
- Name must be unique across all custom fields
- Name maximum length: 100 characters
- `value_type` cannot be changed after creation (immutable)
- Deleting a custom field will cascade delete all associated custom field values
## Calculations
- `assigned_members_count` - Returns the number of distinct members with values for this custom field
## Examples
# Create a new custom field
CustomField.create!(%{
name: "phone_mobile",
value_type: :string,
description: "Mobile phone number"
})
# Create a required custom field
CustomField.create!(%{
name: "emergency_contact",
value_type: :string,
required: true
})
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
primary_read_warning?: false
postgres do
table "custom_fields"
repo Mv.Repo
end
actions do
default_accept [:name, :value_type, :description, :required, :show_in_overview]
read :read do
primary? true
prepare build(sort: [name: :asc])
end
create :create do
accept [:name, :value_type, :description, :required, :show_in_overview]
change Mv.Membership.Changes.GenerateSlug
validate string_length(:slug, min: 1)
end
update :update do
accept [:name, :description, :required, :show_in_overview]
require_atomic? false
validate fn changeset, _context ->
if Ash.Changeset.changing_attribute?(changeset, :value_type) do
{:error, field: :value_type, message: "cannot be changed after creation"}
else
:ok
end
end
end
destroy :destroy_with_values do
primary? true
end
read :prepare_deletion do
argument :id, :uuid, allow_nil?: false
filter expr(id == ^arg(:id))
prepare build(load: [:assigned_members_count])
end
end
policies do
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
attributes do
uuid_primary_key :id
attribute :name, :string,
allow_nil?: false,
public?: true,
constraints: [
max_length: 100,
trim?: true
]
attribute :slug, :string,
allow_nil?: false,
public?: true,
writable?: false,
constraints: [
max_length: 100,
trim?: true
]
attribute :value_type, :atom,
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
allow_nil?: false,
description: "Defines the datatype `CustomFieldValue.value` is interpreted as"
attribute :description, :string,
allow_nil?: true,
public?: true,
constraints: [
max_length: 500,
trim?: true
]
attribute :required, :boolean,
default: false,
allow_nil?: false
attribute :show_in_overview, :boolean,
default: true,
allow_nil?: false,
public?: true,
description: "If true, this custom field will be displayed in the member overview table"
end
relationships do
has_many :custom_field_values, Mv.Membership.CustomFieldValue
end
calculations do
calculate :assigned_members_count,
:integer,
expr(
fragment(
"(SELECT COUNT(DISTINCT member_id) FROM custom_field_values WHERE custom_field_id = ?)",
id
)
)
end
identities do
identity :unique_name, [:name]
identity :unique_slug, [:slug]
end
end

View file

@ -1,143 +0,0 @@
defmodule Mv.Membership.CustomFieldValue do
@moduledoc """
Ash resource representing a custom field value for a member.
## Overview
CustomFieldValues implement the Entity-Attribute-Value (EAV) pattern, allowing
dynamic custom fields to be attached to members. Each custom field value links a
member to a custom field 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 custom field value belongs to (CASCADE delete)
- `belongs_to :custom_field` - The custom field definition (CASCADE delete)
## Constraints
- Each member can have only one custom field value per custom field (unique composite index)
- Custom field values are deleted when the associated member is deleted (CASCADE)
- Custom field values are deleted when the associated custom field is deleted (CASCADE)
- String values maximum length: 10,000 characters
- Email values maximum length: 254 characters (RFC 5321)
## Future Features
- Type-matching validation (value type must match custom field's value_type) - to be implemented
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
import Ash.Expr
postgres do
table "custom_field_values"
repo Mv.Repo
references do
reference :member, on_delete: :delete
reference :custom_field, on_delete: :delete
end
end
actions do
defaults [:create, :read, :update, :destroy]
default_accept [:value, :member_id, :custom_field_id]
read :by_custom_field_id do
argument :custom_field_id, :uuid, allow_nil?: false
filter expr(custom_field_id == ^arg(:custom_field_id))
end
end
# Authorization Policies
# Order matters: Most specific policies first, then general permission check
# Pattern aligns with User and Member resources (bypass for READ, HasPermission for update/destroy)
# Create uses CustomFieldValueCreateScope because Ash cannot apply filters to create actions.
policies do
# SPECIAL CASE: Users can READ custom field values of their linked member
# Bypass needed for list queries (expr triggers auto_filter in Ash)
bypass action_type(:read) do
description "Users can read custom field values of their linked member"
authorize_if expr(member_id == ^actor(:member_id))
end
# CREATE: CustomFieldValueCreateScope (no filter; Ash rejects filters on create)
# - :own_data -> create allowed when member_id == actor.member_id (scope :linked)
# - :read_only -> no create permission
# - :normal_user / :admin -> create allowed (scope :all)
policy action_type(:create) do
description "CustomFieldValue create allowed by permission set scope"
authorize_if Mv.Authorization.Checks.CustomFieldValueCreateScope
end
# READ/UPDATE/DESTROY: HasPermission (scope :linked / :all)
policy action_type([:read, :update, :destroy]) do
description "Check permissions from user's role and permission set"
authorize_if Mv.Authorization.Checks.HasPermission
end
# DEFAULT: Ash implicitly forbids if no policy authorized (fail-closed)
end
attributes do
uuid_primary_key :id
attribute :value, :union,
constraints: [
storage: :type_and_value,
types: [
boolean: [
type: :boolean
],
date: [
type: :date
],
integer: [
type: :integer
],
string: [
type: :string,
constraints: [
max_length: 10_000,
trim?: true
]
],
email: [
type: Mv.Membership.Email
]
]
]
end
relationships do
belongs_to :member, Mv.Membership.Member
belongs_to :custom_field, Mv.Membership.CustomField
end
calculations do
calculate :value_to_string, :string, expr(value[:value] <> "")
end
# Ensure a member can only have one custom field value per custom field
# For example: A member can have only one "phone" custom field value, one "email" custom field value, etc.
identities do
identity :unique_custom_field_per_member, [:member_id, :custom_field_id]
end
end

View file

@ -1,38 +1,4 @@
defmodule Mv.Membership.Email do 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 CustomFieldValue resources are valid email
addresses according to a standard regex pattern.
## Validation Rules
- **Optional**: `nil` and empty strings are allowed (custom fields are optional)
- Minimum length: 5 characters (for non-empty values)
- Maximum length: 254 characters (RFC 5321 maximum)
- Pattern: Standard email format (username@domain.tld)
- Automatic trimming of leading/trailing whitespace (empty strings become `nil`)
## Usage
This type is used in the CustomFieldValue union type for custom fields with
`value_type: :email` in CustomField definitions.
## Example
# In a custom field definition
CustomField.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_pattern ~S/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
@match_regex Regex.compile!(@match_pattern) @match_regex Regex.compile!(@match_pattern)
@min_length 5 @min_length 5
@ -47,18 +13,11 @@ defmodule Mv.Membership.Email do
max_length: @max_length max_length: @max_length
] ]
@impl true
def cast_input(nil, _), do: {:ok, nil}
@impl true @impl true
def cast_input(value, _) when is_binary(value) do def cast_input(value, _) when is_binary(value) do
value = String.trim(value) value = String.trim(value)
cond do cond do
# Empty string after trim becomes nil (optional field)
value == "" ->
{:ok, nil}
String.length(value) < @min_length -> String.length(value) < @min_length ->
:error :error

View file

@ -1,166 +0,0 @@
defmodule Mv.Membership.Group do
@moduledoc """
Ash resource representing a group that members can belong to.
## Overview
Groups allow organizing members into categories (e.g., "Board Members", "Active Members").
Each member can belong to multiple groups, and each group can contain multiple members.
## Attributes
- `name` - Unique group name (required, max 100 chars, case-insensitive uniqueness)
- `slug` - URL-friendly identifier (required, max 100 chars, auto-generated from name, immutable)
- `description` - Optional description (max 500 chars)
## Relationships
- `has_many :member_groups` - Relationship to MemberGroup join table
- `many_to_many :members` - Relationship to Members through MemberGroup
## Constraints
- Name must be unique (case-insensitive, using LOWER(name) in database)
- Slug must be unique (case-sensitive, exact match)
- Name cannot be null
- Slug cannot be null
## Calculations
- `member_count` - Returns the number of members in this group
## Examples
# Create a new group
Group.create!(%{name: "Board Members", description: "Members of the board"})
# => %Group{name: "Board Members", slug: "board-members", ...}
# Update group (slug remains unchanged)
group = Group.get_by_slug!("board-members")
Group.update!(group, %{description: "Updated description"})
# => %Group{slug: "board-members", ...} # slug unchanged!
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
require Ash.Query
alias Mv.Helpers
alias Mv.Helpers.SystemActor
require Logger
postgres do
table "groups"
repo Mv.Repo
end
actions do
defaults [:read, :destroy]
create :create do
accept [:name, :description]
change Mv.Membership.Changes.GenerateSlug
validate string_length(:slug, min: 1)
end
update :update do
accept [:name, :description]
require_atomic? false
end
end
policies do
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from role (all can read; normal_user and admin can create/update/destroy)"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
validations do
validate present(:name)
# Case-insensitive name uniqueness validation
validate fn changeset, context ->
name = Ash.Changeset.get_attribute(changeset, :name)
current_id = Ash.Changeset.get_attribute(changeset, :id)
if name do
check_name_uniqueness(name, current_id, context)
else
:ok
end
end
end
attributes do
uuid_v7_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
constraints max_length: 100,
trim?: true
end
attribute :slug, :string do
allow_nil? false
public? true
writable? false
constraints max_length: 100,
trim?: true
end
attribute :description, :string do
allow_nil? true
public? true
constraints max_length: 500,
trim?: true
end
timestamps()
end
relationships do
has_many :member_groups, Mv.Membership.MemberGroup
many_to_many :members, Mv.Membership.Member, through: Mv.Membership.MemberGroup
end
aggregates do
count :member_count, :member_groups
end
identities do
identity :unique_slug, [:slug]
end
# Private helper function for case-insensitive name uniqueness check
# Uses context actor if available (respects policies), falls back to system actor
defp check_name_uniqueness(name, exclude_id, context) do
# Use context actor if available (respects user permissions), otherwise fall back to system actor
actor =
case context do
%{actor: actor} when not is_nil(actor) -> actor
_ -> SystemActor.get_system_actor()
end
query =
Mv.Membership.Group
|> Ash.Query.filter(fragment("LOWER(?) = LOWER(?)", name, ^name))
|> Helpers.query_exclude_id(exclude_id)
opts = Helpers.ash_actor_opts(actor)
case Ash.read(query, opts) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :name, message: "has already been taken", value: name}
{:error, reason} ->
Logger.warning(
"Name uniqueness validation query failed for group name '#{name}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
)
:ok
end
end
end

View file

@ -1,13 +0,0 @@
defmodule Mv.Membership.JoinNotifier do
@moduledoc """
Behaviour for sending join-related emails (confirmation, already member, already pending).
The domain calls this module instead of MvWeb.Emails directly, so the domain layer
does not depend on the web layer. The default implementation is set in config
(`config :mv, :join_notifier, MvWeb.JoinNotifierImpl`). Tests can override with a mock.
"""
@callback send_confirmation(email :: String.t(), token :: String.t(), opts :: keyword()) ::
{:ok, term()} | {:error, term()}
@callback send_already_member(email :: String.t()) :: {:ok, term()} | {:error, term()}
@callback send_already_pending(email :: String.t()) :: {:ok, term()} | {:error, term()}
end

View file

@ -1,219 +0,0 @@
defmodule Mv.Membership.JoinRequest do
@moduledoc """
Ash resource for public join requests (onboarding, double opt-in).
A JoinRequest is created on form submit with status `pending_confirmation`, then
updated to `submitted` when the user clicks the confirmation link. No User or
Member is created in this flow; promotion happens in a later approval step.
## Public actions (actor: nil)
- `submit` (create) create with token hash and expiry
- `get_by_confirmation_token_hash` (read) lookup by token hash for confirm flow
- `confirm` (update) set status to submitted and invalidate token
## Schema
Typed: email (required), first_name, last_name. Remaining form data in form_data (jsonb).
Confirmation: confirmation_token_hash, confirmation_token_expires_at. Audit: submitted_at, etc.
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "join_requests"
repo Mv.Repo
end
actions do
defaults [:read, :destroy]
create :submit do
description "Create a join request (public form submit); stores token hash and expiry"
primary? true
argument :confirmation_token, :string, allow_nil?: false
accept [:email, :first_name, :last_name, :form_data, :schema_version]
change Mv.Membership.JoinRequest.Changes.SetConfirmationToken
change Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist
end
# Internal/seeding only: create with status submitted (no policy allows; use authorize?: false).
create :create_submitted do
description "Create a join request with status submitted (seeds, internal use only)"
accept [:email, :first_name, :last_name, :form_data, :schema_version]
change Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding
end
read :get_by_confirmation_token_hash do
description "Find a join request by confirmation token hash (for confirm flow only)"
argument :confirmation_token_hash, :string, allow_nil?: false
filter expr(confirmation_token_hash == ^arg(:confirmation_token_hash))
prepare build(sort: [inserted_at: :desc], limit: 1)
end
update :confirm do
description "Mark join request as submitted and invalidate token (after link click)"
primary? true
require_atomic? false
change Mv.Membership.JoinRequest.Changes.ConfirmRequest
end
update :approve do
description "Approve a submitted join request and promote to Member"
require_atomic? false
change Mv.Membership.JoinRequest.Changes.ApproveRequest
end
update :reject do
description "Reject a submitted join request"
require_atomic? false
change Mv.Membership.JoinRequest.Changes.RejectRequest
end
# Internal: resend confirmation (new token) when user submits form again with same email.
# Called from domain with authorize?: false; not exposed to public.
update :regenerate_confirmation_token do
description "Set new confirmation token and expiry (resend flow)"
require_atomic? false
argument :confirmation_token, :string, allow_nil?: false
change Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken
end
end
policies do
# Use :strict so unauthorized access returns Forbidden (not empty list).
# Default :filter would silently return [] for unauthorized reads instead of Forbidden.
default_access_type :strict
# Public actions: bypass so nil actor is immediately authorized (skips all remaining policies).
# Using bypass (not policy) avoids AND-combination with the read policy below.
bypass action(:submit) do
description "Allow unauthenticated submit (public join form)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
bypass action(:get_by_confirmation_token_hash) do
description "Allow unauthenticated lookup by token hash for confirm"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
bypass action(:confirm) do
description "Allow unauthenticated confirm (confirmation link click)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
# READ: bypass for authorized roles (normal_user, admin).
# Uses a SimpleCheck (HasJoinRequestAccess) to avoid HasPermission.auto_filter returning
# expr(false), which would silently produce an empty list instead of Forbidden for
# unauthorized actors. See docs/policy-bypass-vs-haspermission.md.
# Unauthorized actors fall through to no matching policy → Ash default deny (Forbidden).
bypass action_type(:read) do
description "Allow normal_user and admin to read join requests (SimpleCheck bypass)"
authorize_if Mv.Authorization.Checks.HasJoinRequestAccess
end
# Approve/Reject: only actors with JoinRequest update permission
policy action(:approve) do
description "Allow authenticated users with JoinRequest update permission to approve"
authorize_if Mv.Authorization.Checks.HasPermission
end
policy action(:reject) do
description "Allow authenticated users with JoinRequest update permission to reject"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
validations do
# Format/formatting of email is not validated here; invalid addresses may fail at send time
# or can be enforced via an Ash change if needed.
validate present(:email), on: [:create]
end
# Attributes are backend-internal for now; set public? true when exposing via AshJsonApi/AshGraphql
attributes do
uuid_primary_key :id
attribute :status, :atom do
description "pending_confirmation | submitted | approved | rejected"
default :pending_confirmation
constraints one_of: [:pending_confirmation, :submitted, :approved, :rejected]
allow_nil? false
end
attribute :email, :string do
description "Email address (required for join form)"
allow_nil? false
end
attribute :first_name, :string
attribute :last_name, :string
attribute :form_data, :map do
description "Additional form fields (jsonb)"
end
attribute :schema_version, :integer do
description "Version of join form / member_fields for form_data"
end
attribute :confirmation_token_hash, :string do
description "SHA256 hash of confirmation token; raw token only in email link"
end
attribute :confirmation_token_expires_at, :utc_datetime_usec do
description "When the confirmation link expires (e.g. 24h)"
end
attribute :confirmation_sent_at, :utc_datetime_usec do
description "When the confirmation email was sent"
end
attribute :submitted_at, :utc_datetime_usec do
description "When the user confirmed (clicked the link)"
end
attribute :approved_at, :utc_datetime_usec
attribute :rejected_at, :utc_datetime_usec
attribute :reviewed_by_user_id, :uuid
attribute :reviewed_by_display, :string do
description "Denormalized reviewer display (e.g. email) for UI without loading User"
end
attribute :source, :string
create_timestamp :inserted_at
update_timestamp :updated_at
end
relationships do
belongs_to :reviewed_by_user, Mv.Accounts.User do
define_attribute? false
source_attribute :reviewed_by_user_id
end
end
# Public helpers (used by SetConfirmationToken change and domain confirm_join_request)
@doc """
Returns the SHA256 hash of the confirmation token (lowercase hex).
Used when creating a join request (submit) and when confirming by token.
Only one implementation ensures algorithm changes stay in sync.
"""
@spec hash_confirmation_token(String.t()) :: String.t()
def hash_confirmation_token(token) when is_binary(token) do
:crypto.hash(:sha256, token) |> Base.encode16(case: :lower)
end
end

View file

@ -1,33 +0,0 @@
defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do
@moduledoc """
Sets the join request to approved and records the reviewer.
Only transitions from :submitted status. If already approved, returns error
(idempotency guard via status validation). Promotion to Member is handled
by the domain function approve_join_request/2 after calling this action.
"""
use Ash.Resource.Change
alias Mv.Membership.JoinRequest.Changes.Helpers
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, context) do
current_status = Ash.Changeset.get_data(changeset, :status)
if current_status == :submitted do
reviewed_by_id = Helpers.actor_id(context.actor)
reviewed_by_display = Helpers.actor_email(context.actor)
changeset
|> Ash.Changeset.force_change_attribute(:status, :approved)
|> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now())
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display)
else
Ash.Changeset.add_error(changeset,
field: :status,
message: "can only approve a submitted join request (current status: #{current_status})"
)
end
end
end

View file

@ -1,25 +0,0 @@
defmodule Mv.Membership.JoinRequest.Changes.ConfirmRequest do
@moduledoc """
Sets the join request to submitted (confirmation link clicked).
Used by the confirm action after the user clicks the confirmation link.
Only applies when the current status is `:pending_confirmation`, so that
direct calls to the confirm action are idempotent and never overwrite
:submitted, :approved, or :rejected. Token hash is kept so a second click
can still find the record and return success without changing it.
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
current_status = Ash.Changeset.get_data(changeset, :status)
if current_status == :pending_confirmation do
changeset
|> Ash.Changeset.force_change_attribute(:status, :submitted)
|> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now())
else
changeset
end
end
end

View file

@ -1,38 +0,0 @@
defmodule Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist do
@moduledoc """
Filters form_data to only keys that are in the join form allowlist (server-side).
Ensures that even when submit_join_request/2 is called directly (e.g. from tests or API),
only allowlisted custom fields are persisted. Typed fields (email, first_name, last_name)
are not part of form_data; allowlist is join_form_field_ids minus those.
"""
use Ash.Resource.Change
alias Mv.Membership
@typed_fields ["email", "first_name", "last_name"]
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{}
allowlist_ids =
case Membership.get_join_form_allowlist() do
list when is_list(list) ->
list
|> Enum.map(fn item -> item.id end)
|> MapSet.new()
|> MapSet.difference(MapSet.new(@typed_fields))
_ ->
MapSet.new()
end
filtered =
form_data
|> Enum.filter(fn {key, _} -> MapSet.member?(allowlist_ids, to_string(key)) end)
|> Map.new()
Ash.Changeset.force_change_attribute(changeset, :form_data, filtered)
end
end

View file

@ -1,39 +0,0 @@
defmodule Mv.Membership.JoinRequest.Changes.Helpers do
@moduledoc """
Shared helpers for JoinRequest change modules (e.g. ApproveRequest, RejectRequest).
"""
@doc """
Extracts the actor's user id from the Ash change context.
Supports both atom and string keys for compatibility with different actor representations.
"""
@spec actor_id(term()) :: String.t() | nil
def actor_id(nil), do: nil
def actor_id(actor) when is_map(actor) do
Map.get(actor, :id) || Map.get(actor, "id")
end
def actor_id(_), do: nil
@doc """
Extracts the actor's email for display (e.g. reviewed_by_display).
Supports both atom and string keys for compatibility with different actor representations.
"""
@spec actor_email(term()) :: String.t() | nil
def actor_email(nil), do: nil
def actor_email(actor) when is_map(actor) do
raw = Map.get(actor, :email) || Map.get(actor, "email")
if is_nil(raw), do: nil, else: actor_email_string(raw)
end
def actor_email(_), do: nil
defp actor_email_string(raw) do
s = raw |> to_string() |> String.trim()
if s == "", do: nil, else: s
end
end

View file

@ -1,33 +0,0 @@
defmodule Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken do
@moduledoc """
Sets a new confirmation token hash and expiry on an existing join request (resend flow).
Used when the user submits the join form again with the same email while a request
is still pending_confirmation. Internal use only (domain calls with authorize?: false).
"""
use Ash.Resource.Change
alias Mv.Membership.JoinRequest
@confirmation_validity_hours 24
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
token = Ash.Changeset.get_argument(changeset, :confirmation_token)
if is_binary(token) and token != "" do
now = DateTime.utc_now()
expires_at = DateTime.add(now, @confirmation_validity_hours, :hour)
changeset
|> Ash.Changeset.force_change_attribute(
:confirmation_token_hash,
JoinRequest.hash_confirmation_token(token)
)
|> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at)
|> Ash.Changeset.force_change_attribute(:confirmation_sent_at, now)
else
changeset
end
end
end

View file

@ -1,32 +0,0 @@
defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do
@moduledoc """
Sets the join request to rejected and records the reviewer.
Only transitions from :submitted status. Returns an error for any other status.
No reason field in MVP; audit fields (rejected_at, reviewed_by_user_id) are set.
"""
use Ash.Resource.Change
alias Mv.Membership.JoinRequest.Changes.Helpers
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, context) do
current_status = Ash.Changeset.get_data(changeset, :status)
if current_status == :submitted do
reviewed_by_id = Helpers.actor_id(context.actor)
reviewed_by_display = Helpers.actor_email(context.actor)
changeset
|> Ash.Changeset.force_change_attribute(:status, :rejected)
|> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now())
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display)
else
Ash.Changeset.add_error(changeset,
field: :status,
message: "can only reject a submitted join request (current status: #{current_status})"
)
end
end
end

View file

@ -1,32 +0,0 @@
defmodule Mv.Membership.JoinRequest.Changes.SetConfirmationToken do
@moduledoc """
Hashes the confirmation token and sets expiry for the join request (submit flow).
Uses `JoinRequest.hash_confirmation_token/1` so hashing logic lives in one place.
Reads the :confirmation_token argument, stores only its SHA256 hash and sets
confirmation_token_expires_at (e.g. 24h). Raw token is never persisted.
"""
use Ash.Resource.Change
alias Mv.Membership.JoinRequest
@confirmation_validity_hours 24
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
token = Ash.Changeset.get_argument(changeset, :confirmation_token)
if is_binary(token) and token != "" do
hash = JoinRequest.hash_confirmation_token(token)
expires_at = DateTime.utc_now() |> DateTime.add(@confirmation_validity_hours, :hour)
changeset
|> Ash.Changeset.force_change_attribute(:confirmation_token_hash, hash)
|> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at)
|> Ash.Changeset.force_change_attribute(:status, :pending_confirmation)
else
changeset
end
end
end

View file

@ -1,15 +0,0 @@
defmodule Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding do
@moduledoc """
Sets status to :submitted and submitted_at for seed/internal creation.
Used only by the :create_submitted action (e.g. seeds, no policy allows it for normal actors).
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
changeset
|> Ash.Changeset.force_change_attribute(:status, :submitted)
|> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now())
end
end

File diff suppressed because it is too large Load diff

View file

@ -1,42 +0,0 @@
defmodule Mv.Membership.Member.Changes.SetDefaultMembershipFeeType do
@moduledoc """
Ash change that automatically assigns the default membership fee type to new members
if no membership_fee_type_id is explicitly provided.
This change reads the default_membership_fee_type_id from global settings and
assigns it to the member if membership_fee_type_id is nil.
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
# Only set default if membership_fee_type_id is not already set
current_type_id = Ash.Changeset.get_attribute(changeset, :membership_fee_type_id)
if is_nil(current_type_id) do
apply_default_membership_fee_type(changeset)
else
changeset
end
end
defp apply_default_membership_fee_type(changeset) do
case Mv.Membership.get_settings() do
{:ok, settings} ->
if settings.default_membership_fee_type_id do
Ash.Changeset.force_change_attribute(
changeset,
:membership_fee_type_id,
settings.default_membership_fee_type_id
)
else
changeset
end
{:error, _error} ->
# If settings can't be loaded, continue without default
# This prevents member creation from failing if settings are misconfigured
changeset
end
end
end

View file

@ -1,50 +0,0 @@
defmodule Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil do
@moduledoc """
When :user argument is present and nil/empty on update_member, unrelate the current user.
With on_missing: :ignore, manage_relationship does not unrelate when input is nil/[].
This change handles explicit unlink (user: nil or user: %{}) by updating the linked
User to set member_id = nil. Only runs when the argument key is present (policy
ForbidMemberUserLinkUnlessAdmin ensures only admins can pass :user).
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
if unlink_requested?(changeset) do
unrelate_current_user(changeset)
else
changeset
end
end
defp unlink_requested?(changeset) do
args = changeset.arguments || %{}
if Map.has_key?(args, :user) or Map.has_key?(args, "user") do
user_arg = Ash.Changeset.get_argument(changeset, :user)
user_arg == nil or (is_map(user_arg) and map_size(user_arg) == 0)
else
false
end
end
defp unrelate_current_user(changeset) do
member = changeset.data
actor = Map.get(changeset.context || %{}, :actor)
case Ash.load(member, :user, domain: Mv.Membership, authorize?: false) do
{:ok, %{user: user}} when not is_nil(user) ->
# User's :update action only accepts [:email]; use :update_user so
# manage_relationship(:member, ..., on_missing: :unrelate) runs and clears member_id.
user
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
changeset
_ ->
changeset
end
end
end

View file

@ -1,159 +0,0 @@
defmodule Mv.Membership.MemberGroup do
@moduledoc """
Ash resource representing the join table for the many-to-many relationship
between Members and Groups.
## Overview
MemberGroup is a join table that links members to groups. It enables the
many-to-many relationship where:
- A member can belong to multiple groups
- A group can contain multiple members
## Attributes
- `member_id` - Foreign key to Member (required)
- `group_id` - Foreign key to Group (required)
## Relationships
- `belongs_to :member` - Relationship to Member
- `belongs_to :group` - Relationship to Group
## Constraints
- Unique constraint on `(member_id, group_id)` - prevents duplicate memberships
- CASCADE delete: Removing member removes all group associations
- CASCADE delete: Removing group removes all member associations
## Examples
# Add member to group
{:ok, member_group} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id})
# Remove member from group
{:ok, [member_group]} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(member_id == ^member.id and group_id == ^group.id),
domain: Mv.Membership
)
:ok = Membership.destroy_member_group(member_group)
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
require Ash.Query
postgres do
table "member_groups"
repo Mv.Repo
end
actions do
defaults [:read, :destroy]
create :create do
accept [:member_id, :group_id]
end
end
# Authorization: read uses bypass for :linked (own_data only) then HasPermission for :all;
# create/destroy use HasPermission (normal_user + admin only).
# Single check: own_data gets filter via auto_filter; admin does not match, gets :all from HasPermission.
policies do
bypass action_type(:read) do
description "own_data: read only member_groups where member_id == actor.member_id"
authorize_if Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData
end
policy action_type(:read) do
description "Check read permission from role (read_only/normal_user/admin :all)"
authorize_if Mv.Authorization.Checks.HasPermission
end
policy action_type([:create, :destroy]) do
description "Check create/destroy from role (normal_user + admin only)"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
validations do
validate present(:member_id)
validate present(:group_id)
# Prevent duplicate associations
validate fn changeset, context ->
member_id = Ash.Changeset.get_attribute(changeset, :member_id)
group_id = Ash.Changeset.get_attribute(changeset, :group_id)
current_id = Ash.Changeset.get_attribute(changeset, :id)
if member_id && group_id do
check_duplicate_association(member_id, group_id, current_id, context)
else
:ok
end
end
end
attributes do
uuid_v7_primary_key :id
attribute :member_id, :uuid do
allow_nil? false
end
attribute :group_id, :uuid do
allow_nil? false
end
timestamps()
end
relationships do
belongs_to :member, Mv.Membership.Member do
allow_nil? false
end
belongs_to :group, Mv.Membership.Group do
allow_nil? false
end
end
identities do
identity :unique_member_group, [:member_id, :group_id]
end
# Private helper function to check for duplicate associations
# Uses context actor if available (respects policies), falls back to system actor
defp check_duplicate_association(member_id, group_id, exclude_id, context) do
alias Mv.Helpers
alias Mv.Helpers.SystemActor
# Use context actor if available (respects user permissions), otherwise fall back to system actor
actor =
case context do
%{actor: actor} when not is_nil(actor) -> actor
_ -> SystemActor.get_system_actor()
end
query =
Mv.Membership.MemberGroup
|> Ash.Query.filter(member_id == ^member_id and group_id == ^group_id)
|> Helpers.query_exclude_id(exclude_id)
opts = Helpers.ash_actor_opts(actor)
case Ash.read(query, opts) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :member_id, message: "Member is already in this group", value: member_id}
{:error, _reason} ->
# Fail-open: if query fails, allow operation to proceed
# Database constraint will catch duplicates anyway
:ok
end
end
end

View file

@ -1,40 +1,7 @@
defmodule Mv.Membership do defmodule Mv.Membership do
@moduledoc """
Ash Domain for membership management.
## Resources
- `Member` - Club members with personal information and custom field values
- `CustomFieldValue` - Dynamic custom field values attached to members
- `CustomField` - Schema definitions for custom fields
- `Setting` - Global application settings (singleton)
- `Group` - Groups that members can belong to
- `MemberGroup` - Join table for many-to-many relationship between Members and Groups
- `JoinRequest` - Public join form submissions (pending_confirmation submitted after email confirm)
## Public API
The domain exposes these main actions:
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/1`, etc.
- Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3`
- Group management: `create_group/1`, `list_groups/0`, `update_group/2`, `destroy_group/1`
- Member-group associations: `create_member_group/1`, `list_member_groups/0`, `destroy_member_group/1`
## Admin Interface
The domain is configured with AshAdmin for management UI.
"""
use Ash.Domain, use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix] extensions: [AshAdmin.Domain, AshPhoenix]
require Ash.Query
import Ash.Expr
alias Ash.Error.Query.NotFound, as: NotFoundError
alias Mv.Helpers.SystemActor
alias Mv.Membership.JoinRequest
alias Mv.Membership.Member
alias Mv.Membership.SettingsCache
require Logger
admin do admin do
show? true show? true
end end
@ -47,852 +14,18 @@ defmodule Mv.Membership do
define :destroy_member, action: :destroy define :destroy_member, action: :destroy
end end
resource Mv.Membership.CustomFieldValue do resource Mv.Membership.Property do
define :create_custom_field_value, action: :create define :create_property, action: :create
define :list_custom_field_values, action: :read define :list_property, action: :read
define :update_custom_field_value, action: :update define :update_property, action: :update
define :destroy_custom_field_value, action: :destroy define :destroy_property, action: :destroy
end end
resource Mv.Membership.CustomField do resource Mv.Membership.PropertyType do
define :create_custom_field, action: :create define :create_property_type, action: :create
define :list_custom_fields, action: :read define :list_property_types, action: :read
define :update_custom_field, action: :update define :update_property_type, action: :update
define :destroy_custom_field, action: :destroy_with_values define :destroy_property_type, action: :destroy
define :prepare_custom_field_deletion, action: :prepare_deletion, args: [:id]
end end
resource Mv.Membership.Setting do
# Note: create action exists but is not exposed via code interface
# 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
define :update_single_member_field_visibility,
action: :update_single_member_field_visibility
define :update_single_member_field, action: :update_single_member_field
end
resource Mv.Membership.Group do
define :create_group, action: :create
define :list_groups, action: :read
define :update_group, action: :update
define :destroy_group, action: :destroy
end
resource Mv.Membership.MemberGroup do
define :create_member_group, action: :create
define :list_member_groups, action: :read
define :destroy_member_group, action: :destroy
end
resource Mv.Membership.JoinRequest do
# Public submit/confirm and approval domain functions are implemented as custom
# functions below to handle cross-resource operations (Member promotion on approve).
end
end
# Singleton pattern: Get the single settings record
@doc """
Gets the global settings.
Settings should normally be created via the seed script (`priv/repo/seeds.exs`).
If no settings exist, this function will create them as a fallback using the
`ASSOCIATION_NAME` environment variable or "Club Name" as default.
## Returns
- `{:ok, settings}` - The settings record
- `{:ok, nil}` - No settings exist (should not happen if seeds were run)
- `{:error, error}` - Error reading settings
## Examples
iex> {:ok, settings} = Mv.Membership.get_settings()
iex> settings.club_name
"My Club"
"""
def get_settings do
case Process.whereis(SettingsCache) do
nil -> get_settings_uncached()
_pid -> SettingsCache.get()
end
end
@doc false
def get_settings_uncached do
case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do
{:ok, nil} ->
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
Mv.Membership.Setting
|> Ash.Changeset.for_create(:create, %{
club_name: default_club_name,
member_field_visibility: %{"exit_date" => false}
})
|> Ash.create!(domain: __MODULE__)
|> then(fn settings -> {:ok, settings} end)
{:ok, settings} ->
{:ok, settings}
{:error, error} ->
{:error, error}
end
end
@doc """
Updates the global settings.
## Parameters
- `settings` - The settings record to update
- `attrs` - A map of attributes to update (e.g., `%{club_name: "New Name"}`)
## 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_settings(settings, %{club_name: "New Club"})
iex> updated.club_name
"New Club"
"""
def update_settings(settings, attrs) do
case settings
|> Ash.Changeset.for_update(:update, attrs)
|> Ash.update(domain: __MODULE__) do
{:ok, _updated} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end
@doc """
Lists only required custom fields.
This is an optimized version that filters at the database level instead of
loading all custom fields and filtering in memory. Requires an actor for
authorization (CustomField read policy). Callers must pass `actor:`; no default.
## Options
- `:actor` - Required. The actor for authorization (e.g. current user).
All roles can read CustomField; actor must have a valid role.
## Returns
- `{:ok, required_custom_fields}` - List of required custom fields
- `{:error, :missing_actor}` - When actor is nil (caller must pass actor)
- `{:error, error}` - Error reading custom fields (e.g. Forbidden)
## Examples
iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields(actor: actor)
iex> Enum.all?(required_fields, & &1.required)
true
iex> Mv.Membership.list_required_custom_fields(actor: nil)
{:error, :missing_actor}
"""
def list_required_custom_fields(actor: actor) when not is_nil(actor) do
Mv.Membership.CustomField
|> Ash.Query.filter(expr(required == true))
|> Ash.read(domain: __MODULE__, actor: actor)
end
def list_required_custom_fields(actor: nil), do: {:error, :missing_actor}
@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
case settings
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
member_field_visibility: visibility_config
})
|> Ash.update(domain: __MODULE__) do
{:ok, _} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end
@doc """
Atomically updates a single field in the member field visibility configuration.
This action uses PostgreSQL's jsonb_set function to atomically update a single key
in the JSONB map, preventing lost updates in concurrent scenarios. This is the
preferred method for updating individual field visibility settings.
## Parameters
- `settings` - The settings record to update
- `field` - The member field name as a string (e.g., "street", "house_number")
- `show_in_overview` - Boolean value indicating visibility
## 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_single_member_field_visibility(settings, field: "street", show_in_overview: false)
iex> updated.member_field_visibility["street"]
false
"""
def update_single_member_field_visibility(settings,
field: field,
show_in_overview: show_in_overview
) do
case settings
|> Ash.Changeset.new()
|> Ash.Changeset.set_argument(:field, field)
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|> Ash.update(domain: __MODULE__) do
{:ok, _} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end
@doc """
Atomically updates visibility and required for a single member field.
Updates both `member_field_visibility` and `member_field_required` in one
operation. Use this when saving from the member field settings form.
## Parameters
- `settings` - The settings record to update
- `field` - The member field name as a string (e.g., "first_name", "street")
- `show_in_overview` - Boolean value indicating visibility in member overview
- `required` - Boolean value indicating whether the field is required in member forms
## 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_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
iex> updated.member_field_required["first_name"]
true
"""
def update_single_member_field(settings,
field: field,
show_in_overview: show_in_overview,
required: required
) do
case settings
|> Ash.Changeset.new()
|> Ash.Changeset.set_argument(:field, field)
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|> Ash.Changeset.set_argument(:required, required)
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|> Ash.update(domain: __MODULE__) do
{:ok, _} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end
@doc """
Gets a group by its slug.
Uses `Ash.Query.filter` to efficiently find a group by its slug.
The unique index on `slug` ensures efficient lookup performance.
The slug lookup is case-sensitive (exact match required).
## Parameters
- `slug` - The slug to search for (case-sensitive)
- `opts` - Options including `:actor` for authorization
## Returns
- `{:ok, group}` - Found group (with members and member_count loaded)
- `{:ok, nil}` - Group not found
- `{:error, error}` - Error reading group
## Examples
iex> {:ok, group} = Mv.Membership.get_group_by_slug("board-members", actor: actor)
iex> group.name
"Board Members"
iex> {:ok, nil} = Mv.Membership.get_group_by_slug("non-existent", actor: actor)
{:ok, nil}
"""
def get_group_by_slug(slug, opts \\ []) do
load = Keyword.get(opts, :load, [])
require Ash.Query
query =
Mv.Membership.Group
|> Ash.Query.filter(slug == ^slug)
|> Ash.Query.load(load)
opts
|> Keyword.delete(:load)
|> Keyword.put_new(:domain, __MODULE__)
|> then(&Ash.read_one(query, &1))
end
@doc """
Creates a join request (submit flow) and sends the confirmation email.
Generates a confirmation token if not provided in attrs (e.g. for tests, pass
`:confirmation_token` to get a known token). On success, sends one email with
the confirm link to the request email.
## Options
- `:actor` - Must be nil for public submit (policy allows only unauthenticated).
## Returns
- `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent
- `{:ok, :notified_already_member}` - Email already a member; notice sent by email only (no request created)
- `{:ok, :notified_already_pending}` - Email already has pending/submitted request; notice or resend sent by email only
- `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged)
- `{:error, error}` - Validation or authorization error
"""
def submit_join_request(attrs, opts \\ []) do
actor = Keyword.get(opts, :actor)
email = normalize_submit_email(attrs)
pending =
if email != nil and email != "", do: pending_join_request_with_email(email), else: nil
cond do
email != nil and email != "" and member_exists_with_email?(email) ->
send_already_member_and_return(email)
pending != nil ->
handle_already_pending(email, pending)
true ->
do_create_join_request(attrs, actor)
end
end
defp normalize_submit_email(attrs) do
raw = attrs["email"] || attrs[:email]
if is_binary(raw), do: String.trim(raw), else: nil
end
defp member_exists_with_email?(email) when is_binary(email) do
system_actor = SystemActor.get_system_actor()
opts = [actor: system_actor, domain: __MODULE__]
case Ash.get(Member, %{email: email}, opts) do
{:ok, _member} -> true
_ -> false
end
end
defp member_exists_with_email?(_), do: false
defp pending_join_request_with_email(email) when is_binary(email) do
system_actor = SystemActor.get_system_actor()
query =
JoinRequest
|> Ash.Query.filter(expr(email == ^email and status in [:pending_confirmation, :submitted]))
|> Ash.Query.sort(inserted_at: :desc)
|> Ash.Query.limit(1)
case Ash.read_one(query, actor: system_actor, domain: __MODULE__) do
{:ok, request} -> request
_ -> nil
end
end
defp pending_join_request_with_email(_), do: nil
defp join_notifier do
Application.get_env(:mv, :join_notifier, MvWeb.JoinNotifierImpl)
end
defp send_already_member_and_return(email) do
case join_notifier().send_already_member(email) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}")
end
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, :notified_already_member}
end
defp handle_already_pending(email, existing) do
if existing.status == :pending_confirmation do
resend_confirmation_to_pending(email, existing)
else
send_already_pending_and_return(email)
end
end
defp resend_confirmation_to_pending(email, request) do
new_token = generate_confirmation_token()
case request
|> Ash.Changeset.for_update(:regenerate_confirmation_token, %{
confirmation_token: new_token
})
|> Ash.update(domain: __MODULE__, authorize?: false) do
{:ok, _updated} ->
case join_notifier().send_confirmation(email, new_token, resend: true) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}")
end
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, :notified_already_pending}
{:error, _} ->
# Fallback: do not create duplicate; send generic pending email
send_already_pending_and_return(email)
end
end
defp send_already_pending_and_return(email) do
case join_notifier().send_already_pending(email) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}")
end
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, :notified_already_pending}
end
defp do_create_join_request(attrs, actor) do
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
attrs_with_token = Map.put(attrs, :confirmation_token, token)
case Ash.create(JoinRequest, attrs_with_token,
action: :submit,
actor: actor,
domain: __MODULE__
) do
{:ok, request} ->
case join_notifier().send_confirmation(request.email, token, []) do
{:ok, _email} ->
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, request}
{:error, reason} ->
Logger.error(
"Join confirmation email failed for #{request.email}: #{inspect(reason)}"
)
{:error, :email_delivery_failed}
end
error ->
error
end
end
defp generate_confirmation_token do
32
|> :crypto.strong_rand_bytes()
|> Base.url_encode64(padding: false)
end
@doc """
Confirms a join request by token (public confirmation link).
Hashes the token, finds the JoinRequest by confirmation_token_hash, checks that
the token has not expired, then updates to status :submitted. Idempotent: if
already submitted, approved, or rejected, returns the existing record without changing it.
## Options
- `:actor` - Must be nil for public confirm (policy allows only unauthenticated).
## Returns
- `{:ok, request}` - Updated or already-processed JoinRequest
- `{:error, :token_expired}` - Token was found but confirmation_token_expires_at is in the past
- `{:error, error}` - Token unknown/invalid or authorization error
"""
def confirm_join_request(token, opts \\ []) when is_binary(token) do
hash = JoinRequest.hash_confirmation_token(token)
actor = Keyword.get(opts, :actor)
query =
Ash.Query.for_read(JoinRequest, :get_by_confirmation_token_hash, %{
confirmation_token_hash: hash
})
case Ash.read_one(query, actor: actor, domain: __MODULE__) do
{:ok, nil} ->
{:error, NotFoundError.exception(resource: JoinRequest)}
{:ok, request} ->
do_confirm_request(request, actor)
{:error, error} ->
{:error, error}
end
end
defp do_confirm_request(request, _actor)
when request.status in [:submitted, :approved, :rejected] do
{:ok, request}
end
defp do_confirm_request(request, actor) do
if expired?(request.confirmation_token_expires_at) do
{:error, :token_expired}
else
request
|> Ash.Changeset.for_update(:confirm, %{}, domain: __MODULE__)
|> Ash.update(domain: __MODULE__, actor: actor)
end
end
@doc """
Returns whether the public join form is enabled in global settings.
Used by the web layer (JoinRequest LiveViews, Layouts, plugs) to decide whether
to show join-related UI and to gate access to join request pages.
"""
@spec join_form_enabled?() :: boolean()
def join_form_enabled? do
case get_settings() do
{:ok, %{join_form_enabled: true}} -> true
_ -> false
end
end
@doc """
Returns the allowlist of fields configured for the public join form.
Reads the current settings. When the join form is disabled (or no settings exist),
returns an empty list. When enabled, returns each configured field as a map with:
- `:id` - field identifier string (member field name or custom field UUID)
- `:required` - boolean; email is always true
- `:type` - `:member_field` or `:custom_field`
This is the server-side allowlist used by the join form submit action (Subtask 4)
to enforce which fields are accepted from user input.
## Returns
- `[%{id: String.t(), required: boolean(), type: :member_field | :custom_field}]`
- `[]` when join form is disabled or settings are missing
## Examples
iex> Mv.Membership.get_join_form_allowlist()
[%{id: "email", required: true, type: :member_field},
%{id: "first_name", required: false, type: :member_field}]
"""
def get_join_form_allowlist do
case get_settings() do
{:ok, settings} ->
if settings.join_form_enabled do
build_join_form_allowlist(settings)
else
[]
end
{:error, _} ->
[]
end
end
defp build_join_form_allowlist(settings) do
field_ids = settings.join_form_field_ids || []
required_config = settings.join_form_field_required || %{}
member_field_names = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
Enum.map(field_ids, fn id ->
type = if id in member_field_names, do: :member_field, else: :custom_field
required = Map.get(required_config, id, false)
%{id: id, required: required, type: type}
end)
end
defp expired?(nil), do: true
defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt
# ---------------------------------------------------------------------------
# Step 2: Approval domain functions
# ---------------------------------------------------------------------------
@doc """
Lists join requests, optionally filtered by status.
## Options
- `:actor` - Required. The actor for authorization (normal_user or admin).
- `:status` - Optional atom to filter by status (default: `:submitted`).
Pass `:all` to return requests of all statuses.
## Returns
- `{:ok, list}` - List of JoinRequests
- `{:error, error}` - Authorization or query error
"""
@spec list_join_requests(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()}
def list_join_requests(opts \\ []) do
actor = Keyword.get(opts, :actor)
status = Keyword.get(opts, :status, :submitted)
query =
if status == :all do
JoinRequest
|> Ash.Query.sort(inserted_at: :desc)
else
JoinRequest
|> Ash.Query.filter(expr(status == ^status))
|> Ash.Query.sort(inserted_at: :desc)
end
Ash.read(query, actor: actor, domain: __MODULE__)
end
@doc """
Lists join requests with status `:approved` or `:rejected` (history), sorted by most recent first.
Loads `:reviewed_by_user` for displaying the reviewer. Same authorization as `list_join_requests/1`.
## Options
- `:actor` - Required. The actor for authorization (normal_user or admin).
## Returns
- `{:ok, list}` - List of JoinRequests (approved/rejected only)
- `{:error, error}` - Authorization or query error
"""
@spec list_join_requests_history(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()}
def list_join_requests_history(opts \\ []) do
actor = Keyword.get(opts, :actor)
query =
JoinRequest
|> Ash.Query.filter(expr(status in [:approved, :rejected]))
|> Ash.Query.sort(updated_at: :desc)
|> Ash.Query.load(:reviewed_by_user)
Ash.read(query, actor: actor, domain: __MODULE__)
end
@doc """
Returns the count of join requests with status `:submitted` (unprocessed).
Used e.g. for sidebar indicator. Same authorization as `list_join_requests/1`.
## Options
- `:actor` - Required. The actor for authorization (normal_user or admin).
## Returns
- Non-negative integer (0 on error or when unauthorized).
"""
@spec count_submitted_join_requests(keyword()) :: non_neg_integer()
def count_submitted_join_requests(opts \\ []) do
actor = Keyword.get(opts, :actor)
query = JoinRequest |> Ash.Query.filter(expr(status == :submitted))
case Ash.count(query, actor: actor, domain: __MODULE__) do
{:ok, count} when is_integer(count) and count >= 0 ->
count
{:error, error} ->
Logger.debug("count_submitted_join_requests failed: #{inspect(error)}")
0
_ ->
0
end
end
@doc """
Gets a single JoinRequest by id.
## Options
- `:actor` - Required. The actor for authorization.
## Returns
- `{:ok, request}` - The JoinRequest
- `{:ok, nil}` - Not found
- `{:error, error}` - Authorization or query error
"""
@spec get_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t() | nil} | {:error, term()}
def get_join_request(id, opts \\ []) do
actor = Keyword.get(opts, :actor)
Ash.get(JoinRequest, id,
actor: actor,
load: [:reviewed_by_user],
not_found_error?: false,
domain: __MODULE__
)
end
@doc """
Approves a join request and promotes it to a Member.
Finds the JoinRequest by id, calls the :approve action (which sets status to
:approved and records the reviewer), then creates a Member from the typed fields
and form_data. Idempotency: if the request is already approved, returns an error.
## Options
- `:actor` - Required. The reviewer (normal_user or admin).
## Returns
- `{:ok, approved_request}` - Approved JoinRequest
- `{:error, error}` - Status error, authorization error, or Member creation error
"""
@spec approve_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()}
def approve_join_request(id, opts \\ []) do
actor = Keyword.get(opts, :actor)
result =
Ash.transact(JoinRequest, fn ->
with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__),
{:ok, approved} <-
request
|> Ash.Changeset.for_update(:approve, %{}, actor: actor, domain: __MODULE__)
|> Ash.update(actor: actor, domain: __MODULE__),
{:ok, _member} <- promote_to_member(approved, actor) do
{:ok, approved}
end
end)
# Ash.transact returns {:ok, callback_result}; flatten so callers get {:ok, request} | {:error, term()}
case result do
{:ok, inner} -> inner
{:error, _} = err -> err
end
end
@doc """
Rejects a join request.
Finds the JoinRequest by id and calls the :reject action (status :rejected,
records reviewer). No Member is created. Returns error if not in :submitted status.
## Options
- `:actor` - Required. The reviewer (normal_user or admin).
## Returns
- `{:ok, rejected_request}` - Rejected JoinRequest
- `{:error, error}` - Status error or authorization error
"""
@spec reject_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()}
def reject_join_request(id, opts \\ []) do
actor = Keyword.get(opts, :actor)
with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__) do
request
|> Ash.Changeset.for_update(:reject, %{}, actor: actor, domain: __MODULE__)
|> Ash.update(actor: actor, domain: __MODULE__)
end
end
# Builds Member attrs + custom_field_values from a JoinRequest and creates the Member.
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
# Evaluated at compile time so we do not resolve member_fields() on every reduce step.
@member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
defp promote_to_member(%JoinRequest{} = request, actor) do
{member_attrs, custom_field_values} = build_member_attrs(request)
attrs =
if Enum.empty?(custom_field_values) do
member_attrs
else
Map.put(member_attrs, :custom_field_values, custom_field_values)
end
Ash.create(Mv.Membership.Member, attrs,
action: :create_member,
actor: actor,
domain: __MODULE__
)
end
defp build_member_attrs(%JoinRequest{} = request) do
# join_date defaults to today so membership fee cycles can be generated.
base_attrs = %{
email: request.email,
first_name: request.first_name,
last_name: request.last_name,
join_date: Date.utc_today()
}
form_data = request.form_data || %{}
Enum.reduce(form_data, {base_attrs, []}, fn {key, value}, {attrs, cfvs} ->
cond do
key in @member_field_strings ->
atom_key = String.to_existing_atom(key)
{Map.put(attrs, atom_key, value), cfvs}
Regex.match?(@uuid_pattern, key) ->
cfv = %{custom_field_id: key, value: to_string(value)}
{attrs, [cfv | cfvs]}
true ->
{attrs, cfvs}
end
end)
end end
end end

View file

@ -0,0 +1,45 @@
defmodule Mv.Membership.Property do
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
postgres do
table "properties"
repo Mv.Repo
references do
reference :member, on_delete: :delete
end
end
actions do
defaults [:create, :read, :update, :destroy]
default_accept [:value, :member_id, :property_type_id]
end
attributes do
uuid_primary_key :id
attribute :value, :union,
constraints: [
storage: :type_and_value,
types: [
boolean: [type: :boolean],
date: [type: :date],
integer: [type: :integer],
string: [type: :string],
email: [type: Mv.Membership.Email]
]
]
end
relationships do
belongs_to :member, Mv.Membership.Member
belongs_to :property_type, Mv.Membership.PropertyType
end
calculations do
calculate :value_to_string, :string, expr(value[:value] <> "")
end
end

View file

@ -0,0 +1,44 @@
defmodule Mv.Membership.PropertyType do
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
postgres do
table "property_types"
repo Mv.Repo
end
actions do
defaults [:create, :read, :update, :destroy]
default_accept [:name, :value_type, :description, :immutable, :required]
end
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
attribute :value_type, :atom,
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
allow_nil?: false,
description: "Defines the datatype `Property.value` is interpreted as"
attribute :description, :string, allow_nil?: true, public?: true
attribute :immutable, :boolean,
default: false,
allow_nil?: false
attribute :required, :boolean,
default: false,
allow_nil?: false
end
relationships do
has_many :properties, Mv.Membership.Property
end
identities do
identity :unique_name, [:name]
end
end

View file

@ -1,561 +0,0 @@
defmodule Mv.Membership.Setting do
@moduledoc """
Ash resource representing global application settings.
## Overview
Settings is a singleton resource that stores global configuration for the association,
such as the club name, branding information, and membership fee settings. There should
only ever be one settings record in the database.
## 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`.
- `member_field_required` - JSONB map storing which member fields are required in forms
(e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional.
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
- `default_membership_fee_type_id` - Default membership fee type for new members (optional)
- `registration_enabled` - Whether direct registration via /register is allowed (default: true)
- `join_form_enabled` - Whether the public /join page is active (default: false)
- `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is
either a member field name string (e.g. "email") or a custom field UUID. Email is always
included and always required; normalization enforces this automatically.
- `join_form_field_required` - Map of field ID => required boolean for the join form.
Email is always forced to true.
## Singleton Pattern
This resource uses a singleton pattern - there should only be one settings record.
The resource is designed to be read and updated, but not created or destroyed
through normal CRUD operations. Initial settings should be seeded.
## Environment Variable Support
The `club_name` can be set via the `ASSOCIATION_NAME` environment variable.
If set, the environment variable value is used as a fallback when no database
value exists. Database values always take precedence over environment variables.
## Membership Fee Settings
- `include_joining_cycle`: When true, members pay from their joining cycle. When false,
they pay from the next full cycle after joining.
- `default_membership_fee_type_id`: The membership fee type automatically assigned to
new members. Can be nil if no default is set.
## Examples
# Get current settings
{:ok, settings} = Mv.Membership.get_settings()
settings.club_name # => "My Club"
# 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})
# Update visibility and required for a single member field (e.g. from settings UI)
{:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
# Update membership fee settings
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
"""
# primary_read_warning?: false — We use a custom read prepare that selects only public
# attributes and explicitly excludes smtp_password. Ash warns when the primary read does
# not load all attributes; we intentionally omit the password for security.
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
primary_read_warning?: false
# Used in join_form_field_ids validation (compile-time to avoid recompiling regex and list on every validation)
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
alias Ash.Resource.Info, as: ResourceInfo
postgres do
table "settings"
repo Mv.Repo
end
resource do
description "Global application settings (singleton resource)"
end
# Attributes excluded from the default read (sensitive data). Same pattern as smtp_password:
# read only via explicit select when needed; never loaded into default get_settings().
@excluded_from_read [:smtp_password, :oidc_client_secret]
actions do
read :read do
primary? true
# Exclude sensitive attributes (e.g. smtp_password) from default reads. Config reads
# them via explicit select when needed. Uses all attribute names minus excluded so
# the list stays correct when new attributes are added to the resource.
prepare fn query, _context ->
select_attrs =
__MODULE__
|> ResourceInfo.attribute_names()
|> MapSet.to_list()
|> Kernel.--(@excluded_from_read)
Ash.Query.select(query, select_attrs)
end
end
# Internal create action - not exposed via code interface
# 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,
:member_field_required,
:include_joining_cycle,
:default_membership_fee_type_id,
:vereinfacht_api_url,
:vereinfacht_api_key,
:vereinfacht_club_id,
:vereinfacht_app_url,
:oidc_client_id,
:oidc_base_url,
:oidc_redirect_uri,
:oidc_client_secret,
:oidc_admin_group_name,
:oidc_groups_claim,
:oidc_only,
:smtp_host,
:smtp_port,
:smtp_username,
:smtp_password,
:smtp_ssl,
:smtp_from_name,
:smtp_from_email,
:registration_enabled,
:join_form_enabled,
:join_form_field_ids,
:join_form_field_required
]
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
end
update :update do
primary? true
require_atomic? false
accept [
:club_name,
:member_field_visibility,
:member_field_required,
:include_joining_cycle,
:default_membership_fee_type_id,
:vereinfacht_api_url,
:vereinfacht_api_key,
:vereinfacht_club_id,
:vereinfacht_app_url,
:oidc_client_id,
:oidc_base_url,
:oidc_redirect_uri,
:oidc_client_secret,
:oidc_admin_group_name,
:oidc_groups_claim,
:oidc_only,
:smtp_host,
:smtp_port,
:smtp_username,
:smtp_password,
:smtp_ssl,
:smtp_from_name,
:smtp_from_email,
:registration_enabled,
:join_form_enabled,
:join_form_field_ids,
:join_form_field_required
]
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
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]
end
update :update_single_member_field_visibility do
description "Atomically updates a single field in the member_field_visibility JSONB map"
require_atomic? false
argument :field, :string, allow_nil?: false
argument :show_in_overview, :boolean, allow_nil?: false
change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility
end
update :update_single_member_field do
description "Atomically updates visibility and required for a single member field"
require_atomic? false
argument :field, :string, allow_nil?: false
argument :show_in_overview, :boolean, allow_nil?: false
argument :required, :boolean, allow_nil?: false
change Mv.Membership.Setting.Changes.UpdateSingleMemberField
end
update :update_membership_fee_settings do
description "Updates the membership fee configuration"
require_atomic? false
accept [:include_joining_cycle, :default_membership_fee_type_id]
change Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId
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]
# Validate member_field_required map structure and content
validate fn changeset, _context ->
required_config = Ash.Changeset.get_attribute(changeset, :member_field_required)
if required_config && is_map(required_config) do
invalid_values =
Enum.filter(required_config, fn {_key, value} ->
not is_boolean(value)
end)
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
invalid_keys =
Enum.filter(required_config, 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_required,
message: "All values in member_field_required must be booleans"}
not Enum.empty?(invalid_keys) ->
{:error,
field: :member_field_required,
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
true ->
:ok
end
else
:ok
end
end,
on: [:create, :update]
# Validate join_form_field_ids: each entry must be a known member field name
# or a UUID-format string (custom field ID). Normalization (NormalizeJoinFormSettings
# change) runs before validations, so email is already present when this runs.
validate fn changeset, _context ->
field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids)
if is_list(field_ids) and field_ids != [] do
invalid_ids =
Enum.reject(field_ids, fn id ->
is_binary(id) and
(id in @valid_join_form_member_fields or Regex.match?(@uuid_pattern, id))
end)
if Enum.empty?(invalid_ids) do
:ok
else
{:error,
field: :join_form_field_ids,
message:
"Invalid field identifiers: #{inspect(invalid_ids)}. Use member field names or custom field UUIDs."}
end
else
:ok
end
end,
on: [:create, :update]
# Validate default_membership_fee_type_id exists if set
validate fn changeset, context ->
fee_type_id =
Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
if fee_type_id do
# Check existence only; action is already restricted by policy (e.g. admin).
opts = [domain: Mv.MembershipFees, authorize?: false]
case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id, opts) do
{:ok, _} ->
:ok
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
{:error,
field: :default_membership_fee_type_id,
message: "Membership fee type not found"}
{:error, err} ->
# Log unexpected errors (DB timeout, connection errors, etc.)
require Logger
Logger.warning(
"Unexpected error when validating default_membership_fee_type_id: #{inspect(err)}"
)
# Return generic error to user
{:error,
field: :default_membership_fee_type_id,
message: "Could not validate membership fee type"}
end
else
# Optional, can be nil
:ok
end
end,
on: [:create, :update]
end
attributes do
uuid_primary_key :id
attribute :club_name, :string,
allow_nil?: false,
public?: true,
description: "The name of the association/club",
constraints: [
trim?: true,
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."
attribute :member_field_required, :map,
allow_nil?: true,
public?: true,
description:
"Configuration for which member fields are required in forms (JSONB map). Keys are member field names (strings), values are booleans. Email is always required."
# Membership fee settings
attribute :include_joining_cycle, :boolean do
allow_nil? false
default true
public? true
description "Whether to include the joining cycle in membership fee generation"
end
attribute :default_membership_fee_type_id, :uuid do
allow_nil? true
public? true
description "Default membership fee type ID for new members"
end
# Vereinfacht accounting software integration (can be overridden by ENV)
attribute :vereinfacht_api_url, :string do
allow_nil? true
public? true
description "Vereinfacht API base URL (e.g. https://api.verein.visuel.dev/api/v1)"
end
attribute :vereinfacht_api_key, :string do
allow_nil? true
public? false
description "Vereinfacht API key (Bearer token)"
sensitive? true
end
attribute :vereinfacht_club_id, :string do
allow_nil? true
public? true
description "Vereinfacht club ID for multi-tenancy"
end
attribute :vereinfacht_app_url, :string do
allow_nil? true
public? true
description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
end
# OIDC authentication (can be overridden by ENV)
attribute :oidc_client_id, :string do
allow_nil? true
public? true
description "OIDC client ID (e.g. from OIDC_CLIENT_ID)"
end
attribute :oidc_base_url, :string do
allow_nil? true
public? true
description "OIDC provider base URL (e.g. from OIDC_BASE_URL)"
end
attribute :oidc_redirect_uri, :string do
allow_nil? true
public? true
description "OIDC redirect URI for callback (e.g. from OIDC_REDIRECT_URI)"
end
attribute :oidc_client_secret, :string do
allow_nil? true
public? false
description "OIDC client secret (e.g. from OIDC_CLIENT_SECRET)"
sensitive? true
end
attribute :oidc_admin_group_name, :string do
allow_nil? true
public? true
description "OIDC group name that maps to Admin role (e.g. from OIDC_ADMIN_GROUP_NAME)"
end
attribute :oidc_groups_claim, :string do
allow_nil? true
public? true
description "JWT claim name for group list (e.g. from OIDC_GROUPS_CLAIM, default 'groups')"
end
attribute :oidc_only, :boolean do
allow_nil? false
default false
public? true
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
end
# SMTP configuration (can be overridden by ENV)
attribute :smtp_host, :string do
allow_nil? true
public? true
description "SMTP server hostname (e.g. smtp.example.com)"
end
attribute :smtp_port, :integer do
allow_nil? true
public? true
description "SMTP server port (e.g. 587 for TLS, 465 for SSL, 25 for plain)"
end
attribute :smtp_username, :string do
allow_nil? true
public? true
description "SMTP authentication username"
end
attribute :smtp_password, :string do
allow_nil? true
public? false
description "SMTP authentication password (sensitive)"
sensitive? true
end
attribute :smtp_ssl, :string do
allow_nil? true
public? true
description "SMTP TLS/SSL mode: 'tls', 'ssl', or 'none'"
end
attribute :smtp_from_name, :string do
allow_nil? true
public? true
description "Display name for the transactional email sender (e.g. 'Mila'). Overrides MAIL_FROM_NAME env."
end
attribute :smtp_from_email, :string do
allow_nil? true
public? true
description "Email address for the transactional email sender. Must be owned by the SMTP user. Overrides MAIL_FROM_EMAIL env."
end
# Authentication: direct registration toggle
attribute :registration_enabled, :boolean do
allow_nil? false
default true
public? true
description "When true, users can register via /register; when false, only sign-in and join form remain available."
end
# Join form (Beitrittsformular) settings
attribute :join_form_enabled, :boolean do
allow_nil? false
default false
public? true
description "When true, the public /join page is active and new members can submit a request."
end
attribute :join_form_field_ids, {:array, :string} do
allow_nil? true
default []
public? true
description "Ordered list of field IDs shown on the join form. Each entry is a member field name (e.g. 'email') or a custom field UUID. Email is always present after normalization."
end
attribute :join_form_field_required, :map do
allow_nil? true
public? true
description "Map of field ID => required boolean for the join form. Email is always true after normalization."
end
timestamps()
end
relationships do
# Optional relationship to the default membership fee type
# Note: We use manual FK (default_membership_fee_type_id attribute) instead of belongs_to
# to avoid circular dependency between Membership and MembershipFees domains
end
end

View file

@ -1,19 +0,0 @@
defmodule Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId do
@moduledoc """
Ash change that normalizes empty strings to nil for default_membership_fee_type_id.
HTML forms submit empty select values as empty strings (""), but the database
expects nil for optional UUID fields. This change converts "" to nil.
"""
use Ash.Resource.Change
def change(changeset, _opts, _context) do
default_fee_type_id = Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
if default_fee_type_id == "" do
Ash.Changeset.force_change_attribute(changeset, :default_membership_fee_type_id, nil)
else
changeset
end
end
end

View file

@ -1,60 +0,0 @@
defmodule Mv.Membership.Setting.Changes.NormalizeJoinFormSettings do
@moduledoc """
Ash change that normalizes join form field settings before persist.
Applied on create and update actions whenever join form attributes are present.
Rules enforced:
- Email is always added to join_form_field_ids if not already present.
- Email is always marked as required (true) in join_form_field_required.
- Keys in join_form_field_required that are not in join_form_field_ids are dropped.
Only runs when join_form_field_ids is being changed; if only
join_form_field_required changes, normalization still uses the current
(possibly changed) field_ids to strip orphaned required flags.
"""
use Ash.Resource.Change
def change(changeset, _opts, _context) do
changing_ids? = Ash.Changeset.changing_attribute?(changeset, :join_form_field_ids)
changing_required? = Ash.Changeset.changing_attribute?(changeset, :join_form_field_required)
if changing_ids? or changing_required? do
normalize(changeset)
else
changeset
end
end
defp normalize(changeset) do
field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids)
required_config = Ash.Changeset.get_attribute(changeset, :join_form_field_required)
field_ids = normalize_field_ids(field_ids)
required_config = normalize_required(field_ids, required_config)
changeset
|> Ash.Changeset.force_change_attribute(:join_form_field_ids, field_ids)
|> Ash.Changeset.force_change_attribute(:join_form_field_required, required_config)
end
defp normalize_field_ids(nil), do: ["email"]
defp normalize_field_ids(ids) when is_list(ids) do
if "email" in ids do
ids
else
["email" | ids]
end
end
defp normalize_field_ids(_), do: ["email"]
defp normalize_required(field_ids, required_config) do
base = if is_map(required_config), do: required_config, else: %{}
base
|> Map.take(field_ids)
|> Map.put("email", true)
end
end

View file

@ -1,179 +0,0 @@
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
@moduledoc """
Ash change that atomically updates visibility and required for a single member field.
Updates both `member_field_visibility` and `member_field_required` JSONB maps
in one SQL UPDATE to avoid lost updates when saving from the settings UI.
## Arguments
- `field` - The member field name as a string (e.g., "street", "first_name")
- `show_in_overview` - Boolean value indicating visibility in member overview
- `required` - Boolean value indicating whether the field is required in member forms
## Example
settings
|> Ash.Changeset.for_update(:update_single_member_field, %{},
arguments: %{field: "first_name", show_in_overview: true, required: true}
)
|> Ash.update(domain: Mv.Membership)
"""
use Ash.Resource.Change
alias Ash.Error.Invalid
alias Ecto.Adapters.SQL
require Logger
def change(changeset, _opts, _context) do
with {:ok, field} <- get_and_validate_field(changeset),
{:ok, show_in_overview} <- get_and_validate_boolean(changeset, :show_in_overview),
{:ok, required} <- get_and_validate_boolean(changeset, :required) do
add_after_action(changeset, field, show_in_overview, required)
else
{:error, updated_changeset} -> updated_changeset
end
end
defp get_and_validate_field(changeset) do
case Ash.Changeset.get_argument(changeset, :field) do
nil ->
{:error,
add_error(changeset,
field: :field,
message: "field argument is required"
)}
field ->
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
if field in valid_fields do
{:ok, field}
else
{:error,
add_error(
changeset,
field: :field,
message: "Invalid member field: #{field}"
)}
end
end
end
defp get_and_validate_boolean(changeset, :show_in_overview = arg_name) do
do_validate_boolean(changeset, arg_name, :show_in_overview)
end
defp get_and_validate_boolean(changeset, :required = arg_name) do
do_validate_boolean(changeset, arg_name, :member_field_required)
end
defp do_validate_boolean(changeset, arg_name, error_field) do
case Ash.Changeset.get_argument(changeset, arg_name) do
nil ->
{:error,
add_error(
changeset,
field: error_field,
message: "#{arg_name} argument is required"
)}
value when is_boolean(value) ->
{:ok, value}
_ ->
{:error,
add_error(
changeset,
field: error_field,
message: "#{arg_name} must be a boolean"
)}
end
end
defp add_error(changeset, opts) do
Ash.Changeset.add_error(changeset, opts)
end
defp add_after_action(changeset, field, show_in_overview, required) do
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
# Update both JSONB columns in one statement
sql = """
UPDATE settings
SET
member_field_visibility = jsonb_set(
COALESCE(member_field_visibility, '{}'::jsonb),
ARRAY[$1::text],
to_jsonb($2::boolean),
true
),
member_field_required = jsonb_set(
COALESCE(member_field_required, '{}'::jsonb),
ARRAY[$1::text],
to_jsonb($3::boolean),
true
),
updated_at = (now() AT TIME ZONE 'utc')
WHERE id = $4
RETURNING member_field_visibility, member_field_required
"""
uuid_binary = Ecto.UUID.dump!(settings.id)
case SQL.query(Mv.Repo, sql, [field, show_in_overview, required, uuid_binary]) do
{:ok, %{rows: [[updated_visibility, updated_required] | _]}} ->
vis = normalize_jsonb_result(updated_visibility)
req = normalize_jsonb_result(updated_required)
updated_settings = %{
settings
| member_field_visibility: vis,
member_field_required: req
}
{:ok, updated_settings}
{:ok, %{rows: []}} ->
{:error,
Invalid.exception(
field: :member_field_required,
message: "Settings not found"
)}
{:error, error} ->
Logger.error("Failed to atomically update member field settings: #{inspect(error)}")
{:error,
Invalid.exception(
field: :member_field_required,
message: "Failed to update member field settings"
)}
end
end)
end
defp normalize_jsonb_result(updated_jsonb) do
case updated_jsonb do
map when is_map(map) ->
Enum.reduce(map, %{}, fn
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
{k, v}, acc -> Map.put(acc, k, v)
end)
binary when is_binary(binary) ->
case Jason.decode(binary) do
{:ok, decoded} when is_map(decoded) ->
decoded
{:ok, _} ->
%{}
{:error, reason} ->
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
%{}
end
_ ->
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
%{}
end
end
end

View file

@ -1,164 +0,0 @@
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
@moduledoc """
Ash change that atomically updates a single field in the member_field_visibility JSONB map.
This change uses PostgreSQL's jsonb_set function to atomically update a single key
in the JSONB map, preventing lost updates in concurrent scenarios.
## Arguments
- `field` - The member field name as a string (e.g., "street", "house_number")
- `show_in_overview` - Boolean value indicating visibility
## Example
settings
|> Ash.Changeset.for_update(:update_single_member_field_visibility,
%{},
arguments: %{field: "street", show_in_overview: false}
)
|> Ash.update(domain: Mv.Membership)
"""
use Ash.Resource.Change
alias Ash.Error.Invalid
alias Ecto.Adapters.SQL
require Logger
def change(changeset, _opts, _context) do
with {:ok, field} <- get_and_validate_field(changeset),
{:ok, show_in_overview} <- get_and_validate_boolean(changeset, :show_in_overview) do
add_after_action(changeset, field, show_in_overview)
else
{:error, updated_changeset} -> updated_changeset
end
end
defp get_and_validate_field(changeset) do
case Ash.Changeset.get_argument(changeset, :field) do
nil ->
{:error,
add_error(changeset,
field: :member_field_visibility,
message: "field argument is required"
)}
field ->
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
if field in valid_fields do
{:ok, field}
else
{:error,
add_error(
changeset,
field: :member_field_visibility,
message: "Invalid member field: #{field}"
)}
end
end
end
defp get_and_validate_boolean(changeset, arg_name) do
case Ash.Changeset.get_argument(changeset, arg_name) do
nil ->
{:error,
add_error(
changeset,
field: :member_field_visibility,
message: "#{arg_name} argument is required"
)}
value when is_boolean(value) ->
{:ok, value}
_ ->
{:error,
add_error(
changeset,
field: :member_field_visibility,
message: "#{arg_name} must be a boolean"
)}
end
end
defp add_error(changeset, opts) do
Ash.Changeset.add_error(changeset, opts)
end
defp add_after_action(changeset, field, show_in_overview) do
# Use after_action to execute atomic SQL update
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
# Use PostgreSQL jsonb_set for atomic update
# jsonb_set(target, path, new_value, create_missing?)
# path is an array: ['field_name']
# new_value must be JSON: to_jsonb(boolean)
sql = """
UPDATE settings
SET member_field_visibility = jsonb_set(
COALESCE(member_field_visibility, '{}'::jsonb),
ARRAY[$1::text],
to_jsonb($2::boolean),
true
)
WHERE id = $3
RETURNING member_field_visibility
"""
# Convert UUID string to binary for PostgreSQL
uuid_binary = Ecto.UUID.dump!(settings.id)
case SQL.query(Mv.Repo, sql, [field, show_in_overview, uuid_binary]) do
{:ok, %{rows: [[updated_jsonb] | _]}} ->
updated_visibility = normalize_jsonb_result(updated_jsonb)
# Update the settings struct with the new visibility
updated_settings = %{settings | member_field_visibility: updated_visibility}
{:ok, updated_settings}
{:ok, %{rows: []}} ->
{:error,
Invalid.exception(
field: :member_field_visibility,
message: "Settings not found"
)}
{:error, error} ->
Logger.error("Failed to atomically update member_field_visibility: #{inspect(error)}")
{:error,
Invalid.exception(
field: :member_field_visibility,
message: "Failed to update visibility"
)}
end
end)
end
defp normalize_jsonb_result(updated_jsonb) do
case updated_jsonb do
map when is_map(map) ->
# Convert atom keys to strings if needed
Enum.reduce(map, %{}, fn
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
{k, v}, acc -> Map.put(acc, k, v)
end)
binary when is_binary(binary) ->
case Jason.decode(binary) do
{:ok, decoded} when is_map(decoded) ->
decoded
# Not a map after decode
{:ok, _} ->
%{}
{:error, reason} ->
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
%{}
end
_ ->
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
%{}
end
end
end

View file

@ -1,85 +0,0 @@
defmodule Mv.Membership.SettingsCache do
@moduledoc """
Process-based cache for global settings to avoid repeated DB reads on hot paths
(e.g. RegistrationEnabled validation, Layouts.public_page, Plugs).
Uses a short TTL (default 60 seconds). Cache is invalidated on every settings
update so that changes take effect quickly. If no settings process exists
(e.g. in tests), get/1 falls back to direct read.
"""
use GenServer
@default_ttl_seconds 60
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Returns cached settings or fetches and caches them. Uses TTL; invalidate on update.
"""
def get do
case Process.whereis(__MODULE__) do
nil ->
# No cache process (e.g. test) read directly
do_fetch()
_pid ->
GenServer.call(__MODULE__, :get, 10_000)
end
end
@doc """
Invalidates the cache so the next get/0 will refetch from the database.
Call after update_settings and any other path that mutates settings.
"""
def invalidate do
case Process.whereis(__MODULE__) do
nil -> :ok
_pid -> GenServer.cast(__MODULE__, :invalidate)
end
end
@impl true
def init(opts) do
ttl = Keyword.get(opts, :ttl_seconds, @default_ttl_seconds)
state = %{ttl_seconds: ttl, cached: nil, expires_at: nil}
{:ok, state}
end
@impl true
def handle_call(:get, _from, state) do
now = System.monotonic_time(:second)
expired? = state.expires_at == nil or state.expires_at <= now
{result, new_state} =
if expired? do
fetch_and_cache(now, state)
else
{{:ok, state.cached}, state}
end
{:reply, result, new_state}
end
defp fetch_and_cache(now, state) do
case do_fetch() do
{:ok, settings} = ok ->
expires = now + state.ttl_seconds
{ok, %{state | cached: settings, expires_at: expires}}
err ->
result = if state.cached, do: {:ok, state.cached}, else: err
{result, state}
end
end
@impl true
def handle_cast(:invalidate, state) do
{:noreply, %{state | cached: nil, expires_at: nil}}
end
defp do_fetch do
Mv.Membership.get_settings_uncached()
end
end

View file

@ -1,177 +0,0 @@
defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
@moduledoc """
Ash change module that automatically calculates and sets the membership_fee_start_date.
## Logic
1. Only executes if `membership_fee_start_date` is not manually set
2. Requires both `join_date` and `membership_fee_type_id` to be present
3. Reads `include_joining_cycle` setting from global Settings
4. Reads `interval` from the assigned `membership_fee_type`
5. Calculates the start date:
- If `include_joining_cycle = true`: First day of the joining cycle
- If `include_joining_cycle = false`: First day of the next cycle after joining
## Usage
In a Member action:
create :create_member do
# ...
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
end
The change module handles all prerequisite checks internally (join_date, membership_fee_type_id).
If any required data is missing, the changeset is returned unchanged with a warning logged.
"""
use Ash.Resource.Change
require Logger
alias Mv.MembershipFees.CalendarCycles
@impl true
def change(changeset, _opts, context) do
# Only calculate if membership_fee_start_date is not already set
if has_start_date?(changeset) do
changeset
else
calculate_and_set_start_date(changeset, context)
end
end
# Check if membership_fee_start_date is already set (either in changeset or data)
defp has_start_date?(changeset) do
# Check if it's being set in this changeset
case Ash.Changeset.fetch_change(changeset, :membership_fee_start_date) do
{:ok, date} when not is_nil(date) ->
true
_ ->
# Check if it already exists in the data (for updates)
case changeset.data do
%{membership_fee_start_date: date} when not is_nil(date) -> true
_ -> false
end
end
end
defp calculate_and_set_start_date(changeset, context) do
actor = Map.get(context || %{}, :actor)
opts = if actor, do: [actor: actor], else: []
with {:ok, join_date} <- get_join_date(changeset),
{:ok, membership_fee_type_id} <- get_membership_fee_type_id(changeset),
{:ok, interval} <- get_interval(membership_fee_type_id, opts),
{:ok, include_joining_cycle} <- get_include_joining_cycle() do
start_date = calculate_start_date(join_date, interval, include_joining_cycle)
Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date)
else
{:error, :join_date_not_set} ->
# Missing join_date is expected for partial creates
changeset
{:error, :membership_fee_type_not_set} ->
# Missing membership_fee_type_id is expected for partial creates
changeset
{:error, :membership_fee_type_not_found} ->
# This is a data integrity error - membership_fee_type_id references non-existent type
# Return changeset error to fail the action
Ash.Changeset.add_error(
changeset,
field: :membership_fee_type_id,
message: "not found"
)
{:error, reason} ->
# Log warning for other unexpected errors
Logger.warning("Could not auto-set membership_fee_start_date: #{inspect(reason)}")
changeset
end
end
defp get_join_date(changeset) do
# First check the changeset for changes
case Ash.Changeset.fetch_change(changeset, :join_date) do
{:ok, date} when not is_nil(date) ->
{:ok, date}
_ ->
# Then check existing data
case changeset.data do
%{join_date: date} when not is_nil(date) -> {:ok, date}
_ -> {:error, :join_date_not_set}
end
end
end
defp get_membership_fee_type_id(changeset) do
# First check the changeset for changes
case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do
{:ok, id} when not is_nil(id) ->
{:ok, id}
_ ->
# Then check existing data
case changeset.data do
%{membership_fee_type_id: id} when not is_nil(id) -> {:ok, id}
_ -> {:error, :membership_fee_type_not_set}
end
end
end
defp get_interval(membership_fee_type_id, opts) do
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id, opts) do
{:ok, %{interval: interval}} -> {:ok, interval}
{:error, _} -> {:error, :membership_fee_type_not_found}
end
end
defp get_include_joining_cycle do
case Mv.Membership.get_settings() do
{:ok, %{include_joining_cycle: include}} -> {:ok, include}
{:error, _} -> {:ok, true}
end
end
@doc """
Calculates the membership fee start date based on join date, interval, and settings.
## Parameters
- `join_date` - The date the member joined
- `interval` - The billing interval (:monthly, :quarterly, :half_yearly, :yearly)
- `include_joining_cycle` - Whether to include the joining cycle
## Returns
The calculated start date (first day of the appropriate cycle).
## Examples
iex> calculate_start_date(~D[2024-03-15], :yearly, true)
~D[2024-01-01]
iex> calculate_start_date(~D[2024-03-15], :yearly, false)
~D[2025-01-01]
iex> calculate_start_date(~D[2024-03-15], :quarterly, true)
~D[2024-01-01]
iex> calculate_start_date(~D[2024-03-15], :quarterly, false)
~D[2024-04-01]
"""
@spec calculate_start_date(Date.t(), CalendarCycles.interval(), boolean()) :: Date.t()
def calculate_start_date(join_date, interval, include_joining_cycle) do
if include_joining_cycle do
# Start date is the first day of the joining cycle
CalendarCycles.calculate_cycle_start(join_date, interval)
else
# Start date is the first day of the next cycle after joining
join_cycle_start = CalendarCycles.calculate_cycle_start(join_date, interval)
CalendarCycles.next_cycle_start(join_cycle_start, interval)
end
end
end

View file

@ -1,154 +0,0 @@
defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
@moduledoc """
Validates that membership fee type changes only allow same-interval types.
Prevents changing from yearly to monthly, etc. (MVP constraint).
## Usage
In a Member action:
update :update_member do
# ...
change Mv.MembershipFees.Changes.ValidateSameInterval
end
The change module only executes when `membership_fee_type_id` is being changed.
If the new type has a different interval than the current type, a validation error is returned.
"""
use Ash.Resource.Change
@impl true
def change(changeset, _opts, context) do
if changing_membership_fee_type?(changeset) do
validate_interval_match(changeset, context)
else
changeset
end
end
# Check if membership_fee_type_id is being changed
defp changing_membership_fee_type?(changeset) do
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
end
# Validate that the new type has the same interval as the current type
defp validate_interval_match(changeset, context) do
current_type_id = get_current_type_id(changeset)
new_type_id = get_new_type_id(changeset)
actor = Map.get(context || %{}, :actor)
cond do
# If no current type, allow any change (first assignment)
is_nil(current_type_id) ->
changeset
# If new type is nil, reject the change (membership_fee_type_id is required)
is_nil(new_type_id) ->
add_nil_type_error(changeset)
# Both types exist - validate intervals match
true ->
validate_intervals_match(changeset, current_type_id, new_type_id, actor)
end
end
# Validates that intervals match when both types exist
defp validate_intervals_match(changeset, current_type_id, new_type_id, actor) do
case get_intervals(current_type_id, new_type_id, actor) do
{:ok, current_interval, new_interval} ->
if current_interval == new_interval do
changeset
else
add_interval_mismatch_error(changeset, current_interval, new_interval)
end
{:error, reason} ->
# Fail closed: If we can't load the types, reject the change
# This prevents inconsistent data states
add_type_validation_error(changeset, reason)
end
end
# Get current type ID from changeset data
defp get_current_type_id(changeset) do
case changeset.data do
%{membership_fee_type_id: type_id} -> type_id
_ -> nil
end
end
# Get new type ID from changeset
defp get_new_type_id(changeset) do
case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do
{:ok, type_id} -> type_id
:error -> nil
end
end
# Get intervals for both types (actor required for authorization when resource has policies)
defp get_intervals(current_type_id, new_type_id, actor) do
alias Mv.MembershipFees.MembershipFeeType
opts = if actor, do: [actor: actor], else: []
case {
Ash.get(MembershipFeeType, current_type_id, opts),
Ash.get(MembershipFeeType, new_type_id, opts)
} do
{{:ok, current_type}, {:ok, new_type}} ->
{:ok, current_type.interval, new_type.interval}
_ ->
{:error, :type_not_found}
end
end
# Add validation error for interval mismatch
defp add_interval_mismatch_error(changeset, current_interval, new_interval) do
current_interval_name = format_interval(current_interval)
new_interval_name = format_interval(new_interval)
message =
"Cannot change membership fee type: current type uses #{current_interval_name} interval, " <>
"new type uses #{new_interval_name} interval. Only same-interval changes are allowed."
Ash.Changeset.add_error(
changeset,
field: :membership_fee_type_id,
message: message
)
end
# Add validation error when types cannot be loaded
defp add_type_validation_error(changeset, _reason) do
message =
"Could not validate membership fee type intervals. " <>
"The current or new membership fee type no longer exists. " <>
"This may indicate a data consistency issue."
Ash.Changeset.add_error(
changeset,
field: :membership_fee_type_id,
message: message
)
end
# Add validation error when trying to set membership_fee_type_id to nil
defp add_nil_type_error(changeset) do
message = "Cannot remove membership fee type. A membership fee type is required."
Ash.Changeset.add_error(
changeset,
field: :membership_fee_type_id,
message: message
)
end
# Format interval atom to human-readable string
defp format_interval(:monthly), do: "monthly"
defp format_interval(:quarterly), do: "quarterly"
defp format_interval(:half_yearly), do: "half-yearly"
defp format_interval(:yearly), do: "yearly"
defp format_interval(interval), do: to_string(interval)
end

View file

@ -1,146 +0,0 @@
defmodule Mv.MembershipFees.MembershipFeeCycle do
@moduledoc """
Ash resource representing an individual membership fee cycle for a member.
## Overview
MembershipFeeCycle represents a single billing cycle for a member. Each cycle
tracks the payment status and amount for a specific time period.
## Attributes
- `cycle_start` - Start date of the billing cycle (aligned to calendar boundaries)
- `amount` - The fee amount for this cycle (stored for audit trail)
- `status` - Payment status: unpaid, paid, or suspended
- `notes` - Optional notes for this cycle
## Design Decisions
- **No cycle_end field**: Calculated from cycle_start + interval (from fee type)
- **Amount stored per cycle**: Preserves historical amounts when fee type changes
- **Calendar-aligned cycles**: All cycles start on calendar boundaries
## Relationships
- `belongs_to :member` - The member this cycle belongs to
- `belongs_to :membership_fee_type` - The fee type for this cycle
## Constraints
- Unique constraint on (member_id, cycle_start) - one cycle per period per member
- CASCADE delete when member is deleted
- RESTRICT delete on membership_fee_type if cycles exist
"""
use Ash.Resource,
domain: Mv.MembershipFees,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "membership_fee_cycles"
repo Mv.Repo
end
resource do
description "Individual membership fee cycle for a member"
end
actions do
defaults [:read, :destroy]
create :create do
primary? true
accept [:cycle_start, :amount, :status, :notes, :member_id, :membership_fee_type_id]
end
update :update do
primary? true
accept [:status, :notes, :amount]
end
update :mark_as_paid do
description "Mark cycle as paid"
require_atomic? false
accept [:notes]
change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :paid)
end
end
update :mark_as_suspended do
description "Mark cycle as suspended"
require_atomic? false
accept [:notes]
change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :suspended)
end
end
update :mark_as_unpaid do
description "Mark cycle as unpaid (for error correction)"
require_atomic? false
accept [:notes]
change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :unpaid)
end
end
end
# READ: bypass for own_data (:linked) then HasPermission for :all; create/update/destroy: HasPermission only.
policies do
bypass action_type(:read) do
description "own_data: read only cycles where member_id == actor.member_id"
authorize_if Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData
end
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from role (all read; normal_user and admin create/update/destroy)"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
attributes do
uuid_v7_primary_key :id
attribute :cycle_start, :date do
allow_nil? false
public? true
description "Start date of the billing cycle"
end
attribute :amount, :decimal do
allow_nil? false
public? true
description "Fee amount for this cycle (stored for audit trail, non-negative, max 2 decimal places)"
constraints min: 0, scale: 2
end
attribute :status, :atom do
allow_nil? false
public? true
default :unpaid
description "Payment status of this cycle"
constraints one_of: [:unpaid, :paid, :suspended]
end
attribute :notes, :string do
allow_nil? true
public? true
description "Optional notes for this cycle"
end
end
relationships do
belongs_to :member, Mv.Membership.Member do
allow_nil? false
end
belongs_to :membership_fee_type, Mv.MembershipFees.MembershipFeeType do
allow_nil? false
end
end
identities do
identity :unique_cycle_per_member, [:member_id, :cycle_start]
end
end

View file

@ -1,201 +0,0 @@
defmodule Mv.MembershipFees.MembershipFeeType do
@moduledoc """
Ash resource representing a membership fee type definition.
## Overview
MembershipFeeType defines the different types of membership fees that can be
assigned to members. Each type has a fixed interval (billing cycle) and a
default amount.
## Attributes
- `name` - Unique name for the fee type (e.g., "Standard", "Reduced", "Family")
- `amount` - The fee amount in the default currency (decimal)
- `interval` - Billing interval: monthly, quarterly, half_yearly, or yearly
- `description` - Optional description for the fee type
## Immutability
The `interval` field is immutable after creation. This prevents complex
migration scenarios when changing billing cycles. To change intervals,
create a new fee type and migrate members.
## Relationships
- `has_many :members` - Members assigned to this fee type
- `has_many :membership_fee_cycles` - All cycles using this fee type
"""
use Ash.Resource,
domain: Mv.MembershipFees,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "membership_fee_types"
repo Mv.Repo
end
resource do
description "Membership fee type definition with interval and amount"
end
actions do
defaults [:read]
create :create do
primary? true
accept [:name, :amount, :interval, :description]
end
update :update do
primary? true
# require_atomic? false because validation queries (member/cycle counts) are not atomic
# DB constraints serve as the final safeguard if data changes between validation and update
require_atomic? false
# Note: interval is NOT in accept list - it's immutable after creation
accept [:name, :amount, :description]
end
destroy :destroy do
primary? true
# require_atomic? false because validation queries (member/cycle/settings counts) are not atomic
# DB constraints serve as the final safeguard if data changes between validation and delete
require_atomic? false
end
end
policies do
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from role (all can read, only admin can create/update/destroy)"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
validations do
# Prevent interval changes after creation
validate fn changeset, _context ->
if Ash.Changeset.changing_attribute?(changeset, :interval) do
case changeset.data do
# Creating new resource, interval can be set
nil ->
:ok
_existing ->
{:error,
field: :interval, message: "Interval cannot be changed after creation"}
end
else
:ok
end
end,
on: [:update]
# Prevent deletion if assigned to members
validate fn changeset, _context ->
if changeset.action_type == :destroy do
require Ash.Query
# Integrity check: count members without authorization (systemic operation)
member_count =
Mv.Membership.Member
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|> Ash.count!(authorize?: false)
if member_count > 0 do
{:error,
message:
"Cannot delete membership fee type: #{member_count} member(s) are assigned to it"}
else
:ok
end
else
:ok
end
end,
on: [:destroy]
# Prevent deletion if cycles exist
validate fn changeset, _context ->
if changeset.action_type == :destroy do
require Ash.Query
# Integrity check: count cycles without authorization (systemic operation)
cycle_count =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|> Ash.count!(authorize?: false)
if cycle_count > 0 do
{:error,
message:
"Cannot delete membership fee type: #{cycle_count} cycle(s) reference it"}
else
:ok
end
else
:ok
end
end,
on: [:destroy]
# Prevent deletion if used as default in settings
validate fn changeset, _context ->
if changeset.action_type == :destroy do
require Ash.Query
# Integrity check: count settings without authorization (systemic operation)
setting_count =
Mv.Membership.Setting
|> Ash.Query.filter(default_membership_fee_type_id == ^changeset.data.id)
|> Ash.count!(authorize?: false)
if setting_count > 0 do
{:error,
message: "Cannot delete membership fee type: it's used as default in settings"}
else
:ok
end
else
:ok
end
end,
on: [:destroy]
end
attributes do
uuid_v7_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
description "Unique name for the membership fee type"
end
attribute :amount, :decimal do
allow_nil? false
public? true
description "Fee amount in default currency (non-negative, max 2 decimal places)"
constraints min: 0, scale: 2
end
attribute :interval, :atom do
allow_nil? false
public? true
description "Billing interval (immutable after creation)"
constraints one_of: [:monthly, :quarterly, :half_yearly, :yearly]
end
attribute :description, :string do
allow_nil? true
public? true
description "Optional description for the fee type"
end
end
relationships do
has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
has_many :members, Mv.Membership.Member
end
identities do
identity :unique_name, [:name]
end
end

View file

@ -1,49 +0,0 @@
defmodule Mv.MembershipFees do
@moduledoc """
Ash Domain for membership fee management.
## Resources
- `MembershipFeeType` - Defines membership fee types with intervals and amounts
- `MembershipFeeCycle` - Individual membership fee cycles per member
## Public API
The domain exposes these main actions:
- MembershipFeeType CRUD: `create_membership_fee_type/1`, `list_membership_fee_types/0`, `update_membership_fee_type/2`, `destroy_membership_fee_type/1`
- MembershipFeeCycle CRUD: `create_membership_fee_cycle/1`, `list_membership_fee_cycles/0`, `update_membership_fee_cycle/2`, `destroy_membership_fee_cycle/1`
Note: LiveViews may use direct Ash calls instead of these domain functions for performance or flexibility.
## Overview
This domain handles the complete membership fee lifecycle including:
- Fee type definitions (monthly, quarterly, half-yearly, yearly)
- Individual fee cycles for each member
- Payment status tracking (unpaid, paid, suspended)
## Architecture Decisions
- `interval` field on MembershipFeeType is immutable after creation
- `cycle_end` is calculated, not stored (from cycle_start + interval)
- `amount` is stored per cycle for audit trail when prices change
"""
use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix]
admin do
show? true
end
resources do
resource Mv.MembershipFees.MembershipFeeType do
define :create_membership_fee_type, action: :create
define :list_membership_fee_types, action: :read
define :update_membership_fee_type, action: :update
define :destroy_membership_fee_type, action: :destroy
end
resource Mv.MembershipFees.MembershipFeeCycle do
define :create_membership_fee_cycle, action: :create
define :list_membership_fee_cycles, action: :read
define :update_membership_fee_cycle, action: :update
define :destroy_membership_fee_cycle, action: :destroy
end
end
end

View file

@ -1,75 +0,0 @@
defmodule Mix.Tasks.JoinRequests.CleanupExpired do
@moduledoc """
Hard-deletes JoinRequests in status `pending_confirmation` whose confirmation link has expired.
Retention: records with `confirmation_token_expires_at` older than now are deleted.
Intended for cron or Oban (e.g. every hour). See docs/onboarding-join-concept.md.
## Usage
mix join_requests.cleanup_expired
## Examples
$ mix join_requests.cleanup_expired
Deleted 3 expired join request(s).
"""
use Mix.Task
require Ash.Query
require Logger
alias Mv.Membership.JoinRequest
@shortdoc "Deletes join requests in pending_confirmation with expired confirmation token"
@impl Mix.Task
def run(_args) do
Mix.Task.run("app.start")
now = DateTime.utc_now()
query =
JoinRequest
|> Ash.Query.filter(status == :pending_confirmation)
|> Ash.Query.filter(confirmation_token_expires_at < ^now)
# Bypass authorization: cleanup is a system maintenance task (cron/Oban).
# Use bulk_destroy so the data layer can delete in one pass when supported.
opts = [domain: Mv.Membership, authorize?: false]
count =
case Ash.count(query, opts) do
{:ok, n} -> n
{:error, _} -> 0
end
do_run(query, opts, count)
end
defp do_run(_query, _opts, 0) do
Mix.shell().info("No expired join requests to delete.")
0
end
defp do_run(query, opts, count) do
case Ash.bulk_destroy(query, :destroy, %{}, opts) do
%{status: status, errors: errors} when status in [:success, :partial_success] ->
maybe_log_errors(errors)
Mix.shell().info("Deleted #{count} expired join request(s).")
count
%{status: :error, errors: errors} ->
Mix.raise("Failed to delete expired join requests: #{inspect(errors)}")
end
end
defp maybe_log_errors(nil), do: :ok
defp maybe_log_errors([]), do: :ok
defp maybe_log_errors(errors) do
Logger.warning(
"Join requests cleanup: #{length(errors)} error(s) while deleting expired requests: #{inspect(errors)}"
)
end
end

View file

@ -1,70 +1,32 @@
defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
@moduledoc """ @moduledoc """
Sends an email for a new user to confirm their email address. Sends an email for a new user to confirm their email address.
Uses the unified email layout (MvWeb.EmailsView + EmailLayoutView) and
central mail from config (Mv.Mailer.mail_from/0).
""" """
use AshAuthentication.Sender use AshAuthentication.Sender
use Phoenix.Swoosh,
view: MvWeb.EmailsView,
layout: {MvWeb.EmailLayoutView, "layout.html"}
use MvWeb, :verified_routes use MvWeb, :verified_routes
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
require Logger import Swoosh.Email
alias Mv.Mailer 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
`:ok` always. Delivery errors are logged and not re-raised so they do not
crash the caller process (AshAuthentication ignores the return value).
"""
@impl true @impl true
def send(user, token, _) do def send(user, token, _) do
confirm_url = url(~p"/confirm_new_user/#{token}") new()
subject = gettext("Confirm your email address") # Replace with email from env
|> from({"noreply", "noreply@example.com"})
|> to(to_string(user.email))
|> subject("Confirm your email address")
|> html_body(body(token: token))
|> Mailer.deliver!()
end
assigns = %{ defp body(params) do
confirm_url: confirm_url, url = url(~p"/confirm_new_user/#{params[:token]}")
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
email = """
new() <p>Click this link to confirm your email:</p>
|> from(Mailer.mail_from()) <p><a href="#{url}">#{url}</a></p>
|> to(to_string(user.email)) """
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("user_confirmation.html", assigns)
case Mailer.deliver(email) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error(
"Failed to send user confirmation email to #{user.email}: #{inspect(reason)}"
)
:ok
end
end end
end end

View file

@ -1,67 +1,32 @@
defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
@moduledoc """ @moduledoc """
Sends a password reset email. Sends a password reset email
Uses the unified email layout (MvWeb.EmailsView + EmailLayoutView) and
central mail from config (Mv.Mailer.mail_from/0).
""" """
use AshAuthentication.Sender use AshAuthentication.Sender
use Phoenix.Swoosh,
view: MvWeb.EmailsView,
layout: {MvWeb.EmailLayoutView, "layout.html"}
use MvWeb, :verified_routes use MvWeb, :verified_routes
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
require Logger import Swoosh.Email
alias Mv.Mailer 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
`:ok` always. Delivery errors are logged and not re-raised so they do not
crash the caller process (AshAuthentication ignores the return value).
"""
@impl true @impl true
def send(user, token, _) do def send(user, token, _) do
reset_url = url(~p"/password-reset/#{token}") new()
subject = gettext("Reset your password") # Replace with email from env
|> from({"noreply", "noreply@example.com"})
|> to(to_string(user.email))
|> subject("Reset your password")
|> html_body(body(token: token))
|> Mailer.deliver!()
end
assigns = %{ defp body(params) do
reset_url: reset_url, url = url(~p"/password-reset/#{params[:token]}")
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
email = """
new() <p>Click this link to reset your password:</p>
|> from(Mailer.mail_from()) <p><a href="#{url}">#{url}</a></p>
|> to(to_string(user.email)) """
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("password_reset.html", assigns)
case Mailer.deliver(email) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Failed to send password reset email to #{user.email}: #{inspect(reason)}")
:ok
end
end end
end end

View file

@ -1,104 +0,0 @@
defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
@moduledoc """
Validates that the user's email is not already used by another member.
Only validates when:
- User is already linked to a member (member_id != nil) AND email is changing
- User is being linked to a member (member relationship is changing)
This allows creating users with the same email as unlinked members.
"""
use Ash.Resource.Validation
require Logger
@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)
member_changing? = Ash.Changeset.changing_relationship?(changeset, :member)
member_id = Ash.Changeset.get_attribute(changeset, :member_id)
is_linked? = not is_nil(member_id)
# Only validate if:
# 1. User is linked AND email is changing
# 2. User is being linked/unlinked (member relationship changing)
should_validate? = (is_linked? and email_changing?) or member_changing?
if should_validate? do
case Ash.Changeset.fetch_change(changeset, :email) do
{:ok, new_email} ->
# Extract member_id from relationship changes for new links
member_id_to_exclude = get_member_id_from_changeset(changeset)
check_email_uniqueness(new_email, member_id_to_exclude)
:error ->
# No email change, get current email
current_email = Ash.Changeset.get_attribute(changeset, :email)
# Extract member_id from relationship changes for new links
member_id_to_exclude = get_member_id_from_changeset(changeset)
check_email_uniqueness(current_email, member_id_to_exclude)
end
else
:ok
end
end
# Extract member_id from changeset, checking relationship changes first
# This is crucial for new links where member_id is in manage_relationship changes
defp get_member_id_from_changeset(changeset) do
# Try to get from relationships (for new links via manage_relationship)
case Map.get(changeset.relationships, :member) do
[{[%{id: id}], _opts}] when not is_nil(id) ->
# Found in relationships - this is a new link
id
_ ->
# Fall back to attribute (for existing links)
Ash.Changeset.get_attribute(changeset, :member_id)
end
end
defp check_email_uniqueness(email, exclude_member_id) do
alias Mv.Helpers
alias Mv.Helpers.SystemActor
query =
Mv.Membership.Member
|> Ash.Query.filter(email == ^to_string(email))
|> Mv.Helpers.query_exclude_id(exclude_member_id)
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.read(query, opts) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :email, message: "is already used by another member", value: email}
{:error, reason} ->
Logger.warning(
"Email uniqueness validation query failed for user email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
)
:ok
end
end
end

View file

@ -5,40 +5,19 @@ defmodule Mv.Application do
use Application use Application
alias Mv.Helpers.SystemActor
alias Mv.Membership.SettingsCache
alias Mv.Repo
alias Mv.Vereinfacht.SyncFlash
alias MvWeb.Endpoint
alias MvWeb.JoinRateLimit
alias MvWeb.Telemetry
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
SyncFlash.create_table!() children = [
MvWeb.Telemetry,
# SettingsCache not started in test so get_settings runs in the test process (Ecto Sandbox). Mv.Repo,
cache_children = {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
if Application.get_env(:mv, :environment) == :test, do: [], else: [SettingsCache] {Phoenix.PubSub, name: Mv.PubSub},
{AshAuthentication.Supervisor, otp_app: :my},
children = # Start a worker by calling: Mv.Worker.start_link(arg)
[ # {Mv.Worker, arg},
Telemetry, # Start to serve requests, typically the last entry
Repo MvWeb.Endpoint
] ++ ]
cache_children ++
[
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
{Task.Supervisor, name: Mv.TaskSupervisor},
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Mv.PubSub},
{AshAuthentication.Supervisor, otp_app: :my},
SystemActor,
# Start a worker by calling: Mv.Worker.start_link(arg)
# {Mv.Worker, arg},
# Start to serve requests, typically the last entry
Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html # See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options # for other strategies and supported options

View file

@ -1,146 +0,0 @@
defmodule Mv.Authorization.Actor do
@moduledoc """
Helper functions for ensuring User actors have required data loaded
and for querying actor capabilities (e.g. admin, permission set).
## Actor Invariant
Authorization policies (especially HasPermission) require that the User actor
has their `:role` relationship loaded. This module provides helpers to
ensure this invariant is maintained across all entry points:
- LiveView on_mount hooks
- Plug pipelines
- Background jobs
- Tests
## Scope
This module ONLY handles `Mv.Accounts.User` resources. Other resources with
a `:role` field are ignored (returned as-is). This prevents accidental
authorization bypasses and keeps the logic focused.
## Usage
# In LiveView on_mount
def ensure_user_role_loaded(_name, socket) do
user = Actor.ensure_loaded(socket.assigns[:current_user])
assign(socket, :current_user, user)
end
# Check if actor is admin (policy checks, validations)
if Actor.admin?(actor), do: ...
# Get permission set name (string or nil)
ps_name = Actor.permission_set_name(actor)
## Security Note
`ensure_loaded/1` loads the role with `authorize?: false` to avoid circular
dependency (actor needs role loaded to be authorized, but loading role requires
authorization). This is safe because:
- The actor (User) is loading their OWN role (user.role relationship)
- This load is needed FOR authorization checks to work
- The role itself contains no sensitive data (just permission_set reference)
- The actor is already authenticated (passed auth boundary)
Alternative would be to denormalize permission_set_name on User, but that
adds complexity and potential for inconsistency.
"""
require Logger
alias Mv.Helpers.SystemActor
@doc """
Ensures the actor (User) has their `:role` relationship loaded.
- If actor is nil, returns nil
- If role is already loaded, returns actor as-is
- If role is %Ash.NotLoaded{}, loads it and returns updated actor
- If actor is not a User, returns as-is (no-op)
## Examples
iex> Actor.ensure_loaded(nil)
nil
iex> Actor.ensure_loaded(%User{role: %Role{}})
%User{role: %Role{}}
iex> Actor.ensure_loaded(%User{role: %Ash.NotLoaded{}})
%User{role: %Role{}} # role loaded
"""
def ensure_loaded(nil), do: nil
# Only handle Mv.Accounts.User - clear intention, no accidental other resources
def ensure_loaded(%Mv.Accounts.User{role: %Ash.NotLoaded{}} = user) do
load_role(user)
end
def ensure_loaded(actor), do: actor
defp load_role(actor) do
# SECURITY: We skip authorization here because this is a bootstrap scenario:
# - The actor is loading their OWN role (actor.role relationship)
# - This load is needed FOR authorization checks to work (circular dependency)
# - The role itself contains no sensitive data (just permission_set reference)
# - The actor is already authenticated (passed auth boundary)
# Alternative would be to denormalize permission_set_name on User.
case Ash.load(actor, :role, domain: Mv.Accounts, authorize?: false) do
{:ok, loaded_actor} ->
loaded_actor
{:error, error} ->
# Log error but don't crash - fail-closed for authorization
Logger.warning(
"Failed to load actor role: #{inspect(error)}. " <>
"Authorization may fail if role is required."
)
actor
end
end
@doc """
Returns the actor's permission set name (string or atom) from their role, or nil.
Ensures role is loaded (including when role is nil). Supports both atom and
string keys for session/socket assigns. Use for capability checks consistent
with `ActorIsAdmin` and `HasPermission`.
"""
@spec permission_set_name(Mv.Accounts.User.t() | map() | nil) :: String.t() | atom() | nil
def permission_set_name(nil), do: nil
def permission_set_name(actor) do
actor = actor |> ensure_loaded() |> maybe_load_role()
get_in(actor, [Access.key(:role), Access.key(:permission_set_name)]) ||
get_in(actor, [Access.key("role"), Access.key("permission_set_name")])
end
@doc """
Returns true if the actor is the system user or has the admin permission set.
Use for validations and policy checks that require admin capability (e.g.
changing a linked member's email). Consistent with `ActorIsAdmin` policy check.
"""
@spec admin?(Mv.Accounts.User.t() | map() | nil) :: boolean()
def admin?(nil), do: false
def admin?(actor) do
SystemActor.system_user?(actor) or permission_set_name(actor) in ["admin", :admin]
end
# Load role only when it is nil (e.g. actor from session without role). ensure_loaded/1
# already handles %Ash.NotLoaded{}, so we do not double-load in the normal Ash path.
defp maybe_load_role(%Mv.Accounts.User{role: nil} = user) do
case Ash.load(user, :role, domain: Mv.Accounts, authorize?: false) do
{:ok, loaded} -> loaded
_ -> user
end
end
defp maybe_load_role(actor), do: actor
end

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