Compare commits
1 commit
main
...
feature/12
| Author | SHA1 | Date | |
|---|---|---|---|
| c6be9b5104 |
551 changed files with 4140 additions and 128491 deletions
16
.credo.exs
16
.credo.exs
|
|
@ -82,20 +82,14 @@
|
|||
# You can customize the priority of any check
|
||||
# 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,
|
||||
[
|
||||
priority: :low,
|
||||
if_nested_deeper_than: 2,
|
||||
if_called_more_often_than: 0,
|
||||
files: %{excluded: ["test/"]}
|
||||
]},
|
||||
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
|
||||
{Credo.Check.Design.TagFIXME, []},
|
||||
# You can also customize the exit_status of each check.
|
||||
# If you don't want TODO comments to cause `mix credo` to fail, just
|
||||
# set this value to 0 (zero).
|
||||
#
|
||||
{Credo.Check.Design.TagTODO, [exit_status: 0]},
|
||||
{Credo.Check.Design.TagTODO, [exit_status: 2]},
|
||||
|
||||
#
|
||||
## Readability Checks
|
||||
|
|
@ -164,11 +158,11 @@
|
|||
{Credo.Check.Warning.UnusedRegexOperation, []},
|
||||
{Credo.Check.Warning.UnusedStringOperation, []},
|
||||
{Credo.Check.Warning.UnusedTupleOperation, []},
|
||||
{Credo.Check.Warning.WrongTestFileExtension, []},
|
||||
# Module documentation check (enabled after adding @moduledoc to all modules)
|
||||
{Credo.Check.Readability.ModuleDoc, []}
|
||||
{Credo.Check.Warning.WrongTestFileExtension, []}
|
||||
],
|
||||
disabled: [
|
||||
# Checks disabled by the Mitgliederverwaltung Team
|
||||
{Credo.Check.Readability.ModuleDoc, []},
|
||||
#
|
||||
# Checks scheduled for next check update (opt-in for now)
|
||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||
|
|
|
|||
181
.drone.yml
181
.drone.yml
|
|
@ -1,10 +1,10 @@
|
|||
kind: pipeline
|
||||
type: docker
|
||||
name: check-fast
|
||||
name: check
|
||||
|
||||
services:
|
||||
- name: postgres
|
||||
image: docker.io/library/postgres:18.3
|
||||
image: docker.io/library/postgres:17.5
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
|
@ -52,12 +52,10 @@ steps:
|
|||
# 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
|
||||
- mix credo
|
||||
|
||||
- name: wait_for_postgres
|
||||
image: docker.io/library/postgres:18.3
|
||||
image: docker.io/library/postgres:17.5
|
||||
commands:
|
||||
# Wait for postgres to become available
|
||||
- |
|
||||
|
|
@ -72,7 +70,7 @@ steps:
|
|||
echo "Postgres did not become available, aborting."
|
||||
exit 1
|
||||
|
||||
- name: test-fast
|
||||
- name: test
|
||||
image: docker.io/library/elixir:1.18.3-otp-27
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
|
|
@ -83,114 +81,7 @@ steps:
|
|||
- mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- mix deps.get
|
||||
# Run fast tests (excludes slow/performance and UI 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)
|
||||
# Run tests
|
||||
- mix test
|
||||
|
||||
- name: rebuild-cache
|
||||
|
|
@ -209,64 +100,6 @@ volumes:
|
|||
host:
|
||||
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
|
||||
type: docker
|
||||
|
|
@ -284,7 +117,7 @@ environment:
|
|||
|
||||
steps:
|
||||
- name: renovate
|
||||
image: renovate/renovate:43.165
|
||||
image: renovate/renovate:41.72
|
||||
environment:
|
||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||
RENOVATE_TOKEN:
|
||||
|
|
|
|||
57
.env.example
57
.env.example
|
|
@ -1,56 +1 @@
|
|||
# Production Environment Variables for docker-compose.prod.yml
|
||||
# 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
|
||||
OIDC_CLIENT_SECRET=
|
||||
|
|
|
|||
|
|
@ -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
8
.gitignore
vendored
|
|
@ -41,11 +41,3 @@ npm-debug.log
|
|||
.env
|
||||
|
||||
.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/
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
elixir 1.18.3-otp-27
|
||||
erlang 27.3.4
|
||||
just 1.50.0
|
||||
just 1.42.4
|
||||
|
|
|
|||
122
CHANGELOG.md
122
CHANGELOG.md
|
|
@ -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 user’s 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 applicant’s 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)
|
||||
|
||||
3163
CODE_GUIDELINES.md
3163
CODE_GUIDELINES.md
File diff suppressed because it is too large
Load diff
|
|
@ -1,459 +0,0 @@
|
|||
# UI Design Guidelines (Mila / Phoenix LiveView + DaisyUI)
|
||||
|
||||
## Purpose
|
||||
This document defines Mila’s **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:** AshAuthentication’s `_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 AshAuthentication’s 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 library’s 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 de‑emphasised 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 won’t 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 table’s `sticky_header={true}` so the table’s `<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: 4–6s
|
||||
- warning: 6–8s
|
||||
- error: 8–12s (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.
|
||||
|
||||
---
|
||||
20
Dockerfile
20
Dockerfile
|
|
@ -7,14 +7,14 @@
|
|||
# This file is based on these images:
|
||||
#
|
||||
# - 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
|
||||
# - 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 RUNNER_IMAGE="debian:trixie-20260202-slim"
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
|
||||
ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
|
||||
|
||||
FROM ${BUILDER_IMAGE} AS builder
|
||||
FROM ${BUILDER_IMAGE} as builder
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && apt-get install -y build-essential git \
|
||||
|
|
@ -64,15 +64,15 @@ RUN mix release
|
|||
FROM ${RUNNER_IMAGE}
|
||||
|
||||
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/*_*
|
||||
|
||||
# Set the locale
|
||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LANGUAGE=en_US:en
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
ENV LANG en_US.UTF-8
|
||||
ENV LANGUAGE en_US:en
|
||||
ENV LC_ALL en_US.UTF-8
|
||||
|
||||
WORKDIR "/app"
|
||||
RUN chown nobody /app
|
||||
|
|
@ -90,4 +90,4 @@ USER nobody
|
|||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||
# ENTRYPOINT ["/tini", "--"]
|
||||
|
||||
ENTRYPOINT ["/app/bin/docker-entrypoint.sh"]
|
||||
CMD ["/app/bin/server"]
|
||||
|
|
|
|||
76
Justfile
76
Justfile
|
|
@ -1,13 +1,4 @@
|
|||
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
|
||||
mix phx.server
|
||||
|
|
@ -16,7 +7,6 @@ install-dependencies:
|
|||
mix deps.get
|
||||
|
||||
migrate-database:
|
||||
mix compile
|
||||
mix ash.setup
|
||||
|
||||
reset-database:
|
||||
|
|
@ -29,7 +19,7 @@ seed-database:
|
|||
start-database:
|
||||
docker compose up -d
|
||||
|
||||
ci-dev: lint audit test-fast
|
||||
ci-dev: lint audit test
|
||||
|
||||
gettext:
|
||||
mix gettext.extract
|
||||
|
|
@ -38,47 +28,22 @@ gettext:
|
|||
lint:
|
||||
mix format --check-formatted
|
||||
mix compile --warnings-as-errors
|
||||
mix credo --strict
|
||||
# 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
|
||||
mix credo
|
||||
|
||||
audit:
|
||||
mix sobelow --config
|
||||
mix deps.audit
|
||||
mix hex.audit
|
||||
|
||||
# Run all tests
|
||||
test *args: install-dependencies
|
||||
mix test {{args}}
|
||||
# first run unit test and after that run e2e test (especially for accessibility)
|
||||
test: install-dependencies start-database
|
||||
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:
|
||||
mix format
|
||||
|
||||
# Catch-all wrapper for arbitrary mix commands not exposed as their own recipe.
|
||||
mix *args:
|
||||
mix {{args}}
|
||||
|
||||
build-docker-container:
|
||||
docker build --tag mitgliederverwaltung .
|
||||
|
||||
|
|
@ -122,32 +87,3 @@ clean:
|
|||
mix clean
|
||||
rm -rf .elixir_ls
|
||||
rm -rf _build
|
||||
|
||||
# Remove Git merge conflict markers from gettext files
|
||||
remove-gettext-conflicts:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
find priv/gettext -type f -exec sed -i '/^<<<<<<</d; /^=======$/d; /^>>>>>>>/d; /^%%%%%%%/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
662
LICENSE
|
|
@ -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
276
README.md
|
|
@ -1,278 +1,18 @@
|
|||
# Mila
|
||||
# mitgliederverwaltung
|
||||
|
||||
**Mila** — simple, usable, self-hostable membership management for small to mid-sized clubs.
|
||||
|
||||
[](https://drone.cicd.local-it.cloud/local-it/mitgliederverwaltung)
|
||||

|
||||
|
||||
## 🚧 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 don’t need
|
||||
* **Too expensive** — hidden fees, closed ecosystems, vendor lock-in
|
||||
* **Too rigid** — no way to adapt fields, processes, or roles to your club’s 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.
|
||||
## Testing SSO with rauthy
|
||||
|
||||
1. `just run`
|
||||
2. 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
|
||||
4. add client from the admin panel
|
||||
1. go to [localhost:8080](http://localhost:8080), go to the Admin area
|
||||
1. Login with "admin@localhost" and password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in docker-compose.yml
|
||||
1. add client from the admin panel
|
||||
- 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
|
||||
- allowed origins: http://localhost:4000
|
||||
- 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
|
||||
6. abort and run `just run` again
|
||||
|
||||
Now you can log in to Mila via OIDC!
|
||||
|
||||
### OIDC with other providers (Authentik, Keycloak, etc.)
|
||||
|
||||
Mila works with any OIDC-compliant provider. The internal strategy is named `: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
|
||||
1. copy client secret to `.env` file
|
||||
1. abort and run `just run` again
|
||||
|
||||
|
||||
📚 **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
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
@plugin "../vendor/daisyui-theme" {
|
||||
name: "dark";
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
prefersdark: true;
|
||||
color-scheme: "dark";
|
||||
--color-base-100: oklch(30.33% 0.016 252.42);
|
||||
--color-base-200: oklch(25.26% 0.014 253.1);
|
||||
|
|
@ -99,677 +99,4 @@
|
|||
/* Make LiveView wrapper divs transparent for layout */
|
||||
[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
|
||||
de‑emphasised 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 */
|
||||
|
||||
/* ============================================
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
503
assets/js/app.js
503
assets/js/app.js
|
|
@ -21,338 +21,11 @@ import "phoenix_html"
|
|||
import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import topbar from "../vendor/topbar"
|
||||
import Sortable from "../vendor/sortable"
|
||||
|
||||
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, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {
|
||||
_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
|
||||
}
|
||||
params: {_csrf_token: csrfToken}
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
@ -369,177 +42,3 @@ liveSocket.connect()
|
|||
// >> liveSocket.disableLatencySim()
|
||||
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
59
assets/package-lock.json
generated
Normal 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
5
assets/package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"playwright": "^1.55.0"
|
||||
}
|
||||
}
|
||||
2
assets/vendor/sortable.js
vendored
2
assets/vendor/sortable.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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,
|
||||
ecto_repos: [Mv.Repo],
|
||||
generators: [timestamp_type: :utc_datetime],
|
||||
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization]
|
||||
|
||||
# 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"
|
||||
ash_domains: [Mv.Membership, Mv.Accounts]
|
||||
|
||||
# Configures the endpoint
|
||||
config :mv, MvWeb.Endpoint,
|
||||
|
|
@ -96,20 +71,6 @@ config :mv, MvWeb.Endpoint,
|
|||
# at the `config/runtime.exs`.
|
||||
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)
|
||||
config :esbuild,
|
||||
version: "0.17.11",
|
||||
|
|
@ -134,16 +95,7 @@ config :tailwind,
|
|||
# Configures Elixir's Logger
|
||||
config :logger, :default_formatter,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
metadata: [
|
||||
:request_id,
|
||||
:user_id,
|
||||
:member_id,
|
||||
:member_email,
|
||||
:error,
|
||||
:error_type,
|
||||
:cycles_count,
|
||||
:notifications_count
|
||||
]
|
||||
metadata: [:request_id]
|
||||
|
||||
# Use Jason for JSON parsing in Phoenix
|
||||
config :phoenix, :json_library, Jason
|
||||
|
|
|
|||
|
|
@ -93,13 +93,11 @@ config :mv, :secret_key_base, "ryn7D6ssmIHQFWIks2sFiTGATgwwAR1+3bN8p7fy6qVtB8qnx
|
|||
# Signing Secret for Authentication
|
||||
config :mv, :token_signing_secret, "IwUwi65TrEeExwBXXFPGm2I7889NsL"
|
||||
|
||||
# OIDC: only use when ENV (or Settings) are set. When all OIDC ENVs are commented out,
|
||||
# do not set defaults here so the SSO button stays hidden and no MissingSecret occurs.
|
||||
# config :mv, :oidc,
|
||||
# client_id: "mv",
|
||||
# base_url: "http://localhost:8080/auth/v1",
|
||||
# client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
||||
# redirect_uri: "http://localhost:4000/auth/user/oidc/callback"
|
||||
config :mv, :rauthy,
|
||||
client_id: "mv",
|
||||
base_url: "http://localhost:8080/auth/v1",
|
||||
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
||||
redirect_uri: "http://localhost:4000/auth/user/rauthy/callback"
|
||||
|
||||
# AshAuthentication development configuration
|
||||
config :mv, :session_identifier, :jti
|
||||
|
|
|
|||
|
|
@ -16,16 +16,5 @@ config :swoosh, local: false
|
|||
# Do not print debug messages in production
|
||||
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
|
||||
# of environment variables, is done on config/runtime.exs.
|
||||
|
|
|
|||
|
|
@ -7,158 +7,6 @@ import Config
|
|||
# any compile-time configuration in here, as it won't be applied.
|
||||
# The block below contains prod specific runtime configuration.
|
||||
|
||||
# Helper function to read environment variables with Docker secrets support.
|
||||
# Supports the _FILE suffix pattern: if VAR_FILE is set, reads the value from
|
||||
# that file path. Otherwise falls back to VAR directly.
|
||||
# VAR_FILE takes priority and must contain the full absolute path to the secret file.
|
||||
get_env_or_file = fn var_name, default ->
|
||||
file_var = "#{var_name}_FILE"
|
||||
|
||||
case System.get_env(file_var) do
|
||||
nil ->
|
||||
System.get_env(var_name, default)
|
||||
|
||||
file_path ->
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
String.trim_trailing(content)
|
||||
|
||||
{:error, reason} ->
|
||||
raise """
|
||||
Failed to read secret from file specified in #{file_var}="#{file_path}".
|
||||
Error: #{inspect(reason)}
|
||||
"""
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Same as get_env_or_file but raises if the value is not set 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
|
||||
#
|
||||
# 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
|
||||
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
|
||||
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: []
|
||||
|
||||
config :mv, Mv.Repo,
|
||||
# ssl: true,
|
||||
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
|
||||
|
||||
# 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
|
||||
# to check this value into version control, so we use an environment
|
||||
# variable instead.
|
||||
# Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets.
|
||||
secret_key_base =
|
||||
get_env_or_file!.("SECRET_KEY_BASE", """
|
||||
environment variable SECRET_KEY_BASE (or SECRET_KEY_BASE_FILE) is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
""")
|
||||
|
||||
# PHX_HOST or DOMAIN can be used to set the host for the application.
|
||||
# DOMAIN is commonly used in deployment environments (e.g., Portainer templates).
|
||||
host =
|
||||
get_env_non_empty.("PHX_HOST", nil) ||
|
||||
get_env_non_empty.("DOMAIN", nil) ||
|
||||
System.get_env("SECRET_KEY_BASE") ||
|
||||
raise """
|
||||
Please define the PHX_HOST or DOMAIN environment variable.
|
||||
(Variable may be set but empty.)
|
||||
environment variable SECRET_KEY_BASE is missing.
|
||||
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")
|
||||
|
||||
# OIDC configuration (works with any OIDC provider: Authentik, Rauthy, Keycloak, etc.)
|
||||
# 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)
|
||||
config :mv, :rauthy, redirect_uri: "http://localhost:4000/auth/user/rauthy/callback"
|
||||
|
||||
client_secret =
|
||||
if oidc_in_use do
|
||||
get_env_or_file!.("OIDC_CLIENT_SECRET", """
|
||||
environment variable OIDC_CLIENT_SECRET (or OIDC_CLIENT_SECRET_FILE) is missing.
|
||||
This is required when OIDC authentication is configured (OIDC_BASE_URL or OIDC_CLIENT_ID is set).
|
||||
""")
|
||||
else
|
||||
get_env_or_file.("OIDC_CLIENT_SECRET", nil)
|
||||
end
|
||||
# AshAuthentication production configuration
|
||||
config :mv, :session_identifier, :jti
|
||||
|
||||
# Build redirect_uri: use OIDC_REDIRECT_URI if set, otherwise build from host.
|
||||
# Uses HTTPS since production runs behind TLS termination.
|
||||
default_redirect_uri = "https://#{host}/auth/user/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, :require_token_presence_for_authentication, true
|
||||
|
||||
config :mv, MvWeb.Endpoint,
|
||||
url: [host: host, port: 443, scheme: "https"],
|
||||
http: [
|
||||
# Bind on all IPv4 interfaces.
|
||||
# Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only.
|
||||
# Enable IPv6 and bind on all interfaces.
|
||||
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
|
||||
ip: {0, 0, 0, 0},
|
||||
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||
ip: {0, 0, 0, 0, 0, 0, 0, 0},
|
||||
port: port
|
||||
],
|
||||
secret_key_base: secret_key_base,
|
||||
# Allow connections from localhost and 127.0.0.1
|
||||
check_origin: [
|
||||
"//#{host}",
|
||||
"//localhost:#{port}",
|
||||
"//127.0.0.1:#{port}"
|
||||
]
|
||||
secret_key_base: secret_key_base
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
|
|
@ -303,54 +104,21 @@ if config_env() == :prod do
|
|||
#
|
||||
# Check `Plug.SSL` for all available options in `force_ssl`.
|
||||
|
||||
# Transactional emails use the sender from config :mv, :mail_from (overridable via ENV).
|
||||
config :mv,
|
||||
:mail_from,
|
||||
{System.get_env("MAIL_FROM_NAME", "Mila"),
|
||||
System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")}
|
||||
|
||||
# SMTP configuration from environment variables (overrides base adapter in prod).
|
||||
# When SMTP_HOST is set, configure Swoosh to use the SMTP adapter at boot time.
|
||||
# If SMTP is configured only via Settings (Admin UI), the mailer builds the config
|
||||
# per-send at runtime using Mv.Mailer.smtp_config/0 (which uses the same Mv.Smtp.ConfigBuilder).
|
||||
smtp_host_env = System.get_env("SMTP_HOST")
|
||||
|
||||
if smtp_host_env && String.trim(smtp_host_env) != "" do
|
||||
smtp_port_env = parse_positive_integer.(System.get_env("SMTP_PORT"), 587)
|
||||
|
||||
smtp_password_env =
|
||||
case System.get_env("SMTP_PASSWORD") do
|
||||
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
|
||||
# ## Configuring the mailer
|
||||
#
|
||||
# In production you need to configure the mailer to use a different adapter.
|
||||
# Also, you may need to configure the Swoosh API client of your choice if you
|
||||
# are not using SMTP. Here is an example of the configuration:
|
||||
#
|
||||
# config :mv, Mv.Mailer,
|
||||
# adapter: Swoosh.Adapters.Mailgun,
|
||||
# api_key: System.get_env("MAILGUN_API_KEY"),
|
||||
# domain: System.get_env("MAILGUN_DOMAIN")
|
||||
#
|
||||
# For this example you need include a HTTP client required by Swoosh API client.
|
||||
# Swoosh supports Hackney, Req and Finch out of the box:
|
||||
#
|
||||
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
|
||||
#
|
||||
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,17 +12,15 @@ config :mv, Mv.Repo,
|
|||
port: System.get_env("TEST_POSTGRES_PORT", "5000"),
|
||||
database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||
pool: Ecto.Adapters.SQL.Sandbox,
|
||||
pool_size: System.schedulers_online() * 8,
|
||||
queue_target: 5000,
|
||||
queue_interval: 1000,
|
||||
timeout: 60_000
|
||||
pool_size: System.schedulers_online() * 2
|
||||
|
||||
# We don't run a server during test. If one is required,
|
||||
# you can enable the server option below.
|
||||
config :mv, MvWeb.Endpoint,
|
||||
http: [ip: {127, 0, 0, 1}, port: 4002],
|
||||
secret_key_base: "Qbc/hcosiQzgfgMMPVs2slKjY2oqiqhpQHsV3twL9dN5GVDzsmsMWC1L/BZAU3Fd",
|
||||
server: false
|
||||
# Set to true for playwright
|
||||
server: true
|
||||
|
||||
# In test we don't send emails
|
||||
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
|
||||
|
||||
# Use English as default locale in tests so UI tests can assert on English strings.
|
||||
config :mv, :default_locale, "en"
|
||||
|
||||
# Enable SQL Sandbox for async LiveView tests
|
||||
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
|
||||
config :mv, :sql_sandbox, true
|
||||
|
||||
# Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute)
|
||||
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2
|
||||
|
||||
# Ash: silence "after_transaction hooks in surrounding transaction" warning when using
|
||||
# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected).
|
||||
config :ash, warn_on_transaction_hooks?: false
|
||||
# Playwright config
|
||||
config :phoenix_test,
|
||||
endpoint: MvWeb.Endpoint,
|
||||
otp_app: :mv,
|
||||
playwright: [
|
||||
browser: :firefox, #:chromium
|
||||
headless: System.get_env("PW_HEADLESS", "true") in ~w(t true),
|
||||
js_logger: false,
|
||||
screenshot: System.get_env("PW_SCREENSHOT", "false") in ~w(t true),
|
||||
trace: System.get_env("PW_TRACE", "false") in ~w(t true),
|
||||
browser_launch_timeout: 10_000
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -1,16 +1,21 @@
|
|||
networks:
|
||||
local:
|
||||
rauthy-dev:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:18.3-alpine
|
||||
image: postgres:17.5-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: mv_dev
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql
|
||||
- type: volume
|
||||
source: postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
volume:
|
||||
nocopy: true
|
||||
ports:
|
||||
- "5000:5432"
|
||||
networks:
|
||||
|
|
@ -25,7 +30,7 @@ services:
|
|||
|
||||
rauthy:
|
||||
container_name: rauthy-dev
|
||||
image: ghcr.io/sebadob/rauthy:0.35.1
|
||||
image: ghcr.io/sebadob/rauthy:0.32.0
|
||||
environment:
|
||||
- LOCAL_TEST=true
|
||||
- SMTP_URL=mailcrab
|
||||
|
|
@ -34,8 +39,12 @@ services:
|
|||
- LISTEN_SCHEME=http
|
||||
- PUB_URL=localhost:8080
|
||||
- BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345
|
||||
# Disable strict IP validation to allow access from multiple Docker networks
|
||||
- SESSION_VALIDATE_IP=false
|
||||
#- HIQLITE=false
|
||||
#- PG_HOST=db
|
||||
#- PG_PORT=5432
|
||||
#- PG_USER=postgres
|
||||
#- PG_PASSWORD=postgres
|
||||
#- PG_DB_NAME=mv_dev
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
|
|
@ -45,7 +54,9 @@ services:
|
|||
- rauthy-dev
|
||||
- local
|
||||
volumes:
|
||||
- rauthy-data:/app/data
|
||||
- type: volume
|
||||
source: rauthy-data
|
||||
target: /app/data
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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**
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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
|
|
@ -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).
|
||||
|
|
@ -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 |
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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 4–6s, warning 6–8s, error 8–12s; 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
|
||||
- ✅ Member–finance-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
|
|
@ -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**
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
# Onboarding & Join – High-Level Concept
|
||||
|
||||
**Status:** Draft for design decisions and implementation specs. **Prio 1 (Subtasks 1–4) 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. User–Member 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 plug’s **`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_user’s 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 plug’s 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 plug’s `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 plug’s 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
@ -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
|
|
@ -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.
|
||||
|
||||
|
|
@ -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] |
|
||||
+------------------------------------------------------------------+
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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** 🎉
|
||||
|
|
@ -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`.
|
||||
|
|
@ -1,15 +1,6 @@
|
|||
defmodule Mv.Accounts do
|
||||
@moduledoc """
|
||||
AshAuthentication specific domain to handle Authentication for users.
|
||||
|
||||
## Resources
|
||||
- `User` - User accounts with authentication methods (password, OIDC)
|
||||
- `Token` - Session tokens for authentication
|
||||
|
||||
## Public API
|
||||
The domain exposes these main actions:
|
||||
- User CRUD: `create_user/1`, `list_users/0`, `update_user/2`, `destroy_user/1`
|
||||
- Authentication: `create_register_with_oidc/1`, `read_sign_in_with_oidc/1`
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
|
@ -24,8 +15,8 @@ defmodule Mv.Accounts do
|
|||
define :list_users, action: :read
|
||||
define :update_user, action: :update_user
|
||||
define :destroy_user, action: :destroy
|
||||
define :create_register_with_oidc, action: :register_with_oidc
|
||||
define :read_sign_in_with_oidc, action: :sign_in_with_oidc
|
||||
define :create_register_with_rauthy, action: :register_with_rauthy
|
||||
define :read_sign_in_with_rauthy, action: :sign_in_with_rauthy
|
||||
end
|
||||
|
||||
resource Mv.Accounts.Token
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
defmodule Mv.Accounts.Token do
|
||||
@moduledoc """
|
||||
AshAuthentication Token Resource for session management.
|
||||
|
||||
This resource is used by AshAuthentication to manage authentication tokens
|
||||
for user sessions. Tokens are automatically created and managed by the
|
||||
authentication system.
|
||||
AshAuthentication specific ressource
|
||||
"""
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
|
|
|
|||
|
|
@ -5,46 +5,30 @@ defmodule Mv.Accounts.User do
|
|||
use Ash.Resource,
|
||||
domain: Mv.Accounts,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication],
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
extensions: [AshAuthentication]
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Ash.Resource.Preparation.Builtins
|
||||
alias Mv.Authorization.Role, as: RoleResource
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.OidcRoleSync
|
||||
# authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "users"
|
||||
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
|
||||
|
||||
@doc """
|
||||
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
|
||||
session_identifier Application.compile_env!(:mv, :session_identifier)
|
||||
session_identifier Application.compile_env(:mv, :session_identifier, :jti)
|
||||
|
||||
tokens do
|
||||
enabled? true
|
||||
token_resource Mv.Accounts.Token
|
||||
|
||||
require_token_presence_for_authentication? Application.compile_env!(
|
||||
require_token_presence_for_authentication? Application.compile_env(
|
||||
:mv,
|
||||
:require_token_presence_for_authentication
|
||||
:require_token_presence_for_authentication,
|
||||
false
|
||||
)
|
||||
|
||||
store_all_tokens? true
|
||||
|
|
@ -57,7 +41,7 @@ defmodule Mv.Accounts.User do
|
|||
end
|
||||
|
||||
strategies do
|
||||
oidc :oidc do
|
||||
oidc :rauthy do
|
||||
client_id Mv.Secrets
|
||||
base_url Mv.Secrets
|
||||
redirect_uri Mv.Secrets
|
||||
|
|
@ -65,9 +49,6 @@ defmodule Mv.Accounts.User do
|
|||
auth_method :client_secret_jwt
|
||||
code_verifier true
|
||||
|
||||
# Request email and profile scopes from OIDC provider (required for Authentik, Keycloak, etc.)
|
||||
authorization_params scope: "openid email profile"
|
||||
|
||||
# id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87
|
||||
end
|
||||
|
||||
|
|
@ -75,134 +56,20 @@ defmodule Mv.Accounts.User do
|
|||
identity_field :email
|
||||
hash_provider AshAuthentication.BcryptProvider
|
||||
confirmation_required? false
|
||||
|
||||
resettable do
|
||||
sender Mv.Accounts.User.Senders.SendPasswordResetEmail
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
actions do
|
||||
# Default actions for framework/tooling integration:
|
||||
# - :read -> Standard read used across the app and by admin tooling.
|
||||
# - :destroy-> Standard delete used by admin tooling and maintenance tasks.
|
||||
#
|
||||
# NOTE: :create is INTENTIONALLY excluded from defaults!
|
||||
# Using a default :create would bypass email-synchronization logic.
|
||||
# Always use one of these explicit create actions instead:
|
||||
# - :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
|
||||
defaults [:read, :create, :destroy, :update]
|
||||
|
||||
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]
|
||||
# Allow member to be passed as argument for relationship management
|
||||
argument :member, :map, allow_nil?: true
|
||||
upsert? true
|
||||
|
||||
# Note: Default role is automatically assigned via attribute default (see attributes block)
|
||||
|
||||
# Manage the member relationship during user creation
|
||||
change manage_relationship(:member, :member,
|
||||
# Look up existing member and relate to it
|
||||
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
|
||||
|
||||
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 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
|
||||
accept [:email]
|
||||
end
|
||||
|
||||
# Admin action for direct password changes in admin panel
|
||||
|
|
@ -210,59 +77,12 @@ defmodule Mv.Accounts.User do
|
|||
update :admin_set_password do
|
||||
accept [:email]
|
||||
argument :password, :string, allow_nil?: false, sensitive?: true
|
||||
require_atomic? false
|
||||
|
||||
# Set the strategy context that HashPasswordChange expects
|
||||
change set_context(%{strategy_name: :password})
|
||||
|
||||
# Use the official Ash Authentication password change
|
||||
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
|
||||
|
||||
read :get_by_subject do
|
||||
|
|
@ -272,49 +92,19 @@ defmodule Mv.Accounts.User do
|
|||
prepare AshAuthentication.Preparations.FilterBySubject
|
||||
end
|
||||
|
||||
read :sign_in_with_oidc do
|
||||
# Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1).
|
||||
get? true
|
||||
read :sign_in_with_rauthy 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!
|
||||
# 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
|
||||
_ -> []
|
||||
filter expr(email == get_path(^arg(:user_info), [:preferred_username]))
|
||||
end
|
||||
|
||||
Enum.each(users, fn user ->
|
||||
OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
|
||||
end)
|
||||
|
||||
{:ok, result}
|
||||
end)
|
||||
end
|
||||
|
||||
create :register_with_oidc do
|
||||
create :register_with_rauthy do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
upsert? true
|
||||
# Upsert based on oidc_id (primary match for existing OIDC users)
|
||||
upsert_identity :unique_oidc_id
|
||||
# On upsert, only update email - preserve existing role_id
|
||||
upsert_fields [:email]
|
||||
|
||||
validate &__MODULE__.validate_oidc_id_present/2
|
||||
|
||||
|
|
@ -323,225 +113,19 @@ defmodule Mv.Accounts.User do
|
|||
change fn changeset, _ctx ->
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
|
||||
# Support both "email" (standard OIDC like Authentik, Keycloak) and "preferred_username" (Rauthy)
|
||||
email = user_info["email"] || user_info["preferred_username"]
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.change_attribute(:email, email)
|
||||
|> Ash.Changeset.change_attribute(:email, user_info["preferred_username"])
|
||||
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
|
||||
end
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
validations do
|
||||
# Password strength policy: minimum 8 characters for all password-related actions
|
||||
validate string_length(:password, min: 8),
|
||||
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"}
|
||||
validate string_length(:password, min: 8) do
|
||||
where action_is([:register_with_password, :admin_set_password])
|
||||
end
|
||||
else
|
||||
:ok
|
||||
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
|
||||
|
||||
def validate_oidc_id_present(changeset, _context) do
|
||||
|
|
@ -557,53 +141,18 @@ defmodule Mv.Accounts.User do
|
|||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
# IMPORTANT: Email Synchronization
|
||||
# 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 :email, :ci_string, allow_nil?: false, public?: true
|
||||
attribute :hashed_password, :string, sensitive?: true, allow_nil?: true
|
||||
attribute :oidc_id, :string, allow_nil?: true
|
||||
|
||||
# Role assignment: Explicitly defined to enforce default value
|
||||
# This ensures every user has a role, regardless of creation path
|
||||
# (register_with_password, create_user, seeds, etc.)
|
||||
attribute :role_id, :uuid do
|
||||
allow_nil? false
|
||||
default &__MODULE__.default_role_id/0
|
||||
public? false
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
identities do
|
||||
identity :unique_email, [:email]
|
||||
identity :unique_oidc_id, [:oidc_id]
|
||||
identity :unique_member, [:member_id]
|
||||
end
|
||||
|
||||
# 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())
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,38 +1,4 @@
|
|||
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_regex Regex.compile!(@match_pattern)
|
||||
@min_length 5
|
||||
|
|
@ -47,18 +13,11 @@ defmodule Mv.Membership.Email do
|
|||
max_length: @max_length
|
||||
]
|
||||
|
||||
@impl true
|
||||
def cast_input(nil, _), do: {:ok, nil}
|
||||
|
||||
@impl true
|
||||
def cast_input(value, _) when is_binary(value) do
|
||||
value = String.trim(value)
|
||||
|
||||
cond do
|
||||
# Empty string after trim becomes nil (optional field)
|
||||
value == "" ->
|
||||
{:ok, nil}
|
||||
|
||||
String.length(value) < @min_length ->
|
||||
:error
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,40 +1,7 @@
|
|||
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,
|
||||
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
|
||||
show? true
|
||||
end
|
||||
|
|
@ -47,852 +14,18 @@ defmodule Mv.Membership do
|
|||
define :destroy_member, action: :destroy
|
||||
end
|
||||
|
||||
resource Mv.Membership.CustomFieldValue do
|
||||
define :create_custom_field_value, action: :create
|
||||
define :list_custom_field_values, action: :read
|
||||
define :update_custom_field_value, action: :update
|
||||
define :destroy_custom_field_value, action: :destroy
|
||||
resource Mv.Membership.Property do
|
||||
define :create_property, action: :create
|
||||
define :list_property, action: :read
|
||||
define :update_property, action: :update
|
||||
define :destroy_property, action: :destroy
|
||||
end
|
||||
|
||||
resource Mv.Membership.CustomField do
|
||||
define :create_custom_field, action: :create
|
||||
define :list_custom_fields, action: :read
|
||||
define :update_custom_field, action: :update
|
||||
define :destroy_custom_field, action: :destroy_with_values
|
||||
define :prepare_custom_field_deletion, action: :prepare_deletion, args: [:id]
|
||||
resource Mv.Membership.PropertyType do
|
||||
define :create_property_type, action: :create
|
||||
define :list_property_types, action: :read
|
||||
define :update_property_type, action: :update
|
||||
define :destroy_property_type, action: :destroy
|
||||
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
|
||||
|
|
|
|||
45
lib/membership/property.ex
Normal file
45
lib/membership/property.ex
Normal 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
|
||||
44
lib/membership/property_type.ex
Normal file
44
lib/membership/property_type.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,70 +1,32 @@
|
|||
defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||
@moduledoc """
|
||||
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 Phoenix.Swoosh,
|
||||
view: MvWeb.EmailsView,
|
||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
|
||||
use MvWeb, :verified_routes
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
require Logger
|
||||
import Swoosh.Email
|
||||
|
||||
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
|
||||
def send(user, token, _) do
|
||||
confirm_url = url(~p"/confirm_new_user/#{token}")
|
||||
subject = gettext("Confirm your email address")
|
||||
|
||||
assigns = %{
|
||||
confirm_url: confirm_url,
|
||||
subject: subject,
|
||||
app_name: Mailer.mail_from() |> elem(0),
|
||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||
}
|
||||
|
||||
email =
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
# Replace with email from env
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> to(to_string(user.email))
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body("user_confirmation.html", assigns)
|
||||
|> subject("Confirm your email address")
|
||||
|> html_body(body(token: token))
|
||||
|> Mailer.deliver!()
|
||||
end
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
defp body(params) do
|
||||
url = url(~p"/confirm_new_user/#{params[:token]}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error(
|
||||
"Failed to send user confirmation email to #{user.email}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
"""
|
||||
<p>Click this link to confirm your email:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,67 +1,32 @@
|
|||
defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
||||
@moduledoc """
|
||||
Sends a password reset email.
|
||||
|
||||
Uses the unified email layout (MvWeb.EmailsView + EmailLayoutView) and
|
||||
central mail from config (Mv.Mailer.mail_from/0).
|
||||
Sends a password reset email
|
||||
"""
|
||||
|
||||
use AshAuthentication.Sender
|
||||
|
||||
use Phoenix.Swoosh,
|
||||
view: MvWeb.EmailsView,
|
||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
|
||||
use MvWeb, :verified_routes
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
require Logger
|
||||
import Swoosh.Email
|
||||
|
||||
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
|
||||
def send(user, token, _) do
|
||||
reset_url = url(~p"/password-reset/#{token}")
|
||||
subject = gettext("Reset your password")
|
||||
|
||||
assigns = %{
|
||||
reset_url: reset_url,
|
||||
subject: subject,
|
||||
app_name: Mailer.mail_from() |> elem(0),
|
||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||
}
|
||||
|
||||
email =
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
# Replace with email from env
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> to(to_string(user.email))
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body("password_reset.html", assigns)
|
||||
|> subject("Reset your password")
|
||||
|> html_body(body(token: token))
|
||||
|> Mailer.deliver!()
|
||||
end
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
defp body(params) do
|
||||
url = url(~p"/password-reset/#{params[:token]}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to send password reset email to #{user.email}: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
"""
|
||||
<p>Click this link to reset your password:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -5,39 +5,18 @@ defmodule Mv.Application do
|
|||
|
||||
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
|
||||
def start(_type, _args) do
|
||||
SyncFlash.create_table!()
|
||||
|
||||
# SettingsCache not started in test so get_settings runs in the test process (Ecto Sandbox).
|
||||
cache_children =
|
||||
if Application.get_env(:mv, :environment) == :test, do: [], else: [SettingsCache]
|
||||
|
||||
children =
|
||||
[
|
||||
Telemetry,
|
||||
Repo
|
||||
] ++
|
||||
cache_children ++
|
||||
[
|
||||
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
|
||||
{Task.Supervisor, name: Mv.TaskSupervisor},
|
||||
children = [
|
||||
MvWeb.Telemetry,
|
||||
Mv.Repo,
|
||||
{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
|
||||
MvWeb.Endpoint
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue