Compare commits
2 commits
main
...
try-timeou
| Author | SHA1 | Date | |
|---|---|---|---|
| b952a30d37 | |||
| a40b430ed0 |
431 changed files with 25909 additions and 47954 deletions
38
.credo.exs
38
.credo.exs
|
|
@ -82,14 +82,8 @@
|
|||
# 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
|
||||
|
|
@ -114,7 +108,6 @@
|
|||
{Credo.Check.Readability.RedundantBlankLines, []},
|
||||
{Credo.Check.Readability.Semicolons, []},
|
||||
{Credo.Check.Readability.SpaceAfterCommas, []},
|
||||
{Credo.Check.Readability.StrictModuleLayout, []},
|
||||
{Credo.Check.Readability.StringSigils, []},
|
||||
{Credo.Check.Readability.TrailingBlankLine, []},
|
||||
{Credo.Check.Readability.TrailingWhiteSpace, []},
|
||||
|
|
@ -167,19 +160,13 @@
|
|||
{Credo.Check.Warning.UnusedTupleOperation, []},
|
||||
{Credo.Check.Warning.WrongTestFileExtension, []},
|
||||
# Module documentation check (enabled after adding @moduledoc to all modules)
|
||||
{Credo.Check.Readability.ModuleDoc, []},
|
||||
|
||||
# Promoted in the cleanup ratchet (each currently at zero violations):
|
||||
{Credo.Check.Refactor.DoubleBooleanNegation, []},
|
||||
{Credo.Check.Refactor.FilterReject, []},
|
||||
{Credo.Check.Refactor.RejectFilter, []},
|
||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||
{Credo.Check.Warning.LazyLogging, []},
|
||||
{Credo.Check.Warning.LeakyEnvironment, []},
|
||||
{Credo.Check.Warning.MapGetUnsafePass, []},
|
||||
{Credo.Check.Warning.MixEnv, []}
|
||||
{Credo.Check.Readability.ModuleDoc, []}
|
||||
],
|
||||
disabled: [
|
||||
#
|
||||
# Checks scheduled for next check update (opt-in for now)
|
||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||
|
||||
#
|
||||
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
|
||||
# and be sure to use `mix credo --strict` to see low priority checks)
|
||||
|
|
@ -190,7 +177,6 @@
|
|||
{Credo.Check.Design.SkipTestWithoutComment, []},
|
||||
{Credo.Check.Readability.AliasAs, []},
|
||||
{Credo.Check.Readability.BlockPipe, []},
|
||||
# ImplTrue: ~269 violations; deferred to a follow-up.
|
||||
{Credo.Check.Readability.ImplTrue, []},
|
||||
{Credo.Check.Readability.MultiAlias, []},
|
||||
{Credo.Check.Readability.NestedFunctionCalls, []},
|
||||
|
|
@ -200,20 +186,24 @@
|
|||
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
|
||||
{Credo.Check.Readability.SinglePipe, []},
|
||||
{Credo.Check.Readability.Specs, []},
|
||||
{Credo.Check.Readability.StrictModuleLayout, []},
|
||||
{Credo.Check.Readability.WithCustomTaggedTuple, []},
|
||||
{Credo.Check.Refactor.ABCSize, []},
|
||||
# AppendSingleItem: ~10 violations (mostly tests); deferred to a follow-up.
|
||||
{Credo.Check.Refactor.AppendSingleItem, []},
|
||||
# IoPuts: 3 violations in Mv.Release seed output; deferred to a follow-up.
|
||||
{Credo.Check.Refactor.DoubleBooleanNegation, []},
|
||||
{Credo.Check.Refactor.FilterReject, []},
|
||||
{Credo.Check.Refactor.IoPuts, []},
|
||||
# MapMap: ~8 violations; deferred to a follow-up.
|
||||
{Credo.Check.Refactor.MapMap, []},
|
||||
{Credo.Check.Refactor.ModuleDependencies, []},
|
||||
# NegatedIsNil: ~63 violations; deferred to a follow-up.
|
||||
{Credo.Check.Refactor.NegatedIsNil, []},
|
||||
{Credo.Check.Refactor.PassAsyncInTestCases, []},
|
||||
{Credo.Check.Refactor.PipeChainStart, []},
|
||||
{Credo.Check.Refactor.RejectFilter, []},
|
||||
{Credo.Check.Refactor.VariableRebinding, []},
|
||||
{Credo.Check.Warning.LazyLogging, []},
|
||||
{Credo.Check.Warning.LeakyEnvironment, []},
|
||||
{Credo.Check.Warning.MapGetUnsafePass, []},
|
||||
{Credo.Check.Warning.MixEnv, []},
|
||||
{Credo.Check.Warning.UnsafeToAtom, []}
|
||||
|
||||
# {Credo.Check.Refactor.MapInto, []},
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
# Temporarily ignored security advisories
|
||||
#
|
||||
# Format: one GHSA ID per line.
|
||||
# Remove an entry once a patched version is available and the dependency is updated.
|
||||
|
||||
# cowlib >= 2.9.0 <= 2.16.1 — Cookie Request Header Injection via cow_cookie:cookie/1
|
||||
# Severity: low. No patched version available as of 2026-05-20.
|
||||
# Tracked upstream: https://github.com/advisories/GHSA-g2wm-735q-3f56
|
||||
GHSA-g2wm-735q-3f56
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Dialyzer ignore list.
|
||||
#
|
||||
# This file is for PROVEN false positives only. Each entry must carry a
|
||||
# `# why:` comment explaining why Dialyzer is wrong about the call site.
|
||||
# Real findings get fixed by adjusting @spec, return types, or pattern
|
||||
# matches — never silenced here.
|
||||
#
|
||||
# Format: each entry is either a path string, a {path, warning} tuple,
|
||||
# or a {path, warning, line} tuple. See:
|
||||
# https://hexdocs.pm/dialyxir/readme.html#elixir-format
|
||||
[]
|
||||
184
.drone.jsonnet
184
.drone.jsonnet
|
|
@ -1,184 +0,0 @@
|
|||
local elixir = 'docker.io/library/elixir:1.18.3-otp-27';
|
||||
local postgres_image = 'docker.io/library/postgres:18.3';
|
||||
|
||||
local pg_service = {
|
||||
name: 'postgres',
|
||||
image: postgres_image,
|
||||
environment: {
|
||||
POSTGRES_USER: 'postgres',
|
||||
POSTGRES_PASSWORD: 'postgres',
|
||||
},
|
||||
};
|
||||
|
||||
local cache_volume = { name: 'cache', host: { path: '/tmp/drone_cache' } };
|
||||
local cache_mount = [{ name: 'cache', path: '/cache' }];
|
||||
|
||||
local step_compute_cache = {
|
||||
name: 'compute cache key',
|
||||
image: elixir,
|
||||
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',
|
||||
],
|
||||
};
|
||||
|
||||
local step_restore_cache = {
|
||||
name: 'restore-cache',
|
||||
image: 'drillster/drone-volume-cache',
|
||||
settings: { restore: true, mount: ['./deps', './_build', './priv/plts'], ttl: 30 },
|
||||
volumes: cache_mount,
|
||||
};
|
||||
|
||||
local step_lint = {
|
||||
name: 'lint',
|
||||
image: elixir,
|
||||
commands: [
|
||||
'mix local.hex --force', // Install hex package manager
|
||||
'mix deps.get', // Fetch dependencies
|
||||
'mix compile --warnings-as-errors', // Check for compilation errors & warnings
|
||||
'mix format --check-formatted', // Check formatting
|
||||
'mix sobelow --config', // Security checks
|
||||
'mix deps.audit --ignore-file .deps_audit_ignore', // Known vulnerabilities
|
||||
'mix hex.audit', // Unmaintained dependencies
|
||||
'mix credo --strict', // Code quality hints
|
||||
'mix gettext.extract --check-up-to-date', // Translations up to date
|
||||
],
|
||||
};
|
||||
|
||||
local step_typecheck = {
|
||||
name: 'typecheck',
|
||||
image: elixir,
|
||||
commands: [
|
||||
'mix local.hex --force',
|
||||
'mix deps.get',
|
||||
'mkdir -p priv/plts',
|
||||
// Build/refresh PLT — no-op on cache hit, full build (5-15 min) on cache miss.
|
||||
'mix dialyzer --plt',
|
||||
// Actual typecheck. --format short keeps log noise down on red builds.
|
||||
'mix dialyzer --format short',
|
||||
],
|
||||
};
|
||||
|
||||
local step_wait_postgres = {
|
||||
name: 'wait_for_postgres',
|
||||
image: postgres_image,
|
||||
commands: [
|
||||
|||
|
||||
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
|
||||
|||,
|
||||
],
|
||||
};
|
||||
|
||||
local step_rebuild_cache = {
|
||||
name: 'rebuild-cache',
|
||||
image: 'drillster/drone-volume-cache',
|
||||
settings: { rebuild: true, mount: ['./deps', './_build', './priv/plts'] },
|
||||
volumes: cache_mount,
|
||||
};
|
||||
|
||||
// test_cmd is the only thing that differs between the fast and full suites.
|
||||
local test_step(name, test_cmd) = {
|
||||
name: name,
|
||||
image: elixir,
|
||||
environment: {
|
||||
MIX_ENV: 'test',
|
||||
TEST_POSTGRES_HOST: 'postgres',
|
||||
TEST_POSTGRES_PORT: '5432',
|
||||
},
|
||||
commands: ['mix local.hex --force', 'mix deps.get', test_cmd],
|
||||
};
|
||||
|
||||
local test_fast = test_step('test-fast', 'mix test --exclude slow --exclude ui --max-cases 2');
|
||||
local test_all = test_step('test-all', 'mix test');
|
||||
|
||||
// A full check pipeline: identical steps, only name + trigger + test step vary.
|
||||
local check_pipeline(name, trigger, test) = {
|
||||
kind: 'pipeline',
|
||||
type: 'docker',
|
||||
name: name,
|
||||
services: [pg_service],
|
||||
trigger: trigger,
|
||||
steps: [
|
||||
step_compute_cache,
|
||||
step_restore_cache,
|
||||
step_lint,
|
||||
] + (if test.name == 'test-all' then [step_typecheck] else []) + [
|
||||
step_wait_postgres,
|
||||
test,
|
||||
step_rebuild_cache,
|
||||
],
|
||||
volumes: [cache_volume],
|
||||
};
|
||||
|
||||
local docker_publish(name, extra_settings, trigger_event, deps) = {
|
||||
kind: 'pipeline',
|
||||
type: 'docker',
|
||||
name: name,
|
||||
trigger: trigger_event,
|
||||
steps: [{
|
||||
name: 'build-and-publish-container' + (if name == 'build-and-publish' then '-branch' else ''),
|
||||
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' },
|
||||
} + extra_settings,
|
||||
when: trigger_event,
|
||||
}],
|
||||
depends_on: deps,
|
||||
};
|
||||
|
||||
[
|
||||
check_pipeline('check-fast', { branch: { exclude: ['main'] }, event: ['push'] }, test_fast),
|
||||
check_pipeline('check-full', { branch: ['main'], event: ['push'] }, test_all),
|
||||
check_pipeline('check-full-promote', { event: ['promote'], target: ['production'] }, test_all),
|
||||
check_pipeline('check-full-tag', { event: ['tag'] }, test_all),
|
||||
|
||||
docker_publish(
|
||||
'build-and-publish',
|
||||
{ tags: ['latest', '${DRONE_COMMIT_SHA:0:8}'] },
|
||||
{ branch: ['main'], event: ['push'] },
|
||||
['check-full'],
|
||||
),
|
||||
docker_publish(
|
||||
'build-and-release',
|
||||
{ auto_tag: true },
|
||||
{ event: ['tag'] },
|
||||
['check-full-tag'],
|
||||
),
|
||||
|
||||
{
|
||||
kind: 'pipeline',
|
||||
type: 'docker',
|
||||
name: 'renovate',
|
||||
trigger: { event: ['cron', 'custom'], branch: ['main'] },
|
||||
environment: { LOG_LEVEL: 'debug' },
|
||||
steps: [{
|
||||
name: 'renovate',
|
||||
image: 'renovate/renovate:43.165',
|
||||
environment: {
|
||||
RENOVATE_CONFIG_FILE: 'renovate_backend_config.js',
|
||||
RENOVATE_TOKEN: { from_secret: 'RENOVATE_TOKEN' },
|
||||
GITHUB_COM_TOKEN: { from_secret: 'GITHUB_COM_TOKEN' },
|
||||
},
|
||||
commands: [
|
||||
// https://github.com/renovatebot/renovate/discussions/15049
|
||||
'unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL',
|
||||
'renovate-config-validator',
|
||||
'renovate',
|
||||
],
|
||||
}],
|
||||
},
|
||||
]
|
||||
291
.drone.yml
Normal file
291
.drone.yml
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
kind: pipeline
|
||||
type: docker
|
||||
name: check-fast
|
||||
|
||||
services:
|
||||
- name: postgres
|
||||
image: docker.io/library/postgres:18.1
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- push
|
||||
|
||||
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:
|
||||
- set -eu
|
||||
# Install hex package manager
|
||||
- timeout --signal=KILL 3m mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- timeout --signal=KILL 10m mix deps.get
|
||||
# Check for compilation errors & warnings
|
||||
- timeout --signal=KILL 10m mix compile --warnings-as-errors
|
||||
# Check formatting
|
||||
- timeout --signal=KILL 3m mix format --check-formatted
|
||||
# Security checks
|
||||
- timeout --signal=KILL 10m mix sobelow --config
|
||||
# Check dependencies for known vulnerabilities
|
||||
- timeout --signal=KILL 10m mix deps.audit
|
||||
# Check for dependencies that are not maintained anymore
|
||||
- timeout --signal=KILL 10m mix hex.audit
|
||||
# Provide hints for improving code quality
|
||||
- timeout --signal=KILL 15m mix credo
|
||||
# Check that translations are up to date
|
||||
- timeout --signal=KILL 5m mix gettext.extract --check-up-to-date
|
||||
|
||||
- name: wait_for_postgres
|
||||
image: docker.io/library/postgres:18.1
|
||||
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-fast
|
||||
image: docker.io/library/elixir:1.18.3-otp-27
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
TEST_POSTGRES_HOST: postgres
|
||||
TEST_POSTGRES_PORT: 5432
|
||||
commands:
|
||||
- set -eu
|
||||
# Install hex package manager
|
||||
- mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- timeout --signal=KILL 10m mix deps.get
|
||||
# Run fast tests (excludes slow/performance and UI tests)
|
||||
- timeout --signal=KILL 20m mix test --exclude slow --exclude ui
|
||||
|
||||
- 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.1
|
||||
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:
|
||||
- set -eu
|
||||
# Install hex package manager
|
||||
- timeout --signal=KILL 3m mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- timeout --signal=KILL 10m mix deps.get
|
||||
# Check for compilation errors & warnings
|
||||
- timeout --signal=KILL 10m mix compile --warnings-as-errors
|
||||
# Check formatting
|
||||
- timeout --signal=KILL 3m mix format --check-formatted
|
||||
# Security checks
|
||||
- timeout --signal=KILL 10m mix sobelow --config
|
||||
# Check dependencies for known vulnerabilities
|
||||
- timeout --signal=KILL 10m mix deps.audit
|
||||
# Check for dependencies that are not maintained anymore
|
||||
- timeout --signal=KILL 10m mix hex.audit
|
||||
# Provide hints for improving code quality
|
||||
- timeout --signal=KILL 15m mix credo
|
||||
# Check that translations are up to date
|
||||
- timeout --signal=KILL 5m mix gettext.extract --check-up-to-date
|
||||
|
||||
- name: wait_for_postgres
|
||||
image: docker.io/library/postgres:18.1
|
||||
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:
|
||||
- set -eu
|
||||
# Install hex package manager
|
||||
- mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- timeout --signal=KILL 10m mix deps.get
|
||||
# Run all tests (including slow/performance and UI tests)
|
||||
- timeout --signal=KILL 30m mix test
|
||||
|
||||
- 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: build-and-publish
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
- 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
|
||||
auto_tag_suffix: ${DRONE_COMMIT_SHA:0:8}
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
- 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: renovate
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- cron
|
||||
- custom
|
||||
branch:
|
||||
- main
|
||||
|
||||
environment:
|
||||
LOG_LEVEL: debug
|
||||
|
||||
steps:
|
||||
- name: renovate
|
||||
image: renovate/renovate:42.97
|
||||
environment:
|
||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||
RENOVATE_TOKEN:
|
||||
from_secret: RENOVATE_TOKEN
|
||||
GITHUB_COM_TOKEN:
|
||||
from_secret: GITHUB_COM_TOKEN
|
||||
commands:
|
||||
# https://github.com/renovatebot/renovate/discussions/15049
|
||||
- unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL
|
||||
- renovate-config-validator
|
||||
- renovate
|
||||
28
.env.example
28
.env.example
|
|
@ -14,7 +14,6 @@ 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
|
||||
|
|
@ -23,34 +22,11 @@ ASSOCIATION_NAME="Sportsclub XYZ"
|
|||
# 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=mv-dev-shared-secret-not-for-production-do-not-use-anywhere-else
|
||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
||||
# OIDC_CLIENT_SECRET=your-rauthy-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
|
||||
|
|
|
|||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -34,7 +34,6 @@ mv-*.tar
|
|||
# In case you use Node.js/npm, you want to ignore these.
|
||||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
/node_modules/
|
||||
|
||||
.cursor
|
||||
|
||||
|
|
@ -46,11 +45,3 @@ npm-debug.log
|
|||
# Docker secrets directory (generated by `just init-secrets`)
|
||||
/secrets/
|
||||
notes.md
|
||||
|
||||
# Do NOT commit these — they are local to the dev machine
|
||||
.pipeline/
|
||||
.claude/
|
||||
|
||||
# Dialyzer PLT files — built locally and in CI cache, never tracked.
|
||||
/priv/plts/*.plt
|
||||
/priv/plts/*.plt.hash
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 233 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 265 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB |
|
|
@ -1,4 +1,3 @@
|
|||
elixir 1.18.3-otp-27
|
||||
erlang 27.3.4
|
||||
just 1.51.0
|
||||
nodejs 26.2.0
|
||||
just 1.46.0
|
||||
|
|
|
|||
90
CHANGELOG.md
90
CHANGELOG.md
|
|
@ -5,95 +5,7 @@ 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.3.0] - 2026-06-16
|
||||
|
||||
### Added
|
||||
- **GDPR/DSGVO join-form description** – Custom fields can carry a "join form description" that is shown as the field's label on the public join form, with clickable external links (whole URLs and Markdown `[text](url)`). Useful for presenting a GDPR confirmation with a link to an externally hosted privacy declaration before sign-up.
|
||||
- **Join-form description tooltip in member details** – Custom fields that have a join-form description show an info tooltip (prefixed "Beitrittsformular:") on their label in the member detail view.
|
||||
- **Editable join-form description** – Admins can set a field's join-form description in the custom-field settings, with an inline hint about the supported link syntax.
|
||||
- **CSV import – groups column** – Members can be assigned to groups during CSV import via a `Groups`/`Gruppen` column; group names that do not exist yet are created automatically, and re-importing the same file does not create duplicate groups.
|
||||
- **CSV import – membership fee type column** – A `Fee Type`/`Beitragsart` column assigns each member's membership fee type; an unknown name falls back to the default fee type and is flagged in the preview with a link to create it.
|
||||
- **CSV import – mapping preview** – After uploading a file, a preview shows how every column maps (with sample rows and warnings for ignored or unknown columns) and the import only starts once you confirm.
|
||||
- **Dynamic CSV import templates** – The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set.
|
||||
- **Deactivate and reactivate members** – Members can be deactivated directly from the member page: a dialog picks the exit date (prefilled to today, future dates allowed); deactivated members can be reactivated, which clears the exit date.
|
||||
- **Tooltips and OIDC explanation** – Icon-only action buttons (including the Vereinfacht sync control) now carry tooltips and accessible labels, and the OIDC settings section explains that it enables single sign-on.
|
||||
|
||||
### Changed
|
||||
- **Member bulk actions in one menu** – The actions above the member overview (open in email program, copy email addresses, export to CSV, export to PDF) are now collected in a single "Aktionen" dropdown instead of separate buttons. Without a selection they apply to all members, or to the currently filtered members; the trigger shows the active scope. Opening the email program is disabled when too many recipients are selected, with a hint to copy the addresses or use the export instead.
|
||||
- **Dropdown buttons** – Dropdown buttons (actions, filter, column visibility) now show a chevron so they are recognizable as menus.
|
||||
- **Default GDPR custom field** – The seeded GDPR field was shortened from "Datenschutzerklärung akzeptiert" to "DSGVO" and now ships with a default join-form description (with a placeholder link to replace).
|
||||
|
||||
### Fixed
|
||||
- **Authentication background workers start correctly** – The token-cleanup (Expunger) and audit-log batching workers now boot under the application's real configuration instead of an unused OTP app, so they run as intended in production rather than silently doing nothing.
|
||||
- **CSV date round-trip** – Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors.
|
||||
- **CSV import – fee-status columns ignored** – Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists.
|
||||
- **Column-header tooltips clipped** – Tooltips on the members-overview column headers are no longer clipped by the sticky table header.
|
||||
- **Text selection opens member** – Dragging to select text in a members-overview row (for example to copy an email) no longer opens the member details; a plain click still opens them.
|
||||
- **Sort by custom date** – Sorting the member list or member export by a custom date field now orders rows chronologically instead of like text, so e.g. 29.01.1981 correctly comes before 01.03.1982.
|
||||
- **Concurrent member creation no longer deadlocks** – Creating members in parallel (e.g. simultaneous sign-ups, or batch operations) could intermittently fail with a PostgreSQL deadlock; the affected foreign keys are now deferrable, so concurrent member creation succeeds reliably.
|
||||
|
||||
## [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
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08)
|
||||
|
|
|
|||
|
|
@ -60,10 +60,6 @@ We are building a membership management system (Mila) using the following techno
|
|||
7. [Documentation Standards](#7-documentation-standards)
|
||||
8. [Accessibility Guidelines](#8-accessibility-guidelines)
|
||||
|
||||
**Related documents:**
|
||||
- **UI / UX:** [`DESIGN_GUIDELINES.md`](../DESIGN_GUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), **Back button left in header for edit/new forms** (§2.2), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors.
|
||||
- **Vereinfacht API:** [`docs/vereinfacht-api.md`](docs/vereinfacht-api.md) describes the finance-contact sync (find by email filter, minimal create payload, no extra required member fields).
|
||||
|
||||
---
|
||||
|
||||
## 1. Setup and Architectural Conventions
|
||||
|
|
@ -85,14 +81,9 @@ lib/
|
|||
├── membership/ # Membership domain
|
||||
│ ├── membership.ex # Domain definition
|
||||
│ ├── member.ex # Member resource
|
||||
│ ├── join_request.ex # JoinRequest (public join form, double opt-in)
|
||||
│ ├── join_request/ # JoinRequest changes (Helpers, SetConfirmationToken, FilterFormDataByAllowlist, ConfirmRequest, ApproveRequest, RejectRequest)
|
||||
│ ├── custom_field.ex # Custom field (definition) resource
|
||||
│ ├── custom_field_value.ex # Custom field value resource
|
||||
│ ├── setting.ex # Global settings (singleton resource; incl. join form config)
|
||||
│ ├── settings_cache.ex # Process cache for get_settings (TTL; invalidate on update; not started in test)
|
||||
│ ├── join_notifier.ex # Behaviour for join emails (confirmation, already member, already pending)
|
||||
│ ├── setting/ # Setting changes (NormalizeJoinFormSettings, etc.)
|
||||
│ ├── setting.ex # Global settings (singleton resource)
|
||||
│ ├── group.ex # Group resource
|
||||
│ ├── member_group.ex # MemberGroup join table resource
|
||||
│ └── email.ex # Email custom type
|
||||
|
|
@ -121,17 +112,10 @@ lib/
|
|||
│ ├── membership_fees/ # Membership fee business logic
|
||||
│ │ ├── cycle_generator.ex # Cycle generation algorithm
|
||||
│ │ └── calendar_cycles.ex # Calendar cycle calculations
|
||||
│ ├── vereinfacht/ # Vereinfacht accounting API integration
|
||||
│ │ ├── client.ex # HTTP client (finance-contacts: create, update, find by email)
|
||||
│ │ ├── vereinfacht.ex # Business logic (sync_member, sync_members_without_contact)
|
||||
│ │ ├── sync_flash.ex # Flash message helpers for sync results
|
||||
│ │ └── changes/ # Ash changes (SyncContact, sync linked member)
|
||||
│ ├── helpers.ex # Shared helper functions (ash_actor_opts)
|
||||
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix, vereinfacht_required_member_fields)
|
||||
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix)
|
||||
│ ├── application.ex # OTP application
|
||||
│ ├── mailer.ex # Email mailer
|
||||
│ ├── smtp/
|
||||
│ │ └── config_builder.ex # SMTP adapter opts (TLS/sockopts); used by runtime.exs and Mailer
|
||||
│ ├── release.ex # Release tasks
|
||||
│ ├── repo.ex # Database repository
|
||||
│ ├── secrets.ex # Secret management
|
||||
|
|
@ -282,16 +266,6 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
```
|
||||
|
||||
### 1.2.1 Database Seeds
|
||||
|
||||
Seeds are split into **bootstrap** and **dev**. They run on every start (e.g. `just run`, Docker entrypoint) but **exit early** if already applied so startup stays fast and no duplicate data is created.
|
||||
|
||||
- **`priv/repo/seeds.exs`** – Entrypoint. If the admin user (ADMIN_EMAIL or default) already exists, skips entirely (unless `FORCE_SEEDS=true`); otherwise runs `seeds_bootstrap.exs` and, in dev/test, `seeds_dev.exs`.
|
||||
- **`priv/repo/seeds_bootstrap.exs`** – Creates only data required for system startup: membership fee types, custom fields, roles, admin user, system user, global settings (including default membership fee type). No members, no groups. Used in all environments (dev, test, prod).
|
||||
- **`priv/repo/seeds_dev.exs`** – Creates 20 sample members, groups, and optional custom field values. Run only in dev and test.
|
||||
|
||||
In production, running `mix run priv/repo/seeds.exs` (or `Mv.Release.run_seeds/0`) executes only the bootstrap part when not yet applied (no dev seeds unless `RUN_DEV_SEEDS=true`). The “already applied” check uses `Mv.Release.bootstrap_seeds_applied?/0` (admin user exists). Set `FORCE_SEEDS=true` to re-run seeds even when already applied.
|
||||
|
||||
### 1.3 Domain-Driven Design
|
||||
|
||||
**Use Ash Domains for Context Boundaries:**
|
||||
|
|
@ -411,8 +385,6 @@ def process_user(user), do: {:ok, perform_action(user)}
|
|||
|
||||
### 2.3 Error Handling
|
||||
|
||||
**No silent failures:** When an error path assigns a fallback (e.g. empty list, unchanged assigns), always log the error with enough context (e.g. `inspect(error)`, slug, action) and/or set a user-visible flash. Do not only assign the fallback without logging.
|
||||
|
||||
**Use Tagged Tuples:**
|
||||
|
||||
```elixir
|
||||
|
|
@ -651,10 +623,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
```
|
||||
|
||||
**LiveView load budget:** Keep UI-triggered events cheap. `phx-change`, `phx-focus`, and `phx-keydown` must **not** perform database reads by default; work from assigns (e.g. filter in memory) or defer reads to an explicit commit step (e.g. "Add", "Save", "Submit"). Perform DB reads or reloads only on commit events, not on every keystroke or focus. If a read in an event is unavoidable, do at most one deliberate read, document why, and prefer debounce/throttle.
|
||||
|
||||
**LiveView size:** When a LiveView accumulates many features and event handlers (CRUD + add/remove + search + keyboard + modals), extract sub-flows into LiveComponents. The parent keeps auth, initial load, and a single reload after child actions; the component owns the sub-flow and notifies the parent when data changes.
|
||||
|
||||
**Component Design:**
|
||||
|
||||
```elixir
|
||||
|
|
@ -1009,9 +977,9 @@ defmodule Mv.Accounts.User do
|
|||
hashed_password_field :hashed_password
|
||||
end
|
||||
|
||||
oidc :oidc do
|
||||
oauth2 :rauthy do
|
||||
client_id fn _, _ ->
|
||||
Application.fetch_env!(:mv, :oidc)[:client_id]
|
||||
Application.fetch_env!(:mv, :rauthy)[:client_id]
|
||||
end
|
||||
# ... other config
|
||||
end
|
||||
|
|
@ -1144,12 +1112,6 @@ let liveSocket = new LiveSocket("/live", Socket, {
|
|||
})
|
||||
```
|
||||
|
||||
**Vendor assets (third-party JS):**
|
||||
|
||||
Some JavaScript libraries are committed as vendored files in `assets/vendor/` (e.g. `topbar`, `sortable.js`) when they are not available as npm packages or we need a specific build. Document their origin and how to update them:
|
||||
|
||||
- **Sortable.js** (`assets/vendor/sortable.js`): From [SortableJS](https://github.com/SortableJS/Sortable), version noted in the file header (e.g. `/*! Sortable 1.15.6 - MIT ... */`). To update: download the desired release from the repo and replace the file; keep the header comment for traceability.
|
||||
|
||||
### 3.8 Code Quality: Credo
|
||||
|
||||
**Static Code Analysis:**
|
||||
|
|
@ -1266,72 +1228,36 @@ mix deps.update phoenix
|
|||
mix hex.outdated
|
||||
```
|
||||
|
||||
### 3.11 Email: Swoosh and Phoenix.Swoosh
|
||||
### 3.11 Email: Swoosh
|
||||
|
||||
**Mailer and from address:**
|
||||
|
||||
- `Mv.Mailer` (Swoosh) and `Mv.Mailer.mail_from/0` return the configured sender `{name, email}`.
|
||||
- Sender identity priority: `MAIL_FROM_NAME`/`MAIL_FROM_EMAIL` ENV > Settings `smtp_from_name`/`smtp_from_email` > hardcoded defaults `{"Mila", "noreply@example.com"}`.
|
||||
- Access via `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`.
|
||||
- **Important:** On most SMTP servers the sender email must be the same address as `smtp_username` or an alias owned by that account (e.g. Postfix strict relay). Misconfiguration causes a 553 error.
|
||||
|
||||
**SMTP configuration:**
|
||||
|
||||
- SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`).
|
||||
- **ENV-only policy:** If `SMTP_HOST` is set, SMTP is treated as environment-managed only. All SMTP fields in Settings are read-only, SMTP save action is hidden, and the UI shows a warning when required ENV values are missing (`SMTP_USERNAME`, and `SMTP_PASSWORD` or `SMTP_PASSWORD_FILE`). This keeps one source of truth for transport credentials and avoids mixed ENV/DB SMTP states.
|
||||
- **Sensitive settings in DB:** `smtp_password` and `oidc_client_secret` are excluded from the default read of the Setting resource; they are loaded only via explicit select when needed (e.g. `Mv.Config.smtp_password/0`, `Mv.Config.oidc_client_secret/0`). This avoids exposing secrets through `get_settings()`.
|
||||
- **Settings cache:** `Mv.Membership.get_settings/0` uses `Mv.Membership.SettingsCache` when the cache process is running (not in test). Cache has a short TTL and is invalidated on every settings update. This avoids repeated DB reads on hot paths (e.g. `RegistrationEnabled` validation, `Layouts.public_page`). In test, the cache is not started so all callers use `get_settings_uncached/0` in the test process (Ecto Sandbox).
|
||||
- **Join emails (domain → web):** The domain calls `Mv.Membership.JoinNotifier` (config `:join_notifier`, default `MvWeb.JoinNotifierImpl`) for sending join confirmation, already-member, and already-pending emails. This keeps the domain independent of the web layer; tests can override the notifier.
|
||||
- Sender identity is also configurable via ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`) or Settings (`smtp_from_name`, `smtp_from_email`).
|
||||
- `SMTP_PASSWORD_FILE`: path to a file containing the password (Docker Secrets / Kubernetes secrets pattern); overridden by `SMTP_PASSWORD` when both are set.
|
||||
- `SMTP_SSL` values: `tls` (default, port 587), `ssl` (port 465), `none` (port 25).
|
||||
- When `SMTP_HOST` ENV is present at boot, `runtime.exs` configures `Swoosh.Adapters.SMTP` automatically.
|
||||
- When SMTP is configured only via Settings, `Mv.Mailer.smtp_config/0` builds the adapter config per-send.
|
||||
- In test environment, `Swoosh.Adapters.Test` is used regardless of SMTP config. `smtp_config/0` returns `[]` when the mailer adapter is `Swoosh.Adapters.Test`, so per-send SMTP opts never bypass the test mailbox. Port 587/465 sockopts are unit-tested on `Mv.Smtp.ConfigBuilder.build_opts/1` (`test/mv/smtp/config_builder_test.exs`); `test/mv/mailer_smtp_config_test.exs` covers the Test-adapter guard and temporarily sets the adapter to `Swoosh.Adapters.Local` to assert `smtp_config/0` wiring from ENV. Use `Mv.DataCase` for those tests (not plain `ExUnit.Case`) because `smtp_config/0` pulls `Mv.Config` fields that may read Settings from the DB when SMTP user/password ENV vars are unset.
|
||||
- **TLS in OTP 27:** Verify mode defaults to `verify_none` for self-signed/internal certs. Set `SMTP_VERIFY_PEER=true` (or `1`/`yes`) in prod when using public SMTP (Gmail, Mailgun). Config key `:smtp_verify_peer` is set in `runtime.exs` and read by `Mv.Mailer.smtp_config/0`.
|
||||
- **Test email:** `Mv.Mailer.send_test_email(to_email)` sends a transactional test email; returns `{:ok, email}` or `{:error, classified_reason}`. Classified errors: `:sender_rejected`, `:auth_failed`, `:recipient_rejected`, `:tls_failed`, `:connection_failed`, `{:smtp_error, message}`. Each shows a specific message in the UI.
|
||||
- **Production warning:** When SMTP is not configured in production, a warning is shown in the Settings UI. Use `Application.get_env(:mv, :environment, :dev)` (or assign in mount) for environment checks in LiveView/templates; do not use `Mix.env()` at runtime (it is not available in releases).
|
||||
- Access config values via `Mv.Config.smtp_host/0`, `smtp_port/0`, `smtp_username/0`, `smtp_password/0`, `smtp_ssl/0`, `smtp_configured?/0`.
|
||||
|
||||
**AshAuthentication senders:**
|
||||
|
||||
- `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Errors are logged via `Logger.error` and not re-raised so they never crash the caller process.
|
||||
|
||||
**Join confirmation email:**
|
||||
|
||||
- Join emails are sent via `Mv.Membership.JoinNotifier` (default impl: `MvWeb.JoinNotifierImpl` calling `JoinConfirmationEmail`, etc.). `MvWeb.Emails.JoinConfirmationEmail` uses `Mailer.deliver(email, Mailer.smtp_config())` so it uses the same SMTP configuration as the test mail (Settings or boot ENV). On delivery failure, `Mv.Membership.submit_join_request/2` returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI.
|
||||
|
||||
**Unified layout (transactional emails):**
|
||||
|
||||
- All transactional emails (join confirmation, user confirmation, password reset) use the same layout: `MvWeb.EmailLayoutView` (layout) and `MvWeb.EmailsView` (body templates).
|
||||
- Templates live under `lib/mv_web/templates/emails/` (bodies) and `lib/mv_web/templates/emails/layouts/` (layout). Use Gettext in templates for i18n.
|
||||
- See `MvWeb.Emails.JoinConfirmationEmail`, `Mv.Accounts.User.Senders.SendNewUserConfirmationEmail`, `SendPasswordResetEmail` for the pattern; see `docs/email-layout-mockup.md` for layout structure.
|
||||
|
||||
**Sending with layout:**
|
||||
**Mailer Configuration:**
|
||||
|
||||
```elixir
|
||||
use Phoenix.Swoosh, view: MvWeb.EmailsView, layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
defmodule Mv.Mailer do
|
||||
use Swoosh.Mailer, otp_app: :mv
|
||||
end
|
||||
```
|
||||
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(email_address)
|
||||
|> subject(gettext("Subject"))
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|
||||
|> render_body("template_name.html", %{assigns})
|
||||
**Sending Emails:**
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, reason} -> Logger.error("Email delivery failed: #{inspect(reason)}")
|
||||
```elixir
|
||||
defmodule Mv.Accounts.WelcomeEmail do
|
||||
use Phoenix.Swoosh, template_root: "lib/mv_web/templates"
|
||||
import Swoosh.Email
|
||||
|
||||
def send(user) do
|
||||
new()
|
||||
|> to({user.name, user.email})
|
||||
|> from({"Mila", "noreply@mila.example.com"})
|
||||
|> subject("Welcome to Mila!")
|
||||
|> render_body("welcome.html", %{user: user})
|
||||
|> Mv.Mailer.deliver()
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 3.12 Internationalization: Gettext
|
||||
|
||||
**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”.
|
||||
|
||||
**Terminology (DE):** Use consistent terms in translations: “Benutzer*in” / “Benutzer*innen” (not “Nutzer*in”), “E-Mail” (with hyphen, capital M), “CSV-Datei” / “CSV-Import” (compound with hyphen). Keep placeholders (e.g. `%{count}`, `%{reason}`) in msgstr identical to msgid where applicable.
|
||||
|
||||
**Define Translations:**
|
||||
|
||||
```elixir
|
||||
|
|
@ -1341,9 +1267,6 @@ gettext("Welcome to Mila")
|
|||
# With interpolation
|
||||
gettext("Hello, %{name}!", name: user.name)
|
||||
|
||||
# Plural: always pass count binding when message uses %{count}
|
||||
ngettext("Found %{count} member", "Found %{count} members", @count, count: @count)
|
||||
|
||||
# Domain-specific translations
|
||||
dgettext("auth", "Sign in with email")
|
||||
```
|
||||
|
|
@ -1351,20 +1274,15 @@ dgettext("auth", "Sign in with email")
|
|||
**Extract and Merge:**
|
||||
|
||||
```bash
|
||||
# Extract new translatable strings and merge into existing .po files (recommended)
|
||||
mix gettext.extract --merge
|
||||
|
||||
# Alternative: extract only, then merge separately
|
||||
# Extract new translatable strings
|
||||
mix gettext.extract
|
||||
|
||||
# Merge into existing translations
|
||||
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
|
||||
```
|
||||
|
||||
**Gettext merge workflow:** Prefer `mix gettext.extract --merge` so the `.pot` template is regenerated from source and merged into all locale `.po` files in one step. Edit only the `msgstr` values in `.po` files for translations; do not manually change source references, entry order, or the `.pot` file structure. If Git merge conflicts appear in `.po` or `.pot` files, resolve by removing conflict markers (keeping both sides where appropriate), then run `mix gettext.extract --merge`. If the `.pot` file is corrupted, delete it and run `mix gettext.extract --merge` to regenerate it from source.
|
||||
|
||||
### 3.13 Task Runner: Just
|
||||
|
||||
The `Justfile` prepends `~/.asdf/shims`, `~/.asdf/bin`, and `~/.asdf` to `PATH` for all recipes (`set export := true`), so `mix` / `elixir` resolve from `.tool-versions` without shell init. The caller's `PATH` is kept (e.g. Homebrew `asdf`, Docker). Run `asdf install` once per machine; no extra `source` is required for `just run`.
|
||||
|
||||
**Common Commands:**
|
||||
|
||||
```bash
|
||||
|
|
@ -1589,8 +1507,6 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
```
|
||||
|
||||
**LiveView test standards:** Prefer selector-based assertions (`has_element?` on `data-testid`, stable IDs, or semantic markers) over free-text matching (`html =~ "..."`) or broad regex. For repeated flows (e.g. "open add member", "search", "select"), use helpers in `test/support/`. If delete is a LiveView event, test that event and assert both UI and data; if delete uses JS `data-confirm` + non-LV submit, cover the real delete path in a context/service test and add at most one smoke test in the UI. Do not assert or document "single request" or "DB-level sort" without measuring (e.g. query count or timing).
|
||||
|
||||
#### 4.3.5 Component Tests
|
||||
|
||||
Test function components:
|
||||
|
|
@ -1715,8 +1631,6 @@ mix test test/membership/member_test.exs:42
|
|||
|
||||
### 4.7 Testing Best Practices
|
||||
|
||||
**Process environment (`test/test_helper.exs`):** Vereinfacht and OIDC-related `System.get_env/1` keys are cleared at test startup so configuration comes from the test database (Membership settings) unless a test explicitly sets variables in `setup` and restores them with `on_exit`. This matches production priority (ENV over settings) while keeping the suite deterministic when `.env` is loaded (e.g. via `just`).
|
||||
|
||||
**Testing Philosophy: Focus on Business Logic, Not Framework Functionality**
|
||||
|
||||
We test our business logic and domain-specific behavior, not core framework features. Framework features (Ash validations, Ecto relationships, etc.) are already tested by their respective libraries.
|
||||
|
|
@ -1939,7 +1853,7 @@ authentication do
|
|||
hashed_password_field :hashed_password
|
||||
end
|
||||
|
||||
oidc :oidc do
|
||||
oauth2 :rauthy do
|
||||
# OIDC configuration
|
||||
end
|
||||
end
|
||||
|
|
@ -1962,8 +1876,6 @@ policies do
|
|||
end
|
||||
```
|
||||
|
||||
**Record-based authorization:** When a concrete record is available, use `can?(actor, :action, record)` (e.g. `can?(@current_user, :update, @group)`). Use `can?(actor, :action, Resource)` only when no specific record exists (e.g. "can create any group"). In events: resolve the record from assigns, run `can?`, then mutate; avoid an extra DB read just for a "freshness" check if the assign is already the source of truth.
|
||||
|
||||
**Actor Handling in LiveViews:**
|
||||
|
||||
Always use the `current_actor/1` helper for consistent actor access:
|
||||
|
|
@ -2166,7 +2078,7 @@ plug :protect_from_forgery
|
|||
|
||||
```elixir
|
||||
# config/runtime.exs
|
||||
config :mv, :oidc,
|
||||
config :mv, :rauthy,
|
||||
client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
|
||||
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
||||
base_url: System.get_env("OIDC_BASE_URL")
|
||||
|
|
@ -2182,14 +2094,6 @@ mix phx.gen.secret
|
|||
mix phx.gen.secret
|
||||
```
|
||||
|
||||
**Runtime configuration (config/runtime.exs):**
|
||||
|
||||
- Production config is loaded from `config/runtime.exs` at boot (releases and `mix phx.server`). Environment variables are read via helpers so that **empty or invalid values do not cause cryptic crashes** (e.g. `ArgumentError` from `String.to_integer("")`).
|
||||
- **Helpers used:** `get_env_or_file` / `get_env_or_file!` (with `_FILE` support); `get_env_required` (required vars: raises if missing or empty after trim); `get_env_non_empty` (optional string: empty treated as unset, returns default); `parse_positive_integer` (PORT, POOL_SIZE, SMTP_PORT: empty or invalid → default).
|
||||
- **Required vars** (e.g. DATABASE_HOST, PHX_HOST/DOMAIN, SECRET_KEY_BASE): if set but empty, the app raises at boot with a clear message including “(Variable X is set but empty.)”.
|
||||
- **Optional numeric vars** (PORT, POOL_SIZE, SMTP_PORT, DATABASE_PORT): empty or invalid value is treated as “unset” and the documented default is used (e.g. PORT=4000, SMTP_PORT=587).
|
||||
- When adding new ENV in `runtime.exs`, use these helpers instead of raw `System.get_env(...)` and `String.to_integer(...)` so that misconfigured or empty variables fail fast with clear errors.
|
||||
|
||||
### 5.6 Security Headers
|
||||
|
||||
**Configure Security Headers:**
|
||||
|
|
@ -2803,9 +2707,7 @@ Building accessible applications ensures that all users, including those with di
|
|||
|
||||
### 8.2 ARIA Labels and Roles
|
||||
|
||||
**Terminology and semantics:** Use the same terms in UI, tests, and docs (e.g. "modal" vs "inline confirmation"). If the implementation is inline, do not call it a modal in tests or docs.
|
||||
|
||||
**Use ARIA Attributes When Necessary:** Use `role="status"` only for live regions that present advisory status (e.g. search result count, progress). Do not use it on links, buttons, or purely static labels; use semantic HTML or the correct role for the widget. Attach `phx-debounce` / `phx-throttle` to the element that triggers the event (e.g. input), not to the whole form, unless the intent is form-wide.
|
||||
**Use ARIA Attributes When Necessary:**
|
||||
|
||||
```heex
|
||||
<!-- Icon-only buttons need labels -->
|
||||
|
|
@ -2853,14 +2755,6 @@ Building accessible applications ensures that all users, including those with di
|
|||
<div phx-click="action">Click me</div>
|
||||
```
|
||||
|
||||
**Tables (Core Component `<.table>` with `row_click`):**
|
||||
|
||||
- When `row_click` is set, the first column that does not use `col_click` gets `tabindex="0"` and `role="button"` so each row is reachable via Tab. The `TableRowKeydown` hook triggers the row action on Enter and Space (WCAG 2.1.1). Use `row_id` and `row_tooltip` for all clickable tables (e.g. Groups, Users, Roles, Members, Custom Fields, Member Fields) so the table is fully keyboard accessible.
|
||||
|
||||
**Empty table cells (missing values):**
|
||||
|
||||
- Do not use dashes ("-", "—", "–") or "n/a" as placeholders. Use CoreComponents `<.empty_cell sr_text="…">` for a cell with no value, or `<.maybe_value value={…} empty_sr_text="…">` when content is conditional. The cell is visually empty; screen readers get the `sr_text` (e.g. "No cycle", "No group assignment", "Not specified"). See Design Guidelines §8.6.
|
||||
|
||||
**Tab Order:**
|
||||
|
||||
- Ensure logical tab order matches visual order
|
||||
|
|
@ -2870,11 +2764,7 @@ Building accessible applications ensures that all users, including those with di
|
|||
|
||||
### 8.4 Color and Contrast
|
||||
|
||||
**Ensure Sufficient Contrast (WCAG 2.2 AA: 4.5:1 for normal text):**
|
||||
|
||||
- Use the Core Component `<.badge>` for all badges; theme and `app.css` overrides ensure badge text meets 4.5:1 in light and dark theme (solid, soft, and outline styles). Cycle status "suspended" uses variant `:warning` (yellow) to match the edit cycle-status button.
|
||||
- For other UI, prefer theme tokens (`text-*-content` on `bg-*`) or the `.text-success-aa` / `.text-error-aa` utility classes where theme contrast is insufficient.
|
||||
- Member filter join buttons (All / Paid / Unpaid, etc.) use `.member-filter-dropdown`; `app.css` overrides ensure WCAG 4.5:1 for inactive and active states.
|
||||
**Ensure Sufficient Contrast:**
|
||||
|
||||
```elixir
|
||||
# Tailwind classes with sufficient contrast (4.5:1 minimum)
|
||||
|
|
@ -2942,14 +2832,12 @@ Building accessible applications ensures that all users, including those with di
|
|||
|
||||
**Required Fields:**
|
||||
|
||||
Which member fields are required (asterisk, tooltip, validation) is configured in **Settings** (Memberdata section: edit a member field and set "Required"). The member create/edit form and Member resource validation both read `settings.member_field_required`. Email is always required; other fields default to optional. The Vereinfacht integration does not add extra required member fields (the external API accepts a minimal payload when creating contacts and supports filter-by-email for lookup).
|
||||
|
||||
```heex
|
||||
<!-- Mark required fields (value from settings or always true for email) -->
|
||||
<!-- Mark required fields -->
|
||||
<.input
|
||||
field={@form[:first_name]}
|
||||
label={gettext("First Name")}
|
||||
required={@member_field_required_map[:first_name]}
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
```
|
||||
|
|
@ -3043,11 +2931,11 @@ end
|
|||
**Announce Dynamic Content:**
|
||||
|
||||
```heex
|
||||
<!-- Search results announcement (count: required so %{count} is replaced and pluralisation works) -->
|
||||
<!-- Search results announcement -->
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<%= if @searched do %>
|
||||
<span class="sr-only">
|
||||
<%= ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) %>
|
||||
<%= ngettext("Found %{count} member", "Found %{count} members", @count) %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -3093,56 +2981,24 @@ end
|
|||
- [ ] Skip links are available
|
||||
- [ ] Tables have proper structure (th, scope, caption)
|
||||
- [ ] ARIA labels used for icon-only buttons
|
||||
- [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape)
|
||||
- [ ] ARIA state attributes use string values `"true"` / `"false"` (not boolean), e.g. `aria-selected`, `aria-pressed`, `aria-expanded`.
|
||||
- [ ] Tabs: when using `role="tablist"` / `role="tab"`, use roving tabindex (only active tab `tabindex="0"`) and ArrowLeft/ArrowRight to switch tabs.
|
||||
|
||||
### 8.11 Modals and Dialogs
|
||||
### 8.11 DaisyUI Accessibility
|
||||
|
||||
Use a consistent, keyboard-accessible pattern for all confirmation and form modals (e.g. delete role, delete group, delete data field, edit cycle). Do not rely on `data-confirm` (browser `confirm()`) for destructive actions; use a LiveView-controlled `<dialog>` so focus and semantics are correct (WCAG 2.4.3, 2.1.2).
|
||||
|
||||
**Structure and semantics:**
|
||||
|
||||
- Use `<dialog>` with DaisyUI classes `modal modal-open` when the modal is visible.
|
||||
- Add `role="dialog"` and `aria-labelledby` pointing to the modal title’s `id` so screen readers announce the dialog and its purpose.
|
||||
- Give the title (e.g. `<h3>`) a unique `id` (e.g. `id="delete-role-modal-title"`).
|
||||
|
||||
**Focus management (WCAG 2.4.3):**
|
||||
|
||||
- When the modal opens, move focus into the dialog. Use `phx-mounted={JS.focus()}` on the first focusable element:
|
||||
- If the modal has an input (e.g. confirmation text), put `phx-mounted={JS.focus()}` on that input (e.g. delete data field, delete group).
|
||||
- If the modal has only buttons (e.g. confirm/cancel), put `phx-mounted={JS.focus()}` on the Cancel (or first) button so the user can Tab to the primary action and confirm with the keyboard.
|
||||
- This ensures that after choosing "Delete role" (or similar) with the keyboard, focus is inside the modal and the user can confirm or cancel without using the mouse.
|
||||
|
||||
**Layout and consistency:**
|
||||
|
||||
- Use `modal-box` for the content container and `modal-action` for the button row (Cancel + primary action).
|
||||
- Place Cancel (or neutral) first, primary/danger action second.
|
||||
- For destructive actions that require typing a confirmation string, use the same pattern as the delete data field modal: label, value to type, single input, then modal-action buttons.
|
||||
|
||||
**Closing:**
|
||||
|
||||
- Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`).
|
||||
- **MUST** support Escape to close (WCAG / WAI-ARIA dialog pattern): add `phx-keydown="dialog_keydown"` on the `<dialog>` and handle `dialog_keydown` with `key: "Escape"` to close (same effect as Cancel).
|
||||
- **MUST** return focus to the trigger element when the modal closes (WCAG 2.4.3): give the trigger button a stable `id`, use the `FocusRestore` hook on a parent element, and on close (Cancel or Escape) call `push_event(socket, "focus_restore", %{id: "trigger-id"})` so keyboard users land where they started (e.g. "Delete member" button).
|
||||
|
||||
**Reference implementation:** Delete data field modal in `CustomFieldLive.IndexComponent` (input + `phx-mounted={JS.focus()}` on input; `aria-labelledby` on dialog). Delete role modal in `RoleLive.Show` (no input; `phx-mounted={JS.focus()}` on Cancel button).
|
||||
|
||||
### 8.12 DaisyUI Accessibility
|
||||
|
||||
DaisyUI components are designed with accessibility in mind. For modals and dialogs, follow §8.11 (Modals and Dialogs). Example structure:
|
||||
DaisyUI components are designed with accessibility in mind, but ensure:
|
||||
|
||||
```heex
|
||||
<!-- Modal: use dialog + aria-labelledby + focus on first focusable (see §8.11) -->
|
||||
<dialog id="my-modal" class="modal modal-open" role="dialog" aria-labelledby="my-modal-title">
|
||||
<!-- Modal accessibility -->
|
||||
<dialog id="my-modal" class="modal" aria-labelledby="modal-title">
|
||||
<div class="modal-box">
|
||||
<h3 id="my-modal-title" class="text-lg font-bold"><%= gettext("Confirm Deletion") %></h3>
|
||||
<h2 id="modal-title"><%= gettext("Confirm Deletion") %></h2>
|
||||
<p><%= gettext("Are you sure?") %></p>
|
||||
<div class="modal-action">
|
||||
<.button variant="neutral" phx-click="cancel" phx-mounted={JS.focus()}>
|
||||
<button class="btn" onclick="document.getElementById('my-modal').close()">
|
||||
<%= gettext("Cancel") %>
|
||||
</.button>
|
||||
<.button variant="danger" phx-click="confirm_delete"><%= gettext("Delete") %></.button>
|
||||
</button>
|
||||
<button class="btn btn-error" phx-click="confirm-delete">
|
||||
<%= gettext("Delete") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
|
|||
|
|
@ -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 `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.
|
||||
|
||||
---
|
||||
14
Dockerfile
14
Dockerfile
|
|
@ -7,25 +7,25 @@
|
|||
# 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
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && apt-get install -y build-essential git \
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force && \
|
||||
mix local.rebar --force
|
||||
mix local.rebar --force
|
||||
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
|
@ -64,7 +64,7 @@ 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
|
||||
|
|
|
|||
63
Justfile
63
Justfile
|
|
@ -1,26 +1,15 @@
|
|||
set dotenv-load := true
|
||||
set export := true
|
||||
|
||||
# Prepend asdf paths so recipes work without sourcing ~/.asdf/asdf.sh in the shell.
|
||||
# Caller PATH is preserved (Homebrew asdf, docker CLI, etc.). See CODE_GUIDELINES §3.13.
|
||||
home := env_var("HOME")
|
||||
asdf_paths := home + "/.asdf/shims:" + home + "/.asdf/bin:" + home + "/.asdf:"
|
||||
PATH := asdf_paths + env_var("PATH")
|
||||
|
||||
MIX_QUIET := "1"
|
||||
|
||||
run: install-dependencies start-database migrate-database seed-database
|
||||
mix phx.server
|
||||
|
||||
# Dev web server + its database only — no mailcrab/rauthy.
|
||||
server: install-dependencies start-test-db migrate-database seed-database
|
||||
mix phx.server
|
||||
|
||||
install-dependencies:
|
||||
mix deps.get
|
||||
|
||||
migrate-database:
|
||||
mix compile
|
||||
mix ash.setup
|
||||
|
||||
reset-database:
|
||||
|
|
@ -33,30 +22,7 @@ seed-database:
|
|||
start-database:
|
||||
docker compose up -d
|
||||
|
||||
start-test-db:
|
||||
docker compose up -d db
|
||||
|
||||
# Full check suite: lint + audit + the fast tests (slow/ui excluded). No Dialyzer.
|
||||
ci-dev: install-dependencies lint audit test-fast
|
||||
|
||||
# Fast pre-commit check: lint + sobelow + only the affected tests (mix test --stale)
|
||||
# with reduced property runs. Run the full `ci-dev` before pushing.
|
||||
check: install-dependencies lint sobelow test-stale
|
||||
|
||||
# Build the Dialyzer PLT. Idempotent — no-op once the PLT is up to date.
|
||||
# First build takes 5–15 min; subsequent runs are seconds. PLT files live in
|
||||
# priv/plts/ and are gitignored.
|
||||
plt: install-dependencies
|
||||
@mkdir -p priv/plts
|
||||
mix dialyzer --plt
|
||||
|
||||
# Typecheck via Dialyzer. Slow stage, NOT part of ci-dev.
|
||||
typecheck: plt
|
||||
mix dialyzer --format short
|
||||
|
||||
# Full CI: inner loop plus typecheck. Use locally before pushing; Drone CI
|
||||
# runs equivalent steps with PLT caching.
|
||||
ci: ci-dev typecheck
|
||||
ci-dev: lint audit test-fast
|
||||
|
||||
gettext:
|
||||
mix gettext.extract
|
||||
|
|
@ -65,33 +31,24 @@ gettext:
|
|||
lint:
|
||||
mix format --check-formatted
|
||||
mix compile --warnings-as-errors
|
||||
mix credo --strict
|
||||
mix credo
|
||||
# Check that all German translations are filled (UI must be in German)
|
||||
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
|
||||
mix gettext.extract --check-up-to-date
|
||||
|
||||
# Static security scan (Sobelow).
|
||||
sobelow:
|
||||
audit:
|
||||
mix sobelow --config
|
||||
|
||||
# Full security audit: Sobelow + dependency advisory scans.
|
||||
audit: sobelow
|
||||
mix deps.audit --ignore-file .deps_audit_ignore
|
||||
mix deps.audit
|
||||
mix hex.audit
|
||||
|
||||
# Run all tests. No install-dependencies prerequisite so single-file runs stay
|
||||
# fast; run `just install-dependencies` once on a fresh checkout.
|
||||
test *args:
|
||||
# Run all tests
|
||||
test *args: install-dependencies
|
||||
mix test {{args}}
|
||||
|
||||
# Fast tests only (excludes slow/performance and UI tests).
|
||||
test-fast *args:
|
||||
# Run only fast tests (excludes slow/performance and UI tests)
|
||||
test-fast *args: install-dependencies
|
||||
mix test --exclude slow --exclude ui {{args}}
|
||||
|
||||
# Affected fast tests only (mix test --stale) with reduced property runs.
|
||||
test-stale *args:
|
||||
PROPERTY_RUNS=25 mix test --stale --exclude slow --exclude ui {{args}}
|
||||
|
||||
# Run only UI tests
|
||||
ui *args: install-dependencies
|
||||
mix test --only ui {{args}}
|
||||
|
|
@ -111,10 +68,6 @@ test-all *args: install-dependencies
|
|||
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 .
|
||||
|
||||
|
|
|
|||
91
README.md
91
README.md
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
**Mila** — simple, usable, self-hostable membership management for small to mid-sized clubs.
|
||||
|
||||
[](https://drone.cicd.local-it.cloud/local-it/mitgliederverwaltung)
|
||||
[](https://drone.dev.local-it.cloud/local-it/mitgliederverwaltung)
|
||||

|
||||
|
||||
## 🚧 Project Status
|
||||
|
||||
⚠️ **First Version** — Expect breaking changes.
|
||||
⚠️ **Early development** — not production-ready. Expect breaking changes.
|
||||
Contributions and feedback are welcome!
|
||||
|
||||
## ✨ Overview
|
||||
|
|
@ -32,10 +32,10 @@ Most membership tools for clubs are either:
|
|||
|
||||
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
|
||||
## 📸 Screenshots
|
||||
|
||||

|
||||
*This is how Mila might look in action.*
|
||||
|
||||
## 🔑 Features
|
||||
|
||||
|
|
@ -48,10 +48,9 @@ You can find our documentation for users here: https://wiki.local-it.org/s/mila-
|
|||
- ✅ SSO via OIDC (works with Authentik, Rauthy, Keycloak, etc.)
|
||||
- ✅ Sidebar navigation (standard-compliant, accessible)
|
||||
- ✅ Global settings management
|
||||
- ✅ Self-service & online application
|
||||
- 🚧 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))
|
||||
- 🚧 Email sending
|
||||
|
||||
## 🚀 Quick Start (Development)
|
||||
|
||||
|
|
@ -106,9 +105,6 @@ export PATH="${ASDF_DATA_DIR:-$HOME/.asdf}/shims:$PATH"
|
|||
```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:
|
||||
|
|
@ -124,8 +120,8 @@ mix archive.install hex phx_new
|
|||
1. Copy env file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Set OIDC_CLIENT_SECRET inside .env
|
||||
```
|
||||
The dev `OIDC_CLIENT_SECRET` is already preset — no manual GUI step needed.
|
||||
|
||||
2. Start everything (database, Mailcrab, Rauthy, app):
|
||||
```bash
|
||||
|
|
@ -139,19 +135,31 @@ mix archive.install hex phx_new
|
|||
|
||||
## 🔐 Testing SSO locally
|
||||
|
||||
A local **Rauthy** instance is provided in dev. The `mv` client is auto-seeded from `rauthy-bootstrap/clients.json` on first start (and after `docker compose down -v`), so the secret in `.env.example` always matches.
|
||||
Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance is provided.
|
||||
|
||||
Rauthy admin UI: <http://localhost:8080> — login `admin@localhost`, password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in `docker-compose.yml`.
|
||||
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
|
||||
- Client ID: mv
|
||||
- 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.
|
||||
Mila works with any OIDC-compliant provider. The internal strategy is named `:rauthy`, but this is just a name — it works with any provider.
|
||||
|
||||
**Important:** The redirect URI must always end with `/auth/user/oidc/callback`.
|
||||
**Important:** The redirect URI must always end with `/auth/user/rauthy/callback`.
|
||||
|
||||
Example for Authentik:
|
||||
1. Create an OAuth2/OpenID Provider in Authentik
|
||||
2. Set the redirect URI to: `https://your-domain.com/auth/user/oidc/callback`
|
||||
2. Set the redirect URI to: `https://your-domain.com/auth/user/rauthy/callback`
|
||||
3. Configure environment variables:
|
||||
```bash
|
||||
DOMAIN=your-domain.com # or PHX_HOST=your-domain.com
|
||||
|
|
@ -160,11 +168,17 @@ Example for Authentik:
|
|||
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.
|
||||
The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/callback` if not explicitly set.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
- **Env vars:** see `.env.example`
|
||||
- **Env vars:** see `.env.example`
|
||||
- `OIDC_CLIENT_SECRET` — secret for your OIDC client
|
||||
- Database defaults (Docker Compose):
|
||||
- Host: `localhost`
|
||||
- Port: `5000`
|
||||
- User/pass: `postgres` / `postgres`
|
||||
- DB: `mila_dev`
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
|
|
@ -179,8 +193,6 @@ The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/oidc/ca
|
|||
- `lib/mv_web/` — Phoenix controllers, LiveViews, components
|
||||
- `lib/mv/` — Shared helpers and business logic
|
||||
- `assets/` — Tailwind, JavaScript, static files
|
||||
- `test/` — All tests
|
||||
|
||||
|
||||
📚 **Full tech stack details:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
|
||||
📖 **Implementation history:** See [`docs/development-progress-log.md`](docs/development-progress-log.md)
|
||||
|
|
@ -216,19 +228,42 @@ For testing the production Docker build locally:
|
|||
# Copy template and edit
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
# Required variables:
|
||||
SECRET_KEY_BASE=<your-generated-secret>
|
||||
TOKEN_SIGNING_SECRET=<your-generated-secret>
|
||||
DOMAIN=localhost # or PHX_HOST=localhost
|
||||
|
||||
# Optional OIDC configuration:
|
||||
# OIDC_CLIENT_ID=mv
|
||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||
# OIDC_CLIENT_SECRET=<from-your-oidc-provider>
|
||||
# OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/rauthy/callback
|
||||
|
||||
# Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars):
|
||||
# SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base
|
||||
# TOKEN_SIGNING_SECRET_FILE=/run/secrets/token_signing_secret
|
||||
# OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
|
||||
# DATABASE_URL_FILE=/run/secrets/database_url
|
||||
# DATABASE_PASSWORD_FILE=/run/secrets/database_password
|
||||
```
|
||||
|
||||
3. **Start production environment:**
|
||||
3. **Start development environment** (for Rauthy):
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. **Start production environment:**
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up
|
||||
```
|
||||
|
||||
4. **Database migrations run automatically** on app start. For manual migration:
|
||||
5. **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:**
|
||||
6. **Access the production app:**
|
||||
- Production App: http://localhost:4001
|
||||
- Uses same Rauthy instance as dev (localhost:8080)
|
||||
|
||||
|
|
@ -251,9 +286,9 @@ For actual production deployment:
|
|||
## 🤝 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
|
||||
- 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
|
||||
|
||||
|
|
@ -263,4 +298,4 @@ 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
|
||||
- Community links: coming soon.
|
||||
|
|
|
|||
|
|
@ -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,193 +99,6 @@
|
|||
/* 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
|
||||
============================================ */
|
||||
|
|
@ -525,251 +338,4 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
262
assets/js/app.js
262
assets/js/app.js
|
|
@ -21,18 +21,9 @@ 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 = {}
|
||||
|
||||
|
|
@ -82,237 +73,6 @@ Hooks.ComboBox = {
|
|||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// RowSelectionGuard: distinguish drag-to-select-text from a plain click on the members table.
|
||||
// LiveView fires the row navigation push (select_row_and_navigate) on any click. When the user
|
||||
// drags across a cell to select text (e.g. an email to copy) and releases, the mouseup produces a
|
||||
// non-empty text selection; in that case we swallow the click in the capture phase so navigation is
|
||||
// suppressed. A plain click leaves the selection collapsed and navigates as before.
|
||||
Hooks.RowSelectionGuard = {
|
||||
mounted() {
|
||||
this.handleClickCapture = (e) => {
|
||||
const selection = window.getSelection()
|
||||
if (selection && !selection.isCollapsed && selection.toString().trim() !== "") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
// Capture phase so this runs before LiveView's bubbling phx-click handler.
|
||||
this.el.addEventListener("click", this.handleClickCapture, true)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener("click", this.handleClickCapture, true)
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
|
|
@ -326,16 +86,6 @@ Hooks.SidebarState = {
|
|||
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
|
||||
|
|
@ -362,10 +112,7 @@ Hooks.SidebarState = {
|
|||
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {
|
||||
_csrf_token: csrfToken,
|
||||
timezone: getBrowserTimezone()
|
||||
},
|
||||
params: {_csrf_token: csrfToken},
|
||||
hooks: Hooks
|
||||
})
|
||||
|
||||
|
|
@ -481,13 +228,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
|
||||
// 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)
|
||||
|
|
|
|||
2
assets/vendor/sortable.js
vendored
2
assets/vendor/sortable.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -46,18 +46,11 @@ 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: [
|
||||
|
|
@ -96,20 +89,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",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ config :mv, Mv.Repo,
|
|||
username: "postgres",
|
||||
password: "postgres",
|
||||
hostname: "localhost",
|
||||
port: String.to_integer(System.get_env("DB_PORT") || "5000"),
|
||||
port: 5000,
|
||||
database: "mv_dev",
|
||||
stacktrace: true,
|
||||
show_sensitive_data_on_connection_error: true,
|
||||
|
|
@ -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:#{System.get_env("RAUTHY_PORT") || "8080"}/auth/v1",
|
||||
# client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
||||
# redirect_uri: "http://localhost:#{System.get_env("PORT") || "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
|
||||
|
|
|
|||
|
|
@ -32,91 +32,11 @@ get_env_or_file = fn var_name, default ->
|
|||
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.
|
||||
# Same as get_env_or_file but raises if the value is not set
|
||||
get_env_or_file! = fn var_name, error_message ->
|
||||
case get_env_or_file.(var_name, nil) do
|
||||
nil ->
|
||||
raise error_message
|
||||
|
||||
value 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
|
||||
nil -> raise error_message
|
||||
value -> value
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -129,14 +49,12 @@ build_database_url = fn ->
|
|||
nil ->
|
||||
# Build URL from separate components
|
||||
host =
|
||||
get_env_required.("DATABASE_HOST", """
|
||||
DATABASE_HOST is required when DATABASE_URL is not set.
|
||||
""")
|
||||
System.get_env("DATABASE_HOST") ||
|
||||
raise "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.
|
||||
""")
|
||||
System.get_env("DATABASE_USER") ||
|
||||
raise "DATABASE_USER is required when DATABASE_URL is not set"
|
||||
|
||||
password =
|
||||
get_env_or_file!.("DATABASE_PASSWORD", """
|
||||
|
|
@ -144,11 +62,10 @@ build_database_url = fn ->
|
|||
""")
|
||||
|
||||
database =
|
||||
get_env_required.("DATABASE_NAME", """
|
||||
DATABASE_NAME is required when DATABASE_URL is not set.
|
||||
""")
|
||||
System.get_env("DATABASE_NAME") ||
|
||||
raise "DATABASE_NAME is required when DATABASE_URL is not set"
|
||||
|
||||
port = get_env_non_empty.("DATABASE_PORT", "5432")
|
||||
port = System.get_env("DATABASE_PORT", "5432")
|
||||
|
||||
# URL-encode the password to handle special characters
|
||||
encoded_password = URI.encode_www_form(password)
|
||||
|
|
@ -185,7 +102,7 @@ if config_env() == :prod do
|
|||
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.
|
||||
|
|
@ -203,19 +120,17 @@ if config_env() == :prod do
|
|||
# PHX_HOST or DOMAIN can be used to set the host for the application.
|
||||
# DOMAIN is commonly used in deployment environments (e.g., Portainer templates).
|
||||
host =
|
||||
get_env_non_empty.("PHX_HOST", nil) ||
|
||||
get_env_non_empty.("DOMAIN", nil) ||
|
||||
raise """
|
||||
Please define the PHX_HOST or DOMAIN environment variable.
|
||||
(Variable may be set but empty.)
|
||||
"""
|
||||
System.get_env("PHX_HOST") ||
|
||||
System.get_env("DOMAIN") ||
|
||||
raise "Please define the PHX_HOST or DOMAIN environment variable."
|
||||
|
||||
port = parse_positive_integer.(System.get_env("PORT"), 4000)
|
||||
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.
|
||||
# Note: The strategy is named :rauthy internally, but works with any OIDC provider.
|
||||
# The redirect_uri callback path is always /auth/user/rauthy/callback regardless of provider.
|
||||
#
|
||||
# Supports OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE for Docker secrets.
|
||||
# OIDC_CLIENT_SECRET is required only if OIDC is being used (indicated by explicit OIDC env vars).
|
||||
|
|
@ -235,9 +150,9 @@ if config_env() == :prod do
|
|||
|
||||
# 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"
|
||||
default_redirect_uri = "https://#{host}/auth/user/rauthy/callback"
|
||||
|
||||
config :mv, :oidc,
|
||||
config :mv, :rauthy,
|
||||
client_id: oidc_client_id || "mv",
|
||||
base_url: oidc_base_url || "http://localhost:8080/auth/v1",
|
||||
client_secret: client_secret,
|
||||
|
|
@ -303,54 +218,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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ config :mv, Mv.Repo,
|
|||
username: "postgres",
|
||||
password: "postgres",
|
||||
hostname: System.get_env("TEST_POSTGRES_HOST", "localhost"),
|
||||
port: System.get_env("TEST_POSTGRES_PORT") || System.get_env("DB_PORT") || "5000",
|
||||
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,
|
||||
|
|
@ -49,20 +49,6 @@ 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
|
||||
|
||||
# StreamData property tests: generated cases per property, overridable via PROPERTY_RUNS
|
||||
# (the `just check` recipe sets it low for speed; default 100 otherwise).
|
||||
config :stream_data, max_runs: String.to_integer(System.get_env("PROPERTY_RUNS") || "100")
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ services:
|
|||
PHX_HOST: "${PHX_HOST:-localhost}"
|
||||
PORT: "4001"
|
||||
PHX_SERVER: "true"
|
||||
# OIDC config - use host.docker.internal to reach host services
|
||||
# Rauthy 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"
|
||||
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
|
||||
secrets:
|
||||
- db_password
|
||||
- secret_key_base
|
||||
|
|
@ -33,7 +33,7 @@ services:
|
|||
restart: unless-stopped
|
||||
|
||||
db-prod:
|
||||
image: postgres:18.4-alpine
|
||||
image: postgres:18.1-alpine
|
||||
container_name: mv-prod-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
|
|
@ -42,7 +42,7 @@ services:
|
|||
secrets:
|
||||
- db_password
|
||||
volumes:
|
||||
- postgres_data_prod:/var/lib/postgresql
|
||||
- postgres_data_prod:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5001:5432"
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -4,46 +4,40 @@ networks:
|
|||
|
||||
services:
|
||||
db:
|
||||
image: postgres:18.4-alpine
|
||||
image: postgres:18.1-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: mv_dev
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${DB_PORT:-5000}:5432"
|
||||
- "5000:5432"
|
||||
networks:
|
||||
- local
|
||||
|
||||
mailcrab:
|
||||
image: marlonb/mailcrab:latest
|
||||
ports:
|
||||
- "${MAILCRAB_PORT:-1080}:1080"
|
||||
- "1080:1080"
|
||||
networks:
|
||||
- rauthy-dev
|
||||
|
||||
rauthy:
|
||||
# No fixed container_name — Compose derives it from COMPOSE_PROJECT_NAME so
|
||||
# several isolated stacks coexist (e.g. mv-<issue>-rauthy-1). A plain
|
||||
# checkout gets <dir>-rauthy-1.
|
||||
image: ghcr.io/sebadob/rauthy:0.35.2
|
||||
container_name: rauthy-dev
|
||||
image: ghcr.io/sebadob/rauthy:0.34.3
|
||||
environment:
|
||||
- LOCAL_TEST=true
|
||||
- SMTP_URL=mailcrab
|
||||
- SMTP_PORT=1025
|
||||
- SMTP_DANGER_INSECURE=true
|
||||
- LISTEN_SCHEME=http
|
||||
# Advertised URL must match the host-mapped port below.
|
||||
- PUB_URL=localhost:${RAUTHY_PORT:-8080}
|
||||
- PUB_URL=localhost:8080
|
||||
- BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345
|
||||
# Disable strict IP validation to allow access from multiple Docker networks
|
||||
- SESSION_VALIDATE_IP=false
|
||||
# Auto-seed the `mv` OIDC client (id + plain secret) on first DB init.
|
||||
# Re-runs after `docker compose down -v` because the DB is empty again.
|
||||
- BOOTSTRAP_DIR=/app/bootstrap
|
||||
ports:
|
||||
- "${RAUTHY_PORT:-8080}:8080"
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- mailcrab
|
||||
- db
|
||||
|
|
@ -52,7 +46,6 @@ services:
|
|||
- local
|
||||
volumes:
|
||||
- rauthy-data:/app/data
|
||||
- ./rauthy-bootstrap:/app/bootstrap:ro
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
# Documentation index
|
||||
|
||||
Project documentation for Mila (Mitgliederverwaltung). Each area below lists a coarse entry point first, then the deeper references. For engineering conventions see the repo-root `CODE_GUIDELINES.md`; for UI conventions see `DESIGN_GUIDELINES.md`.
|
||||
|
||||
## Roles & permissions
|
||||
- [roles-and-permissions-overview.md](roles-and-permissions-overview.md) — coarse entry: the RBAC concept, evaluated approaches, permission sets and scopes.
|
||||
- [roles-and-permissions-architecture.md](roles-and-permissions-architecture.md) — deep reference: per-resource policies, permission matrix, page-permission plug, authorization bootstrap patterns.
|
||||
- [roles-and-permissions-implementation-plan.md](roles-and-permissions-implementation-plan.md) — historical record of how the MVP was built.
|
||||
- [policy-bypass-vs-haspermission.md](policy-bypass-vs-haspermission.md) — the two-tier Ash pattern (bypass + `expr()` for reads, `HasPermission` for writes).
|
||||
- [page-permission-route-coverage.md](page-permission-route-coverage.md) — route → permission-set access matrix and the page-permission plug behaviour.
|
||||
|
||||
## Membership & fees
|
||||
- [membership-fee-overview.md](membership-fee-overview.md) — coarse entry: terminology (DE↔EN), worked cycle examples, UI mockups.
|
||||
- [membership-fee-architecture.md](membership-fee-architecture.md) — deep reference: design decisions, data model, cycle generation, atomicity.
|
||||
- [vereinfacht-api.md](vereinfacht-api.md) — the Vereinfacht finance-contact sync integration.
|
||||
|
||||
## Members: import, onboarding, custom fields
|
||||
- [csv-member-import-v1.md](csv-member-import-v1.md) — CSV member import: column mapping, validation, error handling, templates.
|
||||
- [onboarding-join-concept.md](onboarding-join-concept.md) — public join flow (double opt-in, JoinRequest), plus unimplemented approval/invite/OIDC design.
|
||||
- [custom-fields-search-performance.md](custom-fields-search-performance.md) — performance of custom-field values in the member search vector.
|
||||
|
||||
## Groups
|
||||
- [groups-architecture.md](groups-architecture.md) — groups design, hierarchy extension path, search integration, A11y notes.
|
||||
|
||||
## Database
|
||||
- [database-schema-readme.md](database-schema-readme.md) — schema overview: tables, relationships, indexes, full-text & fuzzy search.
|
||||
- [database_schema.dbml](database_schema.dbml) — machine-readable DBML schema (for dbdiagram.io / dbdocs.io).
|
||||
|
||||
## Accounts, OIDC & email
|
||||
- [admin-bootstrap-and-oidc-role-sync.md](admin-bootstrap-and-oidc-role-sync.md) — production admin bootstrap and OIDC group → Admin role sync (ENV contract).
|
||||
- [oidc-account-linking.md](oidc-account-linking.md) — secure account linking between password and OIDC accounts.
|
||||
- [email-sync.md](email-sync.md) — email synchronization rules between linked User and Member.
|
||||
- [email-validation.md](email-validation.md) — the dual `:html_input` + `:pow` email-validation strategy.
|
||||
- [email-layout-mockup.md](email-layout-mockup.md) — shared transactional-email layout structure.
|
||||
- [smtp-configuration-concept.md](smtp-configuration-concept.md) — SMTP config precedence, TLS handling, operational caveats.
|
||||
|
||||
## UI & components
|
||||
- [settings-authentication-mockup.txt](settings-authentication-mockup.txt) — Settings → Authentication section layout.
|
||||
- [daisyui-drawer-pattern.md](daisyui-drawer-pattern.md) — the project's sidebar drawer pattern (implemented in `lib/mv_web/components/layouts/sidebar.ex`).
|
||||
- [badge-wcag-phase1-analysis.md](badge-wcag-phase1-analysis.md) — `<.badge>` component API and WCAG contrast design notes.
|
||||
- [pdf-generation-imprintor.md](pdf-generation-imprintor.md) — PDF generation via Imprintor + Typst templates (decision vs. Chromium).
|
||||
|
||||
## Project history & planning
|
||||
- [development-progress-log.md](development-progress-log.md) — coarse implementation history, architecture decisions, migration index, gotchas.
|
||||
- [feature-roadmap.md](feature-roadmap.md) — per-area status matrix and the open/missing backlog.
|
||||
- [test-performance-optimization.md](test-performance-optimization.md) — test-suite speed-up: seeds-test coverage mapping and the `@tag :slow` model.
|
||||
|
|
@ -2,31 +2,28 @@
|
|||
|
||||
## 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()"`.
|
||||
- **Admin bootstrap:** In production, no seeds run. The first admin user is created/updated from environment variables in the Docker entrypoint (after migrate, before 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
|
||||
### Release Task
|
||||
|
||||
- `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.
|
||||
- rel/overlays/bin/docker-entrypoint.sh – After migrate, runs seed_admin(), then starts the server.
|
||||
|
||||
### Seeds (Dev/Test)
|
||||
|
||||
- priv/repo/seeds_bootstrap.exs – Uses ADMIN_PASSWORD or ADMIN_PASSWORD_FILE when set; otherwise fallback "testpassword" only in 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)
|
||||
|
||||
|
|
@ -34,12 +31,7 @@
|
|||
|
||||
- `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.Config (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).
|
||||
- Module: Mv.OidcRoleSyncConfig (oidc_admin_group_name/0, oidc_groups_claim/0).
|
||||
|
||||
### Sync Logic
|
||||
|
||||
|
|
@ -47,8 +39,8 @@
|
|||
|
||||
### 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.
|
||||
1. Registration: register_with_rauthy after_action calls OidcRoleSync.
|
||||
2. Sign-in: sign_in_with_rauthy prepare after_action calls OidcRoleSync for each user.
|
||||
|
||||
### Internal Action
|
||||
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
# Badge Component Design Notes (WCAG)
|
||||
|
||||
Design rationale for the central `<.badge>` core component
|
||||
(`MvWeb.CoreComponents`) and the WCAG-driven theme overrides in
|
||||
`assets/css/app.css`. Before it, badges were raw `<span class="badge ...">`
|
||||
markup with no central component.
|
||||
|
||||
## `<.badge>` API contract
|
||||
|
||||
- `attr :variant` — `:neutral | :primary | :info | :success | :warning | :error`
|
||||
- `attr :style` — `:soft | :solid | :outline` (default: `:soft`)
|
||||
- `attr :size` — `:sm | :md` (default: `:md`)
|
||||
- `attr :sr_label` — optional screen-reader label for icon-only badges
|
||||
- `slot :icon` — optional
|
||||
- `slot :inner_block` — badge text
|
||||
|
||||
## Design rules
|
||||
|
||||
- `:soft` and `:solid` use a visible background; `:soft` is the default. No
|
||||
transparent ghost as a default.
|
||||
- `:outline` **always** sets a background (e.g. `bg-base-100`) so the border
|
||||
stays visible on grey (`base-200`/`base-300`) surfaces.
|
||||
- Ghost only as an explicit opt-in, and then with `bg-base-100` for visibility
|
||||
(plain DaisyUI `badge-ghost` is `base-200` on `base-200` — nearly invisible).
|
||||
- Clickable chips keep `<.badge>` as a plain container with a button in the
|
||||
`inner_block`.
|
||||
|
||||
## WCAG contrast overrides (`app.css`)
|
||||
|
||||
DaisyUI defaults do not reach the WCAG 2.2 AA **4.5:1** text-contrast ratio for
|
||||
badges, so `app.css` adds per-theme overrides on top of the custom `light` /
|
||||
`dark` themes:
|
||||
|
||||
- **Light theme:** darker `--badge-fg` for all solid variants; darker text on
|
||||
`badge-soft`'s tinted background; uniform dark text for `badge-outline` on
|
||||
`base-100`.
|
||||
- **Dark theme:** slightly darkened solid-badge backgrounds so the light
|
||||
`*-content` colors reach 4.5:1; lighter, readable variant tints for
|
||||
`badge-soft`; light `--badge-fg` for `badge-outline` on `base-100`.
|
||||
|
||||
Related: contrast overrides for the member-filter join buttons
|
||||
(`.member-filter-dropdown .join .btn`) under the same 4.5:1 rule.
|
||||
|
||||
## Variant helpers
|
||||
|
||||
- `MembershipFeeHelpers.status_variant/1` → `:success | :error | :warning`.
|
||||
`status_variant(:suspended) -> :warning` (yellow) deliberately matches the
|
||||
Edit button (`btn-warning`), keeping the "suspended" badge the same color as
|
||||
its action.
|
||||
- `RoleLive.Helpers.permission_set_badge_variant/1` →
|
||||
`:neutral | :info | :success | :error`.
|
||||
- `MembershipFeeStatus.format_cycle_status_badge/1` additionally returns a
|
||||
`:variant` for `<.badge>`.
|
||||
|
|
@ -1,172 +1,777 @@
|
|||
# CSV Member Import
|
||||
# CSV Member Import v1 - Implementation Plan
|
||||
|
||||
Reference for how the CSV member import actually behaves. The end-to-end
|
||||
LiveView test (`test/mv_web/live/import_live_test.exs`) and future maintenance
|
||||
depend on the rules documented here.
|
||||
**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
|
||||
|
||||
**Status:** implemented (backend + LiveView UI).
|
||||
## Implementation Status
|
||||
|
||||
Implementation:
|
||||
**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)
|
||||
|
||||
- `lib/mv/membership/import/csv_parser.ex` — BOM stripping, delimiter detection, physical line numbering
|
||||
- `lib/mv/membership/import/header_mapper.ex` — header normalization + column mapping
|
||||
- `lib/mv/membership/import/column_resolver.ex` — read-only resolution of groups + fee-type columns (preview)
|
||||
- `lib/mv/membership/import/member_csv.ex` — `prepare/2`, `process_chunk/4`, validation, member creation
|
||||
- `lib/mv/membership/import/import_runner.ex` — orchestration glue
|
||||
- `lib/mv_web/live/import_live.ex` (+ `import_live/components.ex`) — UI, state machine, chunk driving
|
||||
- `lib/mv_web/controllers/import_template_controller.ex` — on-the-fly template generation
|
||||
**In Progress / Pending:**
|
||||
- ⏳ Issue #9: End-to-End LiveView Tests + Fixtures
|
||||
- ⏳ Issue #10: Documentation Polish
|
||||
|
||||
## Scope
|
||||
**Latest Update:** CSV Import UI fully implemented in GlobalSettingsLive with chunk processing, progress tracking, error display, and custom field support (2026-01-13)
|
||||
|
||||
Admin-only bulk creation of members from an uploaded CSV.
|
||||
---
|
||||
|
||||
- **Create only** — no upsert/update of existing members.
|
||||
- **No deduplication** — a duplicate email fails its row (unique constraint) and is reported as an error.
|
||||
- **Best-effort, row-by-row** — no transactional rollback; a failed row does not abort the import.
|
||||
- **No background jobs** — progress is driven via LiveView `handle_info` chunk messages.
|
||||
- **Errors shown in UI only** — no error-CSV export.
|
||||
## Table of Contents
|
||||
|
||||
Out of scope: upsert, mapping wizard, transactional all-or-nothing, error export, import history/audit.
|
||||
- [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)
|
||||
|
||||
## UI Flow
|
||||
---
|
||||
|
||||
- **Route:** `/admin/import` (LiveView `MvWeb.ImportLive`). Template downloads:
|
||||
`/admin/import/template/en` and `/admin/import/template/de` (dynamic controller, not static files).
|
||||
- **Authorization:** requires `can?(:create, Mv.Membership.Member)`. Non-admins are
|
||||
redirected with a "don't have permission" flash. The import section, the template
|
||||
controller, and the `start_import` event all enforce this.
|
||||
- **Upload:** `allow_upload(:csv_file, accept: .csv, max_entries: 1, auto_upload: true)`.
|
||||
File size limit enforced by `max_file_size`.
|
||||
- **State machine** (`@import_status`): `idle → preview → running → done|error`.
|
||||
- **start_import** parses + resolves the file and transitions to **preview**. This step
|
||||
is **read-only**: no members are created yet. The preview shows the column mapping,
|
||||
sample rows, groups that exist vs. would be created, and fee-type/unknown-column warnings.
|
||||
- **confirm_import** begins processing and creates members chunk by chunk.
|
||||
- **Results:** success count, failure count, error list (each with CSV line number, message,
|
||||
optional field), warnings, and a truncation notice when errors exceed the cap.
|
||||
## Overview & Scope
|
||||
|
||||
## Limits
|
||||
### What We're Building
|
||||
|
||||
- **Max file size:** configurable via `config :mv, csv_import: [max_file_size_mb: ...]` (enforced by `allow_upload`).
|
||||
- **Max rows:** configurable via `config :mv, csv_import: [max_rows: ...]`, default **1000**, excluding header. Enforced in `MemberCSV.prepare/2`; exceeding it yields an error containing `"exceeds"`.
|
||||
- **Chunk size:** 200 rows per chunk.
|
||||
- **Error cap:** 50 errors collected per import overall (`failed` count stays accurate; `errors_truncated?` flag set when exceeded).
|
||||
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.
|
||||
|
||||
## Parsing (`CsvParser.parse/1`)
|
||||
**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`)
|
||||
- **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)
|
||||
|
||||
- Content must be **valid UTF-8** (else error). Empty content / empty header row are errors.
|
||||
- **UTF-8 BOM is stripped first**, before any header handling.
|
||||
- Line endings normalized: `\r\n`, `\r`, `\n` all handled.
|
||||
- **Delimiter auto-detection:** parse the header with both `;` and `,` parsers (NimbleCSV,
|
||||
quote-aware), count non-empty fields each yields, pick the higher; **`;` wins ties**;
|
||||
default `;`.
|
||||
- **Quoting:** double-quote quoting; `""` inside a quoted field is a literal `"`. Newlines
|
||||
inside quoted fields are supported — the record keeps its **start** line number.
|
||||
- **Physical line numbers:** rows are returned as `{csv_line_number, values}` where the line
|
||||
number is the physical 1-based line in the file (header is line 1, first data row is line 2).
|
||||
**Empty lines are skipped but do not shift numbering** — downstream code must use the
|
||||
parser's line numbers, never recompute from row index. (Test asserts an invalid row after a
|
||||
skipped empty line still reports its true physical line, e.g. `Line 4`.)
|
||||
- Completely empty rows are skipped. An unparsable row produces an error naming its line number.
|
||||
**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)
|
||||
|
||||
## Header Mapping & Normalization (`HeaderMapper`)
|
||||
### Out of Scope (v1)
|
||||
|
||||
**`normalize_header/1`** (applied identically to incoming headers, mapping variants, custom
|
||||
field names, group names, and fee-type names):
|
||||
**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
|
||||
|
||||
1. trim, lowercase
|
||||
2. transliterate German chars: `ß → ss`, `ä → ae`, `ö → oe`, `ü → ue` (and uppercase forms)
|
||||
3. unify hyphen variants (en dash U+2013, em dash U+2014, minus U+2212 → `-`)
|
||||
4. punctuation to spaces: `_`, `()[]{}`, `/`, `\` → space
|
||||
5. **remove all whitespace** (so `first name` == `firstname`)
|
||||
6. final trim
|
||||
---
|
||||
|
||||
Matching is on the fully normalized string.
|
||||
## UX Flow
|
||||
|
||||
**Required field:** `email`. Missing it aborts `prepare` with a "Missing required header" error.
|
||||
### Access & Location
|
||||
|
||||
**Unknown member-field columns:** ignored (no error). If an unknown column looks like it
|
||||
could be a custom field that does not exist, a **warning** is emitted (import continues).
|
||||
**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)
|
||||
|
||||
**Duplicate headers** mapping to the same canonical field (or same custom field) are an error.
|
||||
### User Journey
|
||||
|
||||
### Supported member fields and header variants
|
||||
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)
|
||||
|
||||
Source of truth is `@member_field_variants_raw` in `header_mapper.ex`. Variants below are
|
||||
illustrative; matching is via normalization, so casing/hyphen/whitespace differences all collapse.
|
||||
### Error Handling
|
||||
|
||||
| Canonical | Example accepted headers (EN / DE) | Notes |
|
||||
- **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:**
|
||||
- `first_name` / `Vorname` (optional)
|
||||
- `last_name` / `Nachname` (optional)
|
||||
- `email` / `E-Mail` (required)
|
||||
- `street` / `Straße` (optional)
|
||||
- `postal_code` / `PLZ` / `Postleitzahl` (optional)
|
||||
- `city` / `Stadt` (optional)
|
||||
|
||||
**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 |
|
||||
|---|---|---|
|
||||
| `email` (required) | email, e-mail, e_mail, mail, e-mail-adresse / E-Mail | |
|
||||
| `first_name` | first name, firstname / Vorname | |
|
||||
| `last_name` | last name, lastname, surname / Nachname, Familienname | |
|
||||
| `join_date` | join date / Beitrittsdatum | ISO-8601 date |
|
||||
| `exit_date` | exit date / Austrittsdatum | ISO-8601 date |
|
||||
| `notes` | notes / Notizen, Bemerkungen | |
|
||||
| `street` | street, address / Straße, Strasse | |
|
||||
| `house_number` | house number, house no / Hausnummer, Nr, Nr., Nummer | |
|
||||
| `postal_code` | postal code, zip, postcode / PLZ, Postleitzahl | |
|
||||
| `city` | city, town / Stadt, Ort | |
|
||||
| `country` | country / Land, Staat | |
|
||||
| `membership_fee_start_date` | membership fee start date, fee start / Beitragsbeginn | ISO-8601 date |
|
||||
| `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` |
|
||||
| `street` | `street`, `address` | `Straße`, `strasse`, `Strasse` |
|
||||
| `postal_code` | `postal_code`, `zip`, `postcode` | `PLZ`, `plz`, `Postleitzahl`, `postleitzahl` |
|
||||
| `city` | `city`, `town` | `Stadt`, `stadt`, `Ort` |
|
||||
|
||||
### Special relationship columns
|
||||
**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
|
||||
|
||||
- **groups** (headers `Groups` / `Gruppen` / `Gruppe`) — comma-separated group names. Names
|
||||
matched case-insensitively against existing groups; **missing groups are auto-created** during
|
||||
processing. A group-assignment failure fails that row (the member was already created).
|
||||
- **membership_fee_type** (headers `Fee Type`, `fee_type`, `membership_fee_type` / `Beitragsart`)
|
||||
— name matched to an existing `MembershipFeeType`. **Empty cell → default fee type** (no warning).
|
||||
**Matched name → that fee type.** **Unmatched name → default fee type + warning** naming the value.
|
||||
**Unknown columns:** ignored (no error)
|
||||
|
||||
These columns are resolved against the DB read-only in `prepare` (`ColumnResolver`) for the
|
||||
preview; the actual writes happen in `process_chunk`.
|
||||
**Required fields:** `email`
|
||||
|
||||
### Fields not importable (explicitly ignored)
|
||||
**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
|
||||
|
||||
- **membership_fee_status** — computed from fee cycles; not stored. Fee-status header variants
|
||||
(`Membership Fee Status`, `Bezahlstatus`, `Mitgliedsbeitragsstatus`) and the DE export label
|
||||
`Startdatum Mitgliedsbeitrag` are placed in the `ignored` list and never mapped. (The UI notice
|
||||
names `Groups`/`Gruppen`, `Fee Type`/`Beitragsart`, and the always-ignored `Bezahlstatus`.)
|
||||
### CSV Template Files
|
||||
|
||||
## Custom Fields
|
||||
**Location:**
|
||||
- `priv/static/templates/member_import_en.csv`
|
||||
- `priv/static/templates/member_import_de.csv`
|
||||
|
||||
- Custom field columns are matched by the custom field **name** (not slug), using the same
|
||||
normalization. Member fields take priority on a name collision.
|
||||
- **Custom fields must exist in Mila before import.** Unknown custom-field columns are ignored
|
||||
with a warning; the import still runs.
|
||||
- Empty custom-field cells create no value. Values are trimmed; type-validated per the custom
|
||||
field's `value_type`:
|
||||
- **string** — any text (trimmed).
|
||||
- **integer** — must parse fully (`Integer.parse` with no remainder); e.g. `42`, `-10`.
|
||||
- **boolean** — case-insensitive `true/false`, `1/0`, `yes/no`, `ja/nein`.
|
||||
- **date** — ISO-8601 `YYYY-MM-DD`.
|
||||
- **email** — validated with `EctoCommons.EmailValidator` (same checks as member email).
|
||||
- A value failing type validation fails the row. Error message format:
|
||||
`custom_field: <name> – expected <type>, got: <value>` (type label is the human-readable
|
||||
`FieldTypes.label/1`, with format hints for boolean/date).
|
||||
**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)
|
||||
|
||||
## Validation & Member Creation (`process_chunk/4` → `process_row`)
|
||||
**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).
|
||||
|
||||
Per row: validate → create member → create custom-field values → assign groups. Sequential.
|
||||
**Example Usage in LiveView Templates:**
|
||||
|
||||
- **Email** is required and format-validated (`EctoCommons.EmailValidator`, `Mv.Constants.email_validator_checks()`) on a trimmed value. All string member values are trimmed.
|
||||
- **Date fields** (`join_date`, `exit_date`, `membership_fee_start_date`): empty/blank strings are converted to `nil` so Ash accepts them.
|
||||
- Member created via `Mv.Membership.create_member/2`. Custom field values are passed as
|
||||
`custom_field_values` (Ash union `_union_type`/`_union_value` format), omitted when none.
|
||||
- **Errors** are `%MemberCSV.Error{csv_line_number, field, message}`:
|
||||
- `csv_line_number` is the physical line (1-based); never recomputed in this layer.
|
||||
- Validation errors get `field: :email`; Ash errors prefer the field-level error.
|
||||
- **Duplicate email** (unique constraint) is surfaced as a friendly
|
||||
`"email <addr> has already been taken"` message.
|
||||
- **Error capping** (`max_errors`, default 50, tracked across chunks via `existing_error_count`):
|
||||
once the cap is hit, no further errors are collected but **all rows are still processed** and
|
||||
the `failed` count stays accurate; `errors_truncated?` is set and the UI shows a truncation notice.
|
||||
```heex
|
||||
<!-- Using ~p sigil (Phoenix 1.7+) -->
|
||||
<.link href={~p"/templates/member_import_en.csv"} download>
|
||||
<%= gettext("Download English Template") %>
|
||||
</.link>
|
||||
|
||||
## Templates (`ImportTemplateController`)
|
||||
<.link href={~p"/templates/member_import_de.csv"} download>
|
||||
<%= gettext("Download German Template") %>
|
||||
</.link>
|
||||
|
||||
- Generated on the fly (not static files), gated by `can?(:create, Member)`.
|
||||
- One header row: standard member columns (localized EN/DE) + `Groups`/`Gruppen` +
|
||||
`Fee Type`/`Beitragsart` + **every existing custom field name** appended, then one example row.
|
||||
- Semicolon-delimited, RFC-4180 quoting; fields run through `MembersCSV.safe_cell/1` to
|
||||
neutralize spreadsheet formula injection (e.g. a custom-field name like `=HYPERLINK(...)`).
|
||||
<!-- 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**
|
||||
|
|
|
|||
|
|
@ -2,88 +2,242 @@
|
|||
|
||||
## Current Implementation
|
||||
|
||||
The member `search_vector` includes custom field values via database triggers that aggregate all of a member's custom field values, extract the value from each JSONB record (`value->>'_union_value'`), and add them at weight `C`.
|
||||
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'
|
||||
|
||||
Two triggers maintain the vector:
|
||||
## Performance Considerations
|
||||
|
||||
- `members_search_vector_trigger()` — fires on `members` INSERT/UPDATE; runs a subquery `SELECT string_agg(...) FROM custom_field_values WHERE member_id = NEW.id`.
|
||||
- `update_member_search_vector_from_custom_field_value()` — fires on `custom_field_values` INSERT/UPDATE/DELETE; re-aggregates and updates the member's `search_vector`.
|
||||
### 1. Trigger Performance on Member Updates
|
||||
|
||||
Both rely on `custom_field_values_member_id_idx`, so the per-member aggregation is an indexed lookup.
|
||||
**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
|
||||
```
|
||||
|
||||
## Applied Trigger Optimizations
|
||||
**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
|
||||
|
||||
`update_member_search_vector_from_custom_field_value()` was optimized:
|
||||
**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)
|
||||
|
||||
- **Fetch only required member fields** (first_name, last_name, email, etc.) instead of the full record — reduces per-call overhead by roughly 30–50%.
|
||||
- **Early return on UPDATE when the value is unchanged** — skips the expensive re-aggregation entirely.
|
||||
### 2. Trigger Performance on Custom Field Value Changes
|
||||
|
||||
Measured effect per custom-field-value change:
|
||||
**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
|
||||
|
||||
| Case | Before | After |
|
||||
|------|--------|-------|
|
||||
| Value changed | 5–15 ms | 3–10 ms |
|
||||
| Value unchanged (UPDATE) | 5–15 ms | < 1 ms (early return) |
|
||||
**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
|
||||
|
||||
Re-aggregation is still required whenever a value actually changes — that is necessary for `search_vector` consistency.
|
||||
**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)
|
||||
|
||||
## Search Vector Size
|
||||
### 3. Search Vector Size
|
||||
|
||||
- String custom field values are capped at **10,000 characters each**; there is no cap on the number of custom fields per member.
|
||||
- `tsvector` has no hard size limit, but very large vectors (> ~100 KB) degrade GIN index maintenance, tsvector operations, and trigger time. Worst case: 100 fields × 10,000 chars ≈ 1 MB of aggregated text for one member.
|
||||
- **Recommendation:** monitor `search_vector` size in production; consider capping total custom-field content per member if large vectors appear.
|
||||
**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
|
||||
|
||||
## Bulk Imports
|
||||
**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
|
||||
|
||||
The custom-field-value trigger fires once per row, so importing many members with custom fields is expensive. For bulk imports, **temporarily disable the `custom_field_values` trigger**, then re-aggregate `search_vector` in a batch after the import. The initial backfill migration also updates all members in a single transaction (table lock); for > 10,000 members, batch the backfill and run during a maintenance window.
|
||||
**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
|
||||
|
||||
## Search Query Structure
|
||||
### 4. Initial Migration Performance
|
||||
|
||||
Full-text search uses the GIN index on `search_vector` (fast). Substring/custom-field matching adds `EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = id AND ... LIKE ...)` subqueries, which are **not indexed** on the JSONB value (sequential scan) and run even when the FTS branch already matches. This is the main known weakness; it is acceptable at the current scale (< 30 custom fields/member, < 10,000 members) but is the first thing to revisit if search slows.
|
||||
**Current Implementation:**
|
||||
- Updates ALL members in a single transaction:
|
||||
```sql
|
||||
UPDATE members m SET search_vector = ... (subquery for each member)
|
||||
```
|
||||
|
||||
## Search Filter Functions
|
||||
**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
|
||||
|
||||
The search query in `lib/membership/member.ex` is split into modular filter builders, combined as a single OR-chain in priority order:
|
||||
**Recommendation:**
|
||||
- For large datasets (> 10,000 members), consider:
|
||||
- Batch updates (e.g., 1000 members at a time)
|
||||
- Run during maintenance window
|
||||
- Monitor progress
|
||||
|
||||
1. `build_fts_filter/1` — full-text search (highest priority, GIN-indexed, fastest).
|
||||
2. `build_substring_filter/2` — `ILIKE` substring matching on structured fields (postal_code, house_number, email, city, country).
|
||||
3. `build_custom_field_filter/1` — JSONB custom-field value matching via `EXISTS` subquery.
|
||||
4. `build_fuzzy_filter/2` — trigram fuzzy matching on first_name, last_name, street (pg_trgm).
|
||||
### 5. Search Query Performance
|
||||
|
||||
Priority: **FTS > Substring > Custom Fields > Fuzzy**.
|
||||
**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
|
||||
-- search_vector size distribution
|
||||
SELECT
|
||||
pg_size_pretty(octet_length(search_vector::text)) AS size,
|
||||
COUNT(*) AS member_count
|
||||
-- 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;
|
||||
|
||||
-- average / max custom fields per member
|
||||
SELECT
|
||||
AVG(custom_field_count) AS avg_custom_fields,
|
||||
MAX(custom_field_count) AS max_custom_fields
|
||||
-- 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
|
||||
SELECT member_id, COUNT(*) as custom_field_count
|
||||
FROM custom_field_values
|
||||
GROUP BY member_id
|
||||
) subq;
|
||||
|
||||
-- trigger execution time (requires pg_stat_statements)
|
||||
SELECT mean_exec_time, calls, query
|
||||
-- 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;
|
||||
```
|
||||
|
||||
## Future Options (if scale demands)
|
||||
## 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)
|
||||
|
||||
- Generated/searchable text column or materialized view for custom-field substring search (to escape the unindexed JSONB `LIKE`).
|
||||
- Limit which custom fields are searchable, or truncate long values.
|
||||
- External search service (e.g., Elasticsearch) for advanced search.
|
||||
|
|
|
|||
|
|
@ -1,21 +1,533 @@
|
|||
# Sidebar Drawer Pattern (project note)
|
||||
# DaisyUI Drawer Pattern - Standard Implementation
|
||||
|
||||
The app's responsive sidebar uses DaisyUI's drawer in the **combined
|
||||
mobile-overlay + desktop-persistent** configuration. The drawer container,
|
||||
the `mobile-drawer` toggle checkbox and the mobile header live in the `app/1`
|
||||
layout (`lib/mv_web/components/layouts.ex`); the sidebar body and the
|
||||
tap-to-close `drawer-overlay` live in `lib/mv_web/components/layouts/sidebar.ex`.
|
||||
This document describes the standard DaisyUI drawer pattern for implementing responsive sidebars. It covers mobile overlay drawers, desktop persistent sidebars, and their combination.
|
||||
|
||||
## Chosen pattern
|
||||
## 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.
|
||||
|
||||
- `drawer lg:drawer-open` on the container — sidebar is a slide-in overlay on
|
||||
mobile (`< lg`, 1024px) and permanently visible on desktop (`≥ lg`).
|
||||
- A hidden checkbox (`class="drawer-toggle"`, `id="mobile-drawer"`) holds the
|
||||
open/close state; `<label for="mobile-drawer">` elements toggle it. Pure CSS,
|
||||
no JavaScript; the native checkbox provides keyboard accessibility.
|
||||
- The mobile hamburger toggle and the `drawer-overlay` (tap-to-close) are
|
||||
marked `lg:hidden`, so on desktop there is no toggle and no overlay — main
|
||||
content shifts to make room for the fixed-width sidebar.
|
||||
|
||||
This is DaisyUI's standard recommended approach for responsive sidebars; see
|
||||
the DaisyUI drawer docs for the full component API if extending it.
|
||||
|
|
|
|||
|
|
@ -4,54 +4,105 @@
|
|||
|
||||
This document provides a comprehensive overview of the Mila Membership Management System database schema.
|
||||
|
||||
- **DBML file:** [`database_schema.dbml`](./database_schema.dbml) — full per-column intent notes and relationship edges.
|
||||
- **Search-vector performance:** see [`custom-fields-search-performance.md`](./custom-fields-search-performance.md) for trigger cost analysis and tuning.
|
||||
## Quick Links
|
||||
|
||||
The DBML is **hand-maintained** (not auto-generated); keep it in sync with `priv/repo/migrations/`.
|
||||
- **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** | 12 |
|
||||
| **Tables** | 11 |
|
||||
| **Domains** | 4 (Accounts, Membership, MembershipFees, Authorization) |
|
||||
| **Triggers** | 3 (member, custom_field_values, member_groups → member search-vector) |
|
||||
| **Relationships** | 9 |
|
||||
| **Indexes** | 25+ |
|
||||
| **Triggers** | 1 (Full-text search) |
|
||||
|
||||
## Tables Overview
|
||||
|
||||
### Accounts Domain
|
||||
- **`users`** — authentication accounts. Dual auth (Password + OIDC), optional 1:1 link to a member; email is the source of truth when linked.
|
||||
- **`tokens`** — JWT storage for AshAuthentication; multiple purposes, revocation by deletion.
|
||||
|
||||
OIDC account linking is recorded on the `users` table via the `oidc_id` column; there is no separate `user_identities` table.
|
||||
#### `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`** — club member master data. Full-text + fuzzy search, bidirectional email sync with users, flexible address/contact data, `country`, optional `vereinfacht_contact_id` (external vereinfacht.de contact).
|
||||
- **`custom_field_values`** — dynamic per-member attributes. Union-type value in JSONB; one value per custom field per member.
|
||||
- **`custom_fields`** — schema definitions for custom field values (type, `required`/`show_in_overview` flags, optional `join_description`, auto-generated slug).
|
||||
- **`settings`** — global application settings (singleton). Club name (also via `ASSOCIATION_NAME` env), member-field visibility/required maps, fee defaults, plus OIDC, SMTP/mail-from, vereinfacht.de, public join-form, `registration_enabled`, and `oidc_only` configuration. See [Settings configuration columns](#settings-configuration-columns).
|
||||
- **`groups`** — member groupings. Case-insensitive-unique names, auto-generated immutable slugs, optional descriptions; many-to-many with members.
|
||||
- **`member_groups`** — join table for members ↔ groups. Unique `(member_id, group_id)`, CASCADE delete on both sides (join table only).
|
||||
- **`join_requests`** — public join flow (onboarding, double opt-in). Status machine `pending_confirmation → submitted → approved/rejected`; confirmation token stored as hash only, ~24h retention for unconfirmed records.
|
||||
|
||||
#### `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`** — RBAC. Links users to one of four hardcoded permission sets (`own_data`, `read_only`, `normal_user`, `admin`); system roles are deletion-protected.
|
||||
|
||||
### MembershipFees Domain
|
||||
- **`membership_fee_types`** — fee types with immutable billing interval.
|
||||
- **`membership_fee_cycles`** — per-member billing cycles with payment status.
|
||||
|
||||
## Settings configuration columns
|
||||
|
||||
The singleton `settings` row carries runtime configuration (all nullable unless noted). Grouped by area:
|
||||
|
||||
- **Member overview:** `member_field_visibility` (JSONB; absent key = visible), `member_field_required` (JSONB).
|
||||
- **Membership fees:** `include_joining_cycle` (bool, NOT NULL, default true), `default_membership_fee_type_id` (FK → membership_fee_types, ON DELETE SET NULL).
|
||||
- **Registration / login:** `registration_enabled` (bool, NOT NULL, default true), `oidc_only` (bool, NOT NULL, default false).
|
||||
- **OIDC:** `oidc_client_id`, `oidc_client_secret`, `oidc_base_url`, `oidc_redirect_uri`, `oidc_admin_group_name`, `oidc_groups_claim`.
|
||||
- **SMTP / mail-from:** `smtp_host`, `smtp_port` (bigint), `smtp_username`, `smtp_password`, `smtp_ssl`, `smtp_from_name`, `smtp_from_email`.
|
||||
- **vereinfacht.de:** `vereinfacht_api_url`, `vereinfacht_api_key`, `vereinfacht_club_id`, `vereinfacht_app_url`.
|
||||
- **Public join form:** `join_form_enabled` (bool, NOT NULL, default false), `join_form_field_ids` (text[]), `join_form_field_required` (JSONB).
|
||||
#### `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
|
||||
|
||||
|
|
@ -73,47 +124,116 @@ Member (N) ←→ (N) Group
|
|||
Settings (1) → MembershipFeeType (0..1)
|
||||
```
|
||||
|
||||
## Foreign Key On-Delete Behavior
|
||||
### Relationship Details
|
||||
|
||||
| Relationship | On Delete | Rationale |
|
||||
|--------------|-----------|-----------|
|
||||
| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted |
|
||||
| `users.role_id → roles.id` | RESTRICT | Cannot delete a role that still has users |
|
||||
| `custom_field_values.member_id → members.id` | CASCADE | Delete values with member |
|
||||
| `custom_field_values.custom_field_id → custom_fields.id` | CASCADE | Delete values when the custom field is deleted |
|
||||
| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Cannot delete a fee type assigned to members |
|
||||
| `membership_fee_cycles.member_id → members.id` | CASCADE | Cycles deleted with member |
|
||||
| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Cannot delete a fee type with cycles |
|
||||
| `settings.default_membership_fee_type_id → membership_fee_types.id` | SET NULL | Clear default if fee type deleted |
|
||||
| `member_groups.member_id → members.id` | CASCADE | Association removed; member preserved |
|
||||
| `member_groups.group_id → groups.id` | CASCADE | Association removed; group preserved |
|
||||
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)
|
||||
|
||||
`join_requests.reviewed_by_user_id` is intentionally **unconstrained** (no FK); `reviewed_by_display` is denormalized so the UI need not load the reviewer User.
|
||||
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
|
||||
|
||||
**User ↔ Member** is an optional 1:1 (both sides may be NULL; entities exist independently). **Member ↔ Group** is many-to-many through `member_groups` (CASCADE lives only on the join table).
|
||||
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). Afterwards changes sync bidirectionally. Validation prevents email conflicts with other unlinked users.
|
||||
- **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), the external identity recorded via the `oidc_id` column on `users`. At least one method required per user.
|
||||
- **Password:** Email + hashed_password
|
||||
- **OIDC:** Email + oidc_id (Rauthy provider)
|
||||
- At least one method required per user
|
||||
|
||||
### Member Constraints
|
||||
- `first_name` / `last_name`: optional, but if present min 1 char.
|
||||
- `email`: unique, validated format (5–254 chars).
|
||||
- `exit_date` must be after `join_date`.
|
||||
- `postal_code`, `country`: optional, no format validation.
|
||||
- First name and last name required (min 1 char)
|
||||
- Email unique, validated format (5-254 chars)
|
||||
- Join date cannot be in future
|
||||
- Exit date must be after join date
|
||||
- Phone: `+?[0-9\- ]{6,20}`
|
||||
- Postal code: 5 digits
|
||||
|
||||
### CustomFieldValue System
|
||||
- One value per custom field per member. Value stored as a union type in JSONB: `{type: "string|integer|boolean|date|email", value: <actual_value>}`. Custom fields can be marked `required` and toggled `show_in_overview`.
|
||||
- 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): `update_search_vector` runs function `members_search_vector_trigger()`
|
||||
- **Trigger** on `custom_field_values` (INSERT/UPDATE/DELETE): `update_member_search_vector_on_custom_field_value_change` runs function `update_member_search_vector_from_custom_field_value()`
|
||||
- **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)
|
||||
|
||||
|
|
@ -138,46 +258,291 @@ Custom field values are automatically included in the search vector:
|
|||
|
||||
### Usage Example
|
||||
```sql
|
||||
SELECT * FROM members
|
||||
SELECT * FROM members
|
||||
WHERE search_vector @@ to_tsquery('simple', 'john & doe');
|
||||
```
|
||||
|
||||
## Fuzzy Search (Trigram-based)
|
||||
|
||||
- **Extension:** `pg_trgm`; GIN indexes with `gin_trgm_ops` on `first_name`, `last_name`, `email`, `city`, `street`, `notes`.
|
||||
- **Similarity threshold:** 0.2 (default, configurable) — balances precision/recall.
|
||||
- **Added:** November 2025 (PR #187, closes #162).
|
||||
### 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)
|
||||
|
||||
Fuzzy search combines several strategies (applied as an OR-chain alongside full-text and substring matching):
|
||||
### 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
|
||||
|
||||
1. Full-text search — primary filter via tsvector.
|
||||
2. Trigram similarity — `similarity(field, query) > threshold`.
|
||||
3. Word similarity — `word_similarity(query, field) > threshold`.
|
||||
4. Substring matching — `LIKE` / `ILIKE`.
|
||||
5. `%` operator — quick trigram-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
|
||||
|
||||
For the Elixir search action and per-strategy filter functions, see `lib/membership/member.ex` and [`custom-fields-search-performance.md`](./custom-fields-search-performance.md).
|
||||
### 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
|
||||
|
||||
Installed extensions are defined in `Mv.Repo.installed_extensions/0`:
|
||||
### Required PostgreSQL Extensions
|
||||
|
||||
| Extension | Purpose | Notes |
|
||||
|-----------|---------|-------|
|
||||
| `ash-functions` | Ash helper SQL functions | installed by Ash |
|
||||
| `citext` | Case-insensitive text | `users.email` |
|
||||
| `pg_trgm` | Trigram fuzzy search | added in `20251001141005_add_trigram_to_members.exs`; operators `%`, `similarity()`, `word_similarity()` |
|
||||
1. **uuid-ossp**
|
||||
- Purpose: UUID generation functions
|
||||
- Used for: `gen_random_uuid()`, `uuid_generate_v7()`
|
||||
|
||||
`gen_random_uuid()` is built into PostgreSQL; `uuid_generate_v7()` is a custom SQL function defined in a migration (not provided by an extension).
|
||||
2. **citext**
|
||||
- Purpose: Case-insensitive text type
|
||||
- Used for: `users.email` (case-insensitive email matching)
|
||||
|
||||
## Sensitive Data (GDPR / logging)
|
||||
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`
|
||||
|
||||
- **Never log:** `users.hashed_password` (bcrypt), token fields (`jti`, `purpose`, `extra_data`), OIDC/SMTP/vereinfacht secrets in `settings`.
|
||||
- **Personal data:** all member fields, user email, join-request applicant data.
|
||||
### 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-06-15
|
||||
**Schema Version:** 1.6 (12 tables)
|
||||
**Last Updated:** 2026-01-27
|
||||
**Schema Version:** 1.5
|
||||
**Database:** PostgreSQL 17.6 (dev) / 16 (prod)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@
|
|||
// - https://dbdocs.io
|
||||
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
|
||||
//
|
||||
// Version: 1.6
|
||||
// Last Updated: 2026-06-15
|
||||
// Hand-maintained (NOT auto-generated). 12 tables.
|
||||
// Version: 1.4
|
||||
// Last Updated: 2026-01-13
|
||||
|
||||
Project mila_membership_management {
|
||||
database_type: 'PostgreSQL'
|
||||
|
|
@ -26,16 +25,15 @@ Project mila_membership_management {
|
|||
- GDPR-compliant data management
|
||||
|
||||
## Domains:
|
||||
- **Accounts**: User authentication, sessions, OIDC strategy identities
|
||||
- **Membership**: Club member data, custom fields, groups, settings, public join requests
|
||||
- **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 (see Mv.Repo.installed_extensions/0):
|
||||
- ash-functions (Ash helper SQL functions)
|
||||
## Required PostgreSQL Extensions:
|
||||
- uuid-ossp (UUID generation)
|
||||
- citext (case-insensitive text)
|
||||
- pg_trgm (trigram-based fuzzy search)
|
||||
UUIDv7 ids use uuid_generate_v7(), a custom SQL function defined in a migration (not an extension).
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
@ -126,19 +124,17 @@ Table members {
|
|||
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']
|
||||
join_date date [null, note: 'Date when member joined club (cannot be in future)']
|
||||
exit_date date [null, note: 'Date when member left club (must be after join_date)']
|
||||
notes text [null, note: 'Additional notes about member']
|
||||
city text [null, note: 'City of residence']
|
||||
street text [null, note: 'Street name']
|
||||
house_number text [null, note: 'House number']
|
||||
postal_code text [null, note: '5-digit German postal code']
|
||||
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']
|
||||
vereinfacht_contact_id text [null, note: 'External contact id from the vereinfacht.de API (no FK; null if unlinked)']
|
||||
|
||||
|
||||
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)']
|
||||
|
|
@ -172,8 +168,7 @@ Table members {
|
|||
**Search Capabilities:**
|
||||
1. Full-Text Search (tsvector):
|
||||
- `search_vector` is auto-updated via trigger
|
||||
- Weighted fields (A/B/C/D map): see the "Weighted Fields" section of
|
||||
database-schema-readme.md (single source of truth, matches the search trigger)
|
||||
- Weighted fields: first_name (A), last_name (A), email (B), notes (B)
|
||||
- GIN index for fast text search
|
||||
|
||||
2. Fuzzy Search (pg_trgm):
|
||||
|
|
@ -191,9 +186,9 @@ Table members {
|
|||
**Validation Rules:**
|
||||
- first_name, last_name: optional, but if present min 1 character
|
||||
- email: 5-254 characters, valid email format (required)
|
||||
- join_date: cannot be in future
|
||||
- exit_date: must be after join_date (if both present)
|
||||
- postal_code: optional (no format validation)
|
||||
- country: optional
|
||||
- postal_code: exactly 5 digits (if present)
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +224,7 @@ Table custom_field_values {
|
|||
**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 values are deleted when the custom field is deleted (CASCADE)
|
||||
- Custom field cannot be deleted if custom field values exist (RESTRICT)
|
||||
|
||||
**Use Cases:**
|
||||
- Custom membership numbers
|
||||
|
|
@ -245,9 +240,8 @@ Table custom_fields {
|
|||
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']
|
||||
join_description text [null, note: 'Optional label shown for this field on the public join form (e.g., a GDPR confirmation text); supports inline external links. Falls back to name when null.']
|
||||
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']
|
||||
show_in_overview boolean [not null, default: true, note: 'If true, this custom field is displayed in the member overview table and can be sorted']
|
||||
|
||||
indexes {
|
||||
name [unique, name: 'custom_fields_unique_name_index']
|
||||
|
|
@ -264,9 +258,8 @@ Table custom_fields {
|
|||
- `slug`: URL-friendly, human-readable identifier (auto-generated, immutable)
|
||||
- `value_type`: Enforces data type consistency
|
||||
- `description`: Documentation for users/admins
|
||||
- `join_description`: Optional label shown for this field on the public join form (falls back to `name` when null)
|
||||
- `immutable`: Prevents changes after initial creation (e.g., membership numbers)
|
||||
- `required`: Enforces that all members must have this custom field
|
||||
- `show_in_overview`: When true, the field is shown in the member overview table and can be sorted
|
||||
|
||||
**Slug Generation:**
|
||||
- Automatically generated from `name` on creation
|
||||
|
|
@ -281,13 +274,13 @@ Table custom_fields {
|
|||
- `name` must be unique across all custom fields
|
||||
- `slug` must be unique across all custom fields
|
||||
- `slug` cannot be empty (validated on creation)
|
||||
- Deleting a custom field cascades: its custom_field_values are deleted too (ON DELETE CASCADE)
|
||||
|
||||
- Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT)
|
||||
|
||||
**Examples:**
|
||||
- Membership Number (string, required) → slug: "membership-number"
|
||||
- Emergency Contact (string, optional) → slug: "emergency-contact"
|
||||
- Certified Trainer (boolean, optional) → slug: "certified-trainer"
|
||||
- Certification Date (date, optional) → slug: "certification-date"
|
||||
- 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"
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
@ -405,8 +398,8 @@ 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 CASCADE: deleting the custom field deletes its custom_field_values
|
||||
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: cascade]
|
||||
// - 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
|
||||
|
|
@ -468,14 +461,25 @@ Enum membership_fee_status {
|
|||
TableGroup accounts_domain {
|
||||
users
|
||||
tokens
|
||||
|
||||
|
||||
Note: '''
|
||||
**Accounts Domain**
|
||||
|
||||
|
||||
Handles user authentication and session management using AshAuthentication.
|
||||
Supports multiple authentication strategies (Password, OIDC). OIDC linking
|
||||
is recorded on the users table via the oidc_id column (there is no separate
|
||||
user_identities table).
|
||||
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.
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
@ -545,32 +549,9 @@ Table roles {
|
|||
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 config for member fields in overview (JSONB map; absent key = visible)']
|
||||
member_field_required jsonb [null, note: 'Required-field config for member fields (JSONB map)']
|
||||
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: 'Logical reference to membership_fee_types (default fee type for new members) - app-enforced, NO DB foreign key']
|
||||
registration_enabled boolean [not null, default: true, note: 'Whether self-service user registration is enabled']
|
||||
oidc_only boolean [not null, default: false, note: 'If true, only OIDC login is offered (password login hidden)']
|
||||
oidc_client_id text [null, note: 'OIDC client id']
|
||||
oidc_client_secret text [null, note: 'OIDC client secret']
|
||||
oidc_base_url text [null, note: 'OIDC provider base URL (e.g., Rauthy)']
|
||||
oidc_redirect_uri text [null, note: 'OIDC redirect URI']
|
||||
oidc_admin_group_name text [null, note: 'Provider group name mapped to admin role on login']
|
||||
oidc_groups_claim text [null, note: 'JWT claim carrying the user groups for role sync']
|
||||
smtp_host text [null, note: 'Outbound SMTP host']
|
||||
smtp_port bigint [null, note: 'Outbound SMTP port']
|
||||
smtp_username text [null, note: 'SMTP auth username']
|
||||
smtp_password text [null, note: 'SMTP auth password (secret)']
|
||||
smtp_ssl text [null, note: 'SMTP TLS/SSL mode']
|
||||
smtp_from_name text [null, note: 'Display name for the From header (mail_from)']
|
||||
smtp_from_email text [null, note: 'Email address for the From header (mail_from)']
|
||||
vereinfacht_api_url text [null, note: 'vereinfacht.de API base URL']
|
||||
vereinfacht_api_key text [null, note: 'vereinfacht.de API key (secret)']
|
||||
vereinfacht_club_id text [null, note: 'vereinfacht.de club identifier']
|
||||
vereinfacht_app_url text [null, note: 'vereinfacht.de app URL (for links)']
|
||||
join_form_enabled boolean [not null, default: false, note: 'Whether the public join form is enabled']
|
||||
join_form_field_ids text[] [null, note: 'Ordered custom_field ids shown on the public join form']
|
||||
join_form_field_required jsonb [null, note: 'Per-field required config for the join form (JSONB map)']
|
||||
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)']
|
||||
|
||||
|
|
@ -608,123 +589,19 @@ Table settings {
|
|||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MEMBERSHIP DOMAIN — Groups
|
||||
// ============================================
|
||||
|
||||
Table groups {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
|
||||
name text [not null, note: 'Group name (unique case-insensitively via LOWER(name))']
|
||||
slug text [not null, unique, note: 'URL-friendly, immutable identifier auto-generated from name (shared GenerateSlug change)']
|
||||
description text [null, note: 'Optional description']
|
||||
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 {
|
||||
slug [unique, name: 'groups_unique_slug_index', note: 'Case-sensitive unique slug']
|
||||
name [unique, name: 'groups_unique_name_lower_index', note: 'UNIQUE on LOWER(name) - case-insensitive name uniqueness']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Member Groups**
|
||||
|
||||
Flat groupings of members (no hierarchy in current schema). Many-to-many
|
||||
with members via member_groups. `slug` is generated by the shared
|
||||
Mv.Membership.Changes.GenerateSlug change (same as custom_fields) and is
|
||||
used for URL routing (/groups/:slug). Group names feed the member
|
||||
search_vector at weight B (see member_groups note).
|
||||
|
||||
**Future extension path (not yet in schema):**
|
||||
- parent_group_id (self-referential, nullable) + circular-ref guard + path calc for hierarchy
|
||||
- member_group_roles table linking MemberGroup to a Role (position within a group)
|
||||
'''
|
||||
}
|
||||
|
||||
Table member_groups {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
|
||||
member_id uuid [not null, note: 'FK to members']
|
||||
group_id uuid [not null, note: 'FK to groups']
|
||||
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 {
|
||||
(member_id, group_id) [unique, name: 'member_groups_unique_member_group_index', note: 'One association per member per group']
|
||||
member_id [name: 'member_groups_member_id_index']
|
||||
group_id [name: 'member_groups_group_id_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Member ↔ Group Join Table**
|
||||
|
||||
CASCADE delete on BOTH foreign keys (the cascade lives only on the join
|
||||
table; members and groups themselves are never deleted by association
|
||||
removal). INSERT/UPDATE/DELETE here fires the trigger that refreshes the
|
||||
affected member's search_vector so group names (weight B) stay current.
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MEMBERSHIP DOMAIN — Public Join Requests
|
||||
// ============================================
|
||||
|
||||
Table join_requests {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
|
||||
status text [not null, default: 'pending_confirmation', note: 'pending_confirmation → submitted (after email confirm) → approved/rejected']
|
||||
email text [not null, note: 'Applicant email']
|
||||
first_name text [null, note: 'Applicant first name']
|
||||
last_name text [null, note: 'Applicant last name']
|
||||
form_data jsonb [null, note: 'Submitted join-form field values (custom fields)']
|
||||
schema_version integer [null, note: 'Version of the join-form schema used at submission time']
|
||||
confirmation_token_hash text [null, note: 'Hash of the double-opt-in token (raw token never stored)']
|
||||
confirmation_token_expires_at timestamp [null, note: 'Token expiry (UTC)']
|
||||
confirmation_sent_at timestamp [null, note: 'When the confirmation email was sent (UTC)']
|
||||
submitted_at timestamp [null, note: 'When email was confirmed and request submitted (UTC)']
|
||||
approved_at timestamp [null, note: 'When an admin approved (UTC)']
|
||||
rejected_at timestamp [null, note: 'When an admin rejected (UTC)']
|
||||
reviewed_by_user_id uuid [null, note: 'User who reviewed (no FK constraint)']
|
||||
reviewed_by_display text [null, note: 'Reviewer display string, denormalized so the UI need not load the User']
|
||||
source text [null, note: 'Origin of the request (e.g., public form)']
|
||||
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 {
|
||||
confirmation_token_hash [unique, name: 'join_requests_confirmation_token_hash_unique', note: 'Partial unique WHERE confirmation_token_hash IS NOT NULL']
|
||||
email [name: 'join_requests_email_index']
|
||||
status [name: 'join_requests_status_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Public Join Flow (Onboarding, Double Opt-In)**
|
||||
|
||||
Stores public join-form submissions. Double opt-in: the confirmation token
|
||||
is stored as a hash only; unconfirmed records have a ~24h retention and are
|
||||
removed by a scheduled cleanup job. `reviewed_by_user_id` is intentionally
|
||||
unconstrained (no FK); `reviewed_by_display` is denormalized so showing the
|
||||
reviewer does not require loading the User.
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RELATIONSHIPS (Additional)
|
||||
// ============================================
|
||||
|
||||
// MemberGroup → Member (N:1)
|
||||
// - ON DELETE CASCADE (join table only): association removed, member preserved
|
||||
Ref: member_groups.member_id > members.id [delete: cascade]
|
||||
|
||||
// MemberGroup → Group (N:1)
|
||||
// - ON DELETE CASCADE (join table only): association removed, group preserved
|
||||
Ref: member_groups.group_id > groups.id [delete: cascade]
|
||||
|
||||
// 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) — LOGICAL relationship only
|
||||
// - No DB foreign key (cross-domain dependency is deliberately avoided);
|
||||
// referential integrity is enforced in the app (Mv.Membership.Setting)
|
||||
Ref: settings.default_membership_fee_type_id > membership_fee_types.id
|
||||
// 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)
|
||||
|
|
@ -746,16 +623,12 @@ TableGroup membership_domain {
|
|||
custom_field_values
|
||||
custom_fields
|
||||
settings
|
||||
groups
|
||||
member_groups
|
||||
join_requests
|
||||
|
||||
|
||||
Note: '''
|
||||
**Membership Domain**
|
||||
|
||||
|
||||
Core business logic for club membership management.
|
||||
Supports flexible, extensible member data model.
|
||||
Includes member groups (many-to-many), global application settings
|
||||
(singleton), and the public join-request flow.
|
||||
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,38 +1,62 @@
|
|||
# Email Validation Strategy
|
||||
|
||||
We use `EctoCommons.EmailValidator` with **both** `:html_input` and `:pow`
|
||||
checks, defined centrally in `Mv.Constants.email_validator_checks/0`
|
||||
(`@email_validator_checks [:html_input, :pow]`).
|
||||
We use `EctoCommons.EmailValidator` with both `:html_input` and `:pow` checks, defined centrally in `Mv.Constants.email_validator_checks/0`.
|
||||
|
||||
## Why both checks
|
||||
## Checks Used
|
||||
|
||||
- `:html_input` — pragmatic validation matching browser `<input type="email">`
|
||||
behavior; accepts the common formats users expect from web forms.
|
||||
- `:pow` — stricter, spec-following validation (RFC 5322 and related);
|
||||
supports international (Unicode) email addresses.
|
||||
- `:html_input` - Pragmatic validation matching browser `<input type="email">` behavior
|
||||
- `:pow` - Stricter validation following email spec, supports internationalization (Unicode)
|
||||
|
||||
Using both balances user experience (accepting common formats) against
|
||||
technical correctness (validating against email standards) and international
|
||||
support.
|
||||
## 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 applied consistently at every validation point, all reading the
|
||||
single central constant so they stay in sync:
|
||||
The checks are used consistently across all email validation points:
|
||||
|
||||
- `Mv.Membership.Import.MemberCSV.validate_row/3` — CSV import (schemaless
|
||||
changeset: trims whitespace, requires email, then validates format via the
|
||||
shared checks).
|
||||
- `Mv.Membership.Member` validations — Member resource.
|
||||
- `Mv.Accounts.User` validations — User resource.
|
||||
- `Mv.Membership.Import.MemberCSV.validate_row/3` - CSV import validation
|
||||
- `Mv.Membership.Member` validations - Member resource validation
|
||||
- `Mv.Accounts.User` validations - User resource validation
|
||||
|
||||
Member and User use similar schemaless changesets inside their Ash validations.
|
||||
All three locations use `Mv.Constants.email_validator_checks()` to ensure consistency.
|
||||
|
||||
## Changing the validation strategy
|
||||
## Implementation Details
|
||||
|
||||
Update `@email_validator_checks` in `Mv.Constants`; the change applies
|
||||
everywhere automatically.
|
||||
### 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
|
||||
|
||||
**Migration caveat:** tightening validation may invalidate existing data.
|
||||
Consider whether stored emails are still valid, a migration strategy for those
|
||||
that are not, and user communication.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
# Feature Roadmap & Implementation Plan
|
||||
|
||||
**Project:** Mila - Membership Management System
|
||||
**Last Updated:** 2026-03-03
|
||||
**Last Updated:** 2026-01-27
|
||||
**Status:** Active Development
|
||||
|
||||
---
|
||||
|
||||
This is the living per-area roadmap: shipped state (coarse — see `development-progress-log.md` for detail), open issues, and the missing-features backlog. For the actual, current endpoints see `lib/mv_web/router.ex` and `docs/page-permission-route-coverage.md`.
|
||||
## 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)
|
||||
|
||||
---
|
||||
|
||||
## Feature Area Breakdown
|
||||
## Phase 1: Feature Area Breakdown
|
||||
|
||||
### Feature Areas
|
||||
|
||||
|
|
@ -31,10 +36,10 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
|
||||
**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)
|
||||
**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)
|
||||
|
||||
**Current State:**
|
||||
- ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345)
|
||||
|
|
@ -44,11 +49,6 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
- ✅ **Page-level authorization** - LiveView page access control
|
||||
- ✅ **System role protection** - Critical roles cannot be deleted
|
||||
|
||||
**Implemented: OIDC-only mode:**
|
||||
- ✅ Admin Settings: when OIDC-only is enabled, the "Allow direct registration" toggle is disabled with a hint.
|
||||
- ✅ Backend rejects password sign-in and `register_with_password` when OIDC-only is active.
|
||||
- ✅ GET `/sign-in` redirects to OIDC when OIDC-only and OIDC are configured (`MvWeb.Plugs.OidcOnlySignInRedirect`). The `oidc_only` setting and ENV are read via `Mv.Config.oidc_only?/0`.
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Password reset flow
|
||||
- ❌ Email verification
|
||||
|
|
@ -178,11 +178,6 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
- ✅ Navbar with profile button
|
||||
- ✅ Member list as landing page
|
||||
- ✅ Breadcrumbs (basic)
|
||||
- ✅ **Flash: auto-dismiss and consistency** (Design Guidelines §9)
|
||||
- Auto-dismiss implemented via the `FlashAutoDismiss` JS hook (`assets/js/app.js`) driven by the `data-auto-clear-ms` and `data-clear-flash-key` attributes on the flash component (`MvWeb.CoreComponents.flash/1`); the per-flash delay is set through the component's `auto_clear_ms` attribute, and the dismiss button is kept for accessibility.
|
||||
- On timeout the hook pushes LiveView's built-in `lv:clear-flash` event (no custom `handle_event`) and hides the element.
|
||||
- All flashes (including “Email copied”) use the same variants (info, success, warning, error); no special tone. See `DESIGN_GUIDELINES.md` §9.
|
||||
- ❌ Per-kind default durations (info/success 4–6s, warning 6–8s, error 8–12s) are not built in — the delay is a single explicit `auto_clear_ms` value per flash, not a kind-based default.
|
||||
|
||||
**Open Issues:**
|
||||
- [#188](https://git.local-it.org/local-it/mitgliederverwaltung/issues/188) - Check if searching just on typing is accessible (S, Low priority)
|
||||
|
|
@ -247,7 +242,7 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
- ❌ 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
|
||||
- ❌ vereinfacht.digital API integration
|
||||
- ❌ SEPA direct debit support
|
||||
- ❌ Payment reports
|
||||
|
||||
|
|
@ -270,9 +265,6 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
**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
|
||||
|
|
@ -290,7 +282,6 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
- ✅ 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:**
|
||||
|
|
@ -303,12 +294,7 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
#### 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). Backed by `Mv.Statistics` (read-only Ash reads on `Member` + `MembershipFeeCycle`, no new resources); displayed in `MvWeb.StatisticsLive`. Permission: read_only, normal_user, admin (own_data denied).
|
||||
|
||||
**MVP design decisions:**
|
||||
- Charts are HTML/CSS + SVG only — no Contex, no Chart.js (deliberate).
|
||||
- Open amount = total unpaid only; no overdue vs. not-yet-due split in the MVP.
|
||||
- Out of scope (deferred follow-ups): export (CSV/PDF), caching, month/quarter filters, "members per fee type" / "members per group" stats, overdue split, new tables/resources.
|
||||
- ✅ **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
|
||||
|
|
@ -380,7 +366,6 @@ This is the living per-area roadmap: shipped state (coarse — see `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:**
|
||||
|
|
@ -487,13 +472,367 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
---
|
||||
|
||||
## Phase 2: API Endpoint Definition
|
||||
|
||||
### Endpoint Types
|
||||
|
||||
Since this is a **Phoenix LiveView** application with **Ash Framework**, we have three types of endpoints:
|
||||
|
||||
1. **LiveView Endpoints** - Mount points and event handlers
|
||||
2. **HTTP Controller Endpoints** - Traditional REST-style endpoints
|
||||
3. **Ash Resource Actions** - Backend data layer API
|
||||
|
||||
### Authentication Requirements Legend
|
||||
|
||||
- 🔓 **Public** - No authentication required
|
||||
- 🔐 **Authenticated** - Requires valid user session
|
||||
- 👤 **User Role** - Requires specific user role
|
||||
- 🛡️ **Admin Only** - Requires admin privileges
|
||||
|
||||
---
|
||||
|
||||
### 1. Authentication & Authorization Endpoints
|
||||
|
||||
#### HTTP Controller Endpoints
|
||||
|
||||
| Method | Route | Purpose | Auth | Request | Response |
|
||||
|--------|-------|---------|------|---------|----------|
|
||||
| `GET` | `/auth/user/password/sign_in` | Show password login form | 🔓 | - | HTML form |
|
||||
| `POST` | `/auth/user/password/sign_in` | Submit password login | 🔓 | `{email, password}` | Redirect + session cookie |
|
||||
| `GET` | `/auth/user/rauthy` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy |
|
||||
| `GET` | `/auth/user/rauthy/callback` | Handle OIDC callback | 🔓 | `{code, state}` | Redirect + session cookie |
|
||||
| `POST` | `/auth/user/sign_out` | Sign out user | 🔐 | - | Redirect to login |
|
||||
| `GET` | `/auth/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_rauthy` | OIDC authentication | 🔓 | `{oidc_id, email, user_info}` | `{:ok, user}` or `{:error, reason}` |
|
||||
| `User` | `:register_with_password` | Create user with password | 🔓 | `{email, password}` | `{:ok, user}` |
|
||||
| `User` | `:register_with_rauthy` | Create user via OIDC | 🔓 | `{oidc_id, email}` | `{:ok, user}` |
|
||||
| `User` | `:request_password_reset` | Generate reset token | 🔓 | `{email}` | `{:ok, token}` |
|
||||
| `User` | `:reset_password` | Reset password with token | 🔓 | `{token, password}` | `{:ok, user}` |
|
||||
| `Token` | `:revoke` | Revoke authentication token | 🔐 | `{jti}` | `{:ok, token}` |
|
||||
|
||||
#### **NEW: Role & Permission Actions** (Issue #191, #190, #151)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Role` | `:create` | Create new role | 🛡️ | `{name, description, permissions}` | `{:ok, role}` |
|
||||
| `Role` | `:list` | List all roles | 🔐 | - | `[%Role{}]` |
|
||||
| `Role` | `:update` | Update role | 🛡️ | `{id, name, permissions}` | `{:ok, role}` |
|
||||
| `Role` | `:delete` | Delete role | 🛡️ | `{id}` | `{:ok, role}` |
|
||||
| `User` | `:assign_role` | Assign role to user | 🛡️ | `{user_id, role_id}` | `{:ok, user}` |
|
||||
| `User` | `:remove_role` | Remove role from user | 🛡️ | `{user_id, role_id}` | `{:ok, user}` |
|
||||
| `Permission` | `:list` | List all permissions | 🔐 | - | `[%Permission{}]` |
|
||||
| `Permission` | `:check` | Check user permission | 🔐 | `{user_id, resource, action}` | `{:ok, boolean}` |
|
||||
|
||||
---
|
||||
|
||||
### 2. Member Management Endpoints
|
||||
|
||||
#### LiveView Endpoints
|
||||
|
||||
| Mount | Purpose | Auth | Query Params | Events |
|
||||
|-------|---------|------|--------------|--------|
|
||||
| `/members` | Member list with search/sort | 🔐 | `?search=&sort_by=&sort_dir=` | `search`, `sort`, `delete`, `select` |
|
||||
| `/members/new` | Create new member form | 🔐 | - | `save`, `cancel`, `add_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}` |
|
||||
|
||||
---
|
||||
|
||||
For the real, current routes and their authorization, see `lib/mv_web/router.ex` and `docs/page-permission-route-coverage.md` (the per-permission-set route matrix). The Ash resource actions are defined on each resource module under `lib/`. An earlier speculative API catalog for not-yet-existing resources (Payment, Invoice, Report, Notification, AuditLog, Organization) was removed — those are tracked above as missing features per area, not as endpoint specs.
|
||||
|
||||
---
|
||||
|
||||
**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,23 +1,67 @@
|
|||
# Membership Fees - Technical Architecture
|
||||
|
||||
**Feature:** Membership Fee Management — **Status:** Implemented
|
||||
|
||||
Architectural decisions, patterns, module structure, and integration points (no concrete implementation details).
|
||||
|
||||
**Related:** [membership-fee-overview.md](./membership-fee-overview.md) (business logic, worked examples, UI mockups), [database-schema-readme.md](./database-schema-readme.md), [database_schema.dbml](./database_schema.dbml).
|
||||
**Project:** Mila - Membership Management System
|
||||
**Feature:** Membership Fee Management
|
||||
**Version:** 1.0
|
||||
**Last Updated:** 2026-01-13
|
||||
**Status:** ✅ Implemented
|
||||
|
||||
---
|
||||
|
||||
## Core Design Decisions
|
||||
## Purpose
|
||||
|
||||
1. **No redundant fields:**
|
||||
- No `cycle_end` field — calculated from `cycle_start` + `interval`.
|
||||
- No `interval_type` field — read from `membership_fee_type.interval`.
|
||||
- Eliminates data inconsistencies.
|
||||
2. **Interval immutability:** `membership_fee_type.interval` cannot be changed after creation (enforced via an Ash validation in `Mv.MembershipFees.MembershipFeeType`, and the attribute is omitted from the update action's `accept` list). Prevents complex migration scenarios.
|
||||
3. **Historical accuracy:** `amount` stored per cycle for audit trail — old cycles retain their original amount, so membership-fee changes over time stay traceable.
|
||||
4. **Calendar-based cycles:** all cycles aligned to calendar boundaries; simplifies date math and makes generation predictable.
|
||||
5. **Single responsibility:** cycle generation, status management, and calendar logic live in separate modules.
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -25,20 +69,25 @@ Architectural decisions, patterns, module structure, and integration points (no
|
|||
|
||||
### Ash Domain: `Mv.MembershipFees`
|
||||
|
||||
Encapsulates all membership-fee resources and logic.
|
||||
**Purpose:** Encapsulates all membership fee-related resources and logic
|
||||
|
||||
**Resources:**
|
||||
|
||||
- `MembershipFeeType` — membership fee type definitions (admin-managed).
|
||||
- `MembershipFeeCycle` — individual membership fee cycles per member.
|
||||
- `MembershipFeeType` - Membership fee type definitions (admin-managed)
|
||||
- `MembershipFeeCycle` - Individual membership fee cycles per member
|
||||
|
||||
**Public API** (code interface): `create/list/update/destroy_membership_fee_type`, `create/list/update/destroy_membership_fee_cycle`.
|
||||
**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:** LiveViews use direct `Ash.read/create/update/destroy` with `domain: Mv.MembershipFees` instead of the code interface — acceptable for LiveView forms using `AshPhoenix.Form`.
|
||||
**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`.
|
||||
|
||||
The Member resource is extended with membership fee fields.
|
||||
**Extensions:**
|
||||
|
||||
### Module Map
|
||||
- Member resource extended with membership fee fields
|
||||
|
||||
### Module Organization
|
||||
|
||||
```
|
||||
lib/
|
||||
|
|
@ -47,159 +96,636 @@ lib/
|
|||
│ ├── membership_fee_type.ex # MembershipFeeType resource
|
||||
│ ├── membership_fee_cycle.ex # MembershipFeeCycle resource
|
||||
│ └── changes/
|
||||
│ ├── set_membership_fee_start_date.ex # Auto-sets start date
|
||||
│ └── validate_same_interval.ex # Validates interval match on type change
|
||||
│ ├── 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
|
||||
│ ├── cycle_generation_job.ex # Scheduled cycle generation job
|
||||
│ └── calendar_cycles.ex # Calendar cycle calculations
|
||||
│ ├── cycle_generator.ex # Cycle generation algorithm
|
||||
│ └── calendar_cycles.ex # Calendar cycle calculations
|
||||
└── membership/
|
||||
└── member.ex # Extended with membership fee relationships
|
||||
└── member.ex # Extended with membership fee relationships
|
||||
```
|
||||
|
||||
### Separation of Concerns
|
||||
|
||||
- **Domain layer (Ash resources):** data validation, relationships, policy enforcement, action definitions.
|
||||
- **Business logic (`Mv.MembershipFees`):** cycle generation, calendar calculations, date boundaries, status transitions.
|
||||
- **UI layer (LiveView):** interaction, display, authorization checks, form handling.
|
||||
**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
|
||||
|
||||
See [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema.
|
||||
### 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`** — fee types with fixed `interval` (immutable after creation). has_many members, has_many membership_fee_cycles.
|
||||
2. **`membership_fee_cycles`** — per-member cycles. NO `cycle_end`/`interval_type` (calculated). belongs_to member, belongs_to membership_fee_type. Composite uniqueness: one cycle per member per `cycle_start`.
|
||||
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
|
||||
|
||||
- `membership_fee_type_id` (FK, nullable — default applied from settings at the app level)
|
||||
**Fields Added:**
|
||||
|
||||
- `membership_fee_type_id` (FK, NOT NULL with default from settings)
|
||||
- `membership_fee_start_date` (Date, nullable)
|
||||
|
||||
**Existing fields used:** `join_date` (computes membership fee start), `exit_date` (limits cycle generation). These must remain Member fields and should **not** be replaced by custom fields in the future.
|
||||
**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). Read during cycle generation and member creation; written only via admin UI. Validation: default fee type must exist.
|
||||
**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 cycles when member deleted |
|
||||
| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent fee type deletion if cycles exist |
|
||||
| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent fee type deletion if assigned to members |
|
||||
| `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 — `Mv.MembershipFees.CycleGenerator`
|
||||
### Cycle Generation System
|
||||
|
||||
Calculates which cycles should exist for a member, generates the missing ones (idempotent — skips existing), respects `membership_fee_start_date` and `exit_date` boundaries, and uses **PostgreSQL advisory locks per member** to prevent race conditions.
|
||||
**Component:** `Mv.MembershipFees.CycleGenerator`
|
||||
|
||||
**Triggers:** fee type assigned (Ash change); member created with fee type (Ash change); scheduled job (daily/weekly cron); admin manual regeneration (UI).
|
||||
**Responsibilities:**
|
||||
|
||||
**Algorithm:**
|
||||
- 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
|
||||
|
||||
1. Retrieve member with fee type and dates.
|
||||
**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:
|
||||
- No cycles exist → start from `membership_fee_start_date` (or calculated from `join_date`).
|
||||
- Cycles exist → start from the cycle AFTER the last existing one.
|
||||
3. Generate all cycle starts from that point to today (or `exit_date`).
|
||||
4. Create new cycles with the current fee type's amount.
|
||||
- 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 cases:**
|
||||
**Edge Case Handling:**
|
||||
|
||||
- `membership_fee_start_date` NULL → calculate from `join_date` + global setting.
|
||||
- `exit_date` set → stop generation at `exit_date`.
|
||||
- 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.
|
||||
- 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 Cycles — `Mv.MembershipFees.CalendarCycles`
|
||||
### Calendar Cycle Calculations
|
||||
|
||||
Calculates cycle boundaries by interval, the current cycle, the last completed cycle, and `cycle_end` from `cycle_start` + interval.
|
||||
**Component:** `Mv.MembershipFees.CalendarCycles`
|
||||
|
||||
**Functions (high-level):** `calculate_cycle_start/2,3`, `calculate_cycle_end/2`, `next_cycle_start/2`, `current_cycle?/2,3`, `last_completed_cycle?/2,3`.
|
||||
**Responsibilities:**
|
||||
|
||||
**Interval logic:**
|
||||
- Calculate cycle boundaries based on interval type
|
||||
- Determine current cycle
|
||||
- Determine last completed cycle
|
||||
- Calculate cycle_end from cycle_start + interval
|
||||
|
||||
- **Monthly:** 1st of month → last day of month.
|
||||
- **Quarterly:** 1st of quarter (Jan/Apr/Jul/Oct) → last day of quarter.
|
||||
- **Half-yearly:** 1st of half (Jan/Jul) → last day of half.
|
||||
- **Yearly:** Jan 1 → Dec 31.
|
||||
**Functions (high-level):**
|
||||
|
||||
### Status Management — Ash actions on `MembershipFeeCycle`
|
||||
- `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
|
||||
|
||||
Simple state machine unpaid ↔ paid ↔ suspended; all transitions allowed; permissions checked via Ash policies. Actions: `mark_as_paid`, `mark_as_suspended`, `mark_as_unpaid` (error correction). `bulk_mark_as_paid` is low priority / future.
|
||||
**Interval Logic:**
|
||||
|
||||
### Membership Fee Type Change — Ash change on `Member.membership_fee_type_id`
|
||||
- **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
|
||||
|
||||
**Validation:** new type must have the same interval as the old type; different interval is rejected (MVP constraint).
|
||||
### Status Management
|
||||
|
||||
**Side effects on allowed change:** keep all existing cycles; find future unpaid cycles, delete them, regenerate with the new `membership_fee_type_id` and amount.
|
||||
**Component:** Ash actions on `MembershipFeeCycle`
|
||||
|
||||
**Implementation pattern:**
|
||||
**Status Transitions:**
|
||||
|
||||
- Ash change module validates; `after_action` hook triggers regeneration synchronously.
|
||||
- **Regeneration runs in the same transaction as the member update** to ensure atomicity. CycleGenerator uses advisory locks and transactions internally to prevent races.
|
||||
- Simple state machine: unpaid ↔ paid ↔ suspended
|
||||
- No complex validation (all transitions allowed)
|
||||
- Permissions checked via Ash policies
|
||||
|
||||
**Validation behavior:**
|
||||
**Actions Required:**
|
||||
|
||||
- **Fail-closed:** if fee types cannot be loaded during validation, the change is rejected with a validation error.
|
||||
- **Nil prevention:** setting `membership_fee_type_id` to nil is rejected when a current type exists.
|
||||
- `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
|
||||
### Member Resource Integration
|
||||
|
||||
Extension points: fields via migration; relationships (belongs_to, has_many); calculations (current_cycle_status, overdue_count); changes (auto-set `membership_fee_start_date`, validate interval). Backward compatible — new fields nullable/defaulted; existing members get the default fee type from settings.
|
||||
**Extension Points:**
|
||||
|
||||
### Settings System
|
||||
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)
|
||||
|
||||
Store two global settings, admin UI to modify, default values if unset, validation (default fee type must exist).
|
||||
**Backward Compatibility:**
|
||||
|
||||
### Permission System — Implemented
|
||||
- New fields nullable or with defaults
|
||||
- Existing members get default membership fee type from settings
|
||||
- No breaking changes to existing member functionality
|
||||
|
||||
See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full matrix and policy patterns.
|
||||
### Settings System Integration
|
||||
|
||||
**PermissionSets (`lib/mv/authorization/permission_sets.ex`):**
|
||||
**Requirements:**
|
||||
|
||||
- **MembershipFeeType:** all sets read (:all); only admin has create/update/destroy (:all).
|
||||
- **MembershipFeeCycle:** all 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 with 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 the system actor.
|
||||
- Store two global settings
|
||||
- Provide UI for admin to modify
|
||||
- Default values if not set
|
||||
- Validation (e.g., default membership fee type must exist)
|
||||
|
||||
**Resource policies:**
|
||||
**Access Pattern:**
|
||||
|
||||
- `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.
|
||||
- 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:** MembershipFeeType index/form (admin); MembershipFeeCycle table component in the member detail view — implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent` (displays all cycles with status management, amount editing, and manual regeneration for normal_user and admin); Settings form section (admin); member-list status column.
|
||||
**New LiveViews Required:**
|
||||
|
||||
**Extended:** member detail view (membership fees section), member list view (status column), settings page (membership fees section). Use the existing `can?/3` helper for UI conditionals.
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
## Performance Notes
|
||||
## Acceptance Criteria
|
||||
|
||||
**Indexes:** `membership_fee_cycles` on `member_id`, `membership_fee_type_id`, `status`, `cycle_start`, composite unique `(member_id, cycle_start)`; `members(membership_fee_type_id)`.
|
||||
### MembershipFeeType Resource
|
||||
|
||||
**Query:** preload fee type with cycles to avoid N+1; `cycle_end` and `current_cycle_status` are Ash calculations (lazy, not stored); paginate cycle lists > 50.
|
||||
**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
|
||||
|
||||
**No caching in MVP** (fee types rarely change, queries fast). Scheduled generation job: run daily/weekly, batch members, skip unchanged, log failures for retry.
|
||||
### 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:** cycle-overlap logic, prorata, more validation, migration path for existing cycles.
|
||||
- **Phase 3 — Payment details:** `PaymentTransaction` resource linked to cycles, multiple payments per cycle, reconciliation.
|
||||
- **Phase 4 — vereinfacht.digital integration:** external API client, webhook handling, automatic matching, manual review.
|
||||
### 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,20 +1,50 @@
|
|||
# Membership Fees - Overview
|
||||
|
||||
**Feature:** Membership Fee Management — **Status:** Implemented
|
||||
**Project:** Mila - Membership Management System
|
||||
**Feature:** Membership Fee Management
|
||||
**Version:** 1.0
|
||||
**Last Updated:** 2026-01-13
|
||||
**Status:** ✅ Implemented
|
||||
|
||||
Coarse, business-oriented entry point for the Membership Fees system: terminology, worked examples, and UI/UX. For architecture (data model, FK behaviors, module map, generation algorithm, policies) see [membership-fee-architecture.md](./membership-fee-architecture.md).
|
||||
---
|
||||
|
||||
## 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).
|
||||
**Maximum Simplicity:**
|
||||
|
||||
- Minimal complexity
|
||||
- Clear data model without redundancies
|
||||
- Intuitive operation
|
||||
- Calendar cycle-based (Month/Quarter/Half-Year/Year)
|
||||
|
||||
---
|
||||
|
||||
## Terminology (German ↔ English)
|
||||
## Terminology
|
||||
|
||||
**Core entities:**
|
||||
### German ↔ English
|
||||
|
||||
**Core Entities:**
|
||||
|
||||
- Beitragsart ↔ Membership Fee Type
|
||||
- Beitragszyklus ↔ Membership Fee Cycle
|
||||
|
|
@ -26,14 +56,14 @@ Maximum simplicity: minimal complexity, clear data model without redundancies, i
|
|||
- unbezahlt ↔ unpaid
|
||||
- ausgesetzt ↔ suspended / waived
|
||||
|
||||
**Intervals (Frequenz / payment frequency):**
|
||||
**Intervals (Frequenz / Payment Frequency):**
|
||||
|
||||
- monatlich ↔ monthly
|
||||
- quartalsweise ↔ quarterly
|
||||
- halbjährlich ↔ half-yearly / semi-annually
|
||||
- jährlich ↔ yearly / annually
|
||||
|
||||
**UI elements:**
|
||||
**UI Elements:**
|
||||
|
||||
- "Letzter Zyklus" ↔ "Last Cycle" (e.g., 2023 when in 2024)
|
||||
- "Aktueller Zyklus" ↔ "Current Cycle" (e.g., 2024)
|
||||
|
|
@ -42,39 +72,112 @@ Maximum simplicity: minimal complexity, clear data model without redundancies, i
|
|||
|
||||
---
|
||||
|
||||
## Data Model (summary)
|
||||
## Data Model
|
||||
|
||||
Three entities — full schema, FK on-delete behaviors, and design rationale are in [membership-fee-architecture.md](./membership-fee-architecture.md).
|
||||
### Membership Fee Type (MembershipFeeType)
|
||||
|
||||
- **MembershipFeeType:** name, amount (€), `interval` (:monthly/:quarterly/:half_yearly/:yearly), optional description. `interval` is **IMMUTABLE** after creation; admin can change only name/amount/description; on amount change, future unpaid cycles regenerate with the new amount.
|
||||
- **MembershipFeeCycle:** member_id, membership_fee_type_id, `cycle_start` (calendar start: 01.01., 01.04., 01.07., 01.10., …), status (:unpaid default / :paid / :suspended), `amount` (captured at generation time → history when type changes), optional notes. NO `cycle_end` (derived from `cycle_start` + interval), NO `interval_type` (read from the fee type).
|
||||
- **Member extensions:** `membership_fee_type_id` (FK, nullable — default applied from settings at the app level), `membership_fee_start_date` (Date, nullable), plus the existing `exit_date`.
|
||||
```
|
||||
- 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)
|
||||
```
|
||||
|
||||
**Calendar cycle logic:** Monthly 01.01.–31.01., 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.
|
||||
**Important:**
|
||||
|
||||
### `membership_fee_start_date` derivation
|
||||
- `interval` is **IMMUTABLE** after creation!
|
||||
- Admin can only change `name`, `amount`, `description`
|
||||
- On change: Future unpaid cycles regenerated with new amount
|
||||
|
||||
Auto-set from global setting `include_joining_cycle`:
|
||||
### Membership Fee Cycle (MembershipFeeCycle)
|
||||
|
||||
- `include_joining_cycle = true` → first day of the joining month/quarter/year (member pays from the joining cycle).
|
||||
- `include_joining_cycle = false` → first day of the NEXT cycle after joining.
|
||||
```
|
||||
- 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
|
||||
```
|
||||
|
||||
Can be manually overridden by admin. There is intentionally **no** `include_joining_cycle` field on Member — `membership_fee_start_date` makes it unnecessary.
|
||||
**Important:**
|
||||
|
||||
### Global settings
|
||||
- **NO** `cycle_end` - calculated from `cycle_start` + `interval`
|
||||
- **NO** `interval_type` - read from `membership_fee_type.interval`
|
||||
- Avoids redundancy and inconsistencies!
|
||||
|
||||
- `membership_fees.include_joining_cycle` — Boolean (default `true`): whether the joining cycle is billed.
|
||||
- `membership_fees.default_membership_fee_type_id` — UUID (required): fee type auto-assigned to every new member; must be configured in admin settings (prevents members without a fee type).
|
||||
**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
|
||||
### Cycle Generation
|
||||
|
||||
**Triggers:** fee type assigned (incl. at member creation), new cycle begins (cron daily/weekly), admin manual regeneration. Uses PostgreSQL advisory locks per member.
|
||||
**Triggers:**
|
||||
|
||||
**Algorithm:** start from `membership_fee_start_date` if no cycles exist, else from the cycle AFTER the last existing one; generate to today (or `exit_date`); set each cycle's `amount` from the current fee type. **Deleted cycles (gaps) are NOT recreated** — generation always continues after the last existing cycle. (Full algorithm in architecture doc.)
|
||||
- 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):**
|
||||
|
||||
|
|
@ -104,31 +207,93 @@ Generated cycles:
|
|||
- ...
|
||||
```
|
||||
|
||||
### Status transitions
|
||||
### Status Transitions
|
||||
|
||||
unpaid → paid · unpaid → suspended · paid → unpaid · suspended → paid · suspended → unpaid. Admin + Treasurer (Kassenwart) can change status, via the existing permission system.
|
||||
```
|
||||
unpaid → paid
|
||||
unpaid → suspended
|
||||
paid → unpaid
|
||||
suspended → paid
|
||||
suspended → unpaid
|
||||
```
|
||||
|
||||
### Membership fee type change
|
||||
**Permissions:**
|
||||
|
||||
MVP allows changing only to a fee type with the **same interval** (e.g. "Regular (yearly)" → "Reduced (yearly)" ✓; → "Reduced (monthly)" ✗). On change: set `member.membership_fee_type_id`; future **unpaid** cycles deleted and regenerated with the new amount; paid/suspended cycles unchanged (historical amount). Future: enable interval switching (overlap handling, extra validation).
|
||||
- Admin + Treasurer (Kassenwart) can change status
|
||||
- Uses existing permission system
|
||||
|
||||
### Member exit
|
||||
### Membership Fee Type Change
|
||||
|
||||
Cycles generated only up to `member.exit_date`; existing cycles remain visible; an unpaid exit cycle can be marked "suspended". E.g. exit 15.08.2024 with a yearly cycle 01.01.–31.12.2024 → 2024 cycle shown (unpaid, admin may suspend); no 2025+ cycles generated.
|
||||
**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 — column "Membership Fee Status"
|
||||
### Member List View
|
||||
|
||||
- **Default (last completed cycle):** in 2024, shows the 2023 status. Color: green = paid ✓, red = unpaid ✗, gray = suspended ⊘.
|
||||
- **Optional toggle:** "Show current cycle" (2024).
|
||||
- **Filters:** "Unpaid membership fees in last cycle", "Unpaid membership fees in current cycle".
|
||||
**New Column: "Membership Fee Status"**
|
||||
|
||||
### Member Detail View — section "Membership Fees"
|
||||
**Default Display (Last Cycle):**
|
||||
|
||||
**Fee type assignment:**
|
||||
- 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:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
|
|
@ -138,7 +303,7 @@ Cycles generated only up to `member.exit_date`; existing cycles remain visible;
|
|||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Cycle table:**
|
||||
**Cycle Table:**
|
||||
|
||||
```
|
||||
┌───────────────┬──────────┬────────┬──────────┬─────────┐
|
||||
|
|
@ -157,7 +322,11 @@ Cycles generated only up to `member.exit_date`; existing cycles remain visible;
|
|||
Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended
|
||||
```
|
||||
|
||||
**Quick marking:** checkbox per row; "Mark selected as paid/unpaid/suspended" button; bulk action for multiple cycles.
|
||||
**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
|
||||
|
||||
|
|
@ -173,13 +342,18 @@ Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended
|
|||
└────────────┴──────────┴──────────┴────────────┴─────────┘
|
||||
```
|
||||
|
||||
**Edit:** Name ✓, Amount ✓, Description ✓ editable; Interval ✗ **NOT** editable (grayed out).
|
||||
**Edit:**
|
||||
|
||||
**Warning on amount change:**
|
||||
- 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 €
|
||||
|
|
@ -188,7 +362,9 @@ Impact:
|
|||
[Cancel] [Confirm]
|
||||
```
|
||||
|
||||
### Admin: Settings — Membership Fee Configuration
|
||||
### Admin: Settings
|
||||
|
||||
**Membership Fee Configuration:**
|
||||
|
||||
```
|
||||
Default Membership Fee Type: [Dropdown: Membership Fee Types]
|
||||
|
|
@ -221,58 +397,135 @@ Joining: 15.03.2023
|
|||
|
||||
## Edge Cases
|
||||
|
||||
1. **Type change with different interval:** MVP blocks it. UI message:
|
||||
### 1. Membership Fee Type Change with Different Interval
|
||||
|
||||
```
|
||||
Error: Interval change not possible
|
||||
**MVP:** Blocked (only same interval allowed)
|
||||
|
||||
Current membership fee type: "Regular (Yearly)"
|
||||
Selected membership fee type: "Student (Monthly)"
|
||||
**UI:**
|
||||
|
||||
Changing the interval is currently not possible.
|
||||
Please select a membership fee type with interval "Yearly".
|
||||
```
|
||||
Error: Interval change not possible
|
||||
|
||||
[OK]
|
||||
```
|
||||
Current membership fee type: "Regular (Yearly)"
|
||||
Selected membership fee type: "Student (Monthly)"
|
||||
|
||||
Future: allow interval switching with overlap calculation and no duplicate cycles.
|
||||
Changing the interval is currently not possible.
|
||||
Please select a membership fee type with interval "Yearly".
|
||||
|
||||
2. **Exit with unpaid fees (low prio):** on exit, offer to mark unpaid cycles "suspended".
|
||||
[OK]
|
||||
```
|
||||
|
||||
```
|
||||
⚠ Unpaid membership fees present
|
||||
**Future:**
|
||||
|
||||
This member has 1 unpaid cycle(s):
|
||||
- 2024: 60 € (unpaid)
|
||||
- Allow interval switching
|
||||
- Calculate overlaps
|
||||
- Generate new cycles without duplicates
|
||||
|
||||
Do you want to continue?
|
||||
### 2. Exit with Unpaid Membership Fees
|
||||
|
||||
[ ] Mark membership fee as "suspended"
|
||||
[Cancel] [Confirm Exit]
|
||||
```
|
||||
**Scenario:**
|
||||
|
||||
3. **Multiple unpaid cycles:** all shown; select several and bulk-mark.
|
||||
```
|
||||
Member exits: 15.08.2024
|
||||
Yearly cycle 2024: unpaid
|
||||
```
|
||||
|
||||
```
|
||||
┌───────────────┬──────────┬────────┬──────────┬─────────┐
|
||||
│ 2023 │ Yearly │ 50 € │ ☐ Open │ [✓] │
|
||||
│ 2024 │ Yearly │ 60 € │ ☐ Open │ [✓] │
|
||||
│ 2025 │ Yearly │ 60 € │ ☐ Open │ [ ] │
|
||||
└───────────────┴──────────┴────────┴──────────┴─────────┘
|
||||
**UI Notice on Exit: (Low Prio)**
|
||||
|
||||
[Mark selected as paid/unpaid/suspended] (2 selected)
|
||||
```
|
||||
```
|
||||
⚠ Unpaid membership fees present
|
||||
|
||||
4. **Amount changes:** 2023 Regular = 50 €, 2024 = 60 € → 2023 cycle keeps 50 € (history), 2024 generated with 60 €; each cycle shows its historical amount.
|
||||
This member has 1 unpaid cycle(s):
|
||||
- 2024: 60 € (unpaid)
|
||||
|
||||
5. **Date boundaries:** today = 01.01.2025 → current 2025 cycle generated, status unpaid, shown in overview.
|
||||
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:** fee types CRUD; automatic cycle generation; status management (paid/unpaid/suspended); member overview with status; per-member cycle view; quick checkbox marking; bulk actions; amount history; same-interval type change; default fee type; joining-cycle configuration.
|
||||
### MVP (Phase 1)
|
||||
|
||||
**NOT included:** interval change; payment details (date, method); automatic vereinfacht.digital integration; prorata; reports/statistics; reminders/dunning (manual via filters).
|
||||
**Included:**
|
||||
|
||||
**Future:** Phase 2 — payment details, interval change for future unpaid cycles, manual vereinfacht.digital links per member, extended filters. Phase 3 — automated vereinfacht.digital integration, automatic payment matching, SEPA, advanced reports.
|
||||
- ✓ 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
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ This feature implements secure account linking between password-based accounts a
|
|||
|
||||
#### 1. Security Fix: `lib/accounts/user.ex`
|
||||
|
||||
**Change**: The `sign_in_with_oidc` action now filters by `oidc_id` instead of `email`.
|
||||
**Change**: The `sign_in_with_rauthy` action now filters by `oidc_id` instead of `email`.
|
||||
|
||||
```elixir
|
||||
read :sign_in_with_oidc do
|
||||
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
|
||||
|
|
@ -102,12 +102,12 @@ Interactive UI for password verification and account linking.
|
|||
|
||||
**Changes**:
|
||||
|
||||
- `MvWeb.LocaleController`: Sets locale cookie with `http_only` and a config-driven `secure` flag
|
||||
- `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: Application.get_env(:mv, :use_secure_cookies, false)` - the `secure` flag is config-driven (defaults to `false`; enabled in production) so the cookie is only transmitted over HTTPS in production
|
||||
- `secure: true` - Cookie only transmitted over HTTPS in production
|
||||
- `same_site: "Lax"` - CSRF protection
|
||||
|
||||
## Security Considerations
|
||||
|
|
@ -139,6 +139,47 @@ Interactive UI for password verification and account linking.
|
|||
- `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
|
||||
|
|
|
|||
|
|
@ -1,215 +0,0 @@
|
|||
# Onboarding & Join – High-Level Concept
|
||||
|
||||
**Status:** Prio 1 (Subtasks 1–4) and Step 2 (Vorstand approval, Subtask 5) implemented. The Invite-Link / OIDC-JIT join entry paths (§4) are designed here but **not yet implemented**.
|
||||
**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 (which fields, approval required) shall be admin-configurable; MVP may start with sensible defaults.
|
||||
- **Approval:** a Vorstand (board) approval step is the direct follow-up (Step 2) after the public Join; the data model and flow support it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Prio 1: Public Join Page
|
||||
|
||||
### 2.1 Intent
|
||||
|
||||
- **Public** page `/join`: no login; anyone can open and submit.
|
||||
- The result is **not** a User or Member but a **JoinRequest** record, created in the DB 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/account creation, and leaves existing policies (User–Member linking, admin-only link) untouched until a defined promotion flow (after approval) creates User/Member.
|
||||
- **Standard:** data is persisted in the DB from the start (one Ash resource, status-driven). No ETS or stateless token for pre-confirmation storage; the confirm flow only updates the existing record.
|
||||
|
||||
### 2.2 User Flow
|
||||
|
||||
1. Unauthenticated user opens `/join`.
|
||||
2. Short explanation + form ("We will review … you will hear from us").
|
||||
3. **Submit** → JoinRequest created with status `pending_confirmation`; confirmation email 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** → existing JoinRequest updated to `submitted` (`submitted_at` set, confirmation token invalidated); user sees "Thank you, we have received your request."
|
||||
|
||||
**Rationale (double opt-in, DB-first):** email confirmation stays best practice (treated as "submitted" only after the click); the record exists in the DB from submit time, so we get standard Phoenix/Ash persistence, multi-node safety, and a simple `pending_confirmation → submitted` transition. Aligns with AshAuthentication (resource exists before confirm; confirm updates state).
|
||||
|
||||
### 2.3 Data Flow
|
||||
|
||||
- **Input:** only data explicitly allowed for the public form; field set is admin-configured (§2.6). No internal/sensitive fields. **Server-side allowlist:** accepted fields are enforced both in the LiveView (`build_submit_attrs`) and in the resource via **`JoinRequest.Changes.FilterFormDataByAllowlist`**, so even direct API / `submit_join_request` calls persist only allowlisted `form_data` keys.
|
||||
- **On submit:** create a JoinRequest (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, then send the confirmation email.
|
||||
- **On confirm link click:** find by token hash, set status `submitted`, set `submitted_at`, clear/invalidate token fields. If already `submitted`, return success without changing it (idempotent).
|
||||
- **No Member/User creation** in Prio 1; promotion happens later (after approval).
|
||||
|
||||
#### 2.3.1 Pre-Confirmation Store (Decided)
|
||||
|
||||
**Decision:** store in the **database** only, using the **same** JoinRequest resource and table throughout. On submit, create one row (`pending_confirmation`, token hash, expiry); on confirm, update that row to `submitted` — no second table, no ETS, no stateless token.
|
||||
|
||||
**Retention and cleanup:** JoinRequests still in `pending_confirmation` past the token expiry are **hard-deleted** by a scheduled job (Oban cron). Retention period: **24 hours**; document in DSGVO/retention as needed. Multi-node and restart safe; cleanup is a standard cron task.
|
||||
|
||||
#### 2.3.2 JoinRequest: Data Model and Schema
|
||||
|
||||
- **Status:** `pending_confirmation` (initial) → `submitted` (after link click) → later `approved` / `rejected`. Audit: **approved_at**, **rejected_at**, **reviewed_by_user_id**.
|
||||
- **Confirmation:** store **confirmation_token_hash** (not the raw token); **confirmation_token_expires_at**; optional **confirmation_sent_at**. The raw token appears only in the email link; on confirm, hash the incoming token and find the record by hash.
|
||||
- **Payload vs typed columns:** **typed columns** for **email** (required — dedicated field for index, search, dedup, audit) and **first_name** / **last_name** (optional); these align with `Mv.Constants.member_fields()` and the Member resource, supporting approval-list display and straightforward promotion without parsing JSON. **Remaining form data** (other member fields + custom field values) goes in a **jsonb** attribute (`form_data`) plus a **schema_version** so future changes don't break existing records.
|
||||
- *Depends on:* (1) whether the join-form field set is fixed (more typed columns feasible) or dynamic (keep rest in jsonb to avoid migrations); (2) whether approval UI/reporting needs to filter/sort by other fields (e.g. city) — if so, add typed columns later. For MVP, email + first_name + last_name typed and the rest in jsonb balances well with the current codebase.
|
||||
- **Logger hygiene:** do not log the full payload/`form_data`; follow CODE_GUIDELINES on log sanitization.
|
||||
- **Idempotency:** confirm finds the JoinRequest by token hash; if already `submitted`, return success without updating. Optionally enforce a **unique_index on confirmation_token_hash**.
|
||||
- **Abuse metadata:** if stored (e.g. IP hash), classify as security telemetry or PII (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`**; do not rely on the confirm path alone.
|
||||
- **Confirmation route:** use **`/confirm_join/:token`** so the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it — no extra plug change for confirm.
|
||||
- **Abuse:** **honeypot** + **rate limiting** in MVP (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Client IP: prefer **X-Forwarded-For** / **X-Real-IP** behind a reverse proxy (Endpoint `connect_info: [:x_headers]`, `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.
|
||||
- **Data:** minimal PII; no sensitive data on the public form; consider DSGVO when extending. Stored abuse signals: only hashed/aggregated, documented.
|
||||
- **Approval-only:** no automatic User/Member creation from the join form; approval (Step 2) or another trusted path creates identity.
|
||||
- **Ash policies and actor:** JoinRequest has **explicit public actions** allowed with `actor: nil` (`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 a "missing actor"; use an explicit unauthenticated context. See CODE_GUIDELINES §5.0 and `lib/mv/authorization/checks/actor_is_nil.ex`.
|
||||
|
||||
### 2.5 Usability and UX
|
||||
|
||||
- **After submit:** "We have saved your details. To complete your request, please click the link we sent to your email."
|
||||
- Clear heading + short copy ("Become a member / Submit request", "What happens next").
|
||||
- Form only as simple as needed (conversion vs. data hunger).
|
||||
- Confirm success message: neutral, no promise of an account ("We will get in touch").
|
||||
- **Expired confirmation link:** clear message ("This link has expired") + instruction to submit the form again. Exact copy in the implementation spec.
|
||||
- **Re-send confirmation link:** out of scope for Prio 1; if not implemented, **create a separate ticket immediately**. Example UX: "Request new confirmation email" on the confirm/expired page.
|
||||
- Accessibility and i18n: same standards as the 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".
|
||||
- **Join form enabled:** checkbox (`join_form_enabled`); when set, the public `/join` page is active and the config below applies.
|
||||
- **Copyable join link:** when enabled, a copyable full URL to `/join` is shown below the checkbox (above the field list), with a short hint for sharing with applicants.
|
||||
- **Field selection:** from **all existing** member fields (`Mv.Constants.member_fields()`) and **custom fields**, the admin picks which appear on the join form. Stored as a list/set of field identifiers (no separate table); displayed as **badges with X to remove** (like the groups overview), added via dropdown/modal. Detailed UX to be specified in a separate subtask.
|
||||
- **Technically required fields:** only **email** must always be required. All others can be optional or marked required per admin choice; support a "required" flag per selected field.
|
||||
- **Other:** which entry paths are enabled, approval workflow (who can approve) — detailed in Step 2 and later specs.
|
||||
|
||||
---
|
||||
|
||||
## 3. Step 2: Vorstand Approval (implemented)
|
||||
|
||||
- **Goal:** the board can review join requests (e.g. list status "submitted") and approve or reject.
|
||||
- **Route:** **`/join_requests`** (list) and **`/join_requests/:id`** (detail), defined in `MvWeb.Router` → `MvWeb.JoinRequestLive.Index` / `.Show`. Full spec in §3.1.
|
||||
- **Outcome of approval:** approval creates a **Member only** (no User; an admin can link a User later). The optional "also create a User on approval" variant is **not yet implemented**.
|
||||
- **Permissions:** approval uses the existing **normal_user** permission set (e.g. role "Kassenwart"). In `Mv.Authorization.PermissionSets`, normal_user has JoinRequest read + update for scope :all, and `/join_requests` and `/join_requests/:id` are in its allowed pages.
|
||||
|
||||
### 3.1 Step 2 – Approval (detail) — implemented in Subtask 5
|
||||
|
||||
**Route and pages:**
|
||||
|
||||
- **List `/join_requests`:** filter by status (default/primary view: `submitted`); optional view for "all" or "approved/rejected" for audit.
|
||||
- **Detail `/join_requests/:id`:** 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. Approve / Reject actions when status is `submitted`.
|
||||
|
||||
**Backend (`Mv.Membership.JoinRequest`) — actions (authenticated only):**
|
||||
|
||||
- **`approve`** (update, change `JoinRequest.Changes.ApproveRequest`): allowed only when status is `submitted`. Sets `approved`, `approved_at`, `reviewed_by_user_id` / `reviewed_by_display` (actor). Promotion to Member is driven by the domain function (see below), not the change.
|
||||
- **`reject`** (update, change `JoinRequest.Changes.RejectRequest`): allowed only when status is `submitted`. Sets `rejected`, `rejected_at`, `reviewed_by_user_id`. No reason field in MVP.
|
||||
- **Policies:** `approve` and `reject` are each permitted via **`HasPermission`**; the read policy uses **`HasJoinRequestAccess`** (a SimpleCheck) so list/detail can load data. Not allowed for `actor: nil`.
|
||||
- **Domain (`Mv.Membership`):** `list_join_requests/1` (filter by status, with actor), `approve_join_request/2` (id, actor), `reject_join_request/2` (id, actor).
|
||||
|
||||
**Promotion: JoinRequest → Member:**
|
||||
|
||||
- **When:** on successful `approve` only (status was `submitted`).
|
||||
- **Mapping:** typed fields **email**, **first_name**, **last_name** → Member attributes. **form_data** keys matching `Mv.Constants.member_fields()` (string form) → Member attributes; keys that are custom field IDs (UUID) → **CustomFieldValue** records linked to the new Member.
|
||||
- **Defaults:** `join_date` = today. `membership_fee_type_id` is not set here; the Member `create_member` action applies the default fee type from settings (see `Mv.Membership.Member.Changes.SetDefaultMembershipFeeType`).
|
||||
- **Implementation:** the domain function `Mv.Membership.approve_join_request/2` calls the private `promote_to_member/2`, which builds member attributes + custom_field_values and calls Member `create_member` with the reviewer as actor. No User created in MVP.
|
||||
- **Atomicity:** the approve flow (get → update to approved → promote to Member) runs inside **`Ash.transact(JoinRequest, fn -> ... end)`**, so if Member creation fails (validation, unique constraint) the JoinRequest status rolls back.
|
||||
- **Idempotency:** `ApproveRequest` only transitions from `submitted`; a repeated approve on an already-`approved` request is rejected with a status error, so no duplicate Member is created.
|
||||
|
||||
**Permission sets and routing:**
|
||||
|
||||
- **PermissionSets (`Mv.Authorization.PermissionSets`, normal_user):** JoinRequest **read** :all and **update** :all; pages `/join_requests` and `/join_requests/:id`.
|
||||
- **Router (`MvWeb.Router`):** live routes `/join_requests` → `JoinRequestLive.Index` and `/join_requests/:id` → `JoinRequestLive.Show`; entries recorded in **page-permission-route-coverage.md**; plug coverage so normal_user is allowed, read_only/own_data denied.
|
||||
|
||||
**UI/UX (approval):**
|
||||
|
||||
- **List:** table/card list with columns e.g. submitted_at, first_name, last_name, email, status; primary/default filter status = `submitted`; links to detail. Follow existing list patterns (Members/Groups): header, back link, CoreComponents table.
|
||||
- **Detail:** all request data (typed + form_data rendered by field); buttons **Approve** (primary), **Reject** (secondary); reject in MVP has no reason field. Same accessibility/i18n standards.
|
||||
|
||||
**Tests:** 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 + audit; approve-when-already-approved is no-op or error); page permission (normal_user can GET both routes; read_only/own_data cannot); optional LiveView smoke test.
|
||||
|
||||
---
|
||||
|
||||
## 4. Future Entry Paths (Out of Scope Here, not yet implemented)
|
||||
|
||||
- **Invite link (tokenized):** unique link per invitee; submission or account creation tied to the token.
|
||||
- **OIDC first-login (JIT):** first OIDC login creates/links a User and optionally a Member from IdP data.
|
||||
- Both must be design-ready so they can attach to the same approval/creation pipeline later.
|
||||
|
||||
---
|
||||
|
||||
## 5. Concept Evaluation — adopted decisions
|
||||
|
||||
- **Naming:** resource **JoinRequest** (one resource, status + audit timestamps).
|
||||
- **No User/Member from `/join`:** only a JoinRequest, created on submit (`pending_confirmation`), updated to `submitted` on confirmation. Member/User domain unchanged.
|
||||
- **Public actions:** `submit` (create with `pending_confirmation` + send email) and `confirm` (update to `submitted`).
|
||||
- **Public paths:** `/join` explicitly added to the plug's public path list; `/confirm_join/:token` covered by the existing `/confirm*` rule.
|
||||
- **Minimal data:** email technically required; other fields from the admin-configured set, with optional "required" per field.
|
||||
- **Security:** honeypot + rate limiting in MVP; email confirmation before "submitted"; token stored as hash; 24h retention + hard-delete for expired pending.
|
||||
|
||||
Refinements layered in this document: approval as Step 2 (User creation after approval left open); join-form settings as their own section (detailed UX in a subtask); three entry paths placed in the roadmap; pre-confirmation store DB-only with 24h hard-delete; payload split typed (email/first_name/last_name) + jsonb with schema_version.
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisions and Open Points
|
||||
|
||||
**Decided:**
|
||||
|
||||
- **Email confirmation (double opt-in):** JoinRequest created on submit (`pending_confirmation`), updated to `submitted` on link click; treated as submitted only after the click. Reuses the existing AshAuthentication pattern (token + email sender + route).
|
||||
- **Naming:** **JoinRequest**.
|
||||
- **Pre-confirmation store:** DB only, same resource; no ETS, no stateless token. Token stored as **hash**; raw token only in the email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (Oban cron) — see `lib/mix/tasks/join_requests.cleanup_expired.ex`.
|
||||
- **Confirmation route:** **`/confirm_join/:token`** so `starts_with?(path, "/confirm")` covers it.
|
||||
- **Public path for `/join`:** explicitly add `/join` to the plug's `public_path?/1` (e.g. in `CheckPagePermission`).
|
||||
- **JoinRequest schema:** status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for the rest. **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). Idempotent confirm (unique constraint on token hash, or update only when status is `pending_confirmation`).
|
||||
- **Approval outcome:** admin-configurable; default Member only (no User); optional "create User on approval" left for later.
|
||||
- **Rate limiting:** honeypot + rate limiting from the start (e.g. Hammer.Plug).
|
||||
- **Settings:** own section "Onboarding / Join"; `join_form_enabled` + field selection; display as list/badges; detailed UX in a separate subtask.
|
||||
- **Approval permission:** normal_user; JoinRequest read/update and the approval page added to normal_user; no new permission set.
|
||||
- **Approval route:** `/join_requests` (list), `/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 (specify 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 + tests).
|
||||
- Flow: submit → JoinRequest `pending_confirmation` → email sent → click link → JoinRequest `submitted`; no User/Member created.
|
||||
- Anti-abuse: honeypot and rate limiting implemented and tested.
|
||||
- Cleanup: scheduled job hard-deletes `pending_confirmation` JoinRequests older than 24h.
|
||||
- Page-permission and routing tests updated (public-path coverage for `/join` and `/confirm_join/:token`).
|
||||
- Concept and decisions (§6) documented for implementation specs.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation Plan (Subtasks)
|
||||
|
||||
Resend confirmation remains a separate ticket (§2.5, §6).
|
||||
|
||||
**Prio 1 – Public Join (4 subtasks, all shipped):**
|
||||
|
||||
1. **JoinRequest resource and public policies** *(shipped)* — Ash resource per §2.3.2 (status, email required, first_name/last_name, form_data jsonb, schema_version, confirmation_token_hash + expiry, audit timestamps, source); migration; unique_index on confirmation_token_hash for idempotency. Public actions `submit` (create) and `confirm` (update) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`.
|
||||
2. **Submit and confirm flow** *(shipped)* — submit creates JoinRequest + sends confirmation email (reuse AshAuthentication sender); `/confirm_join/:token` verifies token (hash + lookup), updates to `submitted`, sets submitted_at, invalidates token (idempotent if already submitted); Oban hard-delete job for expired `pending_confirmation`.
|
||||
3. **Admin: Join form settings** *(shipped)* — "Onboarding / Join" settings section (§2.6): `join_form_enabled`, field selection (member_fields + custom fields), "required" per field; persisted; **server-side allowlist** available to subtask 4.
|
||||
4. **Public join page and anti-abuse** *(shipped)* — public `/join` route added to the plug's public path list; LiveView with fields from the allowlist; copy per §2.5; honeypot + rate limiting (Hammer.Plug); after-submit and expired-link copy; public-path tests updated to include `/join`.
|
||||
|
||||
**Order and dependencies:** 1 → 2 (flow uses the resource); 3 before/parallel with 4 (form reads the allowlist from settings; MVP subtask 4 can use a default allowlist with 3 following shortly). Recommended: 1 → 2 → 3 → 4.
|
||||
|
||||
**Step 2 – Approval (1 subtask, shipped):**
|
||||
|
||||
5. **Approval UI (Vorstand)** *(shipped)* — routes `/join_requests` (list) → `JoinRequestLive.Index`, `/join_requests/:id` (detail) → `JoinRequestLive.Show`; full spec in §3.1. Lists submitted JoinRequests, approve/reject; on approve creates a Member (no User in MVP). Permission: normal_user has JoinRequest read/update and the two pages in PermissionSets; audit fields populated; promotion JoinRequest → Member via `Mv.Membership.approve_join_request/2` per §3.1.
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
- `docs/roles-and-permissions-architecture.md` — permission sets, roles, page permissions.
|
||||
- `docs/page-permission-route-coverage.md` — public paths, plug behaviour, tests; covers `/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/authorization/checks/actor_is_nil.ex` — the actor:nil public-action check.
|
||||
- `lib/mix/tasks/join_requests.cleanup_expired.ex` — hard-delete of expired `pending_confirmation` JoinRequests (24h retention).
|
||||
- `lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex` — existing confirmation-email pattern (token, link, Mailer).
|
||||
- Hammer / Hammer.Plug (hexdocs.pm/hammer) — rate limiting for Phoenix/Plug.
|
||||
- Issue #308 — original feature/planning context.
|
||||
|
|
@ -19,8 +19,9 @@ This document lists all protected routes, which permission set may access them,
|
|||
| `/users/:id/show/edit` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
|
||||
| `/settings` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/membership_fee_settings` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/membership_fee_settings/new_fee_type` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/membership_fee_settings/:id/edit_fee_type` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/membership_fee_types` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/membership_fee_types/new` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/membership_fee_types/:id/edit` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/groups` | ✗ | ✓ | ✓ | ✓ |
|
||||
| `/groups/new` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/groups/:slug` | ✗ | ✓ | ✓ | ✓ |
|
||||
|
|
@ -30,35 +31,58 @@ This document lists all protected routes, which permission set may access them,
|
|||
| `/admin/roles/new` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/admin/roles/:id` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/admin/roles/:id/edit` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/join_requests` | ✗ | ✗ | ✓ | ✓ |
|
||||
| `/join_requests/:id` | ✗ | ✗ | ✓ | ✓ |
|
||||
| `/admin/datafields` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/admin/import` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/admin/import/template/en` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/admin/import/template/de` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/members/export.csv` | ✗ | ✓ | ✓ | ✓ |
|
||||
| `/members/export.pdf` | ✗ | ✗ | ✗ | ✓ |
|
||||
|
||||
**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. The Approval UI routes `/join_requests` and `/join_requests/:id` are implemented and routed: `normal_user` lists them explicitly in its permission set, and `admin` reaches them through the `*` wildcard.
|
||||
|
||||
**Note on admin-only routes:** `/admin/datafields`, `/admin/import`, `/admin/import/template/en`, `/admin/import/template/de`, and `/members/export.pdf` are not listed explicitly in any permission set; only `admin` can reach them, via the `*` wildcard. `/members/export.csv` is additionally granted explicitly to `read_only` and `normal_user`.
|
||||
**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.
|
||||
|
||||
## 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).
|
||||
- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`
|
||||
|
||||
## Test Coverage
|
||||
|
||||
**File:** `test/mv_web/plugs/check_page_permission_test.exs` covers both unit tests (plug called directly with a mock conn) and full-router integration tests. The route→permission-set matrix above is the source of truth; each permission set (own_data/Mitglied, read_only, normal_user/Kassenwart, admin) is exercised there. Allowed routes return 200; denied routes return 302 → `/users/:id`. `GET /` redirects own_data to its profile. Unauthenticated access is denied and redirected to `/sign-in`; public paths (`/auth/sign-in`, `/register`) are allowed. Error cases (no role, invalid permission_set_name) deny.
|
||||
**File:** `test/mv_web/plugs/check_page_permission_test.exs`
|
||||
|
||||
Two coverage notes:
|
||||
### Unit tests (plug called directly with mock conn)
|
||||
|
||||
- **Linked-member routes** (`/members/:id*` for own_data) are covered by plug unit tests; full-router integration tests for the linked member are skipped due to session/LiveView constraints.
|
||||
- **Join requests:** normal_user and admin are allowed `/join_requests` and `/join_requests/:id` (normal_user via its explicit permission-set pages, admin via the `*` wildcard); read_only and own_data are denied.
|
||||
- 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.
|
||||
|
||||
### 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,71 @@
|
|||
# PDF Generation: Imprintor instead of Chromium
|
||||
# PDF Generation: Imprintor statt Chromium
|
||||
|
||||
## Decision
|
||||
## Übersicht
|
||||
|
||||
For PDF generation we use **Imprintor** (`{:imprintor, "~> 0.6.0"}`) with
|
||||
**Typst** templates, rather than a Chromium-based renderer (Puppeteer, Chrome
|
||||
Headless, etc.). Implemented in `lib/mv/membership/members_pdf.ex`, template at
|
||||
`priv/pdf_templates/members_export.typ`.
|
||||
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).
|
||||
|
||||
## Rationale (Imprintor over Chromium)
|
||||
## Warum Imprintor statt Chromium?
|
||||
|
||||
- **Resource efficiency:** no full browser instance in memory, no
|
||||
browser-rendering pipeline on the CPU.
|
||||
- **Smaller Docker images:** no Chromium install (saves several hundred MB);
|
||||
works in minimal images (e.g. Alpine), with no system dependencies
|
||||
(Chromium, ChromeDriver) to ship or keep updated.
|
||||
- **Elixir-native:** integrates with the BEAM and Elixir error handling instead
|
||||
of managing an external browser process; faster generation and easier
|
||||
parallelism (no browser startup or instance management).
|
||||
- **Smaller attack surface:** no browser engine with its own CVE stream.
|
||||
### 1. Ressourceneffizienz
|
||||
|
||||
## When Chromium would still be warranted
|
||||
- **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)
|
||||
|
||||
A Chromium-based renderer makes sense when the document requires JavaScript
|
||||
execution, dynamic JS-rendered content, modern web CSS features, or full-page
|
||||
screenshots of web pages — none of which apply to our static, template-driven
|
||||
exports.
|
||||
### 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)
|
||||
|
||||
## Usage in this project
|
||||
|
||||
Member export as PDF (member lists / reports) and other static, predefined
|
||||
documents (e.g. membership certificates).
|
||||
|
|
|
|||
|
|
@ -1,56 +1,69 @@
|
|||
# Policy Pattern: Bypass vs. HasPermission
|
||||
|
||||
**Date:** 2026-01-22
|
||||
**Status:** Implemented and Tested
|
||||
**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**:
|
||||
For filter-based permissions (`scope :own`, `scope :linked`), we use a **two-tier authorization pattern**:
|
||||
|
||||
1. **Bypass with `expr()` for READ** — handles list queries via `auto_filter`.
|
||||
2. **HasPermission for UPDATE/CREATE/DESTROY** — uses scope from PermissionSets when a record is present.
|
||||
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 ensures the scope concept in PermissionSets is actually used and not redundant.
|
||||
This pattern ensures that the scope concept in PermissionSets is actually used and not redundant.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
The initial assumption was that `HasPermission` returning `{:filter, expr(...)}` would automatically trigger Ash's `auto_filter` for list queries. It does not:
|
||||
### Initial Assumption (INCORRECT)
|
||||
|
||||
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. List queries fail with empty results.
|
||||
> "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
|
||||
|
||||
# Ash.read(User, actor: user)
|
||||
# Expected: [user] (filtered to own record)
|
||||
# Actual: [] (empty list)
|
||||
# User tries to list all users:
|
||||
Ash.read(User, actor: user)
|
||||
# Expected: Returns [user] (filtered to own record)
|
||||
# Actual: Returns [] (empty list)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Solution
|
||||
|
||||
Bypass for READ, HasPermission for everything else:
|
||||
### Pattern: Bypass for READ, HasPermission for UPDATE
|
||||
|
||||
**User Resource Example:**
|
||||
|
||||
```elixir
|
||||
policies do
|
||||
# AshAuthentication (registration/login)
|
||||
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
# Bypass for READ — handles list queries via auto_filter
|
||||
# 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 — scope from PermissionSets, used when a record is present
|
||||
|
||||
# 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
|
||||
|
|
@ -58,100 +71,260 @@ policies do
|
|||
end
|
||||
```
|
||||
|
||||
Why it works:
|
||||
**Why This Works:**
|
||||
|
||||
| Operation | Record? | Method | Result |
|
||||
|-----------|---------|--------|--------|
|
||||
| READ (list) | No | `bypass` + `expr()` | Ash compiles expr to SQL WHERE → filtered list |
|
||||
| READ (single) | Yes | `bypass` + `expr()` | Ash evaluates expr → true/false |
|
||||
| UPDATE / CREATE / DESTROY | Yes (changeset) | `HasPermission` + scope | `strict_check` evaluates record → authorized |
|
||||
| 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 |
|
||||
|
||||
### UPDATE is controlled by PermissionSets, not hardcoded
|
||||
**Important: UPDATE Strategy**
|
||||
|
||||
UPDATE is **not** a hardcoded bypass. All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) explicitly grant `User.update :own`; `HasPermission` evaluates `scope :own` when a changeset with a record is present. Removing `User.update :own` from a set would remove credential-update ability for that set — intentional.
|
||||
UPDATE is **NOT** a hardcoded bypass. It is controlled by **PermissionSets**:
|
||||
|
||||
**Decision: `read_only` grants `User.update :own`** even though it is "read-only" for member data, so password changes work while member data stays read-only.
|
||||
- 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
|
||||
|
||||
### No explicit `forbid_if always()`
|
||||
**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.
|
||||
|
||||
We do **not** add a trailing `forbid_if always()`. Ash fails closed implicitly — it forbids when no policy authorizes. An explicit terminal forbid breaks tests because it forbids valid operations that earlier policies should authorize.
|
||||
---
|
||||
|
||||
## Why `scope :own` Is NOT Redundant
|
||||
|
||||
`scope :own` is used for operations where a record is present (UPDATE/CREATE/DESTROY), even though the bypass handles READ:
|
||||
### 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
|
||||
%{resource: "User", action: :read, scope: :own, granted: true}, # Not used (bypass handles it)
|
||||
%{resource: "User", action: :update, scope: :own, granted: true}, # USED by HasPermission ✅
|
||||
```
|
||||
|
||||
Proven by `test/mv/accounts/user_policies_test.exs` ("can update own email"): the update succeeds via `HasPermission` with `scope :own` (not via bypass).
|
||||
**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
|
||||
|
||||
Both User and Member follow the same shape — bypass for READ, HasPermission for UPDATE/CREATE/DESTROY — differing only in the actor key and scope:
|
||||
|
||||
### 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` use `scope :own` for read/update; `admin` uses `scope :all`.
|
||||
**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` uses `scope :linked` for read/update; `read_only` uses `scope :all` for read (no update); `normal_user` and `admin` use `scope :all`.
|
||||
**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 `expr()` in bypass works
|
||||
### Why Does `expr()` in Bypass Work?
|
||||
|
||||
Ash treats `expr()` natively in both contexts:
|
||||
**Ash treats `expr()` natively in two contexts:**
|
||||
|
||||
- **strict_check** (single record): evaluates the expression against the record → true/false.
|
||||
- **auto_filter** (list queries): compiles the expression to a SQL WHERE clause applied in the DB query.
|
||||
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
|
||||
# Ash.read(User, actor: user)
|
||||
# Compiled SQL: SELECT * FROM users WHERE id = $1 → [user]
|
||||
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 HasPermission doesn't trigger auto_filter
|
||||
### 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, does NOT call auto_filter.
|
||||
# No record (list query) - return false
|
||||
# Ash STOPS here, does NOT call auto_filter
|
||||
{:ok, false}
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Why return `false`, not `:unknown`?** We tested returning `:unknown`; Ash's policy evaluation still did **not** reliably call `auto_filter`. The `bypass` with `expr()` is the only consistent solution. (`has_permission_test.exs` accordingly expects `false`, not `:unknown`.)
|
||||
**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 a future Ash version reliably calls `auto_filter` when `strict_check` returns `:unknown` or `{:filter, expr}`, the READ bypass could be removed and a single HasPermission policy kept for all operations (with tests updated). **This workaround was first identified under Ash 3.13.x and is still required as of the Ash version pinned in `mix.lock`; the bypass pattern remains necessary and correct.**
|
||||
### 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 policies: <https://hexdocs.pm/ash/policies.html>
|
||||
- Implementation: see the `policies do` block in `Mv.Accounts.User` (`lib/accounts/user.ex`)
|
||||
- Tests: `test/mv/accounts/user_policies_test.exs`, `test/mv/authorization/checks/has_permission_test.exs`
|
||||
- Architecture: `docs/roles-and-permissions-architecture.md`
|
||||
- Permission sets: `lib/mv/authorization/permission_sets.ex`
|
||||
- **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
|
|
@ -63,7 +63,20 @@ During the design phase, we evaluated multiple implementation approaches to find
|
|||
|
||||
### Approach 1: JSONB in Roles Table
|
||||
|
||||
Store all permissions as a single JSONB column directly in the roles table. Simplest schema (single table), flexible, fast to implement — but poor queryability (can't filter by specific permissions), no referential integrity, hard to validate/audit, can't use indexes.
|
||||
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.
|
||||
|
||||
|
|
@ -71,7 +84,22 @@ Store all permissions as a single JSONB column directly in the roles table. Simp
|
|||
|
||||
### Approach 2: Normalized Database Tables
|
||||
|
||||
Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization. Fully queryable, runtime-configurable, strong referential integrity, auditable, indexable — but complex schema (4+ tables), a DB query per check, needs ETS cache + admin UI, 4-5 weeks, overkill for 4 fixed sets.
|
||||
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.
|
||||
|
||||
|
|
@ -79,7 +107,20 @@ Separate tables for `permission_sets`, `permission_set_resources`, `permission_s
|
|||
|
||||
### Approach 3: Custom Authorizer
|
||||
|
||||
Implement a custom Ash Authorizer from scratch instead of using Ash Policies. Full control over logic — but significantly more code, loses Ash's declarative policies (must reimplement query filter generation), harder to test, mixes declarative/imperative, higher bug risk.
|
||||
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.
|
||||
|
||||
|
|
@ -87,7 +128,21 @@ Implement a custom Ash Authorizer from scratch instead of using Ash Policies. Fu
|
|||
|
||||
### Approach 4: Simple Role Enum
|
||||
|
||||
Add a `:role` enum field directly on User with hardcoded checks in each policy. Very simple (< 1 week), no extra tables, fast — but no separation of role (job function) from permission set, can't add roles without code changes, no dynamic config, not extensible to field-level, hard to maintain as requirements grow.
|
||||
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.
|
||||
|
||||
|
|
@ -95,11 +150,33 @@ Add a `:role` enum field directly on User with hardcoded checks in each policy.
|
|||
|
||||
### Approach 5: Hardcoded Permissions with Migration Path (SELECTED for MVP)
|
||||
|
||||
Permission Sets hardcoded in Elixir module, only Roles table in database. Fast (2-3 weeks vs 4-5), maximum performance (zero DB queries, < 1μs), pure-function testing, Git-reviewable permissions, no data migration, keeps role/permission-set separation, clear Phase 3 upgrade path. Trade-offs: permissions not editable at runtime (only role assignment), new permissions need a code deploy, unsuitable if permissions change > 1x/week, limited to the 4 predefined sets.
|
||||
Permission Sets hardcoded in Elixir module, only Roles table in database.
|
||||
|
||||
**Why Selected:** MVP requires 4 fixed sets (not custom ones), no stated need for runtime permission editing, performance is critical, fast time-to-market, and a clear upgrade path exists when runtime config becomes necessary.
|
||||
**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
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -124,7 +201,7 @@ Permission Sets hardcoded in Elixir module, only Roles table in database. Fast (
|
|||
|
||||
**Resource Level (MVP):**
|
||||
- Controls create, read, update, destroy actions on resources
|
||||
- Resources: Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle, JoinRequest
|
||||
- Resources: Member, User, CustomFieldValue, CustomField, Role
|
||||
|
||||
**Page Level (MVP):**
|
||||
- Controls access to LiveView pages
|
||||
|
|
@ -137,7 +214,7 @@ Permission Sets hardcoded in Elixir module, only Roles table in database. Fast (
|
|||
### Special Cases
|
||||
|
||||
1. **Own Credentials:** Users can always edit their own email and password
|
||||
2. **Linked Member Email:** Only administrators or the linked user themselves can change the email of a member linked to a user
|
||||
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)
|
||||
|
||||
---
|
||||
|
|
@ -254,39 +331,46 @@ Users need to create member profiles for themselves (self-service), but only adm
|
|||
- Unlink members from users
|
||||
- Create members pre-linked to arbitrary users
|
||||
|
||||
### Selected Approach: Admin-Only `:user` Argument
|
||||
### Selected Approach: Separate Ash Actions
|
||||
|
||||
Linking is **not** modelled as separate per-operation actions. The Member resource has a single
|
||||
`create_member` and a single `update_member` action; linking and unlinking happen through an
|
||||
optional **`:user` argument** on those actions. `user_id` is deliberately not accepted, so the
|
||||
foreign key cannot be set directly.
|
||||
Instead of complex field-level validation, we use action-based authorization.
|
||||
|
||||
### How Linking Works on the Member Resource
|
||||
### Actions on Member Resource
|
||||
|
||||
**`create_member` / `update_member`** (the only Member write actions)
|
||||
- The optional `:user` argument drives the relationship via `manage_relationship`.
|
||||
- On update, `on_missing: :ignore` means omitting `:user` leaves the link unchanged
|
||||
(no "unlink by omission"); unlink is explicit (`user: nil`).
|
||||
- The policy check `ForbidMemberUserLinkUnlessAdmin` forbids the action for non-admins whenever the
|
||||
`:user` argument is present (any value), so only admins may set or change the link.
|
||||
- Non-admins can still create/update members as long as they do not pass `:user`.
|
||||
**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
|
||||
|
||||
**Self-service** ("a user creates a member linked to themselves") is handled on the **User** side:
|
||||
the admin-only `update_user` action takes a `:member` argument for link/unlink, and the UI exposes
|
||||
the linking controls only to admins.
|
||||
**2. create_member** (Admin only)
|
||||
- Can set user_id to any user or leave unlinked
|
||||
- Full flexibility for admin
|
||||
- UI: Admin member management form
|
||||
|
||||
### Why This Design?
|
||||
**3. link_member_to_user** (Admin only)
|
||||
- Updates existing member to set user_id
|
||||
- Connects unlinked member to user account
|
||||
|
||||
**Single write path:** one create and one update action to reason about, instead of a fan-out of
|
||||
`link_*`/`unlink_*` actions.
|
||||
**4. unlink_member_from_user** (Admin only)
|
||||
- Sets user_id to nil
|
||||
- Disconnects member from user account
|
||||
|
||||
**Centralized rule:** the admin-only constraint lives in one reusable policy check
|
||||
(`ForbidMemberUserLinkUnlessAdmin`).
|
||||
**5. update** (Permission-based)
|
||||
- Normal updates (name, address, etc.)
|
||||
- user_id NOT in accept list (prevents manipulation)
|
||||
- Available to users with Member.update permission
|
||||
|
||||
**Server-Side Security:** `user_id` is never accepted directly, so it cannot be mass-assigned —
|
||||
only argument-driven relationship management can change it.
|
||||
### Why Separate Actions?
|
||||
|
||||
**Better UX:** distinct UI flows for self-service vs. admin linking.
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -402,7 +486,23 @@ Use Custom Validations
|
|||
|
||||
**[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):** Historical record of how the MVP was built (PR #346/#345)
|
||||
**[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,36 +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 / SMTP / Accounting-Software Integration ------------+
|
||||
| ... (unchanged) |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
+-- 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,131 +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. 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.
|
||||
163
docs/statistics-page-implementation-plan.md
Normal file
163
docs/statistics-page-implementation-plan.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# 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,121 +1,877 @@
|
|||
# Test Performance Optimization
|
||||
|
||||
**Last Updated:** 2026-01-28
|
||||
**Status:** Implemented
|
||||
|
||||
This document records the test-suite performance work and — most importantly — the conventions that govern how tests are tagged and run. The seeds-test rationale in §1 is the canonical reference linked from `test/seeds_test.exs`.
|
||||
|
||||
Baseline result: the standard (fast) suite runs ~368 s (~6.1 min) vs. ~445 s before; the full suite (all tests) ~7.4 min. Slow-tagged tests (~25, >1 s each, ~77 s total) are excluded from standard runs and executed via promotion before merge.
|
||||
**Status:** ✅ Active optimization program
|
||||
|
||||
---
|
||||
|
||||
## 1. Seeds Test Suite — coverage mapping
|
||||
## Executive Summary
|
||||
|
||||
The seeds tests were reduced from 13 to 4. The 9 removed tests were dropped because their assertions are already covered by domain-specific test suites — this mapping is the justification and must be preserved:
|
||||
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.
|
||||
|
||||
| Removed seeds test | Covered by |
|
||||
|--------------------|-----------|
|
||||
| `"at least one member has no membership fee type assigned"` | `membership_fees/*_test.exs` |
|
||||
| `"each membership fee type has at least one member"` | `membership_fees/*_test.exs` |
|
||||
| `"members with fee types have cycles with various statuses"` | `cycle_generator_test.exs` |
|
||||
| `"creates all 5 authorization roles with correct permission sets"` | `authorization/*_test.exs` |
|
||||
| `"all roles have valid permission_set_names"` | `authorization/permission_sets_test.exs` |
|
||||
| `"does not change role of users who already have a role"` | merged into general idempotency test |
|
||||
| `"role creation is idempotent"` (detailed) | merged into general idempotency test |
|
||||
### Current Performance Metrics
|
||||
|
||||
### 4 retained critical-bootstrap tests (and why)
|
||||
| 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) |
|
||||
|
||||
These guard deployment-critical invariants that nothing else covers and must stay in the **fast** suite:
|
||||
### Optimization Impact Summary
|
||||
|
||||
1. **Smoke test** — seeds run successfully and create basic data.
|
||||
2. **Idempotency** — seeds can be re-run 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).
|
||||
|
||||
If a new critical bootstrap requirement appears, add a test to the "Critical bootstrap invariants" section in `test/seeds_test.exs`. Removed-test risk is low: a smoke-test failure surfaces broken seeds, and domain tests verify business logic independently of seeds content.
|
||||
| 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** | |
|
||||
|
||||
---
|
||||
|
||||
## 2. Tagging convention: `:slow`
|
||||
## Completed Optimizations
|
||||
|
||||
Tests are split into **fast** (standard CI) and **slow** (run via promotion before merge). A test is tagged `@tag :slow` when **all** of:
|
||||
### 1. Seeds Test Suite Optimization
|
||||
|
||||
- execution time > 1 s, **and**
|
||||
- low risk — does not catch critical regressions in core business logic, **and**
|
||||
- it is a UI/display/formatting test, a workflow-detail test, or an edge case with a large dataset (performance tests with 50+ records always qualify).
|
||||
**Date:** 2026-01-28
|
||||
**Status:** ✅ Completed
|
||||
|
||||
**Never** tag as `:slow`:
|
||||
#### What Changed
|
||||
|
||||
- Core CRUD (Member/User create/update/destroy)
|
||||
- Basic authentication/authorization
|
||||
- Critical bootstrap (admin user, system roles)
|
||||
- Email synchronization
|
||||
- Representative policy tests (one per permission set + action)
|
||||
- A test that is merely slow due to inefficient setup or a bug — fix the setup/bug instead
|
||||
- An integration test — use `@tag :integration` instead
|
||||
- **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)
|
||||
|
||||
Use **`@describetag :slow`** (not `@moduletag`) for describe blocks, so unrelated tests in the same module are not tagged.
|
||||
#### Removed Tests (9 tests)
|
||||
|
||||
One-off isolation fix worth noting as a pattern: a test that loaded *all* members was slow in full runs because of cross-test data accumulation; constraining it with a search query (`/members?query=Alice`) made it both faster and properly isolated. Prefer query filters over loading all records.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 3. Execution model
|
||||
### 2. Full Test Suite via Promotion (`@tag :slow`)
|
||||
|
||||
| Mode | Command | Contents | Time |
|
||||
|------|---------|----------|------|
|
||||
| Fast (default) | `just test-fast` / `mix test --exclude slow --exclude ui` | everything except `:slow` and `:ui` | ~6 min |
|
||||
| Slow only | `just test-slow` / `mix test --only slow` | the ~25 `:slow` tests | ~1.3 min |
|
||||
| Full | `just test` / `mix test` | all tests | ~7.4 min |
|
||||
**Date:** 2026-01-28
|
||||
**Status:** ✅ Completed
|
||||
|
||||
CI: standard pipeline (`check-fast`) runs `mix test --exclude slow --exclude ui`. The full suite (`check-full`) is triggered by promoting a Drone build to `production` and is required before merging to `main` (branch protection).
|
||||
#### What Changed
|
||||
|
||||
To find the slowest tests, run `mix test --slowest N` ad hoc. `test/test_helper.exs` also carries a `slowest: 10` option for `ExUnit.start/1`, but it is commented out by default — uncomment it to print the 10 slowest tests at the end of every run.
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
## 4. Test organization
|
||||
### 3. Critical Test Optimization
|
||||
|
||||
Tests mirror the `lib/` structure:
|
||||
**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
|
||||
├── membership/ # Membership domain
|
||||
├── membership_fees/ # Membership fees domain
|
||||
├── mv/ # Core (accounts, membership, authorization)
|
||||
├── mv_web/ # Web layer (controllers, live, components)
|
||||
└── support/ # conn_case.ex, data_case.ex
|
||||
├── 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Concurrent `create_member` deadlock and deferrable FKs
|
||||
## Best Practices for Test Performance
|
||||
|
||||
A class of intermittent failures (PostgreSQL `deadlock_detected`, SQLSTATE `40P01`) was traced to **concurrent `create_member` transactions**, not to any single test. It surfaced as a `MatchError` on `{:ok, member} = ...` in member-heavy LiveView tests (e.g. `FormMemberSelectionTest`) and reproduced only under CPU contention (≈1 in 12 full-fast-suite runs at high `async: true` concurrency; effectively never on an idle machine).
|
||||
### When Writing New Tests
|
||||
|
||||
**Root cause.** `create_member` writes a cascade in one transaction (member row, `custom_field_values`, the `user` link, fee-type defaulting, cycle generation). Concurrent inserts take FK `FOR KEY SHARE` (MultiXact) locks on shared parent rows across `members` / `users` / `membership_fee_types`; under contention these can form a cross-transaction lock cycle that Postgres resolves by aborting one transaction. It is a product-level concurrency property, **not** test-data contention, so it is not fixable by test-state isolation.
|
||||
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
|
||||
|
||||
**Fix.** Migration `…_make_member_user_fks_deferrable.exs` makes the three FKs (`users.member_id`, `users.role_id`, `members.membership_fee_type_id`) `DEFERRABLE INITIALLY DEFERRED`, moving the FK check (and its lock) to commit time and breaking the cycle. Verified: **0 deadlocks in 15 full-suite runs under maximum CPU contention**, versus 1/12 before. This does **not** weaken integrity — `NOT NULL` is independent of FK deferral, a real dangling reference still aborts the commit, and `ON DELETE RESTRICT` (e.g. `users.role_id`) stays immediate regardless of deferrability. `Mv.DeferrableFkTest` asserts the constraint state as a regression guard (a deterministic in-process concurrent reproduction is infeasible under the Ecto sandbox, which serializes connections by ownership).
|
||||
### When Optimizing Existing Tests
|
||||
|
||||
This deadlock is also a latent **production** risk under concurrent sign-ups; the deferrable-FK fix addresses both.
|
||||
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
|
||||
|
||||
### Async-test-safety checklist (members/groups/custom fields)
|
||||
### Test Tagging Guidelines
|
||||
|
||||
Several member-creating test files historically used `async: false` with a "prevent PostgreSQL deadlocks" comment. With the deferrable-FK migration in place those files are deadlock-safe, but before flipping any such file to `async: true`:
|
||||
#### Tag as `@tag :slow` when:
|
||||
|
||||
- **Prove isolation under load, not just one green run.** Re-run the file (and the full suite) under varying `--seed` **and** CPU contention; a single green run is not evidence (the deadlock and the isolation flakes below are load-dependent).
|
||||
- **Watch for separate async-isolation issues beyond the deadlock.** `index_groups_url_params_test.exs` and `member_filter_component_test.exs` showed filtered-member-leak failures (`refute html =~ name`) under concurrency that are independent of the FK deadlock — these need their own per-file isolation fix before they can run async.
|
||||
1. **Performance Tests:**
|
||||
- Explicitly testing performance characteristics
|
||||
- Using large datasets (50+ records)
|
||||
- Testing scalability or query optimization
|
||||
- Validating N+1 query prevention
|
||||
|
||||
### StreamData generator pitfall
|
||||
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)
|
||||
|
||||
`FilterTooNarrowError` appeared on unlucky seeds (e.g. 222) in a property test that built a value with a reject-filter (`StreamData.filter` discarding ~1/4 of generated pairs). Under full property-run counts this hits too many consecutive rejections. Fix: **construct the desired value directly** instead of generating-then-filtering (preserves the exact domain, no rejection). Prefer constructive generators over reject-filters in property tests.
|
||||
#### 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` §4
|
||||
- CI/CD: `.drone.jsonnet`
|
||||
- Test helper: `test/test_helper.exs`
|
||||
- Just commands: `Justfile` (`test-fast`, `test-slow`, `test`)
|
||||
- **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.
|
||||
|
|
|
|||
269
docs/user-resource-policies-implementation-summary.md
Normal file
269
docs/user-resource-policies-implementation-summary.md
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
# 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`.
|
||||
|
|
@ -9,7 +9,7 @@ defmodule Mv.Accounts do
|
|||
## 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`
|
||||
- Authentication: `create_register_with_rauthy/1`, `read_sign_in_with_rauthy/1`
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
|
@ -24,8 +24,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
|
||||
|
|
|
|||
|
|
@ -8,14 +8,8 @@ defmodule Mv.Accounts.User do
|
|||
extensions: [AshAuthentication],
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
import Ash.Expr
|
||||
|
||||
alias Ash.Resource.Preparation.Builtins
|
||||
alias Mv.Authorization.Role, as: RoleResource
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.OidcRoleSync
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
postgres do
|
||||
table "users"
|
||||
|
|
@ -34,7 +28,7 @@ defmodule Mv.Accounts.User do
|
|||
|
||||
@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)
|
||||
|
|
@ -58,7 +52,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
|
||||
|
|
@ -94,7 +88,7 @@ defmodule Mv.Accounts.User do
|
|||
# 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)
|
||||
# - :register_with_rauthy (for OIDC-based registration)
|
||||
defaults [:read]
|
||||
|
||||
destroy :destroy do
|
||||
|
|
@ -124,8 +118,6 @@ defmodule Mv.Accounts.User do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
create :create_user do
|
||||
|
|
@ -153,8 +145,6 @@ defmodule Mv.Accounts.User do
|
|||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
update :update_user do
|
||||
|
|
@ -188,8 +178,6 @@ defmodule Mv.Accounts.User do
|
|||
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.
|
||||
|
|
@ -223,8 +211,6 @@ defmodule Mv.Accounts.User do
|
|||
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
|
||||
|
|
@ -262,8 +248,6 @@ defmodule Mv.Accounts.User do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
read :get_by_subject do
|
||||
|
|
@ -273,7 +257,7 @@ defmodule Mv.Accounts.User do
|
|||
prepare AshAuthentication.Preparations.FilterBySubject
|
||||
end
|
||||
|
||||
read :sign_in_with_oidc do
|
||||
read :sign_in_with_rauthy do
|
||||
# Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1).
|
||||
get? true
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
|
|
@ -288,27 +272,27 @@ defmodule Mv.Accounts.User do
|
|||
|
||||
# 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 ->
|
||||
prepare Ash.Resource.Preparation.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]
|
||||
u when is_struct(u, User) -> [u]
|
||||
list when is_list(list) -> list
|
||||
_ -> []
|
||||
end
|
||||
|
||||
Enum.each(users, fn user ->
|
||||
OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
|
||||
Mv.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
|
||||
|
|
@ -344,8 +328,6 @@ defmodule Mv.Accounts.User do
|
|||
# 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)
|
||||
|
|
@ -363,12 +345,6 @@ defmodule Mv.Accounts.User do
|
|||
# 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)"
|
||||
|
|
@ -412,14 +388,6 @@ defmodule Mv.Accounts.User do
|
|||
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
|
||||
|
|
@ -503,10 +471,10 @@ defmodule Mv.Accounts.User do
|
|||
|> Enum.map(& &1.id)
|
||||
|
||||
# Count only non-system users with admin role (system user is for internal ops)
|
||||
system_email = SystemActor.system_user_email()
|
||||
system_email = Mv.Helpers.SystemActor.system_user_email()
|
||||
|
||||
count =
|
||||
__MODULE__
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.for_read(:read)
|
||||
|> Ash.Query.filter(expr(role_id in ^admin_role_ids))
|
||||
|> Ash.Query.filter(expr(email != ^system_email))
|
||||
|
|
@ -532,7 +500,7 @@ defmodule Mv.Accounts.User do
|
|||
# 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
|
||||
if Mv.Helpers.SystemActor.system_user?(changeset.data) do
|
||||
{:error,
|
||||
field: :email,
|
||||
message:
|
||||
|
|
@ -661,8 +629,8 @@ defmodule Mv.Accounts.User do
|
|||
case Process.get({__MODULE__, :default_role_id}) do
|
||||
nil ->
|
||||
role_id =
|
||||
case RoleResource.get_mitglied_role() do
|
||||
{:ok, %RoleResource{id: id}} -> id
|
||||
case Mv.Authorization.Role.get_mitglied_role() do
|
||||
{:ok, %Mv.Authorization.Role{id: id}} -> id
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -24,13 +24,10 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
|||
- Allow (new user will be created)
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
|
||||
alias Mv.Accounts.User
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
|
||||
@impl true
|
||||
def init(opts), do: {:ok, opts}
|
||||
|
||||
|
|
@ -46,10 +43,10 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision 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()
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
existing_oidc_user =
|
||||
case User
|
||||
case Mv.Accounts.User
|
||||
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|
||||
|> Ash.read_one(actor: system_actor) do
|
||||
{:ok, user} -> user
|
||||
|
|
@ -65,7 +62,7 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
|||
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
|
||||
case Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^to_string(email))
|
||||
|> Ash.read_one(actor: system_actor) do
|
||||
{:ok, nil} ->
|
||||
|
|
@ -167,7 +164,7 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def atomic?, do: false
|
||||
def atomic?(), do: false
|
||||
|
||||
@impl true
|
||||
def describe(_opts) do
|
||||
|
|
|
|||
|
|
@ -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
|
||||
18
lib/accounts/user_identity.exs
Normal file
18
lib/accounts/user_identity.exs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
defmodule Mv.Accounts.UserIdentity do
|
||||
@moduledoc """
|
||||
AshAuthentication specific ressource
|
||||
"""
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication.UserIdentity],
|
||||
domain: Mv.Accounts
|
||||
|
||||
postgres do
|
||||
table "user_identities"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
user_identity do
|
||||
user_resource Mv.Accounts.User
|
||||
end
|
||||
end
|
||||
|
|
@ -10,10 +10,8 @@ defmodule Mv.Membership.CustomField do
|
|||
## 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.
|
||||
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
|
||||
- `description` - Optional human-readable description
|
||||
- `join_description` - Optional label shown for this field on the public join form
|
||||
(e.g., a GDPR confirmation text); supports inline external links. Falls back to `name` when nil.
|
||||
- `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
|
||||
|
||||
|
|
@ -30,7 +28,6 @@ defmodule Mv.Membership.CustomField do
|
|||
## 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
|
||||
|
|
@ -54,8 +51,7 @@ defmodule Mv.Membership.CustomField do
|
|||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer],
|
||||
primary_read_warning?: false
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "custom_fields"
|
||||
|
|
@ -63,39 +59,15 @@ defmodule Mv.Membership.CustomField do
|
|||
end
|
||||
|
||||
actions do
|
||||
default_accept [
|
||||
:name,
|
||||
:value_type,
|
||||
:description,
|
||||
:join_description,
|
||||
:required,
|
||||
:show_in_overview
|
||||
]
|
||||
|
||||
read :read do
|
||||
primary? true
|
||||
prepare build(sort: [name: :asc])
|
||||
end
|
||||
defaults [:read, :update]
|
||||
default_accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||
|
||||
create :create do
|
||||
accept [:name, :value_type, :description, :join_description, :required, :show_in_overview]
|
||||
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, :join_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
|
||||
|
|
@ -148,15 +120,6 @@ defmodule Mv.Membership.CustomField do
|
|||
trim?: true
|
||||
]
|
||||
|
||||
attribute :join_description, :string,
|
||||
allow_nil?: true,
|
||||
public?: true,
|
||||
description: "Label shown for this field on the public join form; supports external links",
|
||||
constraints: [
|
||||
max_length: 1000,
|
||||
trim?: true
|
||||
]
|
||||
|
||||
attribute :required, :boolean,
|
||||
default: false,
|
||||
allow_nil?: false
|
||||
|
|
|
|||
|
|
@ -38,10 +38,6 @@ defmodule Mv.Membership.Email do
|
|||
@min_length 5
|
||||
@max_length 254
|
||||
|
||||
# These compile-time constants are referenced by the `use` options below, so
|
||||
# they must be declared first; StrictModuleLayout cannot be satisfied by
|
||||
# reordering here without breaking the macro expansion.
|
||||
# credo:disable-for-next-line Credo.Check.Readability.StrictModuleLayout
|
||||
use Ash.Type.NewType,
|
||||
subtype_of: :string,
|
||||
constraints: [
|
||||
|
|
|
|||
|
|
@ -39,10 +39,9 @@ defmodule Mv.Membership.Group do
|
|||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
require Ash.Query
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
postgres do
|
||||
|
|
|
|||
|
|
@ -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,32 +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 =
|
||||
Membership.get_join_form_allowlist()
|
||||
|> Enum.map(fn item -> item.id end)
|
||||
|> MapSet.new()
|
||||
|> MapSet.difference(MapSet.new(@typed_fields))
|
||||
|
||||
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
|
||||
|
|
@ -22,7 +22,8 @@ defmodule Mv.Membership.Member do
|
|||
## Validations
|
||||
- Required: email (all other fields are optional)
|
||||
- Email format validation (using EctoCommons.EmailValidator)
|
||||
- Date validations: exit_date after join_date
|
||||
- Postal code format: exactly 5 digits (German format)
|
||||
- Date validations: join_date not in future, exit_date after join_date
|
||||
- Email uniqueness: prevents conflicts with unlinked users
|
||||
- Linked member email change: only admins or the linked user may change a linked member's email (see `Mv.Membership.Member.Validations.EmailChangePermission`)
|
||||
|
||||
|
|
@ -37,23 +38,12 @@ defmodule Mv.Membership.Member do
|
|||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
import Ash.Expr
|
||||
import Bitwise
|
||||
|
||||
alias Ecto.Adapters.SQL, as: EctoSQL
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.Helpers.VisibilityConfig
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.Repo
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
alias Mv.Helpers
|
||||
require Logger
|
||||
|
||||
@typedoc "An `Mv.Membership.Member` resource record."
|
||||
@type t :: %__MODULE__{}
|
||||
alias Mv.Membership.Helpers.VisibilityConfig
|
||||
|
||||
# Module constants
|
||||
@member_search_limit 10
|
||||
|
|
@ -127,9 +117,6 @@ defmodule Mv.Membership.Member do
|
|||
# Requires both join_date and membership_fee_type_id to be present
|
||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
||||
|
||||
# Sync member to Vereinfacht as finance contact (if configured)
|
||||
change Mv.Vereinfacht.Changes.SyncContact
|
||||
|
||||
# Trigger cycle generation after member creation
|
||||
# Only runs if membership_fee_type_id is set
|
||||
# Note: Cycle generation runs asynchronously to not block the action,
|
||||
|
|
@ -203,9 +190,6 @@ defmodule Mv.Membership.Member do
|
|||
where [changing(:membership_fee_type_id)]
|
||||
end
|
||||
|
||||
# Sync member to Vereinfacht as finance contact (if configured)
|
||||
change Mv.Vereinfacht.Changes.SyncContact
|
||||
|
||||
# Trigger cycle regeneration when membership_fee_type_id changes
|
||||
# This deletes future unpaid cycles and regenerates them with the new type/amount
|
||||
# Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
|
||||
|
|
@ -259,13 +243,6 @@ defmodule Mv.Membership.Member do
|
|||
end)
|
||||
end
|
||||
|
||||
# Internal: set vereinfacht_contact_id after syncing with Vereinfacht API.
|
||||
# Not exposed via code interface; used only by Mv.Vereinfacht.Changes.SyncContact.
|
||||
update :set_vereinfacht_contact_id do
|
||||
require_atomic? false
|
||||
accept [:vereinfacht_contact_id]
|
||||
end
|
||||
|
||||
# Action to handle fuzzy search on specific fields
|
||||
read :search do
|
||||
argument :query, :string, allow_nil?: true
|
||||
|
|
@ -343,12 +320,6 @@ defmodule Mv.Membership.Member do
|
|||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
# Internal sync action: only SystemActor may set vereinfacht_contact_id (used by SyncContact change).
|
||||
policy action(:set_vereinfacht_contact_id) do
|
||||
description "Only system actor may set Vereinfacht contact ID"
|
||||
authorize_if Mv.Authorization.Checks.ActorIsSystemUser
|
||||
end
|
||||
|
||||
# CREATE/UPDATE: Forbid member–user link unless admin, then check permissions
|
||||
# ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
|
||||
# HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all.
|
||||
|
|
@ -477,11 +448,21 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Join date not in future
|
||||
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
|
||||
where: [present(:join_date)],
|
||||
message: "cannot be in the future"
|
||||
|
||||
# Exit date not before join date
|
||||
validate compare(:exit_date, greater_than: :join_date),
|
||||
where: [present([:join_date, :exit_date])],
|
||||
message: "cannot be before join date"
|
||||
|
||||
# Postal code format (only if set)
|
||||
validate match(:postal_code, ~r/^\d{5}$/),
|
||||
where: [present(:postal_code)],
|
||||
message: "must consist of 5 digits"
|
||||
|
||||
# Email validation with EctoCommons.EmailValidator
|
||||
validate fn changeset, _ ->
|
||||
email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
|
|
@ -500,92 +481,48 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Validate required custom fields (actor from validation context only; no fallback).
|
||||
# Only for create_member/update_member; skip for set_vereinfacht_contact_id (internal sync
|
||||
# only sets vereinfacht_contact_id; custom fields were already validated and saved).
|
||||
# Validate required custom fields (actor from validation context only; no fallback)
|
||||
validate fn changeset, context ->
|
||||
provided_values = provided_custom_field_values(changeset)
|
||||
actor = context.actor
|
||||
provided_values = provided_custom_field_values(changeset)
|
||||
actor = context.actor
|
||||
|
||||
case Mv.Membership.list_required_custom_fields(actor: actor) do
|
||||
{:ok, required_custom_fields} ->
|
||||
missing_fields =
|
||||
missing_required_fields(required_custom_fields, provided_values)
|
||||
case Mv.Membership.list_required_custom_fields(actor: actor) do
|
||||
{:ok, required_custom_fields} ->
|
||||
missing_fields = missing_required_fields(required_custom_fields, provided_values)
|
||||
|
||||
if Enum.empty?(missing_fields) do
|
||||
:ok
|
||||
else
|
||||
build_custom_field_validation_error(missing_fields)
|
||||
end
|
||||
if Enum.empty?(missing_fields) do
|
||||
:ok
|
||||
else
|
||||
build_custom_field_validation_error(missing_fields)
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
Logger.warning(
|
||||
"Required custom fields validation: actor not authorized to read CustomField"
|
||||
)
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
Logger.warning(
|
||||
"Required custom fields validation: actor not authorized to read CustomField"
|
||||
)
|
||||
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
|
||||
{:error, :missing_actor} ->
|
||||
Logger.warning("Required custom fields validation: no actor in context")
|
||||
{:error, :missing_actor} ->
|
||||
Logger.warning("Required custom fields validation: no actor in context")
|
||||
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
|
||||
)
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
|
||||
)
|
||||
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"Unable to validate required custom fields. Please try again or contact support."}
|
||||
end
|
||||
end,
|
||||
where: [action_is([:create_member, :update_member])]
|
||||
|
||||
# Validate member fields that are marked as required in settings.
|
||||
# When settings cannot be loaded, enforce only email.
|
||||
validate fn changeset, _context ->
|
||||
required_fields =
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
required_config = settings.member_field_required || %{}
|
||||
normalized = VisibilityConfig.normalize(required_config)
|
||||
|
||||
Enum.filter(Mv.Constants.member_fields(), fn field ->
|
||||
field == :email || Map.get(normalized, field, false)
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Member required-fields validation: could not load settings (#{inspect(reason)}). " <>
|
||||
"Enforcing only email."
|
||||
)
|
||||
|
||||
Enum.filter(Mv.Constants.member_fields(), fn field ->
|
||||
field == :email
|
||||
end)
|
||||
end
|
||||
|
||||
missing =
|
||||
Enum.filter(required_fields, fn field ->
|
||||
value = Ash.Changeset.get_attribute(changeset, field)
|
||||
not member_field_value_present?(field, value)
|
||||
end)
|
||||
|
||||
if Enum.empty?(missing) do
|
||||
:ok
|
||||
else
|
||||
field = hd(missing)
|
||||
|
||||
{:error,
|
||||
field: field, message: Gettext.dgettext(MvWeb.Gettext, "default", "can't be blank")}
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"Unable to validate required custom fields. Please try again or contact support."}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -643,10 +580,6 @@ defmodule Mv.Membership.Member do
|
|||
allow_nil? true
|
||||
end
|
||||
|
||||
attribute :country, :string do
|
||||
allow_nil? true
|
||||
end
|
||||
|
||||
attribute :search_vector, AshPostgres.Tsvector,
|
||||
writable?: false,
|
||||
public?: false,
|
||||
|
|
@ -660,14 +593,6 @@ defmodule Mv.Membership.Member do
|
|||
public? true
|
||||
description "Date from which membership fees should be calculated"
|
||||
end
|
||||
|
||||
# Vereinfacht accounting software integration: ID of the finance contact synced via API.
|
||||
# Set by Mv.Vereinfacht.Changes.SyncContact; not accepted in create/update actions.
|
||||
attribute :vereinfacht_contact_id, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "ID of the finance contact in Vereinfacht (set by sync)"
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
|
|
@ -795,7 +720,7 @@ defmodule Mv.Membership.Member do
|
|||
# nil/[] when membership_fee_type is missing.
|
||||
|
||||
@doc false
|
||||
@spec get_current_cycle(t()) :: MembershipFeeCycle.t() | nil
|
||||
@spec get_current_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
|
||||
def get_current_cycle(member) do
|
||||
today = Date.utc_today()
|
||||
|
||||
|
|
@ -814,7 +739,7 @@ defmodule Mv.Membership.Member do
|
|||
case Map.get(cycle, :membership_fee_type) do
|
||||
%{interval: interval} ->
|
||||
cycle_end =
|
||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
|
||||
Date.compare(cycle.cycle_start, today) in [:lt, :eq] and
|
||||
Date.compare(today, cycle_end) in [:lt, :eq]
|
||||
|
|
@ -825,7 +750,7 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
|
||||
@doc false
|
||||
@spec get_last_completed_cycle(t()) :: MembershipFeeCycle.t() | nil
|
||||
@spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
|
||||
def get_last_completed_cycle(member) do
|
||||
today = Date.utc_today()
|
||||
|
||||
|
|
@ -848,7 +773,7 @@ defmodule Mv.Membership.Member do
|
|||
case Map.get(cycle, :membership_fee_type) do
|
||||
%{interval: interval} ->
|
||||
cycle_end =
|
||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
|
||||
Date.compare(today, cycle_end) == :gt
|
||||
|
||||
|
|
@ -864,14 +789,14 @@ defmodule Mv.Membership.Member do
|
|||
cycles,
|
||||
fn cycle ->
|
||||
interval = Map.get(cycle, :membership_fee_type).interval
|
||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
end,
|
||||
{:desc, Date}
|
||||
)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec get_overdue_cycles(t()) :: [MembershipFeeCycle.t()]
|
||||
@spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()]
|
||||
def get_overdue_cycles(member) do
|
||||
today = Date.utc_today()
|
||||
|
||||
|
|
@ -891,7 +816,7 @@ defmodule Mv.Membership.Member do
|
|||
case Map.get(cycle, :membership_fee_type) do
|
||||
%{interval: interval} ->
|
||||
cycle_end =
|
||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
|
||||
cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt
|
||||
|
||||
|
|
@ -901,25 +826,6 @@ defmodule Mv.Membership.Member do
|
|||
end)
|
||||
end
|
||||
|
||||
# Returns a deterministic 64-bit key for pg_advisory_xact_lock from a member id (UUID string).
|
||||
# Reduces collision risk vs phash2 when multiple members are locked.
|
||||
@doc false
|
||||
def advisory_lock_key_for_member_id(member_id) when is_binary(member_id) do
|
||||
hex = String.replace(member_id, "-", "")
|
||||
|
||||
if String.length(hex) >= 16 do
|
||||
first_8_hex = String.slice(hex, 0, 16)
|
||||
bin = Base.decode16!(first_8_hex, case: :lower)
|
||||
decoded = :binary.decode_unsigned(bin, :big)
|
||||
# Postgres bigint is signed 64-bit; keep in non-negative range
|
||||
rem(decoded, 1 <<< 63)
|
||||
else
|
||||
:erlang.phash2(member_id)
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> :erlang.phash2(member_id)
|
||||
end
|
||||
|
||||
# Regenerates cycles when membership fee type changes
|
||||
# Deletes future unpaid cycles and regenerates them with the new type/amount
|
||||
# Uses advisory lock to prevent concurrent modifications
|
||||
|
|
@ -928,12 +834,15 @@ defmodule Mv.Membership.Member do
|
|||
@doc false
|
||||
# Uses system actor for cycle regeneration (mandatory side effect)
|
||||
def regenerate_cycles_on_type_change(member, _opts \\ []) do
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
today = Date.utc_today()
|
||||
lock_key = advisory_lock_key_for_member_id(member.id)
|
||||
lock_key = :erlang.phash2(member.id)
|
||||
|
||||
# Use advisory lock to prevent concurrent deletion and regeneration
|
||||
# This ensures atomicity when multiple updates happen simultaneously
|
||||
if Repo.in_transaction?() do
|
||||
if Mv.Repo.in_transaction?() do
|
||||
regenerate_cycles_in_transaction(member, today, lock_key)
|
||||
else
|
||||
regenerate_cycles_new_transaction(member, today, lock_key)
|
||||
|
|
@ -943,15 +852,15 @@ defmodule Mv.Membership.Member do
|
|||
# Already in transaction: use advisory lock directly
|
||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
||||
_ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
||||
end
|
||||
|
||||
# Not in transaction: start new transaction with advisory lock
|
||||
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
||||
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
||||
Repo.transaction(fn ->
|
||||
_ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
Mv.Repo.transaction(fn ->
|
||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
|
||||
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
||||
{:ok, notifications} ->
|
||||
|
|
@ -959,7 +868,7 @@ defmodule Mv.Membership.Member do
|
|||
notifications
|
||||
|
||||
{:error, reason} ->
|
||||
Repo.rollback(reason)
|
||||
Mv.Repo.rollback(reason)
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
|
|
@ -973,6 +882,9 @@ defmodule Mv.Membership.Member do
|
|||
# notifications are collected to be sent after transaction commits
|
||||
# Uses system actor for all operations
|
||||
defp do_regenerate_cycles_on_type_change(member, today, opts) do
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
require Ash.Query
|
||||
|
||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||
|
|
@ -982,7 +894,7 @@ defmodule Mv.Membership.Member do
|
|||
# Find all unpaid cycles for this member
|
||||
# We need to check cycle_end for each cycle using its own interval
|
||||
all_unpaid_cycles_query =
|
||||
MembershipFeeCycle
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.Query.filter(status == :unpaid)
|
||||
|> Ash.Query.load([:membership_fee_type])
|
||||
|
|
@ -1011,7 +923,7 @@ defmodule Mv.Membership.Member do
|
|||
case cycle.membership_fee_type do
|
||||
%{interval: interval} ->
|
||||
cycle_end =
|
||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
|
||||
Date.compare(today, cycle_end) in [:lt, :eq]
|
||||
|
||||
|
|
@ -1039,17 +951,18 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise.
|
||||
# Returns the first error for debugging; uses system actor for authorization.
|
||||
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
|
||||
# Uses system actor for authorization to ensure deletion always works
|
||||
defp delete_cycles(cycles_to_delete, actor_opts) do
|
||||
delete_results =
|
||||
Enum.map(cycles_to_delete, fn cycle ->
|
||||
Ash.destroy(cycle, actor_opts)
|
||||
end)
|
||||
|
||||
case Enum.find(delete_results, &match?({:error, _}, &1)) do
|
||||
{:error, reason} -> {:error, reason}
|
||||
nil -> :ok
|
||||
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
|
||||
{:error, :deletion_failed}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1060,7 +973,7 @@ defmodule Mv.Membership.Member do
|
|||
defp regenerate_cycles(member_id, today, opts) do
|
||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||
|
||||
case CycleGenerator.generate_cycles_for_member(
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||
member_id,
|
||||
today: today,
|
||||
skip_lock?: skip_lock?
|
||||
|
|
@ -1091,13 +1004,13 @@ defmodule Mv.Membership.Member do
|
|||
|
||||
# Runs cycle generation synchronously (for test environment)
|
||||
defp handle_cycle_generation_sync(member, initiator) do
|
||||
case CycleGenerator.generate_cycles_for_member(
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||
member.id,
|
||||
today: Date.utc_today(),
|
||||
initiator: initiator
|
||||
) do
|
||||
{:ok, cycles, notifications} ->
|
||||
_ = send_notifications_if_any(notifications)
|
||||
send_notifications_if_any(notifications)
|
||||
|
||||
log_cycle_generation_success(member, cycles, notifications,
|
||||
sync: true,
|
||||
|
|
@ -1112,11 +1025,11 @@ defmodule Mv.Membership.Member do
|
|||
# Runs cycle generation asynchronously (for production environment)
|
||||
defp handle_cycle_generation_async(member, initiator) do
|
||||
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
|
||||
case CycleGenerator.generate_cycles_for_member(member.id,
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id,
|
||||
initiator: initiator
|
||||
) do
|
||||
{:ok, cycles, notifications} ->
|
||||
_ = send_notifications_if_any(notifications)
|
||||
send_notifications_if_any(notifications)
|
||||
|
||||
log_cycle_generation_success(member, cycles, notifications,
|
||||
sync: false,
|
||||
|
|
@ -1235,6 +1148,8 @@ defmodule Mv.Membership.Member do
|
|||
|> String.replace("_", "\\_")
|
||||
end
|
||||
|
||||
defp sanitize_search_query(_), do: ""
|
||||
|
||||
# ============================================================================
|
||||
# Search Filter Builders
|
||||
# ============================================================================
|
||||
|
|
@ -1258,8 +1173,7 @@ defmodule Mv.Membership.Member do
|
|||
contains(postal_code, ^query) or
|
||||
contains(house_number, ^query) or
|
||||
contains(email, ^query) or
|
||||
contains(city, ^query) or
|
||||
contains(country, ^query)
|
||||
contains(city, ^query)
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -1359,24 +1273,17 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Extracts custom field values from existing member data (update scenario).
|
||||
# Actor must come from context; no system-actor fallback (per guidelines).
|
||||
# When no actor is present we skip the load and return empty map.
|
||||
# Extracts custom field values from existing member data (update scenario)
|
||||
defp extract_existing_values(member_data, changeset) do
|
||||
case Map.get(changeset.context, :actor) do
|
||||
nil ->
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.load(member_data, :custom_field_values, opts) do
|
||||
{:ok, %{custom_field_values: existing_values}} ->
|
||||
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
|
||||
actor ->
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.load(member_data, :custom_field_values, opts) do
|
||||
{:ok, %{custom_field_values: existing_values}} ->
|
||||
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1479,14 +1386,4 @@ defmodule Mv.Membership.Member do
|
|||
defp value_present?(_value, :email), do: false
|
||||
|
||||
defp value_present?(_value, _type), do: false
|
||||
|
||||
# Used by member-field-required validation (settings-driven required fields)
|
||||
defp member_field_value_present?(_field, nil), do: false
|
||||
|
||||
defp member_field_value_present?(_, value) when is_binary(value),
|
||||
do: String.trim(value) != ""
|
||||
|
||||
defp member_field_value_present?(_, %Date{}), do: true
|
||||
defp member_field_value_present?(_, value) when is_struct(value, Date), do: true
|
||||
defp member_field_value_present?(_, _), do: false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -37,10 +37,9 @@ defmodule Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil 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)
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|
||||
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
|
||||
|
||||
changeset
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ defmodule Mv.Membership.MemberGroup do
|
|||
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.ReadLinkedForOwnData, member_id_field: :member_id}
|
||||
authorize_if Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData
|
||||
end
|
||||
|
||||
policy action_type(:read) do
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ defmodule Mv.Membership do
|
|||
- `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:
|
||||
|
|
@ -26,16 +25,8 @@ defmodule Mv.Membership do
|
|||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
||||
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 Ash.Query
|
||||
require Logger
|
||||
import Ash.Expr
|
||||
|
||||
admin do
|
||||
show? true
|
||||
|
|
@ -73,8 +64,6 @@ defmodule Mv.Membership do
|
|||
|
||||
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
|
||||
|
|
@ -89,11 +78,6 @@ defmodule Mv.Membership do
|
|||
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
|
||||
|
|
@ -118,16 +102,10 @@ defmodule Mv.Membership do
|
|||
|
||||
"""
|
||||
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
|
||||
# Try to get the first (and only) settings record
|
||||
case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do
|
||||
{:ok, nil} ->
|
||||
# No settings exist - create as fallback (should normally be created via seed script)
|
||||
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
|
||||
|
||||
Mv.Membership.Setting
|
||||
|
|
@ -168,16 +146,9 @@ defmodule Mv.Membership do
|
|||
|
||||
"""
|
||||
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
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update, attrs)
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -241,18 +212,11 @@ defmodule Mv.Membership do
|
|||
|
||||
"""
|
||||
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
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
||||
member_field_visibility: visibility_config
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -285,66 +249,12 @@ defmodule Mv.Membership do
|
|||
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
|
||||
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__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -390,514 +300,4 @@ defmodule Mv.Membership do
|
|||
|> 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()}
|
||||
| {:ok, JoinRequest.t(), [Ash.Notifier.Notification.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
|
||||
|
|
|
|||
|
|
@ -11,17 +11,8 @@ defmodule Mv.Membership.Setting do
|
|||
- `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.
|
||||
|
|
@ -51,25 +42,12 @@ defmodule Mv.Membership.Setting do
|
|||
# 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
|
||||
|
||||
alias Ash.Resource.Info, as: ResourceInfo
|
||||
|
||||
# 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)
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "settings"
|
||||
|
|
@ -80,27 +58,8 @@ defmodule Mv.Membership.Setting 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
|
||||
defaults [:read]
|
||||
|
||||
# Internal create action - not exposed via code interface
|
||||
# Used only as fallback in get_settings/0 if settings don't exist
|
||||
|
|
@ -109,34 +68,9 @@ defmodule Mv.Membership.Setting 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
|
||||
:default_membership_fee_type_id
|
||||
]
|
||||
|
||||
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
|
||||
end
|
||||
|
||||
update :update do
|
||||
|
|
@ -146,34 +80,9 @@ defmodule Mv.Membership.Setting 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
|
||||
:default_membership_fee_type_id
|
||||
]
|
||||
|
||||
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
|
||||
end
|
||||
|
||||
update :update_member_field_visibility do
|
||||
|
|
@ -192,17 +101,6 @@ defmodule Mv.Membership.Setting do
|
|||
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
|
||||
|
|
@ -256,71 +154,6 @@ defmodule Mv.Membership.Setting do
|
|||
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 =
|
||||
|
|
@ -378,12 +211,6 @@ defmodule Mv.Membership.Setting do
|
|||
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
|
||||
|
|
@ -398,158 +225,6 @@ defmodule Mv.Membership.Setting do
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
defmodule Mv.Membership.Setting.Changes.JsonbResult do
|
||||
@moduledoc """
|
||||
Shared normalization for the JSONB column values returned by the atomic
|
||||
single-member-field settings updates.
|
||||
|
||||
PostgreSQL may return a JSONB column as an already-decoded map (atom or string
|
||||
keys) or as a raw JSON string depending on the driver path. This helper
|
||||
normalizes either form to a string-keyed map, returning `%{}` for unexpected
|
||||
or undecodable input.
|
||||
"""
|
||||
|
||||
alias Ash.Error.Invalid
|
||||
alias Ecto.Adapters.SQL
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Runs an atomic single-statement JSONB settings UPDATE inside an after_action
|
||||
and maps the RETURNING row back onto the settings struct.
|
||||
|
||||
Shared by the single-member-field change modules, which differ only in the
|
||||
SQL statement, its parameters, the row-to-settings mapping, and the error
|
||||
labels. The three-branch result handling (row found / not found / SQL error)
|
||||
is identical and lives here.
|
||||
|
||||
Options:
|
||||
- `:sql` - The UPDATE statement with a RETURNING clause (required).
|
||||
- `:params` - The full parameter list for the statement (required).
|
||||
- `:on_row` - 1-arity function mapping the RETURNING row (a list of column
|
||||
values) to the updated settings struct (required).
|
||||
- `:error_field` - Ash error field for the not-found / failure errors.
|
||||
- `:not_found_message` - Error message when no row matched.
|
||||
- `:error_message` - Error message when the SQL statement failed.
|
||||
- `:log_message` - Log prefix written on SQL failure.
|
||||
"""
|
||||
@spec run_update(keyword()) ::
|
||||
{:ok, map()} | {:error, Exception.t()}
|
||||
def run_update(opts) do
|
||||
sql = Keyword.fetch!(opts, :sql)
|
||||
params = Keyword.fetch!(opts, :params)
|
||||
on_row = Keyword.fetch!(opts, :on_row)
|
||||
error_field = Keyword.fetch!(opts, :error_field)
|
||||
not_found_message = Keyword.fetch!(opts, :not_found_message)
|
||||
error_message = Keyword.fetch!(opts, :error_message)
|
||||
log_message = Keyword.fetch!(opts, :log_message)
|
||||
|
||||
case SQL.query(Mv.Repo, sql, params) do
|
||||
{:ok, %{rows: [row | _]}} ->
|
||||
{:ok, on_row.(row)}
|
||||
|
||||
{:ok, %{rows: []}} ->
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: error_field,
|
||||
message: not_found_message
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error("#{log_message}: #{inspect(error)}")
|
||||
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: error_field,
|
||||
message: error_message
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Normalizes a JSONB column value to a string-keyed map.
|
||||
"""
|
||||
@spec normalize(map() | binary() | any()) :: map()
|
||||
def normalize(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,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,136 +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 Mv.Membership.Setting.Changes.JsonbResult
|
||||
|
||||
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)
|
||||
|
||||
JsonbResult.run_update(
|
||||
sql: sql,
|
||||
params: [field, show_in_overview, required, uuid_binary],
|
||||
on_row: fn [updated_visibility, updated_required | _] ->
|
||||
%{
|
||||
settings
|
||||
| member_field_visibility: JsonbResult.normalize(updated_visibility),
|
||||
member_field_required: JsonbResult.normalize(updated_required)
|
||||
}
|
||||
end,
|
||||
error_field: :member_field_required,
|
||||
not_found_message: "Settings not found",
|
||||
error_message: "Failed to update member field settings",
|
||||
log_message: "Failed to atomically update member field settings"
|
||||
)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
@ -19,7 +19,9 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
|
|||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Mv.Membership.Setting.Changes.JsonbResult
|
||||
alias Ash.Error.Invalid
|
||||
alias Ecto.Adapters.SQL
|
||||
require Logger
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
with {:ok, field} <- get_and_validate_field(changeset),
|
||||
|
|
@ -104,17 +106,59 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
|
|||
# Convert UUID string to binary for PostgreSQL
|
||||
uuid_binary = Ecto.UUID.dump!(settings.id)
|
||||
|
||||
JsonbResult.run_update(
|
||||
sql: sql,
|
||||
params: [field, show_in_overview, uuid_binary],
|
||||
on_row: fn [updated_jsonb | _] ->
|
||||
%{settings | member_field_visibility: JsonbResult.normalize(updated_jsonb)}
|
||||
end,
|
||||
error_field: :member_field_visibility,
|
||||
not_found_message: "Settings not found",
|
||||
error_message: "Failed to update visibility",
|
||||
log_message: "Failed to atomically update member_field_visibility"
|
||||
)
|
||||
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
|
||||
|
|
@ -26,6 +26,8 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
|||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
|
||||
@impl true
|
||||
|
|
@ -81,6 +83,11 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
|||
policies do
|
||||
bypass action_type(:read) do
|
||||
description "own_data: read only cycles where member_id == actor.member_id"
|
||||
authorize_if {Mv.Authorization.Checks.ReadLinkedForOwnData, member_id_field: :member_id}
|
||||
authorize_if Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData
|
||||
end
|
||||
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
defmodule Mix.Tasks.JoinRequests.CleanupExpired do
|
||||
@shortdoc "Deletes join requests in pending_confirmation with expired confirmation token"
|
||||
@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
|
||||
|
||||
alias Mv.Membership.JoinRequest
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@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,26 +1,15 @@
|
|||
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
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias Mv.Mailer
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Sends a confirmation email to a new user.
|
||||
|
||||
|
|
@ -33,39 +22,25 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
|||
- `_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).
|
||||
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
|
||||
"""
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
confirm_url = url(~p"/confirm_new_user/#{token}")
|
||||
subject = gettext("Confirm your email address")
|
||||
new()
|
||||
# Replace with email from env
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> to(to_string(user.email))
|
||||
|> subject("Confirm your email address")
|
||||
|> html_body(body(token: token))
|
||||
|> Mailer.deliver!()
|
||||
end
|
||||
|
||||
assigns = %{
|
||||
confirm_url: confirm_url,
|
||||
subject: subject,
|
||||
app_name: Mailer.mail_from() |> elem(0),
|
||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||
}
|
||||
defp body(params) do
|
||||
url = url(~p"/confirm_new_user/#{params[:token]}")
|
||||
|
||||
email =
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(to_string(user.email))
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body("user_confirmation.html", assigns)
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error(
|
||||
"Failed to send user confirmation email to #{user.email}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
"""
|
||||
<p>Click this link to confirm your email:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,26 +1,15 @@
|
|||
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
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias Mv.Mailer
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Sends a password reset email to a user.
|
||||
|
||||
|
|
@ -33,36 +22,25 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
|||
- `_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).
|
||||
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
|
||||
"""
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
reset_url = url(~p"/password-reset/#{token}")
|
||||
subject = gettext("Reset your password")
|
||||
new()
|
||||
# Replace with email from env
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> to(to_string(user.email))
|
||||
|> subject("Reset your password")
|
||||
|> html_body(body(token: token))
|
||||
|> Mailer.deliver!()
|
||||
end
|
||||
|
||||
assigns = %{
|
||||
reset_url: reset_url,
|
||||
subject: subject,
|
||||
app_name: Mailer.mail_from() |> elem(0),
|
||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||
}
|
||||
defp body(params) do
|
||||
url = url(~p"/password-reset/#{params[:token]}")
|
||||
|
||||
email =
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(to_string(user.email))
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body("password_reset.html", assigns)
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to send password reset email to #{user.email}: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
"""
|
||||
<p>Click this link to reset your password:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,40 +5,21 @@ 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},
|
||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Mv.PubSub},
|
||||
{AshAuthentication.Supervisor, otp_app: :mv},
|
||||
SystemActor,
|
||||
# Start a worker by calling: Mv.Worker.start_link(arg)
|
||||
# {Mv.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
Endpoint
|
||||
]
|
||||
children = [
|
||||
MvWeb.Telemetry,
|
||||
Mv.Repo,
|
||||
{Task.Supervisor, name: Mv.TaskSupervisor},
|
||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Mv.PubSub},
|
||||
{AshAuthentication.Supervisor, otp_app: :my},
|
||||
Mv.Helpers.SystemActor,
|
||||
# Start a worker by calling: Mv.Worker.start_link(arg)
|
||||
# {Mv.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
MvWeb.Endpoint
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
|
|
|
|||
|
|
@ -49,10 +49,10 @@ defmodule Mv.Authorization.Actor do
|
|||
adds complexity and potential for inconsistency.
|
||||
"""
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
@doc """
|
||||
Ensures the actor (User) has their `:role` relationship loaded.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
defmodule Mv.Authorization.Checks.ActorIsNil do
|
||||
@moduledoc """
|
||||
Policy check: true only when the actor is nil (unauthenticated).
|
||||
|
||||
Used for the public join flow so that submit and confirm actions are allowed
|
||||
only when called without an authenticated user (e.g. from the public /join form
|
||||
and confirmation link). See docs/onboarding-join-concept.md.
|
||||
"""
|
||||
use Ash.Policy.SimpleCheck
|
||||
|
||||
@impl true
|
||||
def describe(_opts), do: "actor is nil (unauthenticated)"
|
||||
|
||||
@impl true
|
||||
def match?(nil, _context, _opts), do: true
|
||||
def match?(_actor, _context, _opts), do: false
|
||||
end
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
defmodule Mv.Authorization.Checks.ActorIsSystemUser do
|
||||
@moduledoc """
|
||||
Policy check: true only when the actor is the system user (e.g. system@mila.local).
|
||||
|
||||
Used to restrict internal actions (e.g. Member.set_vereinfacht_contact_id) so that
|
||||
only code paths using SystemActor can perform them, not regular admins.
|
||||
"""
|
||||
use Ash.Policy.SimpleCheck
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
@impl true
|
||||
def describe(_opts), do: "actor is the system user"
|
||||
|
||||
@impl true
|
||||
def match?(actor, _context, _opts), do: SystemActor.system_user?(actor)
|
||||
end
|
||||
|
|
@ -22,7 +22,6 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do
|
|||
end
|
||||
"""
|
||||
use Ash.Policy.Check
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Authorization.PermissionSets
|
||||
|
||||
@impl true
|
||||
|
|
@ -68,5 +67,5 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do
|
|||
end
|
||||
end
|
||||
|
||||
defp ensure_role_loaded(actor), do: Actor.ensure_loaded(actor)
|
||||
defp ensure_role_loaded(actor), do: Mv.Authorization.Actor.ensure_loaded(actor)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
defmodule Mv.Authorization.Checks.HasJoinRequestAccess do
|
||||
@moduledoc """
|
||||
Simple policy check: true when the actor's role has JoinRequest read/update permission.
|
||||
|
||||
Used for bypass policies on JoinRequest read actions. Uses SimpleCheck (not a filter-based
|
||||
check) so Ash does NOT call auto_filter, which would silently return an empty list for
|
||||
unauthorized actors instead of Forbidden.
|
||||
|
||||
Returns true for permission sets that grant JoinRequest read :all (normal_user, admin).
|
||||
Returns false for all others (own_data, read_only, nil actor).
|
||||
"""
|
||||
use Ash.Policy.SimpleCheck
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Authorization.PermissionSets
|
||||
|
||||
@impl true
|
||||
def describe(_opts), do: "actor has JoinRequest read/update access (normal_user or admin)"
|
||||
|
||||
@impl true
|
||||
def match?(actor, _context, _opts) do
|
||||
with ps_name when not is_nil(ps_name) <- Actor.permission_set_name(actor),
|
||||
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
||||
permissions <- PermissionSets.get_permissions(ps_atom) do
|
||||
Enum.any?(permissions.resources, fn p ->
|
||||
p.resource == "JoinRequest" and p.action == :read and p.granted
|
||||
end)
|
||||
else
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -79,13 +79,9 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
"""
|
||||
|
||||
use Ash.Policy.Check
|
||||
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Authorization.PermissionSets
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
alias Mv.Authorization.PermissionSets
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
|
|
@ -401,6 +397,6 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
# Fallback: Load role if not loaded (in case on_mount didn't run)
|
||||
# Delegates to centralized Actor helper
|
||||
defp ensure_role_loaded(actor) do
|
||||
Actor.ensure_loaded(actor)
|
||||
Mv.Authorization.Actor.ensure_loaded(actor)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
defmodule Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData do
|
||||
@moduledoc """
|
||||
Policy check for MemberGroup read: true only when actor has permission set "own_data"
|
||||
AND record.member_id == actor.member_id.
|
||||
|
||||
Used in a bypass so that own_data gets the linked filter (via auto_filter for list queries),
|
||||
while admin with member_id does not match and gets :all from HasPermission.
|
||||
|
||||
- With a record (e.g. get by id): returns true only when own_data and member_id match.
|
||||
- Without a record (list query): strict_check returns false; auto_filter adds filter when own_data.
|
||||
"""
|
||||
use Ash.Policy.Check
|
||||
|
||||
alias Mv.Authorization.Checks.ActorPermissionSetIs
|
||||
|
||||
@impl true
|
||||
def type, do: :filter
|
||||
|
||||
@impl true
|
||||
def describe(_opts),
|
||||
do: "own_data can read only member_groups where member_id == actor.member_id"
|
||||
|
||||
@impl true
|
||||
def strict_check(actor, authorizer, _opts) do
|
||||
record = get_record_from_authorizer(authorizer)
|
||||
is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data")
|
||||
|
||||
cond do
|
||||
# List query + own_data: return :unknown so authorizer applies auto_filter (keyword list)
|
||||
is_nil(record) and is_own_data ->
|
||||
{:ok, :unknown}
|
||||
|
||||
is_nil(record) ->
|
||||
{:ok, false}
|
||||
|
||||
not is_own_data ->
|
||||
{:ok, false}
|
||||
|
||||
record.member_id == actor.member_id ->
|
||||
{:ok, true}
|
||||
|
||||
true ->
|
||||
{:ok, false}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def auto_filter(actor, _authorizer, _opts) do
|
||||
if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") &&
|
||||
Map.get(actor, :member_id) do
|
||||
[member_id: actor.member_id]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp get_record_from_authorizer(authorizer) do
|
||||
case authorizer.subject do
|
||||
%{data: data} when not is_nil(data) -> data
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
defmodule Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData do
|
||||
@moduledoc """
|
||||
Policy check for MembershipFeeCycle read: true only when actor has permission set "own_data"
|
||||
AND record.member_id == actor.member_id.
|
||||
|
||||
Used in a bypass so that own_data gets the linked filter (via auto_filter for list queries),
|
||||
while admin with member_id does not match and gets :all from HasPermission.
|
||||
|
||||
- With a record (e.g. get by id): returns true only when own_data and member_id match.
|
||||
- Without a record (list query): return :unknown so authorizer applies auto_filter.
|
||||
"""
|
||||
use Ash.Policy.Check
|
||||
|
||||
alias Mv.Authorization.Checks.ActorPermissionSetIs
|
||||
|
||||
@impl true
|
||||
def type, do: :filter
|
||||
|
||||
@impl true
|
||||
def describe(_opts),
|
||||
do: "own_data can read only membership_fee_cycles where member_id == actor.member_id"
|
||||
|
||||
@impl true
|
||||
def strict_check(actor, authorizer, _opts) do
|
||||
record = get_record_from_authorizer(authorizer)
|
||||
is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data")
|
||||
|
||||
cond do
|
||||
is_nil(record) and is_own_data ->
|
||||
{:ok, :unknown}
|
||||
|
||||
is_nil(record) ->
|
||||
{:ok, false}
|
||||
|
||||
not is_own_data ->
|
||||
{:ok, false}
|
||||
|
||||
record.member_id == actor.member_id ->
|
||||
{:ok, true}
|
||||
|
||||
true ->
|
||||
{:ok, false}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def auto_filter(actor, _authorizer, _opts) do
|
||||
if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") &&
|
||||
Map.get(actor, :member_id) do
|
||||
[member_id: actor.member_id]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp get_record_from_authorizer(authorizer) do
|
||||
case authorizer.subject do
|
||||
%{data: data} when not is_nil(data) -> data
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
defmodule Mv.Authorization.Checks.OidcOnlyActive do
|
||||
@moduledoc """
|
||||
Policy check: true when OIDC-only mode is active (Config.oidc_only?()).
|
||||
|
||||
Used to forbid password sign-in when only OIDC (SSO) sign-in is allowed.
|
||||
"""
|
||||
use Ash.Policy.SimpleCheck
|
||||
|
||||
alias Mv.Config
|
||||
|
||||
@impl true
|
||||
def describe(_opts), do: "OIDC-only mode is active"
|
||||
|
||||
@impl true
|
||||
def match?(_actor, _context, _opts), do: Config.oidc_only?()
|
||||
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