Compare commits
2 commits
main
...
persist-so
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eb8834a11 | |||
| 36dcf3fbe1 |
552 changed files with 13958 additions and 107747 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',
|
||||
],
|
||||
}],
|
||||
},
|
||||
]
|
||||
178
.drone.yml
Normal file
178
.drone.yml
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
kind: pipeline
|
||||
type: docker
|
||||
name: check
|
||||
|
||||
services:
|
||||
- name: postgres
|
||||
image: docker.io/library/postgres:17.6
|
||||
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:
|
||||
# Install hex package manager
|
||||
- mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- mix deps.get
|
||||
# Check for compilation errors & warnings
|
||||
- mix compile --warnings-as-errors
|
||||
# Check formatting
|
||||
- mix format --check-formatted
|
||||
# Security checks
|
||||
- mix sobelow --config
|
||||
# Check dependencies for known vulnerabilities
|
||||
- mix deps.audit
|
||||
# Check for dependencies that are not maintained anymore
|
||||
- mix hex.audit
|
||||
# Provide hints for improving code quality
|
||||
- mix credo
|
||||
|
||||
- name: wait_for_postgres
|
||||
image: docker.io/library/postgres:17.6
|
||||
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
|
||||
image: docker.io/library/elixir:1.18.3-otp-27
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
TEST_POSTGRES_HOST: postgres
|
||||
TEST_POSTGRES_PORT: 5432
|
||||
commands:
|
||||
# Install hex package manager
|
||||
- mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- mix deps.get
|
||||
# Run tests
|
||||
- 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
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: renovate
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- cron
|
||||
- custom
|
||||
branch:
|
||||
- main
|
||||
|
||||
environment:
|
||||
LOG_LEVEL: debug
|
||||
|
||||
steps:
|
||||
- name: renovate
|
||||
image: renovate/renovate:41.151
|
||||
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
|
||||
41
.env.example
41
.env.example
|
|
@ -11,46 +11,9 @@ PHX_HOST=localhost
|
|||
# Recommended: Association settings
|
||||
ASSOCIATION_NAME="Sportsclub XYZ"
|
||||
|
||||
# Optional: Admin user (created/updated on container start via Release.seed_admin)
|
||||
# In production, set these so the first admin can log in. Change password without redeploy:
|
||||
# bin/mv eval "Mv.Release.seed_admin()" (with new ADMIN_PASSWORD or ADMIN_PASSWORD_FILE)
|
||||
# FORCE_SEEDS=true re-runs bootstrap seeds even when admin user exists (e.g. after changing roles/custom fields).
|
||||
# ADMIN_EMAIL=admin@example.com
|
||||
# ADMIN_PASSWORD=secure-password
|
||||
# ADMIN_PASSWORD_FILE=/run/secrets/admin_password
|
||||
|
||||
# Optional: OIDC Configuration
|
||||
# These have defaults in docker-compose.prod.yml, only override if needed
|
||||
# OIDC_CLIENT_ID=mv
|
||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/oidc/callback
|
||||
# OIDC_CLIENT_SECRET=mv-dev-shared-secret-not-for-production-do-not-use-anywhere-else
|
||||
|
||||
# Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope)
|
||||
# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in.
|
||||
# OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list).
|
||||
# OIDC_ADMIN_GROUP_NAME=admin
|
||||
# OIDC_GROUPS_CLAIM=groups
|
||||
|
||||
# Optional: Show only OIDC sign-in on login page (hide password form).
|
||||
# When set to true and OIDC is configured, users see only the Single Sign-On button.
|
||||
# OIDC_ONLY=true
|
||||
|
||||
# Optional: Vereinfacht accounting integration (finance-contacts sync)
|
||||
# If set, these override values from Settings UI; those fields become read-only.
|
||||
# VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1
|
||||
# VEREINFACHT_API_KEY=your-api-key
|
||||
# VEREINFACHT_CLUB_ID=2
|
||||
# VEREINFACHT_APP_URL=https://app.verein.visuel.dev
|
||||
|
||||
# Optional: Mail / SMTP (transactional emails). If set, overrides Settings UI.
|
||||
# Export current UI settings to .env: mix mv.export_smtp_to_env
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USERNAME=user
|
||||
# SMTP_PASSWORD=secret
|
||||
# SMTP_PASSWORD_FILE=/run/secrets/smtp_password
|
||||
# SMTP_SSL=tls
|
||||
# SMTP_VERIFY_PEER=false
|
||||
# MAIL_FROM_EMAIL=noreply@example.com
|
||||
# MAIL_FROM_NAME=Mila
|
||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
||||
# OIDC_CLIENT_SECRET=your-rauthy-client-secret
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
# Forgejo Configuration
|
||||
|
||||
This directory contains configuration files for Forgejo (self-hosted Git service).
|
||||
|
||||
## Pull Request Template
|
||||
|
||||
The `pull_request_template.md` is automatically loaded when creating a new Pull Request. It provides a checklist and instructions for the PR workflow, including how to run the full test suite before merging.
|
||||
|
||||
## Branch Protection Setup
|
||||
|
||||
To enforce the full test suite before merging to `main`, configure branch protection in Forgejo:
|
||||
|
||||
### Steps:
|
||||
|
||||
1. Go to **Repository Settings** → **Branches** → **Protected Branches**
|
||||
2. Add a new rule for branch: `main`
|
||||
3. Configure the following settings:
|
||||
- ☑️ **Enable Branch Protection**
|
||||
- ☑️ **Require status checks to pass before merging**
|
||||
- Add required check: `check-full`
|
||||
- ☐ **Require approvals** (optional, based on team preference)
|
||||
- ☑️ **Block if there are outstanding requests** (optional)
|
||||
|
||||
### What this does:
|
||||
|
||||
- The **"Merge"** button in PRs will only be enabled after `check-full` passes
|
||||
- `check-full` is triggered by **promoting** a build in Drone CI (see PR template)
|
||||
- This ensures all tests (including slow and UI tests) run before merging
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Create PR** → Fast test suite (`check-fast`) runs automatically
|
||||
2. **Development** → Fast tests run on every push for quick feedback
|
||||
3. **Ready to merge:**
|
||||
- Remove `WIP:` from PR title
|
||||
- Go to Drone CI and **promote** the build to `production`
|
||||
- This triggers `check-full` (full test suite)
|
||||
4. **After full tests pass** → Merge button becomes available
|
||||
5. **Merge to main** → Container is built and published
|
||||
|
||||
## Secrets Required
|
||||
|
||||
Make sure the following secrets are configured in Drone CI:
|
||||
|
||||
- `DRONE_REGISTRY_USERNAME` - For container registry
|
||||
- `DRONE_REGISTRY_TOKEN` - For container registry
|
||||
- `RENOVATE_TOKEN` - For Renovate bot
|
||||
- `GITHUB_COM_TOKEN` - For Renovate bot (GitHub dependencies)
|
||||
13
.gitignore
vendored
13
.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
|
||||
|
||||
|
|
@ -42,15 +41,3 @@ npm-debug.log
|
|||
.env
|
||||
|
||||
.elixir_ls/
|
||||
|
||||
# Docker secrets directory (generated by `just init-secrets`)
|
||||
/secrets/
|
||||
notes.md
|
||||
|
||||
# Do NOT commit these — they are local to the dev machine
|
||||
.pipeline/
|
||||
.claude/
|
||||
|
||||
# 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.43.0
|
||||
|
|
|
|||
132
CHANGELOG.md
132
CHANGELOG.md
|
|
@ -5,145 +5,15 @@ 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
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
|
||||
### Added
|
||||
- **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08)
|
||||
- Four hardcoded permission sets: `own_data`, `read_only`, `normal_user`, `admin`
|
||||
- Database-backed roles with permission set references
|
||||
- Member resource policies with scope filtering (`:own`, `:linked`, `:all`)
|
||||
- Authorization checks via `Mv.Authorization.Checks.HasPermission`
|
||||
- System role protection (critical roles cannot be deleted)
|
||||
- Role management UI at `/admin/roles`
|
||||
- **Membership Fees System** - Full implementation
|
||||
- Membership fee types with intervals (monthly, quarterly, half_yearly, yearly)
|
||||
- Individual billing cycles per member with payment status tracking
|
||||
- Cycle generation and regeneration
|
||||
- Global membership fee settings
|
||||
- UI components for fee management
|
||||
- **Global Settings Management** - Singleton settings resource
|
||||
- Club name configuration (with environment variable support)
|
||||
- Member field visibility settings
|
||||
- Membership fee default settings
|
||||
- **Sidebar Navigation** - Replaced navbar with standard-compliant sidebar (#260, 2026-01-12)
|
||||
- **CSV Import Templates** - German and English templates (#329, 2026-01-13)
|
||||
- Template files in `priv/static/templates/`
|
||||
- CSV specification documented
|
||||
- User-Member linking with fuzzy search autocomplete (#168)
|
||||
- PostgreSQL trigram-based member search with typo tolerance
|
||||
- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support
|
||||
- Bilingual UI (German/English) for member linking workflow
|
||||
- **Bulk email copy feature** - Copy email addresses of selected members to clipboard (#230)
|
||||
- Email format: "First Last <email>" with semicolon separator (compatible with email clients)
|
||||
- CopyToClipboard JavaScript hook with fallback for older browsers
|
||||
- Button shows count of visible selected members (respects search/filter)
|
||||
- German/English translations
|
||||
- Docker secrets support via `_FILE` environment variables for all sensitive configuration (SECRET_KEY_BASE, TOKEN_SIGNING_SECRET, OIDC_CLIENT_SECRET, DATABASE_URL, DATABASE_PASSWORD)
|
||||
|
||||
### Changed
|
||||
- **Actor Handling Refactoring** (2026-01-09)
|
||||
- Standardized actor access with `current_actor/1` helper function
|
||||
- `ash_actor_opts/1` helper for consistent authorization options
|
||||
- `submit_form/3` wrapper for form submissions with actor
|
||||
- All Ash operations now properly pass `actor` parameter
|
||||
- **Error Handling Improvements** (2026-01-13)
|
||||
- Replaced `Ash.read!` with proper error handling in LiveViews
|
||||
- Consistent flash message handling for authorization errors
|
||||
- Early return patterns for unauthenticated users
|
||||
|
||||
### Fixed
|
||||
- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
|
||||
- Relationship data extraction from Ash manage_relationship during validation
|
||||
- Copy button count now shows only visible selected members when filtering
|
||||
- Language headers in German `.po` files (corrected from "en" to "de")
|
||||
- Critical deny-filter bug in authorization system (2026-01-08)
|
||||
- HasPermission auto_filter and strict_check implementation (2026-01-08)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,27 +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.)
|
||||
│ ├── group.ex # Group resource
|
||||
│ ├── member_group.ex # MemberGroup join table resource
|
||||
│ ├── custom_field.ex # CustomFieldValue type resource
|
||||
│ └── email.ex # Email custom type
|
||||
├── membership_fees/ # MembershipFees domain
|
||||
│ ├── membership_fees.ex # Domain definition
|
||||
│ ├── membership_fee_type.ex # Membership fee type resource
|
||||
│ ├── membership_fee_cycle.ex # Membership fee cycle resource
|
||||
│ └── changes/ # Ash changes for membership fees
|
||||
├── mv/authorization/ # Authorization domain
|
||||
│ ├── authorization.ex # Domain definition
|
||||
│ ├── role.ex # Role resource
|
||||
│ ├── permission_sets.ex # Hardcoded permission sets
|
||||
│ └── checks/ # Authorization checks
|
||||
├── mv/ # Core application modules
|
||||
│ ├── accounts/ # Domain-specific logic
|
||||
│ │ └── user/
|
||||
|
|
@ -118,31 +96,18 @@ lib/
|
|||
│ ├── membership/ # Domain-specific logic
|
||||
│ │ └── member/
|
||||
│ │ └── validations/
|
||||
│ ├── 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)
|
||||
│ ├── 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
|
||||
│ └── statistics.ex # Reporting: member/cycle aggregates (counts, sums by year)
|
||||
│ └── secrets.ex # Secret management
|
||||
├── mv_web/ # Web interface layer
|
||||
│ ├── components/ # UI components
|
||||
│ │ ├── core_components.ex
|
||||
│ │ ├── table_components.ex
|
||||
│ │ ├── layouts.ex
|
||||
│ │ └── layouts/ # Layout templates
|
||||
│ │ ├── sidebar.ex
|
||||
│ │ ├── navbar.ex
|
||||
│ │ └── root.html.heex
|
||||
│ ├── controllers/ # HTTP controllers
|
||||
│ │ ├── auth_controller.ex
|
||||
|
|
@ -151,11 +116,6 @@ lib/
|
|||
│ │ ├── error_html.ex
|
||||
│ │ ├── error_json.ex
|
||||
│ │ └── page_html/
|
||||
│ ├── helpers/ # Web layer helper modules
|
||||
│ │ ├── member_helpers.ex # Member display utilities
|
||||
│ │ ├── membership_fee_helpers.ex # Membership fee formatting
|
||||
│ │ ├── date_formatter.ex # Date formatting utilities
|
||||
│ │ └── field_type_formatter.ex # Field type display formatting
|
||||
│ ├── live/ # LiveView modules
|
||||
│ │ ├── components/ # LiveView-specific components
|
||||
│ │ │ ├── search_bar_component.ex
|
||||
|
|
@ -163,21 +123,11 @@ lib/
|
|||
│ │ ├── member_live/ # Member CRUD LiveViews
|
||||
│ │ ├── custom_field_value_live/ # CustomFieldValue CRUD LiveViews
|
||||
│ │ ├── custom_field_live/
|
||||
│ │ ├── user_live/ # User management LiveViews
|
||||
│ │ ├── role_live/ # Role management LiveViews
|
||||
│ │ ├── membership_fee_type_live/ # Membership fee type LiveViews
|
||||
│ │ ├── membership_fee_settings_live.ex # Membership fee settings
|
||||
│ │ ├── global_settings_live.ex # Global settings
|
||||
│ │ ├── group_live/ # Group management LiveViews
|
||||
│ │ ├── import_export_live.ex # CSV import/export LiveView (mount/events/glue only)
|
||||
│ │ ├── import_export_live/ # Import/Export UI components
|
||||
│ │ │ └── components.ex # custom_fields_notice, template_links, import_form, progress, results
|
||||
│ │ ├── statistics_live.ex # Statistics page (aggregates, year filter, joins/exits by year)
|
||||
│ │ └── contribution_type_live/ # Contribution types (mock-up)
|
||||
│ │ └── user_live/ # User management LiveViews
|
||||
│ ├── auth_overrides.ex # AshAuthentication overrides
|
||||
│ ├── endpoint.ex # Phoenix endpoint
|
||||
│ ├── gettext.ex # I18n configuration
|
||||
│ ├── live_helpers.ex # LiveView lifecycle hooks and helpers
|
||||
│ ├── live_helpers.ex # LiveView helpers
|
||||
│ ├── live_user_auth.ex # LiveView authentication
|
||||
│ ├── router.ex # Application router
|
||||
│ └── telemetry.ex # Telemetry configuration
|
||||
|
|
@ -218,8 +168,7 @@ test/
|
|||
├── seeds_test.exs # Database seed tests
|
||||
└── support/ # Test helpers
|
||||
├── conn_case.ex # Controller test helpers
|
||||
├── data_case.ex # Data layer test helpers
|
||||
└── fixtures.ex # Shared test fixtures (Mv.Fixtures)
|
||||
└── data_case.ex # Data layer test helpers
|
||||
```
|
||||
|
||||
### 1.2 Module Organization
|
||||
|
|
@ -227,7 +176,7 @@ test/
|
|||
**Module Naming:**
|
||||
|
||||
- **Modules:** Use `PascalCase` with full namespace (e.g., `Mv.Accounts.User`)
|
||||
- **Domains:** Top-level domains are `Mv.Accounts`, `Mv.Membership`, `Mv.MembershipFees`, and `Mv.Authorization`
|
||||
- **Domains:** Top-level domains are `Mv.Accounts` and `Mv.Membership`
|
||||
- **Resources:** Resource modules should be singular nouns (e.g., `Member`, not `Members`)
|
||||
- **Context functions:** Use `snake_case` and verb-first naming (e.g., `create_user`, `list_members`)
|
||||
|
||||
|
|
@ -282,16 +231,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 +350,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 +588,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
|
||||
|
|
@ -682,180 +615,7 @@ def card(assigns) do
|
|||
end
|
||||
```
|
||||
|
||||
### 3.3 CSV Import Configuration
|
||||
|
||||
**CSV Import Limits:**
|
||||
|
||||
CSV import functionality supports configurable limits to prevent resource exhaustion:
|
||||
|
||||
```elixir
|
||||
# config/config.exs
|
||||
config :mv,
|
||||
csv_import: [
|
||||
max_file_size_mb: 10, # Maximum file size in megabytes
|
||||
max_rows: 1000 # Maximum number of data rows (excluding header)
|
||||
]
|
||||
```
|
||||
|
||||
**Accessing Configuration:**
|
||||
|
||||
Use `Mv.Config` helper functions:
|
||||
|
||||
```elixir
|
||||
# Get max file size in bytes
|
||||
max_bytes = Mv.Config.csv_import_max_file_size_bytes()
|
||||
|
||||
# Get max file size in megabytes
|
||||
max_mb = Mv.Config.csv_import_max_file_size_mb()
|
||||
|
||||
# Get max rows
|
||||
max_rows = Mv.Config.csv_import_max_rows()
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
- Set reasonable limits based on server resources
|
||||
- Display limits to users in UI
|
||||
- Validate file size before upload
|
||||
- Process imports in chunks (default: 200 rows per chunk)
|
||||
- Cap error collection (default: 50 errors per import)
|
||||
|
||||
### 3.4 Page-Level Authorization
|
||||
|
||||
**CheckPagePermission Plug:**
|
||||
|
||||
Use `MvWeb.Plugs.CheckPagePermission` for page-level authorization:
|
||||
|
||||
```elixir
|
||||
# lib/mv_web/router.ex
|
||||
defmodule MvWeb.Router do
|
||||
use MvWeb, :router
|
||||
|
||||
# Add plug to router pipeline
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_live_flash
|
||||
plug :put_root_layout, html: {MvWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug MvWeb.Plugs.CheckPagePermission # Page-level authorization
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Permission Set Route Matrix:**
|
||||
|
||||
Routes are mapped to permission sets:
|
||||
- `own_data`: Can access `/profile` and `/members/:id` (own linked member only)
|
||||
- `read_only`: Can read all data, cannot modify
|
||||
- `normal_user`: Can read and modify most data
|
||||
- `admin`: Full access to all routes
|
||||
|
||||
**Usage in LiveViews:**
|
||||
|
||||
```elixir
|
||||
# Check page access before mount
|
||||
def mount(_params, _session, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
if MvWeb.Authorization.can_access_page?(actor, "/admin/roles") do
|
||||
{:ok, assign(socket, :roles, load_roles(actor))}
|
||||
else
|
||||
{:ok, redirect(socket, to: ~p"/")}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Public Paths:**
|
||||
|
||||
Public paths (login, OIDC callbacks) are excluded from permission checks automatically.
|
||||
|
||||
### 3.5 System Actor Pattern
|
||||
|
||||
**When to Use System Actor:**
|
||||
|
||||
Some operations must always run regardless of user permissions. These are **systemic operations** that are mandatory side effects:
|
||||
|
||||
- **Email synchronization** (Member ↔ User)
|
||||
- **Email uniqueness validation** (data integrity requirement)
|
||||
- **Cycle generation** (if defined as mandatory side effect)
|
||||
- **Background jobs**
|
||||
- **Seeds**
|
||||
|
||||
**Implementation:**
|
||||
|
||||
Use `Mv.Helpers.SystemActor.get_system_actor/0` for all systemic operations:
|
||||
|
||||
```elixir
|
||||
# Good - Email sync uses system actor
|
||||
def get_linked_member(user) do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
opts = Helpers.ash_actor_opts(system_actor)
|
||||
|
||||
case Ash.get(Mv.Membership.Member, id, opts) do
|
||||
{:ok, member} -> member
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Bad - Using user actor for systemic operation
|
||||
def get_linked_member(user, actor) do
|
||||
opts = Helpers.ash_actor_opts(actor) # May fail if user lacks permissions!
|
||||
# ...
|
||||
end
|
||||
```
|
||||
|
||||
**System Actor Details:**
|
||||
|
||||
- System actor is a user with admin role (email: "system@mila.local")
|
||||
- Cached in Agent for performance
|
||||
- Falls back to admin user from seeds if system user doesn't exist
|
||||
- Should NEVER be used for user-initiated actions (only systemic operations)
|
||||
|
||||
**DO NOT use system actor as a fallback:**
|
||||
|
||||
- **Never** fall back to `Mv.Helpers.SystemActor.get_system_actor()` when an actor is missing or nil (e.g. in validations, changes, or when reading from context).
|
||||
- Fallbacks hide bugs (callers forget to pass actor) and can cause privilege escalation (unauthenticated or low-privilege paths run with system rights).
|
||||
- If no actor is available, fail explicitly (validation error, Forbidden, or clear error message). Fix the caller to pass the correct actor instead of adding a fallback.
|
||||
- Use system actor only where the operation is **explicitly** a systemic operation (see list above); never as a "safety net" when actor is absent.
|
||||
|
||||
**User Mode vs System Mode:**
|
||||
|
||||
- **User Mode**: User-initiated actions use the actual user actor, policies are enforced
|
||||
- **System Mode**: Systemic operations use system actor, bypass user permissions
|
||||
|
||||
**Authorization Bootstrap Patterns:**
|
||||
|
||||
Two mechanisms exist for bypassing standard authorization:
|
||||
|
||||
1. **system_actor** (systemic operations) - Admin user for operations that must always succeed
|
||||
```elixir
|
||||
# Good: Systemic operation
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
Ash.read(Member, actor: system_actor)
|
||||
|
||||
# Bad: User-initiated action
|
||||
# Never use system_actor for user-initiated actions!
|
||||
```
|
||||
|
||||
2. **authorize?: false** (bootstrap only) - Skips policies for circular dependencies
|
||||
```elixir
|
||||
# Good: Bootstrap (seeds, SystemActor loading)
|
||||
Accounts.create_user!(%{email: admin_email}, authorize?: false)
|
||||
|
||||
# Bad: User-initiated action
|
||||
Ash.destroy(member, authorize?: false) # Never do this!
|
||||
```
|
||||
|
||||
**Decision Guide:**
|
||||
- Use **system_actor** for email sync, cycle generation, validations, and test fixtures
|
||||
- Use **authorize?: false** only for bootstrap (seeds, circular dependencies)
|
||||
- Always document why `authorize?: false` is necessary
|
||||
- **Note:** NoActor bypass was removed to prevent masking authorization bugs in tests
|
||||
|
||||
**See also:** `docs/roles-and-permissions-architecture.md` (Authorization Bootstrap Patterns section)
|
||||
|
||||
### 3.6 Ash Framework
|
||||
### 3.3 Ash Framework
|
||||
|
||||
**Resource Definition Best Practices:**
|
||||
|
||||
|
|
@ -1009,9 +769,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
|
||||
|
|
@ -1058,17 +818,14 @@ end
|
|||
|
||||
```heex
|
||||
<!-- Leverage DaisyUI component classes -->
|
||||
<!-- Note: Navbar has been replaced with Sidebar (see lib/mv_web/components/layouts/sidebar.ex) -->
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input id="drawer-toggle" type="checkbox" class="drawer-toggle" />
|
||||
<div class="drawer-content">
|
||||
<!-- Page content -->
|
||||
<div class="navbar bg-base-100">
|
||||
<div class="navbar-start">
|
||||
<a class="btn btn-ghost text-xl">Mila</a>
|
||||
</div>
|
||||
<div class="drawer-side">
|
||||
<label for="drawer-toggle" class="drawer-overlay"></label>
|
||||
<aside class="w-64 min-h-full bg-base-200">
|
||||
<!-- Sidebar content -->
|
||||
</aside>
|
||||
<div class="navbar-end">
|
||||
<.link navigate={~p"/members"} class="btn btn-primary">
|
||||
Members
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
|
@ -1144,18 +901,8 @@ 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:**
|
||||
|
||||
We use **Credo** for static code analysis to ensure code quality, consistency, and maintainability. Credo checks are **mandatory** and must pass before code can be merged.
|
||||
|
||||
**Run Credo Regularly:**
|
||||
|
||||
```bash
|
||||
|
|
@ -1166,13 +913,6 @@ mix credo
|
|||
mix credo --strict
|
||||
```
|
||||
|
||||
**CI Enforcement:**
|
||||
|
||||
- ✅ **All Credo checks must pass in CI pipeline**
|
||||
- ✅ Pull requests will be blocked if Credo checks fail
|
||||
- ✅ Run `mix credo --strict` locally before pushing to catch issues early
|
||||
- ✅ Address all Credo warnings and errors before requesting code review
|
||||
|
||||
**Key Credo Checks Enabled:**
|
||||
|
||||
- Consistency checks (spacing, line endings, parameter patterns)
|
||||
|
|
@ -1266,72 +1006,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 +1045,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 +1052,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
|
||||
|
|
@ -1426,8 +1122,7 @@ test/
|
|||
│ └── components/
|
||||
└── support/ # Test helpers
|
||||
├── conn_case.ex # Controller test setup
|
||||
├── data_case.ex # Database test setup
|
||||
└── fixtures.ex # Shared test fixtures (Mv.Fixtures)
|
||||
└── data_case.ex # Database test setup
|
||||
```
|
||||
|
||||
**Test File Naming:**
|
||||
|
|
@ -1589,8 +1284,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,42 +1408,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.
|
||||
|
||||
**What We Test:**
|
||||
- Business rules and validations specific to our domain
|
||||
- Custom business logic (slug generation, calculations, etc.)
|
||||
- Integration between our resources
|
||||
- Database-level constraints (unique constraints, foreign keys, CASCADE)
|
||||
- Query performance (N+1 prevention)
|
||||
|
||||
**What We Don't Test:**
|
||||
- Framework core functionality (Ash validations work, Ecto relationships work, etc.)
|
||||
- Standard CRUD operations without custom logic
|
||||
- Framework-provided features that are already tested upstream
|
||||
- Detailed slug generation edge cases (Umlauts, truncation, etc.) if covered by reusable change tests
|
||||
|
||||
**Example:**
|
||||
```elixir
|
||||
# ✅ GOOD - Tests our business rule
|
||||
test "slug is immutable (doesn't change when name is updated)" do
|
||||
{:ok, group} = Membership.create_group(%{name: "Original"}, actor: actor)
|
||||
original_slug = group.slug
|
||||
|
||||
{:ok, updated} = Membership.update_group(group, %{name: "New"}, actor: actor)
|
||||
assert updated.slug == original_slug # Business rule: slug doesn't change
|
||||
end
|
||||
|
||||
# ❌ AVOID - Tests framework functionality
|
||||
test "Ash.Changeset validates required fields" do
|
||||
# This is already tested by Ash framework
|
||||
end
|
||||
```
|
||||
|
||||
**Descriptive Test Names:**
|
||||
|
||||
```elixir
|
||||
|
|
@ -1845,87 +1502,10 @@ end
|
|||
- Mock external services
|
||||
- Use fixtures efficiently
|
||||
|
||||
**Performance Tests:**
|
||||
|
||||
Performance tests that explicitly validate performance characteristics should be tagged with `@tag :slow` or `@describetag :slow` to exclude them from standard test runs. This improves developer feedback loops while maintaining comprehensive coverage.
|
||||
|
||||
**When to Tag as `:slow`:**
|
||||
|
||||
- Tests that explicitly measure execution time or validate performance characteristics
|
||||
- Tests that use large datasets (e.g., 50+ records) to test scalability
|
||||
- Tests that validate query optimization (N+1 prevention, index usage)
|
||||
|
||||
**Tagging Guidelines:**
|
||||
|
||||
- Use `@tag :slow` for individual tests
|
||||
- Use `@describetag :slow` for entire describe blocks (not `@moduletag`, as it affects all tests in the module)
|
||||
- Performance tests should include measurable assertions (query counts, timing with tolerance, etc.)
|
||||
|
||||
**UI Tests:**
|
||||
|
||||
UI tests that validate basic HTML rendering, Phoenix LiveView navigation, or framework functionality (Gettext translations, form elements, UI state changes) should be tagged with `@tag :ui` or `@describetag :ui` to exclude them from fast CI runs. Use `@tag :ui` for individual tests and `@describetag :ui` for describe blocks. UI tests can be consolidated when they test similar elements (e.g., multiple translation tests combined into one). Do not tag business logic tests (e.g., "can delete a user"), validation tests, or data persistence tests as `:ui`.
|
||||
|
||||
**Running Tests:**
|
||||
|
||||
```bash
|
||||
# Fast tests only (excludes slow and UI tests)
|
||||
mix test --exclude slow --exclude ui
|
||||
# Or use the Justfile command:
|
||||
just test-fast
|
||||
|
||||
# UI tests only
|
||||
mix test --only ui
|
||||
# Or use the Justfile command:
|
||||
just ui
|
||||
|
||||
# Performance tests only
|
||||
mix test --only slow
|
||||
# Or use the Justfile command:
|
||||
just slow
|
||||
|
||||
# All tests (including slow and UI tests)
|
||||
mix test
|
||||
# Or use the Justfile command:
|
||||
just test
|
||||
# Or use the Justfile command:
|
||||
just test-all
|
||||
```
|
||||
|
||||
**Test Organization Best Practices:**
|
||||
|
||||
- **Fast Tests (Standard CI):** Business logic, validations, data persistence, edge cases
|
||||
- **UI Tests (Full Test Suite):** Basic HTML rendering, navigation, translations, UI state
|
||||
- **Performance Tests (Full Test Suite):** Query optimization, large datasets, timing assertions
|
||||
|
||||
This organization ensures fast feedback in standard CI while maintaining comprehensive coverage via promotion before merge.
|
||||
---
|
||||
|
||||
## 5. Security Guidelines
|
||||
|
||||
### 5.0 No system-actor fallbacks (mandatory)
|
||||
|
||||
**Do not use the system actor as a fallback when an actor is missing.**
|
||||
|
||||
Examples of forbidden patterns:
|
||||
|
||||
```elixir
|
||||
# ❌ FORBIDDEN - Fallback to system actor when actor is nil
|
||||
actor = Map.get(changeset.context, :actor) || Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# ❌ FORBIDDEN - "Safety" fallback in validations, changes, or helpers
|
||||
actor = opts[:actor] || Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# ❌ FORBIDDEN - Default actor in function options
|
||||
def list_something(opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor) || Mv.Helpers.SystemActor.get_system_actor()
|
||||
# ...
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** Fallbacks hide missing-actor bugs and can lead to privilege escalation (e.g. a request without actor would run with system privileges). Always require the caller to pass the actor for user-facing or context-dependent operations; if none is available, return an error or fail validation instead of using the system actor.
|
||||
|
||||
**Allowed:** Use the system actor only where the operation is **by design** a systemic operation (e.g. email sync, seeds, test fixtures, background jobs) and you explicitly call `SystemActor.get_system_actor()` at that call site—never as a fallback when `actor` is nil or absent.
|
||||
|
||||
### 5.1 Authentication & Authorization
|
||||
|
||||
**Use AshAuthentication:**
|
||||
|
|
@ -1939,7 +1519,7 @@ authentication do
|
|||
hashed_password_field :hashed_password
|
||||
end
|
||||
|
||||
oidc :oidc do
|
||||
oauth2 :rauthy do
|
||||
# OIDC configuration
|
||||
end
|
||||
end
|
||||
|
|
@ -1955,128 +1535,17 @@ policies do
|
|||
authorize_if always()
|
||||
end
|
||||
|
||||
# Use HasPermission check for role-based authorization
|
||||
policy action_type([:read, :update, :create, :destroy]) do
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
# Specific permissions
|
||||
policy action_type([:read, :update]) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
end
|
||||
|
||||
policy action_type(:destroy) do
|
||||
authorize_if actor_attribute_equals(:role, :admin)
|
||||
end
|
||||
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:
|
||||
|
||||
```elixir
|
||||
# In LiveView modules
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, ash_actor_opts: 1, submit_form: 3]
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
case Ash.read(Mv.Membership.Member, ash_actor_opts(actor)) do
|
||||
{:ok, members} ->
|
||||
{:ok, assign(socket, :members, members)}
|
||||
{:error, error} ->
|
||||
{:ok, put_flash(socket, :error, "Failed to load members")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("save", %{"member" => params}, socket) do
|
||||
actor = current_actor(socket)
|
||||
form = AshPhoenix.Form.for_create(Mv.Membership.Member, :create)
|
||||
|
||||
case submit_form(form, params, actor) do
|
||||
{:ok, member} ->
|
||||
{:noreply, push_navigate(socket, to: ~p"/members/#{member.id}")}
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, :form, form)}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Never use bang calls (`Ash.read!`, `Ash.get!`) without error handling:**
|
||||
|
||||
```elixir
|
||||
# Bad - will crash on authorization errors
|
||||
members = Ash.read!(Mv.Membership.Member, actor: actor)
|
||||
|
||||
# Good - proper error handling
|
||||
case Ash.read(Mv.Membership.Member, actor: actor) do
|
||||
{:ok, members} -> # success
|
||||
{:error, %Ash.Error.Forbidden{}} -> # handle authorization error
|
||||
{:error, error} -> # handle other errors
|
||||
end
|
||||
```
|
||||
|
||||
### 5.1a Authorization in Tests
|
||||
|
||||
**IMPORTANT:** All tests must explicitly provide an actor for Ash operations. The NoActor bypass has been removed to prevent masking authorization bugs.
|
||||
|
||||
**Exception: AshAuthentication Bypass Tests**
|
||||
|
||||
Tests that verify the AshAuthentication bypass mechanism are a **conscious exception**. These tests must verify that registration/login works **without an actor** via the `AshAuthenticationInteraction` check. To enable this bypass in tests, set the context explicitly:
|
||||
|
||||
```elixir
|
||||
# ✅ GOOD - Testing AshAuthentication bypass (conscious exception)
|
||||
changeset =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{...})
|
||||
|> Ash.Changeset.set_context(%{private: %{ash_authentication?: true}})
|
||||
|
||||
{:ok, user} = Ash.create(changeset) # No actor - tests bypass mechanism
|
||||
|
||||
# ❌ BAD - Using system_actor masks the bypass test
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
Ash.create(changeset, actor: system_actor) # Tests admin permissions, not bypass!
|
||||
```
|
||||
|
||||
**Test Fixtures:**
|
||||
|
||||
All test fixtures use `system_actor` for authorization:
|
||||
|
||||
```elixir
|
||||
# test/support/fixtures.ex
|
||||
def member_fixture(attrs \\ %{}) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
attrs
|
||||
|> Enum.into(%{...})
|
||||
|> Mv.Membership.create_member(actor: system_actor)
|
||||
end
|
||||
```
|
||||
|
||||
**Why Explicit Actors in Tests:**
|
||||
|
||||
- Prevents masking authorization bugs
|
||||
- Makes authorization requirements explicit
|
||||
- Tests fail if authorization is broken (fail-fast)
|
||||
- Consistent with production code patterns
|
||||
|
||||
**Using system_actor in Tests:**
|
||||
|
||||
```elixir
|
||||
# ✅ GOOD - Explicit actor in tests
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
Ash.create!(Member, attrs, actor: system_actor)
|
||||
|
||||
# ❌ BAD - Missing actor (will fail)
|
||||
Ash.create!(Member, attrs) # Forbidden error!
|
||||
```
|
||||
|
||||
**For Bootstrap Operations:**
|
||||
|
||||
Use `authorize?: false` only for bootstrap scenarios (seeds, SystemActor initialization):
|
||||
|
||||
```elixir
|
||||
# ✅ GOOD - Bootstrap only
|
||||
Accounts.create_user!(%{email: admin_email}, authorize?: false)
|
||||
|
||||
# ❌ BAD - Never use in tests for normal operations
|
||||
Ash.create!(Member, attrs, authorize?: false) # Never do this!
|
||||
```
|
||||
|
||||
### 5.2 Password Security
|
||||
|
||||
**Use bcrypt for Password Hashing:**
|
||||
|
|
@ -2166,7 +1635,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 +1651,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 +2264,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 +2312,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 +2321,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 +2389,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 +2488,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 +2538,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.
|
||||
|
||||
---
|
||||
16
Dockerfile
16
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
|
||||
|
|
@ -90,4 +90,4 @@ USER nobody
|
|||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||
# ENTRYPOINT ["/tini", "--"]
|
||||
|
||||
ENTRYPOINT ["/app/bin/docker-entrypoint.sh"]
|
||||
CMD ["/app/bin/server"]
|
||||
|
|
|
|||
115
Justfile
115
Justfile
|
|
@ -1,26 +1,12 @@
|
|||
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 +19,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
|
||||
|
||||
gettext:
|
||||
mix gettext.extract
|
||||
|
|
@ -65,56 +28,19 @@ gettext:
|
|||
lint:
|
||||
mix format --check-formatted
|
||||
mix compile --warnings-as-errors
|
||||
mix credo --strict
|
||||
# Check that all German translations are filled (UI must be in German)
|
||||
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
|
||||
mix gettext.extract --check-up-to-date
|
||||
mix credo
|
||||
|
||||
# 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:
|
||||
mix test {{args}}
|
||||
|
||||
# Fast tests only (excludes slow/performance and UI tests).
|
||||
test-fast *args:
|
||||
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}}
|
||||
|
||||
# Run only slow/performance tests
|
||||
slow *args: install-dependencies
|
||||
mix test --only slow {{args}}
|
||||
|
||||
# Run only slow/performance tests (alias for consistency)
|
||||
test-slow *args: install-dependencies
|
||||
mix test --only slow {{args}}
|
||||
|
||||
# Run all tests (fast + slow + ui)
|
||||
test-all *args: install-dependencies
|
||||
test *args: install-dependencies start-database
|
||||
mix test {{args}}
|
||||
|
||||
format:
|
||||
mix format
|
||||
|
||||
# Catch-all wrapper for arbitrary mix commands not exposed as their own recipe.
|
||||
mix *args:
|
||||
mix {{args}}
|
||||
|
||||
build-docker-container:
|
||||
docker build --tag mitgliederverwaltung .
|
||||
|
||||
|
|
@ -157,33 +83,4 @@ regen-migrations migration_name commit_hash='':
|
|||
clean:
|
||||
mix clean
|
||||
rm -rf .elixir_ls
|
||||
rm -rf _build
|
||||
|
||||
# Remove Git merge conflict markers from gettext files
|
||||
remove-gettext-conflicts:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
find priv/gettext -type f -exec sed -i '/^<<<<<<</d; /^=======$/d; /^>>>>>>>/d; /^%%%%%%%/d; /^++++++/d; s/^+//; s/^-//' {} \;
|
||||
|
||||
# Production environment commands
|
||||
# ================================
|
||||
|
||||
# Initialize secrets directory with generated secrets (only if not exists)
|
||||
init-prod-secrets:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ -d "secrets" ]; then
|
||||
echo "Secrets directory already exists. Skipping generation."
|
||||
exit 0
|
||||
fi
|
||||
echo "Creating secrets directory and generating secrets..."
|
||||
mkdir -p secrets
|
||||
mix phx.gen.secret > secrets/secret_key_base.txt
|
||||
mix phx.gen.secret > secrets/token_signing_secret.txt
|
||||
openssl rand -base64 32 | tr -d '\n' > secrets/db_password.txt
|
||||
touch secrets/oidc_client_secret.txt
|
||||
echo "Secrets generated in ./secrets/"
|
||||
|
||||
# Start production environment with Docker Compose
|
||||
start-prod: init-prod-secrets
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
rm -rf _build
|
||||
112
README.md
112
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,26 +32,23 @@ 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
|
||||
|
||||
- ✅ Manage member data with ease
|
||||
- ✅ Membership fees & payment status tracking
|
||||
- ✅ Full-text search with fuzzy matching
|
||||
- ✅ Sorting & filtering
|
||||
- ✅ Roles & permissions (RBAC system with 4 permission sets)
|
||||
- 🚧 Overview of membership fees & payment status
|
||||
- ✅ Full-text search
|
||||
- 🚧 Sorting & filtering
|
||||
- 🚧 Roles & permissions (e.g. board, treasurer)
|
||||
- ✅ Custom fields (flexible per club needs)
|
||||
- ✅ SSO via OIDC (works with Authentik, Rauthy, Keycloak, etc.)
|
||||
- ✅ Sidebar navigation (standard-compliant, accessible)
|
||||
- ✅ Global settings management
|
||||
- ✅ Self-service & online application
|
||||
- ✅ Accessibility improvements (WCAG 2.1 AA compliant keyboard navigation)
|
||||
- ✅ Email sending
|
||||
- ✅ Integration of Accounting-Software ([Vereinfacht](https://github.com/vereinfacht/vereinfacht))
|
||||
- ✅ SSO via OIDC (tested with Rauthy)
|
||||
- 🚧 Self-service & online application
|
||||
- 🚧 Accessibility, GDPR, usability improvements
|
||||
- 🚧 Email sending
|
||||
|
||||
## 🚀 Quick Start (Development)
|
||||
|
||||
|
|
@ -106,9 +103,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 +118,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,32 +133,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
|
||||
|
||||
### OIDC with other providers (Authentik, Keycloak, etc.)
|
||||
|
||||
Mila works with any OIDC-compliant provider. The internal strategy is named `:oidc` — it works with any OIDC-compliant provider.
|
||||
|
||||
**Important:** The redirect URI must always end with `/auth/user/oidc/callback`.
|
||||
|
||||
Example for Authentik:
|
||||
1. Create an OAuth2/OpenID Provider in Authentik
|
||||
2. Set the redirect URI to: `https://your-domain.com/auth/user/oidc/callback`
|
||||
3. Configure environment variables:
|
||||
```bash
|
||||
DOMAIN=your-domain.com # or PHX_HOST=your-domain.com
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_BASE_URL=https://auth.example.com/application/o/your-app
|
||||
OIDC_CLIENT_SECRET=your-client-secret # or use OIDC_CLIENT_SECRET_FILE
|
||||
```
|
||||
|
||||
The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/oidc/callback` if not explicitly set.
|
||||
Now you can log in to Mila via OIDC!
|
||||
|
||||
## ⚙️ 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
|
||||
|
||||
|
|
@ -175,12 +168,9 @@ The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/oidc/ca
|
|||
- **Auth:** AshAuthentication (OIDC + password)
|
||||
|
||||
**Code Structure:**
|
||||
- `lib/accounts/` & `lib/membership/` & `lib/membership_fees/` & `lib/mv/authorization/` — Ash resources and domains
|
||||
- `lib/accounts/` & `lib/membership/` — Ash resources and domains
|
||||
- `lib/mv_web/` — Phoenix controllers, LiveViews, components
|
||||
- `lib/mv/` — Shared helpers and business logic
|
||||
- `assets/` — Tailwind, JavaScript, static files
|
||||
- `test/` — All tests
|
||||
|
||||
|
||||
📚 **Full tech stack details:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
|
||||
📖 **Implementation history:** See [`docs/development-progress-log.md`](docs/development-progress-log.md)
|
||||
|
|
@ -216,19 +206,35 @@ 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>
|
||||
PHX_HOST=localhost
|
||||
|
||||
# Optional (have defaults in docker-compose.prod.yml):
|
||||
# OIDC_CLIENT_ID=mv
|
||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
||||
# OIDC_CLIENT_SECRET=<from-rauthy-client>
|
||||
```
|
||||
|
||||
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. **Run database migrations:**
|
||||
```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)
|
||||
|
||||
|
|
@ -244,16 +250,16 @@ For actual production deployment:
|
|||
- Set `OIDC_BASE_URL` to your production OIDC provider
|
||||
- Configure proper Docker networks
|
||||
3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik)
|
||||
4. **Use secure secrets management** — All sensitive environment variables support a `_FILE` suffix for Docker secrets (e.g., `SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base`). See `docker-compose.prod.yml` for an example setup with Docker secrets.
|
||||
4. **Use secure secrets management** (environment variables, Docker secrets, vault)
|
||||
5. **Configure database backups**
|
||||
|
||||
|
||||
## 🤝 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 +269,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,677 +99,4 @@
|
|||
/* Make LiveView wrapper divs transparent for layout */
|
||||
[data-phx-session] { display: contents }
|
||||
|
||||
/* Honeypot: off-screen and minimal size so bots fill it, humans never see it (best practice) */
|
||||
.join-form-helper {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.join-form-helper .join-form-helper-input {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
}
|
||||
|
||||
/* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers.
|
||||
Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline
|
||||
spacing; use inherited values so custom stylesheets can override. */
|
||||
[popover] {
|
||||
line-height: inherit;
|
||||
letter-spacing: inherit;
|
||||
word-spacing: inherit;
|
||||
}
|
||||
|
||||
/* WCAG 2 AA: success/error/warning text. Light theme: dark tones on light bg; dark theme: light tones on dark bg. */
|
||||
.text-success-aa {
|
||||
color: oklch(0.35 0.12 165);
|
||||
}
|
||||
|
||||
.text-error-aa {
|
||||
color: oklch(0.45 0.2 25);
|
||||
}
|
||||
|
||||
.text-warning-aa {
|
||||
color: oklch(0.45 0.14 75);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .text-success-aa {
|
||||
color: oklch(0.72 0.12 165);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .text-error-aa {
|
||||
color: oklch(0.75 0.18 25);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .text-warning-aa {
|
||||
color: oklch(0.78 0.14 75);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use
|
||||
Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures
|
||||
outline badges always have a visible background in both themes. */
|
||||
[data-theme="light"] .badge.badge-outline,
|
||||
[data-theme="dark"] .badge.badge-outline {
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA (4.5:1 for normal text): Form labels. DaisyUI .label uses 60% opacity,
|
||||
which fails contrast. Override to 85% of base-content so labels stay slightly
|
||||
de‑emphasised vs body text but meet the minimum ratio. Match .label directly
|
||||
so the override applies even when data-theme is not yet set (e.g. initial load). */
|
||||
.label {
|
||||
color: color-mix(in oklab, var(--color-base-content) 85%, transparent);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background.
|
||||
Theme tokens *-content are often too light on * backgrounds in light theme, and
|
||||
badge-soft uses variant as text on a light tint (low contrast). We override
|
||||
--badge-fg (and for soft, color) so badge text meets 4.5:1 in both themes. */
|
||||
|
||||
/* Light theme: use dark text on all colored badges (solid, soft, outline). */
|
||||
[data-theme="light"] .badge.badge-primary {
|
||||
--badge-fg: oklch(0.25 0.08 47);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-primary.badge-soft {
|
||||
color: oklch(0.38 0.14 47);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-success {
|
||||
--badge-fg: oklch(0.26 0.06 165);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-success.badge-soft {
|
||||
color: oklch(0.35 0.10 165);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-error {
|
||||
--badge-fg: oklch(0.22 0.08 25);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-error.badge-soft {
|
||||
color: oklch(0.38 0.14 25);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-warning {
|
||||
--badge-fg: oklch(0.28 0.06 75);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-warning.badge-soft {
|
||||
color: oklch(0.42 0.12 75);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-info {
|
||||
--badge-fg: oklch(0.26 0.08 250);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-info.badge-soft {
|
||||
color: oklch(0.38 0.12 250);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-neutral {
|
||||
--badge-fg: oklch(0.22 0.01 285);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-neutral.badge-soft {
|
||||
color: oklch(0.32 0.02 285);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-outline.badge-primary,
|
||||
[data-theme="light"] .badge.badge-outline.badge-success,
|
||||
[data-theme="light"] .badge.badge-outline.badge-error,
|
||||
[data-theme="light"] .badge.badge-outline.badge-warning,
|
||||
[data-theme="light"] .badge.badge-outline.badge-info,
|
||||
[data-theme="light"] .badge.badge-outline.badge-neutral {
|
||||
--badge-fg: oklch(0.25 0.02 285);
|
||||
}
|
||||
|
||||
/* Dark theme: ensure badge backgrounds are dark enough for light content (4.5:1).
|
||||
Slightly darken solid variant backgrounds so theme *-content (light) passes. */
|
||||
[data-theme="dark"] .badge.badge-primary:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.42 0.20 277);
|
||||
--badge-fg: oklch(0.97 0.02 277);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-success:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.42 0.10 185);
|
||||
--badge-fg: oklch(0.97 0.01 185);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-error:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.42 0.18 18);
|
||||
--badge-fg: oklch(0.97 0.02 18);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-warning:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.48 0.14 58);
|
||||
--badge-fg: oklch(0.22 0.02 58);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-info:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.45 0.14 242);
|
||||
--badge-fg: oklch(0.97 0.02 242);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-neutral:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.32 0.02 257);
|
||||
--badge-fg: oklch(0.96 0.01 257);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-soft.badge-primary { color: oklch(0.85 0.12 277); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-success { color: oklch(0.82 0.08 165); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-error { color: oklch(0.82 0.14 25); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-warning { color: oklch(0.88 0.10 75); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-info { color: oklch(0.85 0.10 250); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-neutral { color: oklch(0.90 0.01 257); }
|
||||
[data-theme="dark"] .badge.badge-outline.badge-primary,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-success,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-error,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-warning,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-info,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-neutral {
|
||||
--badge-fg: oklch(0.92 0.02 257);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA: Member filter join buttons (All / Paid / Unpaid, group, boolean).
|
||||
Inactive state uses base-content on a light/dark surface; active state ensures
|
||||
*-content on * background meets 4.5:1. */
|
||||
.member-filter-dropdown .join .btn {
|
||||
/* Inactive: ensure readable text (theme base-content may be low contrast on btn default) */
|
||||
border-color: var(--color-base-300);
|
||||
}
|
||||
[data-theme="light"] .member-filter-dropdown .join .btn:not(.btn-active) {
|
||||
color: oklch(0.25 0.02 285);
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
[data-theme="light"] .member-filter-dropdown .join .btn.btn-success.btn-active {
|
||||
background-color: oklch(0.42 0.12 165);
|
||||
color: oklch(0.98 0.01 165);
|
||||
}
|
||||
[data-theme="light"] .member-filter-dropdown .join .btn.btn-error.btn-active {
|
||||
background-color: oklch(0.42 0.18 18);
|
||||
color: oklch(0.98 0.02 18);
|
||||
}
|
||||
[data-theme="dark"] .member-filter-dropdown .join .btn:not(.btn-active) {
|
||||
color: oklch(0.92 0.02 257);
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-success.btn-active {
|
||||
background-color: oklch(0.42 0.10 165);
|
||||
color: oklch(0.97 0.01 165);
|
||||
}
|
||||
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-error.btn-active {
|
||||
background-color: oklch(0.42 0.18 18);
|
||||
color: oklch(0.97 0.02 18);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Sidebar Base Styles
|
||||
============================================ */
|
||||
|
||||
/* Desktop Sidebar Base */
|
||||
.sidebar {
|
||||
@apply flex flex-col bg-base-200 min-h-screen;
|
||||
@apply transition-[width] duration-300 ease-in-out;
|
||||
@apply relative;
|
||||
width: 16rem; /* Expanded: w-64 */
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
/* Collapsed State */
|
||||
[data-sidebar-expanded="false"] .sidebar {
|
||||
width: 4rem; /* Collapsed: w-16 */
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Header - Logo Centering
|
||||
============================================ */
|
||||
|
||||
/* Header container with smooth transition for gap */
|
||||
.sidebar > div:first-child {
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Text Labels - Hide in Collapsed State
|
||||
============================================ */
|
||||
|
||||
.menu-label {
|
||||
@apply transition-all duration-200 whitespace-nowrap;
|
||||
transition-delay: 0ms; /* Expanded: sofort sichtbar */
|
||||
}
|
||||
|
||||
[data-sidebar-expanded="false"] .sidebar .menu-label {
|
||||
@apply opacity-0 w-0 overflow-hidden pointer-events-none;
|
||||
transition-delay: 300ms; /* Warte bis Sidebar eingeklappt ist (300ms = duration der Sidebar width transition) */
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Toggle Button Icon Swap
|
||||
============================================ */
|
||||
|
||||
.sidebar-collapsed-icon {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
[data-sidebar-expanded="false"] .sidebar .sidebar-expanded-icon {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
[data-sidebar-expanded="false"] .sidebar .sidebar-collapsed-icon {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Menu Groups - Show/Hide Based on State
|
||||
============================================ */
|
||||
|
||||
.expanded-menu-group {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.collapsed-menu-group {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
[data-sidebar-expanded="false"] .sidebar .expanded-menu-group {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
[data-sidebar-expanded="false"] .sidebar .collapsed-menu-group {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
/* Collapsed menu group button: center icon under logo */
|
||||
.sidebar .collapsed-menu-group button {
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Menu Groups - Disable hover and active on expanded-menu-group header
|
||||
============================================ */
|
||||
|
||||
/* Disable all interactive effects on expanded-menu-group header (no href, not clickable)
|
||||
Using [role="group"] to increase specificity and avoid !important */
|
||||
.sidebar .menu > li.expanded-menu-group > div[role="group"]:not(a) {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Higher specificity selector to override DaisyUI menu hover styles
|
||||
DaisyUI uses :where() which has 0 specificity, but the compiled CSS might have higher specificity
|
||||
Using [role="group"] attribute selector increases specificity without !important */
|
||||
.sidebar .menu > li.expanded-menu-group > div[role="group"]:not(a):hover,
|
||||
.sidebar .menu > li.expanded-menu-group > div[role="group"]:not(a):active,
|
||||
.sidebar .menu > li.expanded-menu-group > div[role="group"]:not(a):focus {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Elements Only Visible in Expanded State
|
||||
============================================ */
|
||||
|
||||
.expanded-only {
|
||||
@apply block transition-opacity duration-200;
|
||||
}
|
||||
|
||||
[data-sidebar-expanded="false"] .sidebar .expanded-only {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Tooltip - Only Show in Collapsed State
|
||||
============================================ */
|
||||
|
||||
.sidebar .tooltip::before,
|
||||
.sidebar .tooltip::after {
|
||||
@apply opacity-0 pointer-events-none;
|
||||
}
|
||||
|
||||
[data-sidebar-expanded="false"] .sidebar .tooltip:hover::before,
|
||||
[data-sidebar-expanded="false"] .sidebar .tooltip:hover::after {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Menu Item Alignment - Icons Centered Under Logo
|
||||
============================================ */
|
||||
|
||||
/* Base alignment: Icons centered under logo (32px from left edge)
|
||||
- Logo center: 16px padding + 16px (half of 32px) = 32px
|
||||
- Icon center should be at 32px: 22px start + 10px (half of 20px) = 32px
|
||||
- Menu has p-2 (8px), so links need 14px additional padding-left */
|
||||
|
||||
.sidebar .menu > li > a,
|
||||
.sidebar .menu > li > button,
|
||||
.sidebar .menu > li.expanded-menu-group > div,
|
||||
.sidebar .menu > div.collapsed-menu-group > button {
|
||||
@apply transition-all duration-300;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
/* Collapsed state: same padding to keep icons at same position
|
||||
- Remove gap so label (which is opacity-0 w-0) doesn't create space
|
||||
- Keep padding-left at 14px so icons stay centered under logo */
|
||||
[data-sidebar-expanded="false"] .sidebar .menu > li > a,
|
||||
[data-sidebar-expanded="false"] .sidebar .menu > li > button,
|
||||
[data-sidebar-expanded="false"] .sidebar .menu > li.expanded-menu-group > div,
|
||||
[data-sidebar-expanded="false"] .sidebar .menu > div.collapsed-menu-group > button {
|
||||
@apply gap-0;
|
||||
padding-left: 14px;
|
||||
padding-right: 14px; /* Center icon horizontally in 64px sidebar */
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* ============================================
|
||||
Footer Button Alignment - Left Aligned in Collapsed State
|
||||
============================================ */
|
||||
|
||||
[data-sidebar-expanded="false"] .sidebar .dropdown > button {
|
||||
@apply px-0;
|
||||
/* Buttons stay at left position, only label disappears */
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
User Menu Button - Focus Ring on Avatar
|
||||
============================================ */
|
||||
|
||||
/* Focus ring appears on the avatar when button is focused */
|
||||
.user-menu-button:focus .avatar > div {
|
||||
@apply ring-2 ring-primary ring-offset-2 ring-offset-base-200;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
User Menu Button - Smooth Centering Transition
|
||||
============================================ */
|
||||
|
||||
/* User menu button transitions smoothly to center */
|
||||
.user-menu-button {
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
/* In collapsed state, center avatar under logo
|
||||
- Avatar is 32px (w-8), center it in 64px sidebar
|
||||
- (64px - 32px) / 2 = 16px padding → avatar center at 32px (same as logo center) */
|
||||
[data-sidebar-expanded="false"] .sidebar .user-menu-button {
|
||||
@apply gap-0;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
User Menu Button - Hover Ring on Avatar
|
||||
============================================ */
|
||||
|
||||
/* Smooth transition for avatar ring effects */
|
||||
.user-menu-button .avatar > div {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
/* Hover ring appears on the avatar when button is hovered */
|
||||
.user-menu-button:hover .avatar > div {
|
||||
@apply ring-1 ring-neutral ring-offset-1 ring-offset-base-200;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Mobile Drawer Width
|
||||
============================================ */
|
||||
|
||||
/* Auf Mobile (< 1024px) ist die Sidebar immer w-64 (16rem) wenn geöffnet */
|
||||
@media (max-width: 1023px) {
|
||||
.drawer-side .sidebar {
|
||||
width: 16rem; /* w-64 auch auf Mobile */
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Drawer Side Overflow Fix für Desktop
|
||||
============================================ */
|
||||
|
||||
/* Im Desktop-Modus (lg:drawer-open) overflow auf visible setzen
|
||||
damit Dropdowns und Tooltips über Main Content erscheinen können */
|
||||
@media (min-width: 1024px) {
|
||||
.drawer.lg\:drawer-open .drawer-side {
|
||||
overflow: visible !important;
|
||||
overflow-x: visible !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Collapsed Sidebar: User Menu Dropdown Richtung
|
||||
============================================ */
|
||||
|
||||
/* Bei eingeklappter Sidebar liegt der Avatar-Button am linken Rand.
|
||||
dropdown-end würde das Menü nach links öffnen (off-screen).
|
||||
Stattdessen nach rechts öffnen (in den Content-Bereich). */
|
||||
#app-layout[data-sidebar-expanded="false"] .dropdown.dropdown-top > ul.dropdown-content {
|
||||
right: auto !important;
|
||||
left: 0 !important;
|
||||
}
|
||||
|
||||
/* Sign-in: hide SSO button and "or" divider when OIDC is not configured.
|
||||
Scoped to #sign-in-page to avoid hiding unrelated elements. */
|
||||
#sign-in-page[data-oidc-configured="false"] [id*="oidc"] {
|
||||
display: none !important;
|
||||
}
|
||||
#sign-in-page[data-oidc-configured="false"] a[href*="oidc"] {
|
||||
display: none !important;
|
||||
}
|
||||
#sign-in-page[data-oidc-configured="false"] .divider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Sign-in: when OIDC-only mode is on, hide password form and "or" divider (show only SSO). */
|
||||
#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] [id*="password"] {
|
||||
display: none !important;
|
||||
}
|
||||
#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] .divider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 1.4.3: Primary button contrast (AA)
|
||||
============================================ */
|
||||
|
||||
/* Override DaisyUI theme --color-primary-content so text on btn-primary (brand)
|
||||
meets 4.5:1. In DevTools: inspect .btn-primary, check computed --color-primary
|
||||
and --color-primary-content; verify contrast at https://webaim.org/resources/contrastchecker/ */
|
||||
|
||||
/* Light theme: primary is orange (brand); primary-content must be dark. */
|
||||
[data-theme="light"] {
|
||||
--color-primary-content: oklch(0.18 0.02 47);
|
||||
--color-error: oklch(55% 0.253 17.585);
|
||||
--color-error-content: oklch(98% 0 0);
|
||||
}
|
||||
|
||||
/* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */
|
||||
[data-theme="dark"] {
|
||||
--color-error: oklch(55% 0.253 17.585);
|
||||
--color-error-content: oklch(98% 0 0);
|
||||
|
||||
--color-primary: oklch(72% 0.17 45);
|
||||
--color-primary-content: oklch(0.18 0.02 47);
|
||||
|
||||
--color-secondary: oklch(48% 0.233 277.117);
|
||||
--color-secondary-content: oklch(98% 0 0);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 2.2 AA: Tab list inactive tab text contrast (4.5:1)
|
||||
============================================ */
|
||||
#member-tablist .tab:not(.tab-active) {
|
||||
color: oklch(0.35 0.02 285);
|
||||
}
|
||||
[data-theme="dark"] #member-tablist .tab:not(.tab-active) {
|
||||
color: oklch(0.72 0.02 257);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 2.2 AA: Link contrast - primary and accent
|
||||
============================================ */
|
||||
[data-theme="light"] .link.link-primary {
|
||||
color: oklch(0.45 0.15 35);
|
||||
}
|
||||
[data-theme="light"] .link.link-primary:hover {
|
||||
color: oklch(0.38 0.14 35);
|
||||
}
|
||||
[data-theme="dark"] .link.link-primary {
|
||||
color: oklch(0.82 0.14 45);
|
||||
}
|
||||
[data-theme="dark"] .link.link-primary:hover {
|
||||
color: oklch(0.88 0.12 45);
|
||||
}
|
||||
[data-theme="dark"] .link.link-accent {
|
||||
color: oklch(0.82 0.18 292);
|
||||
}
|
||||
[data-theme="dark"] .link.link-accent:hover {
|
||||
color: oklch(0.88 0.16 292);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 2.2 AA: Danger zone heading contrast (dark theme)
|
||||
============================================ */
|
||||
[data-theme="dark"] #danger-zone-heading.text-error {
|
||||
color: oklch(0.78 0.18 25);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 2.2 AA: Blue link contrast in dark theme
|
||||
============================================ */
|
||||
[data-theme="dark"] a.text-blue-700,
|
||||
[data-theme="dark"] a.text-blue-600,
|
||||
[data-theme="dark"] a.hover\:text-blue-800 {
|
||||
color: oklch(0.72 0.16 255);
|
||||
}
|
||||
[data-theme="dark"] a.text-blue-700:hover,
|
||||
[data-theme="dark"] a.text-blue-600:hover {
|
||||
color: oklch(0.82 0.14 255);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 2.2 AA: Password / form label on light box in dark theme
|
||||
============================================ */
|
||||
[data-theme="dark"] .bg-gray-50 {
|
||||
background-color: var(--color-base-200);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
[data-theme="dark"] .bg-gray-50 .label,
|
||||
[data-theme="dark"] .bg-gray-50 .mb-1.label,
|
||||
[data-theme="dark"] .bg-gray-50 .text-gray-600,
|
||||
[data-theme="dark"] .bg-gray-50 .text-gray-700,
|
||||
[data-theme="dark"] .bg-gray-50 strong,
|
||||
[data-theme="dark"] .bg-gray-50 p,
|
||||
[data-theme="dark"] .bg-gray-50 li {
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
/* Dark mode: orange/red info boxes (admin note, OIDC warning) – dark bg, light text */
|
||||
[data-theme="dark"] .bg-orange-50 {
|
||||
background-color: oklch(0.32 0.06 55);
|
||||
border-color: oklch(0.42 0.08 55);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
[data-theme="dark"] .bg-orange-50 .text-orange-800,
|
||||
[data-theme="dark"] .bg-orange-50 p,
|
||||
[data-theme="dark"] .bg-orange-50 strong {
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
[data-theme="dark"] .bg-red-50 {
|
||||
background-color: oklch(0.32 0.08 25);
|
||||
border-color: oklch(0.42 0.12 25);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
[data-theme="dark"] .bg-red-50 .text-red-800,
|
||||
[data-theme="dark"] .bg-red-50 .text-red-700,
|
||||
[data-theme="dark"] .bg-red-50 p,
|
||||
[data-theme="dark"] .bg-red-50 strong {
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
/* This file is for your main application CSS */
|
||||
|
||||
/* ============================================
|
||||
SortableList: drag-and-drop table rows
|
||||
============================================ */
|
||||
|
||||
/* Ghost row: placeholder showing where the dragged item will be dropped.
|
||||
Background fills the gap; text invisible so layout matches original row. */
|
||||
.sortable-ghost {
|
||||
background-color: var(--color-base-300) !important;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.sortable-ghost td {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Chosen row: the row being actively dragged (follows the cursor). */
|
||||
.sortable-chosen {
|
||||
background-color: var(--color-base-200);
|
||||
box-shadow: 0 4px 16px -2px oklch(0 0 0 / 0.18);
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
/* Drag handle button: only grab cursor, no hover effect for mouse users.
|
||||
Keyboard outline is handled via JS outline style. */
|
||||
[data-sortable-handle] button {
|
||||
cursor: grab;
|
||||
}
|
||||
[data-sortable-handle] button:hover {
|
||||
background-color: transparent !important;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
* Default interactive table rows: neutral hover/focus-visible fill for clickable rows.
|
||||
* Uses :has(:focus-visible) so keyboard navigation highlights the row without sticky mouse-focus artifacts.
|
||||
*/
|
||||
.table.table-zebra tbody tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible)) > td {
|
||||
background-color: var(--color-base-300);
|
||||
}
|
||||
|
||||
/*
|
||||
* Sticky first column in zebra tables: opaque backgrounds per row.
|
||||
* Use nth-child (not HEEx row index) so LiveStream rows stay iterable only via :for (Phoenix LV requirement).
|
||||
*/
|
||||
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr:nth-child(odd) > td.sticky-first-col-cell {
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr:nth-child(even) > td.sticky-first-col-cell {
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
|
||||
/*
|
||||
* Checkbox-selected rows: keep zebra backgrounds; only accent the sticky checkbox column.
|
||||
*/
|
||||
[data-sticky-first-col-rows="true"]
|
||||
.table.table-zebra
|
||||
tbody
|
||||
tr[data-selected="true"]
|
||||
> td.sticky-first-col-cell {
|
||||
box-shadow: inset 2px 0 0 var(--color-primary);
|
||||
}
|
||||
|
||||
[data-sticky-first-col-rows="true"]
|
||||
.table.table-zebra
|
||||
tbody
|
||||
tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible))
|
||||
> td.sticky-first-col-cell {
|
||||
background-color: var(--color-base-300);
|
||||
/* Left accent only; keep the familiar orange primary accent. */
|
||||
box-shadow: inset 2px 0 0 var(--color-primary);
|
||||
}
|
||||
|
||||
/*
|
||||
* Sticky member selection table: drop mouse-only focus outlines that read like a thin frame around the row;
|
||||
* keyboard :focus-visible keeps DaisyUI control outlines (checkbox / tabindex cell).
|
||||
*/
|
||||
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-sticky-first-col-rows="true"]
|
||||
.table.table-zebra
|
||||
tbody
|
||||
tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible)):not(:last-child) {
|
||||
/* DaisyUI draws a bottom border on each row; hiding it while highlighted avoids a boxy “frame”. */
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr td:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
|
|
|||
521
assets/js/app.js
521
assets/js/app.js
|
|
@ -21,48 +21,12 @@ import "phoenix_html"
|
|||
import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import topbar from "../vendor/topbar"
|
||||
import Sortable from "../vendor/sortable"
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
|
||||
function getBrowserTimezone() {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || null
|
||||
} catch (_e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Hooks for LiveView components
|
||||
let Hooks = {}
|
||||
|
||||
// CopyToClipboard hook: Copies text to clipboard when triggered by server event
|
||||
Hooks.CopyToClipboard = {
|
||||
mounted() {
|
||||
this.handleEvent("copy_to_clipboard", ({text}) => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).catch(err => {
|
||||
console.error("Clipboard write failed:", err)
|
||||
})
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = text
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-999999px"
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand("copy")
|
||||
} catch (err) {
|
||||
console.error("Fallback clipboard copy failed:", err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ComboBox hook: Prevents form submission when Enter is pressed in dropdown
|
||||
Hooks.ComboBox = {
|
||||
mounted() {
|
||||
|
|
@ -79,295 +43,46 @@ Hooks.ComboBox = {
|
|||
|
||||
destroyed() {
|
||||
this.el.removeEventListener("keydown", this.handleKeyDown)
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// TableRowKeydown hook: WCAG 2.1.1 — when a table row cell has data-row-clickable,
|
||||
// Enter and Space trigger a click so row_click tables are keyboard activatable
|
||||
Hooks.TableRowKeydown = {
|
||||
// MemberSortPersistence hook: Persists sorting order to a cookie
|
||||
Hooks.MemberSortPersistence = {
|
||||
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)
|
||||
this.handleEvent("persist_sort", ({ sort_field, sort_order }) => {
|
||||
const setCookie = (name, value) => {
|
||||
const secure = window.location.protocol === "https:" ? "Secure" : "";
|
||||
document.cookie = `${name}=${encodeURIComponent(
|
||||
value
|
||||
)}; path=/; SameSite=Lax; ${secure}`;
|
||||
};
|
||||
setCookie("member_sort_field", sort_field);
|
||||
setCookie("member_sort_order", sort_order);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener("keydown", this.handleKeydown)
|
||||
// Helper to read and decode cookie value
|
||||
const getCookie = (name) => {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) {
|
||||
return decodeURIComponent(parts.pop().split(";").shift());
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
// Restore state from localStorage
|
||||
const expanded = localStorage.getItem('sidebar-expanded') !== 'false'
|
||||
this.setSidebarState(expanded)
|
||||
|
||||
// Expose toggle function globally
|
||||
window.toggleSidebar = () => {
|
||||
const current = this.el.dataset.sidebarExpanded === 'true'
|
||||
this.setSidebarState(!current)
|
||||
}
|
||||
},
|
||||
|
||||
updated() {
|
||||
// LiveView patches data-sidebar-expanded back to the template default ("true")
|
||||
// on every DOM update. Re-apply the stored state from localStorage after each patch.
|
||||
const expanded = localStorage.getItem('sidebar-expanded') !== 'false'
|
||||
const current = this.el.dataset.sidebarExpanded === 'true'
|
||||
if (current !== expanded) {
|
||||
this.setSidebarState(expanded)
|
||||
}
|
||||
},
|
||||
|
||||
setSidebarState(expanded) {
|
||||
// Convert boolean to string for consistency
|
||||
const expandedStr = expanded ? 'true' : 'false'
|
||||
|
||||
// Update data-attribute (CSS reacts to this)
|
||||
this.el.dataset.sidebarExpanded = expandedStr
|
||||
|
||||
// Persist to localStorage
|
||||
localStorage.setItem('sidebar-expanded', expandedStr)
|
||||
|
||||
// Update ARIA for accessibility
|
||||
const toggleBtn = document.getElementById('sidebar-toggle')
|
||||
if (toggleBtn) {
|
||||
toggleBtn.setAttribute('aria-expanded', expandedStr)
|
||||
}
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
// Cleanup
|
||||
delete window.toggleSidebar
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {
|
||||
_csrf_token: csrfToken,
|
||||
timezone: getBrowserTimezone()
|
||||
params: () => {
|
||||
return {
|
||||
_csrf_token: csrfToken,
|
||||
member_sort_field: getCookie("member_sort_field"),
|
||||
member_sort_order: getCookie("member_sort_order"),
|
||||
};
|
||||
},
|
||||
hooks: Hooks
|
||||
})
|
||||
hooks: Hooks,
|
||||
});
|
||||
|
||||
// Listen for custom events from LiveView
|
||||
window.addEventListener("phx:set-input-value", (e) => {
|
||||
|
|
@ -392,177 +107,3 @@ liveSocket.connect()
|
|||
// >> liveSocket.disableLatencySim()
|
||||
window.liveSocket = liveSocket
|
||||
|
||||
// Sidebar accessibility improvements
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const drawerToggle = document.getElementById("mobile-drawer")
|
||||
const sidebarToggle = document.getElementById("sidebar-toggle")
|
||||
const sidebar = document.getElementById("main-sidebar")
|
||||
|
||||
if (!drawerToggle || !sidebarToggle || !sidebar) return
|
||||
|
||||
// Manage tabindex for sidebar elements based on open/closed state
|
||||
const updateSidebarTabIndex = (isOpen) => {
|
||||
// Find all potentially focusable elements (including those with tabindex="-1")
|
||||
const allFocusableElements = sidebar.querySelectorAll(
|
||||
'a[href], button, select, input:not([type="hidden"]), [tabindex]'
|
||||
)
|
||||
|
||||
allFocusableElements.forEach(el => {
|
||||
// Skip the overlay button
|
||||
if (el.closest('.drawer-overlay')) return
|
||||
|
||||
if (isOpen) {
|
||||
// Remove tabindex="-1" to make focusable when open
|
||||
if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') === '-1') {
|
||||
el.removeAttribute('tabindex')
|
||||
}
|
||||
} else {
|
||||
// Set tabindex="-1" to remove from tab order when closed
|
||||
if (!el.hasAttribute('tabindex')) {
|
||||
el.setAttribute('tabindex', '-1')
|
||||
} else if (el.getAttribute('tabindex') !== '-1') {
|
||||
// Store original tabindex in data attribute before setting to -1
|
||||
if (!el.hasAttribute('data-original-tabindex')) {
|
||||
el.setAttribute('data-original-tabindex', el.getAttribute('tabindex'))
|
||||
}
|
||||
el.setAttribute('tabindex', '-1')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Find first focusable element in sidebar
|
||||
// Priority: first navigation link (menuitem) > other links > other focusable elements
|
||||
const getFirstFocusableElement = () => {
|
||||
// First, try to find the first navigation link (menuitem)
|
||||
const firstNavLink = sidebar.querySelector('a[href][role="menuitem"]:not([tabindex="-1"])')
|
||||
if (firstNavLink && !firstNavLink.closest('.drawer-overlay')) {
|
||||
return firstNavLink
|
||||
}
|
||||
|
||||
// Fallback: any navigation link
|
||||
const firstLink = sidebar.querySelector('a[href]:not([tabindex="-1"])')
|
||||
if (firstLink && !firstLink.closest('.drawer-overlay')) {
|
||||
return firstLink
|
||||
}
|
||||
|
||||
// Last resort: any other focusable element
|
||||
const focusableSelectors = [
|
||||
'button:not([tabindex="-1"]):not([disabled])',
|
||||
'select:not([tabindex="-1"]):not([disabled])',
|
||||
'input:not([tabindex="-1"]):not([disabled]):not([type="hidden"])',
|
||||
'[tabindex]:not([tabindex="-1"])'
|
||||
]
|
||||
|
||||
for (const selector of focusableSelectors) {
|
||||
const element = sidebar.querySelector(selector)
|
||||
if (element && !element.closest('.drawer-overlay')) {
|
||||
return element
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Update aria-expanded when drawer state changes
|
||||
const updateAriaExpanded = () => {
|
||||
const isOpen = drawerToggle.checked
|
||||
sidebarToggle.setAttribute("aria-expanded", isOpen.toString())
|
||||
|
||||
// Update dropdown aria-expanded if present
|
||||
const userMenuButton = sidebar.querySelector('button[aria-haspopup="true"]')
|
||||
if (userMenuButton) {
|
||||
const dropdown = userMenuButton.closest('.dropdown')
|
||||
const isDropdownOpen = dropdown?.classList.contains('dropdown-open')
|
||||
if (userMenuButton) {
|
||||
userMenuButton.setAttribute("aria-expanded", (isDropdownOpen || false).toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for changes to the drawer checkbox
|
||||
drawerToggle.addEventListener("change", () => {
|
||||
// On desktop (lg:drawer-open), the mobile drawer must never open.
|
||||
// The hamburger label is lg:hidden, but guard here as a safety net
|
||||
// against any accidental toggles (e.g. from overlapping elements or JS).
|
||||
if (drawerToggle.checked && window.innerWidth >= 1024) {
|
||||
drawerToggle.checked = false
|
||||
return
|
||||
}
|
||||
const isOpen = drawerToggle.checked
|
||||
updateAriaExpanded()
|
||||
updateSidebarTabIndex(isOpen)
|
||||
if (!isOpen) {
|
||||
// When closing, return focus to toggle button
|
||||
sidebarToggle.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// Update on initial load
|
||||
updateAriaExpanded()
|
||||
updateSidebarTabIndex(drawerToggle.checked)
|
||||
|
||||
// Close sidebar with ESC key
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && drawerToggle.checked) {
|
||||
drawerToggle.checked = false
|
||||
updateAriaExpanded()
|
||||
updateSidebarTabIndex(false)
|
||||
// Return focus to toggle button
|
||||
sidebarToggle.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// Improve keyboard navigation for sidebar toggle
|
||||
sidebarToggle.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
const wasOpen = drawerToggle.checked
|
||||
drawerToggle.checked = !drawerToggle.checked
|
||||
updateAriaExpanded()
|
||||
|
||||
// If opening, move focus to first element in sidebar
|
||||
if (!wasOpen && drawerToggle.checked) {
|
||||
updateSidebarTabIndex(true)
|
||||
// Use setTimeout to ensure DOM is updated
|
||||
setTimeout(() => {
|
||||
const firstElement = getFirstFocusableElement()
|
||||
if (firstElement) {
|
||||
firstElement.focus()
|
||||
}
|
||||
}, 50)
|
||||
} else if (wasOpen && !drawerToggle.checked) {
|
||||
updateSidebarTabIndex(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Also handle click events to update tabindex and focus
|
||||
sidebarToggle.addEventListener("click", () => {
|
||||
setTimeout(() => {
|
||||
const isOpen = drawerToggle.checked
|
||||
updateSidebarTabIndex(isOpen)
|
||||
if (isOpen) {
|
||||
const firstElement = getFirstFocusableElement()
|
||||
if (firstElement) {
|
||||
firstElement.focus()
|
||||
}
|
||||
}
|
||||
}, 50)
|
||||
})
|
||||
|
||||
// Handle dropdown keyboard navigation
|
||||
const userMenuButton = sidebar?.querySelector('button[aria-haspopup="true"]')
|
||||
if (userMenuButton) {
|
||||
userMenuButton.addEventListener("click", () => {
|
||||
setTimeout(updateAriaExpanded, 0)
|
||||
})
|
||||
|
||||
userMenuButton.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
userMenuButton.click()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
2
assets/vendor/sortable.js
vendored
2
assets/vendor/sortable.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -46,35 +46,10 @@ config :spark,
|
|||
]
|
||||
]
|
||||
|
||||
# IANA timezone database for DateTime.shift_zone (browser timezone display)
|
||||
config :elixir, :time_zone_database, Tz.TimeZoneDatabase
|
||||
|
||||
config :mv,
|
||||
ecto_repos: [Mv.Repo],
|
||||
generators: [timestamp_type: :utc_datetime],
|
||||
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization]
|
||||
|
||||
# Environment (dev/test/prod). Use this instead of Mix.env() at runtime; Mix.env() is
|
||||
# not available in releases. Set once at compile time via config_env().
|
||||
config :mv, :environment, config_env()
|
||||
|
||||
# CSV Import configuration
|
||||
config :mv,
|
||||
csv_import: [
|
||||
max_file_size_mb: 10,
|
||||
max_rows: 1000
|
||||
]
|
||||
|
||||
# PDF Export configuration
|
||||
config :mv,
|
||||
pdf_export: [
|
||||
row_limit: 5000
|
||||
]
|
||||
|
||||
# OIDC group → role sync (optional). Overridden in runtime.exs from ENV in production.
|
||||
config :mv, :oidc_role_sync,
|
||||
admin_group_name: nil,
|
||||
groups_claim: "groups"
|
||||
ash_domains: [Mv.Membership, Mv.Accounts]
|
||||
|
||||
# Configures the endpoint
|
||||
config :mv, MvWeb.Endpoint,
|
||||
|
|
@ -96,20 +71,6 @@ config :mv, MvWeb.Endpoint,
|
|||
# at the `config/runtime.exs`.
|
||||
config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local
|
||||
|
||||
# SMTP TLS verification: false = allow self-signed/internal certs; true = verify_peer (use for public SMTP).
|
||||
# Overridden in runtime.exs from SMTP_VERIFY_PEER when SMTP is configured via ENV in prod.
|
||||
config :mv, :smtp_verify_peer, false
|
||||
|
||||
# Default mail "from" address for transactional emails (join confirmation,
|
||||
# user confirmation, password reset). Override in config/runtime.exs from ENV.
|
||||
config :mv, :mail_from, {"Mila", "noreply@example.com"}
|
||||
|
||||
# Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP.
|
||||
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10
|
||||
|
||||
# Join emails: notifier implementation (domain → web abstraction). Override in test to inject a mock.
|
||||
config :mv, :join_notifier, MvWeb.JoinNotifierImpl
|
||||
|
||||
# Configure esbuild (the version is required)
|
||||
config :esbuild,
|
||||
version: "0.17.11",
|
||||
|
|
@ -134,16 +95,7 @@ config :tailwind,
|
|||
# Configures Elixir's Logger
|
||||
config :logger, :default_formatter,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
metadata: [
|
||||
:request_id,
|
||||
:user_id,
|
||||
:member_id,
|
||||
:member_email,
|
||||
:error,
|
||||
:error_type,
|
||||
:cycles_count,
|
||||
:notifications_count
|
||||
]
|
||||
metadata: [:request_id]
|
||||
|
||||
# Use Jason for JSON parsing in Phoenix
|
||||
config :phoenix, :json_library, Jason
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,158 +7,6 @@ import Config
|
|||
# any compile-time configuration in here, as it won't be applied.
|
||||
# The block below contains prod specific runtime configuration.
|
||||
|
||||
# Helper function to read environment variables with Docker secrets support.
|
||||
# Supports the _FILE suffix pattern: if VAR_FILE is set, reads the value from
|
||||
# that file path. Otherwise falls back to VAR directly.
|
||||
# VAR_FILE takes priority and must contain the full absolute path to the secret file.
|
||||
get_env_or_file = fn var_name, default ->
|
||||
file_var = "#{var_name}_FILE"
|
||||
|
||||
case System.get_env(file_var) do
|
||||
nil ->
|
||||
System.get_env(var_name, default)
|
||||
|
||||
file_path ->
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
String.trim_trailing(content)
|
||||
|
||||
{:error, reason} ->
|
||||
raise """
|
||||
Failed to read secret from file specified in #{file_var}="#{file_path}".
|
||||
Error: #{inspect(reason)}
|
||||
"""
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Same as get_env_or_file but raises if the value is not set or empty (after trim).
|
||||
# Empty values lead to unclear runtime errors; failing at boot with a clear message is preferred.
|
||||
get_env_or_file! = fn var_name, error_message ->
|
||||
case get_env_or_file.(var_name, nil) do
|
||||
nil ->
|
||||
raise error_message
|
||||
|
||||
value when is_binary(value) ->
|
||||
trimmed = String.trim(value)
|
||||
|
||||
if trimmed == "" do
|
||||
raise """
|
||||
#{error_message}
|
||||
(Variable #{var_name} or #{var_name}_FILE is set but the value is empty.)
|
||||
"""
|
||||
else
|
||||
trimmed
|
||||
end
|
||||
|
||||
value ->
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
# Returns default when env_value is nil, empty after trim, or not a valid positive integer.
|
||||
# Used for PORT, POOL_SIZE, SMTP_PORT to avoid ArgumentError on empty or invalid values.
|
||||
parse_positive_integer = fn env_value, default ->
|
||||
case env_value do
|
||||
nil ->
|
||||
default
|
||||
|
||||
v when is_binary(v) ->
|
||||
case String.trim(v) do
|
||||
"" ->
|
||||
default
|
||||
|
||||
trimmed ->
|
||||
case Integer.parse(trimmed) do
|
||||
{n, _} when n > 0 -> n
|
||||
_ -> default
|
||||
end
|
||||
end
|
||||
|
||||
_ ->
|
||||
default
|
||||
end
|
||||
end
|
||||
|
||||
# Returns default when the key is missing or the value is empty (after trim).
|
||||
# Use for optional string ENV vars (e.g. DATABASE_PORT) so empty string is treated as "unset".
|
||||
get_env_non_empty = fn key, default ->
|
||||
case System.get_env(key) do
|
||||
nil ->
|
||||
default
|
||||
|
||||
v when is_binary(v) ->
|
||||
trimmed = String.trim(v)
|
||||
if trimmed == "", do: default, else: trimmed
|
||||
|
||||
v ->
|
||||
v
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the trimmed value when set and non-empty; otherwise raises with error_message.
|
||||
# Use for required vars (DATABASE_HOST, etc.) so "set but empty" fails at boot with a clear message.
|
||||
get_env_required = fn key, error_message ->
|
||||
case System.get_env(key) do
|
||||
nil ->
|
||||
raise error_message
|
||||
|
||||
v when is_binary(v) ->
|
||||
trimmed = String.trim(v)
|
||||
|
||||
if trimmed == "" do
|
||||
raise """
|
||||
#{error_message}
|
||||
(Variable #{key} is set but empty.)
|
||||
"""
|
||||
else
|
||||
trimmed
|
||||
end
|
||||
|
||||
v ->
|
||||
v
|
||||
end
|
||||
end
|
||||
|
||||
# Build database URL from individual components or use DATABASE_URL directly.
|
||||
# Supports both approaches:
|
||||
# 1. DATABASE_URL (or DATABASE_URL_FILE) - full connection URL
|
||||
# 2. Separate vars: DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD (or _FILE), DATABASE_NAME, DATABASE_PORT
|
||||
build_database_url = fn ->
|
||||
case get_env_or_file.("DATABASE_URL", nil) do
|
||||
nil ->
|
||||
# Build URL from separate components
|
||||
host =
|
||||
get_env_required.("DATABASE_HOST", """
|
||||
DATABASE_HOST is required when DATABASE_URL is not set.
|
||||
""")
|
||||
|
||||
user =
|
||||
get_env_required.("DATABASE_USER", """
|
||||
DATABASE_USER is required when DATABASE_URL is not set.
|
||||
""")
|
||||
|
||||
password =
|
||||
get_env_or_file!.("DATABASE_PASSWORD", """
|
||||
DATABASE_PASSWORD or DATABASE_PASSWORD_FILE is required when DATABASE_URL is not set.
|
||||
""")
|
||||
|
||||
database =
|
||||
get_env_required.("DATABASE_NAME", """
|
||||
DATABASE_NAME is required when DATABASE_URL is not set.
|
||||
""")
|
||||
|
||||
port = get_env_non_empty.("DATABASE_PORT", "5432")
|
||||
|
||||
# URL-encode the password to handle special characters
|
||||
encoded_password = URI.encode_www_form(password)
|
||||
"ecto://#{user}:#{encoded_password}@#{host}:#{port}/#{database}"
|
||||
|
||||
url ->
|
||||
url
|
||||
end
|
||||
end
|
||||
|
||||
# ## Using releases
|
||||
#
|
||||
# If you use `mix release`, you need to explicitly enable the server
|
||||
|
|
@ -172,20 +20,20 @@ if System.get_env("PHX_SERVER") do
|
|||
config :mv, MvWeb.Endpoint, server: true
|
||||
end
|
||||
|
||||
# OIDC group → Admin role sync: read from ENV in all environments (dev/test/prod)
|
||||
config :mv, :oidc_role_sync,
|
||||
admin_group_name: System.get_env("OIDC_ADMIN_GROUP_NAME"),
|
||||
groups_claim: System.get_env("OIDC_GROUPS_CLAIM") || "groups"
|
||||
|
||||
if config_env() == :prod do
|
||||
database_url = build_database_url.()
|
||||
database_url =
|
||||
System.get_env("DATABASE_URL") ||
|
||||
raise """
|
||||
environment variable DATABASE_URL is missing.
|
||||
For example: ecto://USER:PASS@HOST/DATABASE
|
||||
"""
|
||||
|
||||
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
|
||||
|
||||
config :mv, Mv.Repo,
|
||||
# ssl: true,
|
||||
url: database_url,
|
||||
pool_size: parse_positive_integer.(System.get_env("POOL_SIZE"), 10),
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
|
||||
socket_options: maybe_ipv6
|
||||
|
||||
# The secret key base is used to sign/encrypt cookies and other secrets.
|
||||
|
|
@ -193,74 +41,45 @@ if config_env() == :prod do
|
|||
# want to use a different value for prod and you most likely don't want
|
||||
# to check this value into version control, so we use an environment
|
||||
# variable instead.
|
||||
# Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets.
|
||||
secret_key_base =
|
||||
get_env_or_file!.("SECRET_KEY_BASE", """
|
||||
environment variable SECRET_KEY_BASE (or SECRET_KEY_BASE_FILE) is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
""")
|
||||
|
||||
# PHX_HOST or DOMAIN can be used to set the host for the application.
|
||||
# DOMAIN is commonly used in deployment environments (e.g., Portainer templates).
|
||||
host =
|
||||
get_env_non_empty.("PHX_HOST", nil) ||
|
||||
get_env_non_empty.("DOMAIN", nil) ||
|
||||
System.get_env("SECRET_KEY_BASE") ||
|
||||
raise """
|
||||
Please define the PHX_HOST or DOMAIN environment variable.
|
||||
(Variable may be set but empty.)
|
||||
environment variable SECRET_KEY_BASE is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
"""
|
||||
|
||||
port = parse_positive_integer.(System.get_env("PORT"), 4000)
|
||||
host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable."
|
||||
port = String.to_integer(System.get_env("PORT") || "4000")
|
||||
|
||||
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||
|
||||
# OIDC configuration (works with any OIDC provider: Authentik, Rauthy, Keycloak, etc.)
|
||||
# The redirect_uri callback path is /auth/user/oidc/callback.
|
||||
#
|
||||
# Supports OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE for Docker secrets.
|
||||
# OIDC_CLIENT_SECRET is required only if OIDC is being used (indicated by explicit OIDC env vars).
|
||||
oidc_base_url = System.get_env("OIDC_BASE_URL")
|
||||
oidc_client_id = System.get_env("OIDC_CLIENT_ID")
|
||||
oidc_in_use = not is_nil(oidc_base_url) or not is_nil(oidc_client_id)
|
||||
|
||||
client_secret =
|
||||
if oidc_in_use do
|
||||
get_env_or_file!.("OIDC_CLIENT_SECRET", """
|
||||
environment variable OIDC_CLIENT_SECRET (or OIDC_CLIENT_SECRET_FILE) is missing.
|
||||
This is required when OIDC authentication is configured (OIDC_BASE_URL or OIDC_CLIENT_ID is set).
|
||||
""")
|
||||
else
|
||||
get_env_or_file.("OIDC_CLIENT_SECRET", nil)
|
||||
end
|
||||
|
||||
# Build redirect_uri: use OIDC_REDIRECT_URI if set, otherwise build from host.
|
||||
# Uses HTTPS since production runs behind TLS termination.
|
||||
default_redirect_uri = "https://#{host}/auth/user/oidc/callback"
|
||||
|
||||
config :mv, :oidc,
|
||||
client_id: oidc_client_id || "mv",
|
||||
base_url: oidc_base_url || "http://localhost:8080/auth/v1",
|
||||
client_secret: client_secret,
|
||||
redirect_uri: System.get_env("OIDC_REDIRECT_URI") || default_redirect_uri
|
||||
# Rauthy OIDC configuration
|
||||
config :mv, :rauthy,
|
||||
client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
|
||||
base_url: System.get_env("OIDC_BASE_URL") || "http://localhost:8080/auth/v1",
|
||||
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
||||
redirect_uri:
|
||||
System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback"
|
||||
|
||||
# Token signing secret from environment variable
|
||||
# This overrides the placeholder value set in prod.exs
|
||||
# Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets.
|
||||
token_signing_secret =
|
||||
get_env_or_file!.("TOKEN_SIGNING_SECRET", """
|
||||
environment variable TOKEN_SIGNING_SECRET (or TOKEN_SIGNING_SECRET_FILE) is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
""")
|
||||
System.get_env("TOKEN_SIGNING_SECRET") ||
|
||||
raise """
|
||||
environment variable TOKEN_SIGNING_SECRET is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
"""
|
||||
|
||||
config :mv, :token_signing_secret, token_signing_secret
|
||||
|
||||
config :mv, MvWeb.Endpoint,
|
||||
url: [host: host, port: 443, scheme: "https"],
|
||||
http: [
|
||||
# Bind on all IPv4 interfaces.
|
||||
# Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only.
|
||||
# Enable IPv6 and bind on all interfaces.
|
||||
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
|
||||
ip: {0, 0, 0, 0},
|
||||
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||
ip: {0, 0, 0, 0, 0, 0, 0, 0},
|
||||
port: port
|
||||
],
|
||||
secret_key_base: secret_key_base,
|
||||
|
|
@ -303,54 +122,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,13 +9,10 @@ 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,
|
||||
queue_target: 5000,
|
||||
queue_interval: 1000,
|
||||
timeout: 60_000
|
||||
pool_size: System.schedulers_online() * 2
|
||||
|
||||
# We don't run a server during test. If one is required,
|
||||
# you can enable the server option below.
|
||||
|
|
@ -48,21 +45,3 @@ config :mv, :token_signing_secret, "test_secret_key_for_ash_authentication_token
|
|||
config :mv, :session_identifier, :unsafe
|
||||
|
||||
config :mv, :require_token_presence_for_authentication, false
|
||||
|
||||
# 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")
|
||||
|
|
|
|||
|
|
@ -2,60 +2,37 @@ services:
|
|||
app:
|
||||
image: git.local-it.org/local-it/mitgliederverwaltung:latest
|
||||
container_name: mv-prod-app
|
||||
ports:
|
||||
- "4001:4001"
|
||||
# Use host network for local testing to access localhost:8080 (Rauthy)
|
||||
# In real production, remove this and use external OIDC provider
|
||||
network_mode: host
|
||||
environment:
|
||||
# Database configuration using separate variables
|
||||
# Use Docker service name for internal networking
|
||||
DATABASE_HOST: "db-prod"
|
||||
DATABASE_PORT: "5432"
|
||||
DATABASE_USER: "postgres"
|
||||
DATABASE_NAME: "mv_prod"
|
||||
DATABASE_PASSWORD_FILE: "/run/secrets/db_password"
|
||||
# Phoenix secrets via Docker secrets
|
||||
SECRET_KEY_BASE_FILE: "/run/secrets/secret_key_base"
|
||||
TOKEN_SIGNING_SECRET_FILE: "/run/secrets/token_signing_secret"
|
||||
PHX_HOST: "${PHX_HOST:-localhost}"
|
||||
DATABASE_URL: "ecto://postgres:postgres@localhost:5001/mv_prod"
|
||||
SECRET_KEY_BASE: "${SECRET_KEY_BASE}"
|
||||
TOKEN_SIGNING_SECRET: "${TOKEN_SIGNING_SECRET}"
|
||||
PHX_HOST: "${PHX_HOST}"
|
||||
PORT: "4001"
|
||||
PHX_SERVER: "true"
|
||||
# OIDC config - use host.docker.internal to reach host services
|
||||
# Rauthy OIDC config - uses localhost because of host network mode
|
||||
OIDC_CLIENT_ID: "mv"
|
||||
OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1"
|
||||
OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret"
|
||||
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/oidc/callback"
|
||||
secrets:
|
||||
- db_password
|
||||
- secret_key_base
|
||||
- token_signing_secret
|
||||
- oidc_client_secret
|
||||
OIDC_BASE_URL: "http://localhost:8080/auth/v1"
|
||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
|
||||
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
|
||||
depends_on:
|
||||
- db-prod
|
||||
restart: unless-stopped
|
||||
|
||||
db-prod:
|
||||
image: postgres:18.4-alpine
|
||||
image: postgres:16-alpine
|
||||
container_name: mv-prod-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: mv_prod
|
||||
secrets:
|
||||
- db_password
|
||||
volumes:
|
||||
- postgres_data_prod:/var/lib/postgresql
|
||||
- postgres_data_prod:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5001:5432"
|
||||
restart: unless-stopped
|
||||
|
||||
secrets:
|
||||
db_password:
|
||||
file: ./secrets/db_password.txt
|
||||
secret_key_base:
|
||||
file: ./secrets/secret_key_base.txt
|
||||
token_signing_secret:
|
||||
file: ./secrets/token_signing_secret.txt
|
||||
oidc_client_secret:
|
||||
file: ./secrets/oidc_client_secret.txt
|
||||
|
||||
volumes:
|
||||
postgres_data_prod:
|
||||
|
|
|
|||
|
|
@ -4,46 +4,44 @@ networks:
|
|||
|
||||
services:
|
||||
db:
|
||||
image: postgres:18.4-alpine
|
||||
image: postgres:17.6-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: mv_dev
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql
|
||||
- type: volume
|
||||
source: postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
volume:
|
||||
nocopy: true
|
||||
ports:
|
||||
- "${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.32.0
|
||||
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
|
||||
|
|
@ -51,8 +49,9 @@ services:
|
|||
- rauthy-dev
|
||||
- local
|
||||
volumes:
|
||||
- rauthy-data:/app/data
|
||||
- ./rauthy-bootstrap:/app/bootstrap:ro
|
||||
- type: volume
|
||||
source: rauthy-data
|
||||
target: /app/data
|
||||
|
||||
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.
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
# Admin Bootstrap and OIDC Role Sync
|
||||
|
||||
## Overview
|
||||
|
||||
- **Admin bootstrap:** In production, the Docker entrypoint runs migrate, then `Mv.Release.run_seeds/0` (skips if admin user already exists unless `FORCE_SEEDS=true`; set `RUN_DEV_SEEDS=true` to also run dev seeds), then `seed_admin/0` from ENV, then the server. Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`.
|
||||
- **OIDC role sync:** Optional mapping from OIDC groups (e.g. from Authentik profile scope) to the Admin role. Users in the configured admin group get the Admin role on registration and on each sign-in.
|
||||
|
||||
## Admin Bootstrap (Part A)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `RUN_DEV_SEEDS` – If set to `"true"`, `run_seeds/0` also runs dev seeds (members, groups, sample data). Otherwise only bootstrap seeds run.
|
||||
- `FORCE_SEEDS` – If set to `"true"`, seeds are run even when the admin user already exists (e.g. after changing bootstrap data such as roles or custom fields). Otherwise seeds are skipped when bootstrap was already applied.
|
||||
- `ADMIN_EMAIL` – Email of the admin user to create/update. If unset, seed_admin/0 does nothing.
|
||||
- `ADMIN_PASSWORD` – Password for the admin user. If unset (and no file), no new user is created; if a user with ADMIN_EMAIL already exists (e.g. OIDC-only), their role is set to Admin (no password change).
|
||||
- `ADMIN_PASSWORD_FILE` – Path to a file containing the password (e.g. Docker secret).
|
||||
|
||||
### Release Tasks
|
||||
|
||||
- `Mv.Release.run_seeds/0` – If the admin user already exists (bootstrap already applied), skips unless `FORCE_SEEDS=true`; otherwise runs bootstrap seeds (fee types, custom fields, roles, settings). If `RUN_DEV_SEEDS` env is `"true"`, also runs dev seeds (members, groups, sample data). Safe to call on every start.
|
||||
- `Mv.Release.seed_admin/0` – Reads ADMIN_EMAIL and password from ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. If both email and password are set: creates or updates the user with the Admin role. If only ADMIN_EMAIL is set: sets the Admin role on an existing user with that email (for OIDC-only admins); does not create a user. Idempotent.
|
||||
|
||||
### Entrypoint
|
||||
|
||||
- rel/overlays/bin/docker-entrypoint.sh – After migrate, runs run_seeds(), then seed_admin(), then starts the server.
|
||||
|
||||
### Seeds (Dev/Test)
|
||||
|
||||
- priv/repo/seeds_bootstrap.exs – Uses ADMIN_PASSWORD or ADMIN_PASSWORD_FILE when set; otherwise fallback "testpassword" only in dev/test.
|
||||
|
||||
## OIDC Role Sync (Part B)
|
||||
|
||||
### Configuration
|
||||
|
||||
- `OIDC_ADMIN_GROUP_NAME` – OIDC group name that maps to the Admin role. If unset, no role sync.
|
||||
- `OIDC_GROUPS_CLAIM` – JWT claim name for group list (default "groups").
|
||||
- Module: Mv.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).
|
||||
|
||||
### Sync Logic
|
||||
|
||||
- Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info) – If admin group configured, sets user role to Admin or Mitglied based on user_info groups.
|
||||
|
||||
### Where It Runs
|
||||
|
||||
1. Registration: register_with_oidc after_action calls OidcRoleSync.
|
||||
2. Sign-in: sign_in_with_oidc prepare after_action calls OidcRoleSync for each user.
|
||||
|
||||
### Internal Action
|
||||
|
||||
- User.set_role_from_oidc_sync – Internal update (role_id only). Used by OidcRoleSync; not exposed.
|
||||
|
||||
## See Also
|
||||
|
||||
- .env.example – Admin and OIDC group env vars.
|
||||
- lib/mv/release.ex – seed_admin/0.
|
||||
- lib/mv/oidc_role_sync.ex – Sync implementation.
|
||||
- docs/oidc-account-linking.md – OIDC account linking.
|
||||
|
|
@ -1,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 +0,0 @@
|
|||
# CSV Member Import
|
||||
|
||||
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.
|
||||
|
||||
**Status:** implemented (backend + LiveView UI).
|
||||
|
||||
Implementation:
|
||||
|
||||
- `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
|
||||
|
||||
## Scope
|
||||
|
||||
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.
|
||||
|
||||
Out of scope: upsert, mapping wizard, transactional all-or-nothing, error export, import history/audit.
|
||||
|
||||
## 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.
|
||||
|
||||
## Limits
|
||||
|
||||
- **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).
|
||||
|
||||
## Parsing (`CsvParser.parse/1`)
|
||||
|
||||
- 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.
|
||||
|
||||
## Header Mapping & Normalization (`HeaderMapper`)
|
||||
|
||||
**`normalize_header/1`** (applied identically to incoming headers, mapping variants, custom
|
||||
field names, group names, and fee-type names):
|
||||
|
||||
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.
|
||||
|
||||
**Required field:** `email`. Missing it aborts `prepare` with a "Missing required header" error.
|
||||
|
||||
**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).
|
||||
|
||||
**Duplicate headers** mapping to the same canonical field (or same custom field) are an error.
|
||||
|
||||
### Supported member fields and header variants
|
||||
|
||||
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.
|
||||
|
||||
| Canonical | Example accepted headers (EN / DE) | Notes |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
|
||||
### Special relationship columns
|
||||
|
||||
- **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.
|
||||
|
||||
These columns are resolved against the DB read-only in `prepare` (`ColumnResolver`) for the
|
||||
preview; the actual writes happen in `process_chunk`.
|
||||
|
||||
### Fields not importable (explicitly ignored)
|
||||
|
||||
- **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`.)
|
||||
|
||||
## Custom Fields
|
||||
|
||||
- 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).
|
||||
|
||||
## Validation & Member Creation (`process_chunk/4` → `process_row`)
|
||||
|
||||
Per row: validate → create member → create custom-field values → assign groups. Sequential.
|
||||
|
||||
- **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.
|
||||
|
||||
## Templates (`ImportTemplateController`)
|
||||
|
||||
- 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(...)`).
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
# Performance Analysis: Custom Fields in Search Vector
|
||||
|
||||
## 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`.
|
||||
|
||||
Two triggers maintain the vector:
|
||||
|
||||
- `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`.
|
||||
|
||||
Both rely on `custom_field_values_member_id_idx`, so the per-member aggregation is an indexed lookup.
|
||||
|
||||
## Applied Trigger Optimizations
|
||||
|
||||
`update_member_search_vector_from_custom_field_value()` was optimized:
|
||||
|
||||
- **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.
|
||||
|
||||
Measured effect per custom-field-value change:
|
||||
|
||||
| Case | Before | After |
|
||||
|------|--------|-------|
|
||||
| Value changed | 5–15 ms | 3–10 ms |
|
||||
| Value unchanged (UPDATE) | 5–15 ms | < 1 ms (early return) |
|
||||
|
||||
Re-aggregation is still required whenever a value actually changes — that is necessary for `search_vector` consistency.
|
||||
|
||||
## 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.
|
||||
|
||||
## Bulk Imports
|
||||
|
||||
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.
|
||||
|
||||
## Search Query Structure
|
||||
|
||||
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.
|
||||
|
||||
## Search Filter Functions
|
||||
|
||||
The search query in `lib/membership/member.ex` is split into modular filter builders, combined as a single OR-chain in priority order:
|
||||
|
||||
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).
|
||||
|
||||
Priority: **FTS > Substring > Custom Fields > Fuzzy**.
|
||||
|
||||
## Monitoring Queries
|
||||
|
||||
```sql
|
||||
-- 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
|
||||
FROM (
|
||||
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
|
||||
FROM pg_stat_statements
|
||||
WHERE query LIKE '%members_search_vector_trigger%'
|
||||
ORDER BY mean_exec_time DESC;
|
||||
```
|
||||
|
||||
## Future Options (if scale demands)
|
||||
|
||||
- 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 +0,0 @@
|
|||
# Sidebar Drawer Pattern (project note)
|
||||
|
||||
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`.
|
||||
|
||||
## Chosen pattern
|
||||
|
||||
- `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,180 +4,461 @@
|
|||
|
||||
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 |
|
||||
| **Domains** | 4 (Accounts, Membership, MembershipFees, Authorization) |
|
||||
| **Triggers** | 3 (member, custom_field_values, member_groups → member search-vector) |
|
||||
| **Tables** | 5 |
|
||||
| **Domains** | 2 (Accounts, Membership) |
|
||||
| **Relationships** | 3 |
|
||||
| **Indexes** | 15+ |
|
||||
| **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.
|
||||
|
||||
### 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.
|
||||
#### `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
|
||||
|
||||
### MembershipFees Domain
|
||||
- **`membership_fee_types`** — fee types with immutable billing interval.
|
||||
- **`membership_fee_cycles`** — per-member billing cycles with payment status.
|
||||
#### `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
|
||||
|
||||
## 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).
|
||||
#### `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
|
||||
|
||||
## Key Relationships
|
||||
|
||||
```
|
||||
User (0..1) ←→ (0..1) Member
|
||||
↓ ↓
|
||||
Tokens (N) CustomFieldValues (N)
|
||||
↓ ↓
|
||||
Role (N:1) CustomField (1)
|
||||
↓
|
||||
Tokens (N)
|
||||
|
||||
Member (1) → (N) MembershipFeeCycles
|
||||
Member (1) → (N) Properties
|
||||
↓
|
||||
MembershipFeeType (1)
|
||||
|
||||
Member (N) ←→ (N) Group
|
||||
↓ ↓
|
||||
MemberGroups (N) MemberGroups (N)
|
||||
|
||||
Settings (1) → MembershipFeeType (0..1)
|
||||
CustomField (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. **Member → Properties (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)
|
||||
|
||||
**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. **CustomFieldValue → CustomField (N:1)**
|
||||
- Properties reference type definition
|
||||
- `ON DELETE RESTRICT` - cannot delete type if in use
|
||||
- Type defines data structure
|
||||
|
||||
## Important Business Rules
|
||||
|
||||
### Email Synchronization
|
||||
- **User.email is the source of truth when linked.** On linking, `Member.email ← User.email` (overwrite). 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)
|
||||
- Birth date cannot be in future
|
||||
- Join date cannot be in future
|
||||
- Exit date must be after join date
|
||||
- Phone: `+?[0-9\- ]{6,20}`
|
||||
- Postal code: 5 digits
|
||||
|
||||
### 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
|
||||
- `paid` (partial B-tree) - Payment status queries
|
||||
|
||||
**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 `member_groups` (INSERT/UPDATE/DELETE): `update_member_search_vector_on_member_groups_change` runs function `update_member_search_vector_from_member_groups()`
|
||||
- **Trigger:** `members_search_vector_trigger()`
|
||||
- **Function:** Automatically updates `search_vector` on INSERT/UPDATE
|
||||
- **Index Type:** GIN (Generalized Inverted Index)
|
||||
|
||||
### Weighted Fields
|
||||
- **Weight A (highest):** first_name, last_name
|
||||
- **Weight B:** email, notes, group names (from member_groups → groups)
|
||||
- **Weight C:** city, street, house_number, postal_code, custom_field_values
|
||||
- **Weight B:** email, notes
|
||||
- **Weight C:** birth_date, phone_number, city, street, house_number, postal_code
|
||||
- **Weight D (lowest):** join_date, exit_date
|
||||
|
||||
### Group Names in Search
|
||||
Group names are included in the member search vector so that searching for a group name (e.g. "Vorstand") finds all members in that group:
|
||||
- Group names are aggregated from `member_groups` joined with `groups` and receive weight 'B'
|
||||
- The trigger `update_member_search_vector_on_member_groups_change` runs on INSERT/UPDATE/DELETE on `member_groups` and refreshes the affected member's `search_vector`
|
||||
- See migration `20260217120000_add_group_names_to_member_search_vector.exs` (Issue #375)
|
||||
|
||||
### Custom Field Values in Search
|
||||
Custom field values are automatically included in the search vector:
|
||||
- All custom field values (string, integer, boolean, date, email) are aggregated and added to the search vector
|
||||
- Values are converted to text format for indexing
|
||||
- Custom field values receive weight 'C' (same as city, etc.)
|
||||
- The search vector is automatically updated when custom field values are created, updated, or deleted via database triggers
|
||||
|
||||
### Usage Example
|
||||
```sql
|
||||
SELECT * FROM members
|
||||
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, paid)
|
||||
- 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. **Partial indexes:** `members.paid` index only non-NULL values
|
||||
5. **Search optimization:** Full-text search via tsvector, not LIKE
|
||||
|
||||
## Visualization
|
||||
|
||||
### Using dbdiagram.io
|
||||
|
||||
1. Visit [https://dbdiagram.io](https://dbdiagram.io)
|
||||
2. Click "Import" → "From file"
|
||||
3. Upload `database_schema.dbml`
|
||||
4. View interactive diagram with relationships
|
||||
|
||||
### Using dbdocs.io
|
||||
|
||||
1. Install dbdocs CLI: `npm install -g dbdocs`
|
||||
2. Generate docs: `dbdocs build database_schema.dbml`
|
||||
3. View generated documentation
|
||||
|
||||
### VS Code Extension
|
||||
|
||||
Install "DBML Language" extension to view/edit DBML files with:
|
||||
- Syntax highlighting
|
||||
- Inline documentation
|
||||
- Error checking
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Sensitive Data
|
||||
|
||||
**Encrypted:**
|
||||
- `users.hashed_password` (bcrypt)
|
||||
|
||||
**Should Not Log:**
|
||||
- hashed_password
|
||||
- tokens (jti, purpose, extra_data)
|
||||
|
||||
**Personal Data (GDPR):**
|
||||
- All member fields (name, email, birth_date, address)
|
||||
- User email
|
||||
- Token subject
|
||||
|
||||
### Access Control
|
||||
|
||||
- Implement through Ash policies
|
||||
- Row-level security considerations for future
|
||||
- Audit logging for sensitive operations
|
||||
|
||||
## Backup Recommendations
|
||||
|
||||
### Critical Tables (Priority 1)
|
||||
- `members` - Core business data
|
||||
- `users` - Authentication data
|
||||
- `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:** 2025-11-13
|
||||
**Schema Version:** 1.1
|
||||
**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.2
|
||||
// Last Updated: 2025-11-13
|
||||
|
||||
Project mila_membership_management {
|
||||
database_type: 'PostgreSQL'
|
||||
|
|
@ -26,16 +25,13 @@ 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
|
||||
- **MembershipFees**: Membership fee types and billing cycles
|
||||
- **Authorization**: Role-based access control (RBAC)
|
||||
- **Accounts**: User authentication and session management
|
||||
- **Membership**: Club member data and custom fields
|
||||
|
||||
## 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).
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
@ -123,22 +119,21 @@ Table tokens {
|
|||
|
||||
Table members {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key (sortable by creation time)']
|
||||
first_name text [null, note: 'Member first name (min length: 1 if present)']
|
||||
last_name text [null, note: 'Member last name (min length: 1 if present)']
|
||||
first_name text [not null, note: 'Member first name (min length: 1)']
|
||||
last_name text [not null, note: 'Member last name (min length: 1)']
|
||||
email text [not null, unique, note: 'Member email address (5-254 chars, validated)']
|
||||
join_date date [null, note: 'Date when member joined club']
|
||||
birth_date date [null, note: 'Date of birth (cannot be in future)']
|
||||
paid boolean [null, note: 'Payment status flag']
|
||||
phone_number text [null, note: 'Contact phone number (format: +?[0-9\- ]{6,20})']
|
||||
join_date date [null, note: 'Date when member joined club (cannot be in future)']
|
||||
exit_date date [null, note: 'Date when member left club (must be after join_date)']
|
||||
notes text [null, note: 'Additional notes about member']
|
||||
city text [null, note: 'City of residence']
|
||||
street text [null, note: 'Street name']
|
||||
house_number text [null, note: 'House number']
|
||||
postal_code text [null, note: '5-digit German postal code']
|
||||
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)']
|
||||
|
|
@ -151,16 +146,16 @@ Table members {
|
|||
email [name: 'members_email_idx', note: 'B-tree index for exact lookups']
|
||||
last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting']
|
||||
join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters']
|
||||
membership_fee_type_id [name: 'members_membership_fee_type_id_index', note: 'B-tree index for fee type lookups']
|
||||
(paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Club Member Master Data**
|
||||
|
||||
Core entity for membership management containing:
|
||||
- Personal information (name, email)
|
||||
- Contact details (address)
|
||||
- Membership status (join/exit dates, membership fee cycles)
|
||||
- Personal information (name, birth date, email)
|
||||
- Contact details (phone, address)
|
||||
- Membership status (join/exit dates, payment status)
|
||||
- Additional notes
|
||||
|
||||
**Email Synchronization:**
|
||||
|
|
@ -172,8 +167,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):
|
||||
|
|
@ -185,15 +179,15 @@ Table members {
|
|||
**Relationships:**
|
||||
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
|
||||
- 1:N with custom_field_values (custom dynamic fields)
|
||||
- Optional N:1 with membership_fee_types - assigned fee type
|
||||
- 1:N with membership_fee_cycles - billing history
|
||||
|
||||
**Validation Rules:**
|
||||
- first_name, last_name: optional, but if present min 1 character
|
||||
- email: 5-254 characters, valid email format (required)
|
||||
- first_name, last_name: min 1 character
|
||||
- email: 5-254 characters, valid email format
|
||||
- birth_date: cannot be in future
|
||||
- join_date: cannot be in future
|
||||
- exit_date: must be after join_date (if both present)
|
||||
- postal_code: optional (no format validation)
|
||||
- country: optional
|
||||
- phone_number: matches pattern ^\+?[0-9\- ]{6,20}$
|
||||
- postal_code: exactly 5 digits
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +223,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 +239,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 +257,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,105 +273,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_FEES DOMAIN
|
||||
// ============================================
|
||||
|
||||
Table membership_fee_types {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
|
||||
name text [not null, unique, note: 'Unique name for the fee type (e.g., "Standard", "Reduced")']
|
||||
amount numeric(10,2) [not null, note: 'Fee amount in default currency (CHECK: >= 0)']
|
||||
interval text [not null, note: 'Billing interval (CHECK: IN monthly, quarterly, half_yearly, yearly) - immutable']
|
||||
description text [null, note: 'Optional description for the fee type']
|
||||
|
||||
indexes {
|
||||
name [unique, name: 'membership_fee_types_unique_name_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Membership Fee Type Definitions**
|
||||
|
||||
Defines the different types of membership fees with fixed billing intervals.
|
||||
|
||||
**Attributes:**
|
||||
- `name`: Unique identifier for the fee type
|
||||
- `amount`: Default fee amount (stored per cycle for audit trail)
|
||||
- `interval`: Billing cycle - immutable after creation
|
||||
- `description`: Optional documentation
|
||||
|
||||
**Interval Values:**
|
||||
- `monthly`: 1st to last day of month
|
||||
- `quarterly`: 1st of Jan/Apr/Jul/Oct to last day of quarter
|
||||
- `half_yearly`: 1st of Jan/Jul to last day of half
|
||||
- `yearly`: Jan 1 to Dec 31
|
||||
|
||||
**Immutability:**
|
||||
The `interval` field cannot be changed after creation to prevent
|
||||
complex migration scenarios. Create a new fee type to change intervals.
|
||||
|
||||
**Relationships:**
|
||||
- 1:N with members - members assigned to this fee type
|
||||
- 1:N with membership_fee_cycles - all cycles using this fee type
|
||||
|
||||
**Deletion Behavior:**
|
||||
- ON DELETE RESTRICT: Cannot delete if members or cycles reference it
|
||||
'''
|
||||
}
|
||||
|
||||
Table membership_fee_cycles {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
|
||||
cycle_start date [not null, note: 'Start date of the billing cycle']
|
||||
amount numeric(10,2) [not null, note: 'Fee amount for this cycle (CHECK: >= 0)']
|
||||
status text [not null, default: 'unpaid', note: 'Payment status (CHECK: IN unpaid, paid, suspended)']
|
||||
notes text [null, note: 'Optional notes for this cycle']
|
||||
member_id uuid [not null, note: 'FK to members - the member this cycle belongs to']
|
||||
membership_fee_type_id uuid [not null, note: 'FK to membership_fee_types - fee type for this cycle']
|
||||
|
||||
indexes {
|
||||
member_id [name: 'membership_fee_cycles_member_id_index']
|
||||
membership_fee_type_id [name: 'membership_fee_cycles_membership_fee_type_id_index']
|
||||
status [name: 'membership_fee_cycles_status_index']
|
||||
cycle_start [name: 'membership_fee_cycles_cycle_start_index']
|
||||
(member_id, cycle_start) [unique, name: 'membership_fee_cycles_unique_cycle_per_member_index', note: 'One cycle per member per cycle_start']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Individual Membership Fee Cycles**
|
||||
|
||||
Represents a single billing cycle for a member with payment tracking.
|
||||
|
||||
**Design Decisions:**
|
||||
- `cycle_end` is NOT stored - calculated from cycle_start + interval
|
||||
- `amount` is stored per cycle to preserve historical values when fee type amount changes
|
||||
- Cycles are aligned to calendar boundaries
|
||||
|
||||
**Status Values:**
|
||||
- `unpaid`: Payment pending (default)
|
||||
- `paid`: Payment received
|
||||
- `suspended`: Payment suspended (e.g., hardship case)
|
||||
|
||||
**Constraints:**
|
||||
- Unique: One cycle per member per cycle_start date
|
||||
- member_id: Required (belongs_to)
|
||||
- membership_fee_type_id: Required (belongs_to)
|
||||
|
||||
**Relationships:**
|
||||
- N:1 with members - the member this cycle belongs to
|
||||
- N:1 with membership_fee_types - the fee type for this cycle
|
||||
|
||||
**Deletion Behavior:**
|
||||
- ON DELETE CASCADE (member_id): Cycles deleted when member deleted
|
||||
- ON DELETE RESTRICT (membership_fee_type_id): Cannot delete fee type if cycles exist
|
||||
- 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,24 +305,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]
|
||||
|
||||
// Member → MembershipFeeType (N:1)
|
||||
// - Many members can be assigned to one fee type
|
||||
// - Optional relationship (member can have no fee type)
|
||||
// - ON DELETE RESTRICT: Cannot delete fee type if members are assigned
|
||||
Ref: members.membership_fee_type_id > membership_fee_types.id [delete: restrict]
|
||||
|
||||
// MembershipFeeCycle → Member (N:1)
|
||||
// - Many cycles belong to one member
|
||||
// - ON DELETE CASCADE: Cycles deleted when member deleted
|
||||
Ref: membership_fee_cycles.member_id > members.id [delete: cascade]
|
||||
|
||||
// MembershipFeeCycle → MembershipFeeType (N:1)
|
||||
// - Many cycles reference one fee type
|
||||
// - ON DELETE RESTRICT: Cannot delete fee type if cycles reference it
|
||||
Ref: membership_fee_cycles.membership_fee_type_id > membership_fee_types.id [delete: restrict]
|
||||
// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist
|
||||
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict]
|
||||
|
||||
// ============================================
|
||||
// ENUMS
|
||||
|
|
@ -446,21 +330,6 @@ Enum token_purpose {
|
|||
email_confirmation [note: 'Email verification tokens']
|
||||
}
|
||||
|
||||
// Billing interval for membership fee types
|
||||
Enum membership_fee_interval {
|
||||
monthly [note: '1st to last day of month']
|
||||
quarterly [note: '1st of Jan/Apr/Jul/Oct to last day of quarter']
|
||||
half_yearly [note: '1st of Jan/Jul to last day of half']
|
||||
yearly [note: 'Jan 1 to Dec 31']
|
||||
}
|
||||
|
||||
// Payment status for membership fee cycles
|
||||
Enum membership_fee_status {
|
||||
unpaid [note: 'Payment pending (default)']
|
||||
paid [note: 'Payment received']
|
||||
suspended [note: 'Payment suspended']
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TABLE GROUPS
|
||||
// ============================================
|
||||
|
|
@ -468,276 +337,12 @@ 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).
|
||||
'''
|
||||
}
|
||||
|
||||
TableGroup membership_fees_domain {
|
||||
membership_fee_types
|
||||
membership_fee_cycles
|
||||
|
||||
Note: '''
|
||||
**Membership Fees Domain**
|
||||
|
||||
Handles membership fee management including:
|
||||
- Fee type definitions with intervals
|
||||
- Individual billing cycles per member
|
||||
- Payment status tracking
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUTHORIZATION DOMAIN
|
||||
// ============================================
|
||||
|
||||
Table roles {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
|
||||
name text [not null, unique, note: 'Unique role name (e.g., "Vorstand", "Admin", "Mitglied")']
|
||||
description text [null, note: 'Human-readable description of the role']
|
||||
permission_set_name text [not null, note: 'Permission set name: "own_data", "read_only", "normal_user", or "admin"']
|
||||
is_system_role boolean [not null, default: false, note: 'If true, role cannot be deleted (protects critical roles)']
|
||||
inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
|
||||
updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
|
||||
|
||||
indexes {
|
||||
name [unique, name: 'roles_unique_name_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Role-Based Access Control (RBAC)**
|
||||
|
||||
Roles link users to permission sets. Each role references one of four hardcoded
|
||||
permission sets defined in the application code.
|
||||
|
||||
**Permission Sets:**
|
||||
- `own_data`: Users can only access their own linked member data
|
||||
- `read_only`: Users can read all data but cannot modify
|
||||
- `normal_user`: Users can read and modify most data (standard permissions)
|
||||
- `admin`: Full access to all features and settings
|
||||
|
||||
**System Roles:**
|
||||
- System roles (is_system_role = true) cannot be deleted
|
||||
- Protects critical roles like "Mitglied" (member) from accidental deletion
|
||||
- Only set via seed scripts or internal actions
|
||||
|
||||
**Relationships:**
|
||||
- 1:N with users - users assigned to this role
|
||||
- ON DELETE RESTRICT: Cannot delete role if users are assigned
|
||||
|
||||
**Constraints:**
|
||||
- `name` must be unique
|
||||
- `permission_set_name` must be a valid permission set (validated in application)
|
||||
- System roles cannot be deleted (enforced via validation)
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MEMBERSHIP DOMAIN (Additional Tables)
|
||||
// ============================================
|
||||
|
||||
Table settings {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
|
||||
club_name text [not null, note: 'The name of the association/club (min length: 1)']
|
||||
member_field_visibility jsonb [null, note: 'Visibility 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)']
|
||||
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)']
|
||||
inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
|
||||
updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
|
||||
|
||||
indexes {
|
||||
default_membership_fee_type_id [name: 'settings_default_membership_fee_type_id_index', note: 'B-tree index for fee type lookups']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Global Application Settings (Singleton Resource)**
|
||||
|
||||
Stores global configuration for the association/club. There should only ever
|
||||
be one settings record in the database (singleton pattern).
|
||||
|
||||
**Attributes:**
|
||||
- `club_name`: The name of the association/club (required, can be set via ASSOCIATION_NAME env var)
|
||||
- `member_field_visibility`: JSONB map storing visibility configuration for member fields
|
||||
(e.g., `{"street": false, "house_number": false}`). Fields not in the map default to `true`.
|
||||
- `include_joining_cycle`: When true, members pay from their joining cycle. When false,
|
||||
they pay from the next full cycle after joining.
|
||||
- `default_membership_fee_type_id`: The membership fee type automatically assigned to
|
||||
new members. Can be nil if no default is set.
|
||||
|
||||
**Singleton Pattern:**
|
||||
- Only one settings record should exist
|
||||
- Designed to be read and updated, not created/destroyed via normal CRUD
|
||||
- Initial settings should be seeded
|
||||
|
||||
**Environment Variable Support:**
|
||||
- `club_name` can be set via `ASSOCIATION_NAME` environment variable
|
||||
- Database values always take precedence over environment variables
|
||||
|
||||
**Relationships:**
|
||||
- Optional N:1 with membership_fee_types - default fee type for new members
|
||||
- ON DELETE SET NULL: If default fee type is deleted, setting is cleared
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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
|
||||
|
||||
// ============================================
|
||||
// TABLE GROUPS (Updated)
|
||||
// ============================================
|
||||
|
||||
TableGroup authorization_domain {
|
||||
roles
|
||||
|
||||
Note: '''
|
||||
**Authorization Domain**
|
||||
|
||||
Handles role-based access control (RBAC) with hardcoded permission sets.
|
||||
Roles link users to permission sets for authorization.
|
||||
Supports multiple authentication strategies (Password, OIDC).
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
@ -745,17 +350,12 @@ TableGroup membership_domain {
|
|||
members
|
||||
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.
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
|
|||
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).
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
|
||||
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
|
||||
4. **Sync is bidirectional**: User ↔ Member (but User always wins on link)
|
||||
5. **Linked member email change** - When a member is linked, only administrators or the linked user may change that member's email (Member resource validation `EmailChangePermission`). Because User.email wins on link and changes sync Member → User, allowing anyone to change a linked member's email would overwrite that user's account email; this rule keeps sync under control.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
# Email Validation Strategy
|
||||
|
||||
We use `EctoCommons.EmailValidator` with **both** `:html_input` and `:pow`
|
||||
checks, defined centrally in `Mv.Constants.email_validator_checks/0`
|
||||
(`@email_validator_checks [:html_input, :pow]`).
|
||||
|
||||
## Why both checks
|
||||
|
||||
- `: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.
|
||||
|
||||
Using both balances user experience (accepting common formats) against
|
||||
technical correctness (validating against email standards) and international
|
||||
support.
|
||||
|
||||
## Usage
|
||||
|
||||
The checks are applied consistently at every validation point, all reading the
|
||||
single central constant so they stay in sync:
|
||||
|
||||
- `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.
|
||||
|
||||
Member and User use similar schemaless changesets inside their Ash validations.
|
||||
|
||||
## Changing the validation strategy
|
||||
|
||||
Update `@email_validator_checks` in `Mv.Constants`; the change applies
|
||||
everywhere automatically.
|
||||
|
||||
**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
|
||||
**Status:** Active Development
|
||||
**Last Updated:** 2025-11-10
|
||||
**Status:** Planning Phase
|
||||
|
||||
---
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -24,45 +29,25 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
- ✅ **OIDC account linking with password verification** (PR #192, closes #171)
|
||||
- ✅ **Secure OIDC email collision handling** (PR #192)
|
||||
- ✅ **Automatic linking for passwordless users** (PR #192)
|
||||
- ✅ **Page Permission Router Plug** - Page-level authorization (PR #390, closes #388, 2026-01-27)
|
||||
- Route-based permission checking
|
||||
- Automatic redirects for unauthorized access
|
||||
- Integration with permission sets
|
||||
|
||||
**Closed Issues:**
|
||||
- ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13)
|
||||
- ✅ [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen — fixed via `MvWeb.AuthOverridesDE` locale-specific module (2026-03-13)
|
||||
- ✅ [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen — fixed locale selector bug with `Gettext.get_locale(MvWeb.Gettext)` (2026-03-13)
|
||||
|
||||
**Open Issues:** (none remaining for Authentication UI)
|
||||
|
||||
**Current State:**
|
||||
- ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345)
|
||||
- ✅ **Permission system** - Four hardcoded permission sets (`own_data`, `read_only`, `normal_user`, `admin`)
|
||||
- ✅ **Database-backed roles** - Roles table with permission set references
|
||||
- ✅ **Resource policies** - Member resource policies with scope filtering
|
||||
- ✅ **Page-level authorization** - LiveView page access control
|
||||
- ✅ **System role protection** - Critical roles cannot be deleted
|
||||
|
||||
**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`.
|
||||
**Open Issues:**
|
||||
- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low)
|
||||
- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Role-based access control (RBAC)
|
||||
- ❌ Permission system
|
||||
- ❌ Password reset flow
|
||||
- ❌ Email verification
|
||||
- ❌ Two-factor authentication (future)
|
||||
|
||||
**Related Issues:**
|
||||
- ✅ [#345](https://git.local-it.org/local-it/mitgliederverwaltung/issues/345) - Member Resource Policies (closed 2026-01-13)
|
||||
- ✅ [#191](https://git.local-it.org/local-it/mitgliederverwaltung/issues/191) - Implement Roles in Ash (M) - Completed
|
||||
- ✅ [#190](https://git.local-it.org/local-it/mitgliederverwaltung/issues/190) - Implement Permissions in Ash (M) - Completed
|
||||
- ✅ [#151](https://git.local-it.org/local-it/mitgliederverwaltung/issues/151) - Define implementation plan for roles and permissions (M) - Completed
|
||||
- ✅ [#388](https://git.local-it.org/local-it/mitgliederverwaltung/issues/388) - Page Permission Router Plug (closed 2026-01-27)
|
||||
- ✅ [#386](https://git.local-it.org/local-it/mitgliederverwaltung/issues/386) - CustomField Resource Policies (closed 2026-01-27)
|
||||
- ✅ [#369](https://git.local-it.org/local-it/mitgliederverwaltung/issues/369) - CustomFieldValue Resource Policies (closed 2026-01-27)
|
||||
- ✅ [#363](https://git.local-it.org/local-it/mitgliederverwaltung/issues/363) - User Resource Policies (closed 2026-01-27)
|
||||
- [#191](https://git.local-it.org/local-it/mitgliederverwaltung/issues/191) - Implement Roles in Ash (M)
|
||||
- [#190](https://git.local-it.org/local-it/mitgliederverwaltung/issues/190) - Implement Permissions in Ash (M)
|
||||
- [#151](https://git.local-it.org/local-it/mitgliederverwaltung/issues/151) - Define implementation plan for roles and permissions (M) [3/7 tasks done]
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -80,28 +65,9 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
- ✅ Sorting by basic fields
|
||||
- ✅ User-Member linking (optional 1:1)
|
||||
- ✅ Email synchronization between User and Member
|
||||
- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230)
|
||||
- ✅ **Groups** - Organize members into groups (PR #378, #382, #423, closes #371, #372, #374, #375, 2026-01/02)
|
||||
- Many-to-many relationship with groups
|
||||
- Groups management UI (`/groups`)
|
||||
- Filter and sort by groups in member list
|
||||
- Per-group filter in member list: one row per group with All / Yes / No (All/Alle); URL params `group_<uuid>=in|not_in`
|
||||
- Groups displayed in member overview and detail views
|
||||
- Member search includes group names (search by group name finds members in that group; search_vector + trigger on member_groups)
|
||||
- ✅ **CSV Import** - Import members from CSV files (PR #359, #394, #395, closes #335, #336, #338, 2026-01-27)
|
||||
- Member field import
|
||||
- Custom field value import
|
||||
- Real-time progress tracking
|
||||
- Error reporting
|
||||
|
||||
**Closed Issues:**
|
||||
- ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12)
|
||||
- ✅ [#371](https://git.local-it.org/local-it/mitgliederverwaltung/issues/371) - Add groups resource (closed 2026-01-27)
|
||||
- ✅ [#372](https://git.local-it.org/local-it/mitgliederverwaltung/issues/372) - Groups Admin UI (closed 2026-01-27)
|
||||
- ✅ [#375](https://git.local-it.org/local-it/mitgliederverwaltung/issues/375) - Search Integration (group names in member search) (implemented 2026-02-17)
|
||||
- ✅ [#335](https://git.local-it.org/local-it/mitgliederverwaltung/issues/335) - CSV Import UI (closed 2026-01-27)
|
||||
- ✅ [#336](https://git.local-it.org/local-it/mitgliederverwaltung/issues/336) - Config for import limits (closed 2026-01-27)
|
||||
- ✅ [#338](https://git.local-it.org/local-it/mitgliederverwaltung/issues/338) - Custom field CSV import (closed 2026-01-27)
|
||||
|
||||
**Open Issues:**
|
||||
- [#169](https://git.local-it.org/local-it/mitgliederverwaltung/issues/169) - Allow combined creation of Users/Members (M, Low priority)
|
||||
|
|
@ -114,7 +80,7 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
- ❌ Advanced filters (date ranges, multiple criteria)
|
||||
- ❌ Pagination (currently all members loaded)
|
||||
- ❌ Bulk operations (bulk delete, bulk update)
|
||||
- ❌ Excel import for members
|
||||
- ❌ Member import/export (CSV, Excel)
|
||||
- ❌ Member profile photos/avatars
|
||||
- ❌ Member history/audit log
|
||||
- ❌ Duplicate detection
|
||||
|
|
@ -133,10 +99,10 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
**Closed Issues:**
|
||||
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S)
|
||||
- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M)
|
||||
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Remove birthday field from default configuration (S) - Closed 2025-12-02
|
||||
|
||||
**Open Issues:**
|
||||
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
|
||||
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority)
|
||||
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
|
|
@ -178,11 +144,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)
|
||||
|
|
@ -225,29 +186,19 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
|
||||
**Current State:**
|
||||
- ✅ Basic "paid" boolean field on members
|
||||
- ✅ **Membership Fee Types Management** - Full CRUD implementation
|
||||
- ✅ **Membership Fee Cycles** - Individual billing cycles per member
|
||||
- ✅ **Membership Fee Settings** - Global settings (include_joining_cycle, default_fee_type)
|
||||
- ✅ **Cycle Generation** - Automatic cycle generation for members
|
||||
- ✅ **Payment Status Tracking** - Status per cycle (unpaid, paid, suspended)
|
||||
- ✅ **Member Fee Assignment** - Members can be assigned to fee types
|
||||
- ✅ **Cycle Regeneration** - Regenerate cycles when fee type changes
|
||||
- ✅ **UI Components** - Membership fee status in member list and detail views
|
||||
- ⚠️ No payment tracking
|
||||
|
||||
**Open Issues:**
|
||||
- [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority)
|
||||
- ✅ [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Membership Fee Mockup Pages (Preview) - Implemented
|
||||
|
||||
**Implemented Pages:**
|
||||
- `/membership_fee_types` - Membership Fee Types Management (fully functional)
|
||||
- `/membership_fee_settings` - Global Membership Fee Settings (fully functional)
|
||||
- `/members/:id` - Member detail view with membership fee cycles
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Payment records/transactions (external payment tracking)
|
||||
- ❌ Membership fee configuration
|
||||
- ❌ Payment records/transactions
|
||||
- ❌ Payment history per member
|
||||
- ❌ Payment reminders
|
||||
- ❌ Payment status tracking (pending, paid, overdue)
|
||||
- ❌ 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
|
||||
|
||||
|
|
@ -260,21 +211,17 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
|
||||
**Current State:**
|
||||
- ✅ AshAdmin integration (basic)
|
||||
- ✅ **Global Settings Management** - `/settings` page (singleton resource)
|
||||
- ✅ **Club/Organization profile** - Club name configuration
|
||||
- ✅ **Member Field Visibility Settings** - Configure which fields show in overview
|
||||
- ✅ **CustomFieldValue type management UI** - Full CRUD for custom fields
|
||||
- ✅ **Role Management UI** - Full CRUD for roles (`/admin/roles`)
|
||||
- ✅ **Membership Fee Settings** - Global fee settings management
|
||||
- ⚠️ No user-facing admin UI
|
||||
|
||||
**Open Issues:**
|
||||
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
|
||||
|
||||
**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:**
|
||||
- ❌ Global settings management
|
||||
- ❌ Club/Organization profile
|
||||
- ❌ Email templates configuration
|
||||
- ❌ CustomFieldValue type management UI (user-facing)
|
||||
- ❌ Role and permission management UI
|
||||
- ❌ System health dashboard
|
||||
- ❌ Audit log viewer
|
||||
- ❌ Backup/restore functionality
|
||||
|
|
@ -290,7 +237,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,15 +249,10 @@ 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.
|
||||
- ❌ No reporting features
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Extended member statistics dashboard
|
||||
- ❌ Member statistics dashboard
|
||||
- ❌ Membership growth charts
|
||||
- ❌ Payment reports
|
||||
- ❌ Custom report builder
|
||||
|
|
@ -325,27 +266,13 @@ This is the living per-area roadmap: shipped state (coarse — see `development-
|
|||
|
||||
**Current State:**
|
||||
- ✅ Seed data script
|
||||
- ✅ **CSV Import Templates** - German and English templates (#329, 2026-01-13)
|
||||
- Template files in `priv/static/templates/member_import_de.csv` and `member_import_en.csv`
|
||||
- CSV specification documented in `docs/csv-member-import-v1.md`
|
||||
- ✅ **CSV Import Implementation** - Full CSV import feature (#335, #336, #338, 2026-01-27)
|
||||
- Import/Export LiveView (`/import_export`)
|
||||
- Member field import (email, first_name, last_name, etc.)
|
||||
- Custom field value import (all types: string, integer, boolean, date, email)
|
||||
- Real-time progress tracking
|
||||
- Error and warning reporting with line numbers
|
||||
- Configurable limits (max file size, max rows)
|
||||
- Chunked processing (200 rows per chunk)
|
||||
- Admin-only access
|
||||
|
||||
**Closed Issues:**
|
||||
- ✅ [#335](https://git.local-it.org/local-it/mitgliederverwaltung/issues/335) - CSV Import UI (closed 2026-01-27)
|
||||
- ✅ [#336](https://git.local-it.org/local-it/mitgliederverwaltung/issues/336) - Config for import limits (closed 2026-01-27)
|
||||
- ✅ [#338](https://git.local-it.org/local-it/mitgliederverwaltung/issues/338) - Custom field CSV import (closed 2026-01-27)
|
||||
- ⚠️ No user-facing import/export
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ CSV import for members
|
||||
- ❌ Excel import for members
|
||||
- ❌ Import validation preview (before import)
|
||||
- ❌ Import validation and preview
|
||||
- ❌ Import error handling
|
||||
- ❌ Bulk data export
|
||||
- ❌ Backup export
|
||||
- ❌ Data migration tools
|
||||
|
|
@ -380,7 +307,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 +413,343 @@ 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/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
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/custom-fields` | List custom fields | 🛡️ | `new`, `edit`, `delete` |
|
||||
| `/custom-fields/new` | Create custom field | 🛡️ | `save`, `cancel` |
|
||||
| `/custom-fields/:id/edit` | Edit custom field | 🛡️ | `save`, `cancel`, `delete` |
|
||||
|
||||
#### 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
|
||||
|
||||
| Method | Route | Purpose | Auth | Request | Response |
|
||||
|--------|-------|---------|------|---------|----------|
|
||||
| `POST` | `/locale` | Set user locale | 🔐 | `{locale: "de"}` | Redirect with cookie |
|
||||
| `GET` | `/locales` | List available locales | 🔓 | - | `["de", "en"]` |
|
||||
|
||||
---
|
||||
|
||||
### 7. Payment & Fees Management Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW - Issue #156)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/payments` | Payment list | 🔐 | `new`, `record_payment`, `send_reminder` |
|
||||
| `/payments/:id` | Payment detail | 🔐 | `edit`, `delete`, `mark_paid` |
|
||||
| `/fees` | Fee configuration | 🛡️ | `create`, `edit`, `delete` |
|
||||
| `/invoices` | Invoice list | 🔐 | `generate`, `download`, `send` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Fee` | `:create` | Create fee type | 🛡️ | `{name, amount, frequency}` | `{:ok, fee}` |
|
||||
| `Fee` | `:read` | List fees | 🔐 | - | `[%Fee{}]` |
|
||||
| `Payment` | `:create` | Record payment | 🔐 | `{member_id, fee_id, amount, date}` | `{:ok, payment}` |
|
||||
| `Payment` | `:list_by_member` | Member payment history | 🔐 | `{member_id}` | `[%Payment{}]` |
|
||||
| `Payment` | `:mark_paid` | Mark as paid | 🔐 | `{id}` | `{:ok, payment}` |
|
||||
| `Invoice` | `:generate` | Generate invoice | 🔐 | `{member_id, fee_id, period}` | `{:ok, invoice}` |
|
||||
| `Invoice` | `:send` | Send invoice via email | 🔐 | `{id}` | `{:ok, sent}` |
|
||||
| `Payment` | `:import_vereinfacht` | Import from vereinfacht.digital | 🛡️ | `{transactions}` | `{:ok, count}` |
|
||||
|
||||
---
|
||||
|
||||
### 8. Admin Panel & Configuration Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/admin` | Admin dashboard | 🛡️ | - |
|
||||
| `/admin/settings` | Global settings | 🛡️ | `save` |
|
||||
| `/admin/organization` | Organization profile | 🛡️ | `save` |
|
||||
| `/admin/email-templates` | Email template editor | 🛡️ | `create`, `edit`, `preview` |
|
||||
| `/admin/audit-log` | System audit log | 🛡️ | `filter`, `export` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Setting` | `:get` | Get setting value | 🔐 | `{key}` | `value` |
|
||||
| `Setting` | `:set` | Set setting value | 🛡️ | `{key, value}` | `{:ok, setting}` |
|
||||
| `Setting` | `:list` | List all settings | 🛡️ | - | `[%Setting{}]` |
|
||||
| `Organization` | `:read` | Get organization info | 🔐 | - | `%Organization{}` |
|
||||
| `Organization` | `:update` | Update organization | 🛡️ | `{name, logo, ...}` | `{:ok, org}` |
|
||||
| `AuditLog` | `:list` | List audit entries | 🛡️ | `{filters, pagination}` | `[%AuditLog{}]` |
|
||||
|
||||
---
|
||||
|
||||
### 9. Communication & Notifications Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/communications` | Communication history | 🔐 | `new`, `view` |
|
||||
| `/communications/new` | Create email broadcast | 🔐 | `select_recipients`, `preview`, `send` |
|
||||
| `/notifications` | User notifications | 🔐 | `mark_read`, `mark_all_read` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `EmailBroadcast` | `:create` | Create broadcast | 🔐 | `{subject, body, recipient_filter}` | `{:ok, broadcast}` |
|
||||
| `EmailBroadcast` | `:send` | Send broadcast | 🔐 | `{id}` | `{:ok, sent_count}` |
|
||||
| `EmailTemplate` | `:create` | Create template | 🛡️ | `{name, subject, body}` | `{:ok, template}` |
|
||||
| `EmailTemplate` | `:render` | Render template | 🔐 | `{id, variables}` | `rendered_html` |
|
||||
| `Notification` | `:create` | Create notification | System | `{user_id, type, message}` | `{:ok, notification}` |
|
||||
| `Notification` | `:list_for_user` | Get user notifications | 🔐 | `{user_id}` | `[%Notification{}]` |
|
||||
| `Notification` | `:mark_read` | Mark as read | 🔐 | `{id}` | `{:ok, notification}` |
|
||||
|
||||
---
|
||||
|
||||
### 10. Reporting & Analytics Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/reports` | Reports dashboard | 🔐 | `generate`, `schedule` |
|
||||
| `/reports/members` | Member statistics | 🔐 | `filter`, `export` |
|
||||
| `/reports/payments` | Payment reports | 🔐 | `filter`, `export` |
|
||||
| `/reports/custom` | Custom report builder | 🛡️ | `build`, `save`, `run` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Report` | `:generate_member_stats` | Member statistics | 🔐 | `{date_range, filters}` | Statistics object |
|
||||
| `Report` | `:generate_payment_stats` | Payment statistics | 🔐 | `{date_range}` | Statistics object |
|
||||
| `Report` | `:export_to_csv` | Export report to CSV | 🔐 | `{report_type, filters}` | CSV file |
|
||||
| `Report` | `:export_to_pdf` | Export report to PDF | 🔐 | `{report_type, filters}` | PDF file |
|
||||
| `Report` | `:schedule` | Schedule recurring report | 🛡️ | `{report_type, frequency, recipients}` | `{:ok, schedule}` |
|
||||
|
||||
---
|
||||
|
||||
### 11. Data Import/Export Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/import` | Data import wizard | 🛡️ | `upload`, `map_fields`, `preview`, `import` |
|
||||
| `/export` | Data export tool | 🔐 | `select_data`, `configure`, `export` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Member` | `:import_csv` | Import members from CSV | 🛡️ | `{file, field_mapping}` | `{:ok, imported, errors}` |
|
||||
| `Member` | `:validate_import` | Validate import data | 🛡️ | `{file, field_mapping}` | `{:ok, validation_results}` |
|
||||
| `Member` | `:export_csv` | Export members to CSV | 🔐 | `{filters}` | CSV file |
|
||||
| `Member` | `:export_excel` | Export members to Excel | 🔐 | `{filters}` | Excel file |
|
||||
| `Database` | `:export_backup` | Full database backup | 🛡️ | - | Backup file |
|
||||
| `Database` | `:import_backup` | Restore from backup | 🛡️ | `{file}` | `{:ok, restored}` |
|
||||
|
||||
---
|
||||
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
# Groups - Technical Architecture
|
||||
|
||||
**Feature:** Groups Management
|
||||
**Status:** Implemented (authorization: see [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md))
|
||||
|
||||
This document records the durable design of the Groups feature: data model, key decisions, integration points, accessibility rules, and the planned extension paths. The original implementation plan (estimations, vertical slices, per-issue acceptance criteria, testing/migration strategy) has been removed now that the feature has shipped.
|
||||
|
||||
**Related:** [database-schema-readme.md](./database-schema-readme.md), [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## Core Design Decisions
|
||||
|
||||
1. **Many-to-many:** members can belong to multiple groups and vice versa, via the `member_groups` join table (a separate Ash resource).
|
||||
2. **Flat structure:** no hierarchy in the current schema; the design leaves a clear path to add it later (see [Future Extensibility](#future-extensibility)).
|
||||
3. **Minimal attributes:** `name`, `description`, `slug`. The `slug` is auto-generated from `name`, immutable, URL-friendly.
|
||||
4. **Cascade on the join table only:** deleting a group (or member) removes the `member_groups` associations but never deletes members/groups themselves. Group deletion requires explicit confirmation (typing the group name).
|
||||
5. **Search integration:** group names are included in the member `search_vector` (not a separate search index).
|
||||
|
||||
## Domain & Resources
|
||||
|
||||
Groups live in the **`Mv.Membership`** domain alongside Members and CustomFields.
|
||||
|
||||
- `Mv.Membership.Group` (`lib/membership/group.ex`) — attributes `name`, `slug`, `description`; `has_many :member_groups`, `many_to_many :members`; `member_count` aggregate (`count :member_count, :member_groups`); `unique_slug` identity for slug lookups. Slug is generated by the shared **`Mv.Membership.Changes.GenerateSlug`** change (the same change CustomFields uses), generated on create and immutable on update.
|
||||
- `Mv.Membership.MemberGroup` (`lib/membership/member_group.ex`) — join table; `belongs_to :member`, `belongs_to :group`; unique on `(member_id, group_id)`. Has `create`/`destroy` actions only (no `update`); group membership is managed by creating and destroying these join rows.
|
||||
- `Mv.Membership.Member` (extended) — `has_many :member_groups`, `many_to_many :groups`. Group membership is managed through the `MemberGroup` join resource, not via dedicated Member actions.
|
||||
|
||||
## Data Model
|
||||
|
||||
### `groups`
|
||||
- `id` (UUIDv7), `name` (required), `slug` (required, immutable, auto-generated), `description` (optional), timestamps.
|
||||
- Uniqueness: `name` unique case-insensitively (`UNIQUE` on `LOWER(name)`, index `groups_unique_name_lower_index`); `slug` unique case-sensitively (`groups_unique_slug_index`).
|
||||
|
||||
### `member_groups` (join table)
|
||||
- `id` (UUIDv7), `member_id`, `group_id`, timestamps.
|
||||
- Unique `(member_id, group_id)` prevents duplicates; indexes on `member_id` and `group_id`.
|
||||
- **CASCADE delete on both foreign keys** — the cascade is intentionally on the join table only.
|
||||
|
||||
For exact columns/indexes see `database_schema.dbml`.
|
||||
|
||||
## Search Integration
|
||||
|
||||
Group names are part of the member full-text search:
|
||||
|
||||
- They are aggregated from `member_groups` joined to `groups` and added to `members.search_vector` at **weight B**.
|
||||
- The trigger `update_member_search_vector_on_member_groups_change` runs `update_member_search_vector_from_member_groups()` on **INSERT/UPDATE/DELETE on `member_groups`** and refreshes the affected member's `search_vector`.
|
||||
- Migration `20260217120000_add_group_names_to_member_search_vector.exs` (Issue #375). No Elixir search change is needed — searching a group name finds its members automatically.
|
||||
|
||||
## UI Surface (implemented)
|
||||
|
||||
- **`/groups`** — index table (name, description, member count, actions), sorted by name at the DB level. Create button → `/groups/new`.
|
||||
- **`/groups/:slug`** — detail: group info, member list, inline add-member combobox (search/autocomplete, excludes members already in the group), per-row remove (no confirmation), edit/delete. Add/remove are guarded by `:update` permission both in the UI and server-side in the event handlers.
|
||||
- **`/groups/:slug/edit`** and **`/groups/new`** — separate form pages; slug not editable. Edit does auth in `mount/3` and loads the group once in `handle_params/3`.
|
||||
- **Delete confirmation modal** — warns with member count (pluralized), requires typing the group name to enable delete (`phx-debounce="200"`), stays open on mismatch, authorizes server-side.
|
||||
- **Member overview** — "Groups" column with badges; filter dropdown (persisted in URL query params); sort by group; group names searchable.
|
||||
- **Member detail** — Groups shown as a data field in Personal Data (below Linked User), button-style links to `/groups/:slug`.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- **Do not use `role="status"` on group badges or navigation links.** That role is for live regions (screen-reader announcements), not for static labels or navigation. Use `aria-label` (e.g. "Member of group X") instead.
|
||||
- `role="status"` with `aria-live="polite"` is appropriate only for dynamic announcements (filter changes, member-count updates).
|
||||
- Clickable filter badges (optional enhancement) must be real `<button type="button">` with an `aria-label`; removal icons get `aria-hidden="true"`.
|
||||
- Filter `<select>` needs `id`, `name`, `aria-label`. Delete modal uses `role="dialog"` with `aria-labelledby`/`aria-describedby`.
|
||||
- All interactive elements keyboard-focusable; modals trap focus, Escape closes, Enter/Space activate.
|
||||
|
||||
## Authorization
|
||||
|
||||
Implemented; Group and MemberGroup policies and PermissionSets are in place. Authorization uses `Mv.Authorization.Checks.HasPermission`. Full permission matrix and policy patterns: [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md).
|
||||
|
||||
Per-permission-set access to `Group`:
|
||||
|
||||
| Permission set | read | create | update | destroy |
|
||||
|----------------|:----:|:------:|:------:|:-------:|
|
||||
| `own_data` | ✓ (`:all`) | — | — | — |
|
||||
| `read_only` | ✓ (`:all`) | — | — | — |
|
||||
| `normal_user` | ✓ (`:all`) | ✓ | ✓ | ✓ |
|
||||
| `admin` | ✓ (`:all`) | ✓ | ✓ | ✓ |
|
||||
|
||||
Groups are public information: every set with member-read access can read groups, using `:all` scope.
|
||||
|
||||
`MemberGroup` (the join resource) has only `read`/`create`/`destroy` actions (no `update`). Its read scope differs for `own_data` (`:linked` — a member sees only their own memberships) while `read_only`/`normal_user`/`admin` read `:all`; create/destroy follow the same pattern as Group (normal_user + admin). Adding/removing a member to/from a group is a `MemberGroup` create/destroy, not a Group update.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Preload groups** when querying members to avoid N+1; preload only `id, name, slug`. Filter by group at the DB level.
|
||||
- With proper preloading the overview is efficient up to ~100 members (the original scope); beyond that, paginate, lazy-load the groups column, or aggregate group counts in the DB.
|
||||
- Group detail: paginate large member lists (> 50), compute member count via the `member_count` aggregate, sort at the DB level.
|
||||
- Search: group names in the GIN-indexed `search_vector`; trigger updates on `member_groups` changes (see above).
|
||||
|
||||
## Future Extensibility
|
||||
|
||||
**Hierarchical groups** (design path, not yet in schema):
|
||||
- Add nullable `parent_group_id` to `groups` (self-referential `parent_group` relationship).
|
||||
- Add a **circular-reference guard** validation.
|
||||
- Add a `path` calculation (e.g. "Parent > Child > Grandchild").
|
||||
- Migration: add the column with `NULL` default (all groups become root-level), then the FK constraint, validation, and UI.
|
||||
|
||||
**Roles/positions in groups:**
|
||||
- Add a **`member_group_roles`** table linking `MemberGroup` to a `Role` (e.g. "Leiter", "Mitglied"); extend `MemberGroup` with a `role_id` FK; surface the role in member/group detail.
|
||||
|
||||
**Other planned attributes:** founded/dissolved dates, status (active/inactive), badge color/icon — all as nullable additive columns. Group-specific permission sets and member-level self-assignment permissions are also future work.
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
# 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).
|
||||
|
||||
---
|
||||
|
||||
## Core Design Decisions
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Domain Structure
|
||||
|
||||
### Ash Domain: `Mv.MembershipFees`
|
||||
|
||||
Encapsulates all membership-fee resources and logic.
|
||||
|
||||
**Resources:**
|
||||
|
||||
- `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`.
|
||||
|
||||
**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`.
|
||||
|
||||
The Member resource is extended with membership fee fields.
|
||||
|
||||
### Module Map
|
||||
|
||||
```
|
||||
lib/
|
||||
├── membership_fees/
|
||||
│ ├── membership_fees.ex # Ash domain definition
|
||||
│ ├── 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
|
||||
├── mv/
|
||||
│ └── membership_fees/
|
||||
│ ├── cycle_generator.ex # Cycle generation algorithm
|
||||
│ ├── cycle_generation_job.ex # Scheduled cycle generation job
|
||||
│ └── calendar_cycles.ex # Calendar cycle calculations
|
||||
└── membership/
|
||||
└── 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.
|
||||
|
||||
---
|
||||
|
||||
## Data Architecture
|
||||
|
||||
See [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema.
|
||||
|
||||
### 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`.
|
||||
|
||||
### Member Table Extensions
|
||||
|
||||
- `membership_fee_type_id` (FK, nullable — default applied from settings at the app level)
|
||||
- `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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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 |
|
||||
|
||||
---
|
||||
|
||||
## Business Logic Architecture
|
||||
|
||||
### Cycle Generation — `Mv.MembershipFees.CycleGenerator`
|
||||
|
||||
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.
|
||||
|
||||
**Triggers:** fee type assigned (Ash change); member created with fee type (Ash change); scheduled job (daily/weekly cron); admin manual regeneration (UI).
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
1. Retrieve member with 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.
|
||||
|
||||
**Edge cases:**
|
||||
|
||||
- `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.
|
||||
|
||||
### Calendar Cycles — `Mv.MembershipFees.CalendarCycles`
|
||||
|
||||
Calculates cycle boundaries by interval, the current cycle, the last completed cycle, and `cycle_end` from `cycle_start` + interval.
|
||||
|
||||
**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`.
|
||||
|
||||
**Interval logic:**
|
||||
|
||||
- **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.
|
||||
|
||||
### Status Management — Ash actions on `MembershipFeeCycle`
|
||||
|
||||
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.
|
||||
|
||||
### Membership Fee Type Change — Ash change on `Member.membership_fee_type_id`
|
||||
|
||||
**Validation:** new type must have the same interval as the old type; different interval is rejected (MVP constraint).
|
||||
|
||||
**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.
|
||||
|
||||
**Implementation pattern:**
|
||||
|
||||
- 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.
|
||||
|
||||
**Validation behavior:**
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Member Resource
|
||||
|
||||
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.
|
||||
|
||||
### Settings System
|
||||
|
||||
Store two global settings, admin UI to modify, default values if unset, validation (default fee type must exist).
|
||||
|
||||
### Permission System — Implemented
|
||||
|
||||
See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full matrix and policy patterns.
|
||||
|
||||
**PermissionSets (`lib/mv/authorization/permission_sets.ex`):**
|
||||
|
||||
- **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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## Performance Notes
|
||||
|
||||
**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)`.
|
||||
|
||||
**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.
|
||||
|
||||
**No caching in MVP** (fee types rarely change, queries fast). Scheduled generation job: run daily/weekly, batch members, skip unchanged, 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.
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
# Membership Fees - Overview
|
||||
|
||||
**Feature:** Membership Fee Management — **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).
|
||||
|
||||
---
|
||||
|
||||
## Core Principle
|
||||
|
||||
Maximum simplicity: minimal complexity, clear data model without redundancies, intuitive operation, calendar-cycle-based (Month / Quarter / Half-Year / Year).
|
||||
|
||||
---
|
||||
|
||||
## Terminology (German ↔ English)
|
||||
|
||||
**Core entities:**
|
||||
|
||||
- Beitragsart ↔ Membership Fee Type
|
||||
- Beitragszyklus ↔ Membership Fee Cycle
|
||||
- Mitgliedsbeitrag ↔ Membership Fee
|
||||
|
||||
**Status:**
|
||||
|
||||
- bezahlt ↔ paid
|
||||
- unbezahlt ↔ unpaid
|
||||
- ausgesetzt ↔ suspended / waived
|
||||
|
||||
**Intervals (Frequenz / payment frequency):**
|
||||
|
||||
- monatlich ↔ monthly
|
||||
- quartalsweise ↔ quarterly
|
||||
- halbjährlich ↔ half-yearly / semi-annually
|
||||
- jährlich ↔ yearly / annually
|
||||
|
||||
**UI elements:**
|
||||
|
||||
- "Letzter Zyklus" ↔ "Last Cycle" (e.g., 2023 when in 2024)
|
||||
- "Aktueller Zyklus" ↔ "Current Cycle" (e.g., 2024)
|
||||
- "Als bezahlt markieren" ↔ "Mark as paid"
|
||||
- "Aussetzen" ↔ "Suspend" / "Waive"
|
||||
|
||||
---
|
||||
|
||||
## Data Model (summary)
|
||||
|
||||
Three entities — full schema, FK on-delete behaviors, and design rationale are in [membership-fee-architecture.md](./membership-fee-architecture.md).
|
||||
|
||||
- **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`.
|
||||
|
||||
**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.
|
||||
|
||||
### `membership_fee_start_date` derivation
|
||||
|
||||
Auto-set from global setting `include_joining_cycle`:
|
||||
|
||||
- `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.
|
||||
|
||||
Can be manually overridden by admin. There is intentionally **no** `include_joining_cycle` field on Member — `membership_fee_start_date` makes it unnecessary.
|
||||
|
||||
### Global settings
|
||||
|
||||
- `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).
|
||||
|
||||
---
|
||||
|
||||
## Business Logic
|
||||
|
||||
### 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.
|
||||
|
||||
**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.)
|
||||
|
||||
**Example (Yearly):**
|
||||
|
||||
```
|
||||
Joining date: 15.03.2023
|
||||
include_joining_cycle: true
|
||||
→ membership_fee_start_date: 01.01.2023
|
||||
|
||||
Generated cycles:
|
||||
- 01.01.2023 - 31.12.2023 (joining cycle)
|
||||
- 01.01.2024 - 31.12.2024
|
||||
- 01.01.2025 - 31.12.2025 (current year)
|
||||
```
|
||||
|
||||
**Example (Quarterly):**
|
||||
|
||||
```
|
||||
Joining date: 15.03.2023
|
||||
include_joining_cycle: false
|
||||
→ membership_fee_start_date: 01.04.2023
|
||||
|
||||
Generated cycles:
|
||||
- 01.04.2023 - 30.06.2023 (first full quarter)
|
||||
- 01.07.2023 - 30.09.2023
|
||||
- 01.10.2023 - 31.12.2023
|
||||
- 01.01.2024 - 31.03.2024
|
||||
- ...
|
||||
```
|
||||
|
||||
### Status transitions
|
||||
|
||||
unpaid → paid · unpaid → suspended · paid → unpaid · suspended → paid · suspended → unpaid. Admin + Treasurer (Kassenwart) can change status, via the existing permission system.
|
||||
|
||||
### Membership fee type change
|
||||
|
||||
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).
|
||||
|
||||
### Member exit
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## UI/UX Design
|
||||
|
||||
### Member List View — column "Membership Fee Status"
|
||||
|
||||
- **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".
|
||||
|
||||
### Member Detail View — section "Membership Fees"
|
||||
|
||||
**Fee type assignment:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Membership Fee Type: [Dropdown] │
|
||||
│ ⚠ Only types with same interval │
|
||||
│ can be selected │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Cycle table:**
|
||||
|
||||
```
|
||||
┌───────────────┬──────────┬────────┬──────────┬─────────┐
|
||||
│ Cycle │ Interval │ Amount │ Status │ Action │
|
||||
├───────────────┼──────────┼────────┼──────────┼─────────┤
|
||||
│ 01.01.2023- │ Yearly │ 50 € │ ☑ Paid │ │
|
||||
│ 31.12.2023 │ │ │ │ │
|
||||
├───────────────┼──────────┼────────┼──────────┼─────────┤
|
||||
│ 01.01.2024- │ Yearly │ 60 € │ ☐ Open │ [Mark │
|
||||
│ 31.12.2024 │ │ │ │ as paid]│
|
||||
├───────────────┼──────────┼────────┼──────────┼─────────┤
|
||||
│ 01.01.2025- │ Yearly │ 60 € │ ☐ Open │ [Mark │
|
||||
│ 31.12.2025 │ │ │ │ as paid]│
|
||||
└───────────────┴──────────┴────────┴──────────┴─────────┘
|
||||
|
||||
Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended
|
||||
```
|
||||
|
||||
**Quick marking:** checkbox per row; "Mark selected as paid/unpaid/suspended" button; bulk action for multiple cycles.
|
||||
|
||||
### Admin: Membership Fee Types Management
|
||||
|
||||
**List:**
|
||||
|
||||
```
|
||||
┌────────────┬──────────┬──────────┬────────────┬─────────┐
|
||||
│ Name │ Amount │ Interval │ Members │ Actions │
|
||||
├────────────┼──────────┼──────────┼────────────┼─────────┤
|
||||
│ Regular │ 60 € │ Yearly │ 45 │ [Edit] │
|
||||
│ Reduced │ 30 € │ Yearly │ 12 │ [Edit] │
|
||||
│ Student │ 20 € │ Monthly │ 8 │ [Edit] │
|
||||
└────────────┴──────────┴──────────┴────────────┴─────────┘
|
||||
```
|
||||
|
||||
**Edit:** Name ✓, Amount ✓, Description ✓ editable; Interval ✗ **NOT** editable (grayed out).
|
||||
|
||||
**Warning on amount change:**
|
||||
|
||||
```
|
||||
⚠ Change amount to 65 €?
|
||||
|
||||
Impact:
|
||||
- 45 members affected
|
||||
- Future unpaid cycles will be generated with 65 €
|
||||
- Already paid cycles remain with old amount
|
||||
|
||||
[Cancel] [Confirm]
|
||||
```
|
||||
|
||||
### Admin: Settings — Membership Fee Configuration
|
||||
|
||||
```
|
||||
Default Membership Fee Type: [Dropdown: Membership Fee Types]
|
||||
|
||||
Selected: "Regular (60 €, Yearly)"
|
||||
|
||||
This membership fee type is automatically assigned to all new members.
|
||||
Can be changed individually per member.
|
||||
|
||||
---
|
||||
|
||||
☐ Include joining cycle
|
||||
|
||||
When active:
|
||||
Members pay from the cycle of their joining.
|
||||
|
||||
Example (Yearly):
|
||||
Joining: 15.03.2023
|
||||
→ Pays from 2023
|
||||
|
||||
When inactive:
|
||||
Members pay from the next full cycle.
|
||||
|
||||
Example (Yearly):
|
||||
Joining: 15.03.2023
|
||||
→ Pays from 2024
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
1. **Type change with different interval:** MVP blocks it. UI message:
|
||||
|
||||
```
|
||||
Error: Interval change not possible
|
||||
|
||||
Current membership fee type: "Regular (Yearly)"
|
||||
Selected membership fee type: "Student (Monthly)"
|
||||
|
||||
Changing the interval is currently not possible.
|
||||
Please select a membership fee type with interval "Yearly".
|
||||
|
||||
[OK]
|
||||
```
|
||||
|
||||
Future: allow interval switching with overlap calculation and no duplicate cycles.
|
||||
|
||||
2. **Exit with unpaid fees (low prio):** on exit, offer to mark unpaid cycles "suspended".
|
||||
|
||||
```
|
||||
⚠ Unpaid membership fees present
|
||||
|
||||
This member has 1 unpaid cycle(s):
|
||||
- 2024: 60 € (unpaid)
|
||||
|
||||
Do you want to continue?
|
||||
|
||||
[ ] Mark membership fee as "suspended"
|
||||
[Cancel] [Confirm Exit]
|
||||
```
|
||||
|
||||
3. **Multiple unpaid cycles:** all shown; select several and bulk-mark.
|
||||
|
||||
```
|
||||
┌───────────────┬──────────┬────────┬──────────┬─────────┐
|
||||
│ 2023 │ Yearly │ 50 € │ ☐ Open │ [✓] │
|
||||
│ 2024 │ Yearly │ 60 € │ ☐ Open │ [✓] │
|
||||
│ 2025 │ Yearly │ 60 € │ ☐ Open │ [ ] │
|
||||
└───────────────┴──────────┴────────┴──────────┴─────────┘
|
||||
|
||||
[Mark selected as paid/unpaid/suspended] (2 selected)
|
||||
```
|
||||
|
||||
4. **Amount changes:** 2023 Regular = 50 €, 2024 = 60 € → 2023 cycle keeps 50 € (history), 2024 generated with 60 €; each cycle shows its historical amount.
|
||||
|
||||
5. **Date boundaries:** today = 01.01.2025 → current 2025 cycle generated, status unpaid, 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.
|
||||
|
||||
**NOT included:** interval change; payment details (date, method); automatic vereinfacht.digital integration; prorata; reports/statistics; reminders/dunning (manual via filters).
|
||||
|
||||
**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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
# Page Permission – Route and Test Coverage
|
||||
|
||||
This document lists all protected routes, which permission set may access them, and how they are covered by tests.
|
||||
|
||||
## Protected Routes (Router scope with CheckPagePermission in :browser)
|
||||
|
||||
| Route | own_data | read_only | normal_user | admin |
|
||||
|-------|----------|-----------|-------------|-------|
|
||||
| `/` | ✗ | ✓ | ✓ | ✓ |
|
||||
| `/members` | ✗ | ✓ | ✓ | ✓ |
|
||||
| `/members/new` | ✗ | ✗ | ✓ | ✓ |
|
||||
| `/members/:id` | ✓ (linked only) | ✓ | ✓ | ✓ |
|
||||
| `/members/:id/edit` | ✓ (linked only) | ✗ | ✓ | ✓ |
|
||||
| `/members/:id/show/edit` | ✓ (linked only) | ✗ | ✓ | ✓ |
|
||||
| `/users` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/users/new` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/users/:id` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
|
||||
| `/users/:id/edit` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
|
||||
| `/users/:id/show/edit` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
|
||||
| `/settings` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/membership_fee_settings` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/membership_fee_settings/new_fee_type` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/membership_fee_settings/:id/edit_fee_type` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/groups` | ✗ | ✓ | ✓ | ✓ |
|
||||
| `/groups/new` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/groups/:slug` | ✗ | ✓ | ✓ | ✓ |
|
||||
| `/groups/:slug/edit` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/statistics` | ✗ | ✓ | ✓ | ✓ |
|
||||
| `/admin/roles` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/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`.
|
||||
|
||||
## Public Paths (no permission check)
|
||||
|
||||
- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`, **`/join`**
|
||||
|
||||
The public join page `GET /join` is explicitly public (Subtask 4); unauthenticated access returns 200 when join form is enabled, 404 when disabled. Unit test: `test/mv_web/plugs/check_page_permission_test.exs` (plug allows /join); integration: `test/mv_web/live/join_live_test.exs`.
|
||||
|
||||
The join confirmation route `GET /confirm_join/:token` is public (matched by `/confirm*`). Unit tests: `test/mv_web/controllers/join_confirm_controller_test.exs` (stubbed callback, no integration).
|
||||
|
||||
## Test Coverage
|
||||
|
||||
**File:** `test/mv_web/plugs/check_page_permission_test.exs` 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.
|
||||
|
||||
Two coverage notes:
|
||||
|
||||
- **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.
|
||||
|
||||
## Plug behaviour: reserved segments
|
||||
|
||||
The plug treats `"new"` as a reserved path segment so that patterns like `/members/:id` and `/groups/:slug` do not match `/members/new` or `/groups/new`. Thus `/groups/new` is only allowed when the permission set explicitly lists `/groups/new` (currently only admin).
|
||||
|
||||
## Role and member_id loading
|
||||
|
||||
The plug may reload the user's role (and optionally `member_id`) before checking page permission. Session/`load_from_session` can leave the role unloaded; the plug uses `Mv.Authorization.Actor.ensure_loaded/1` (and, when needed, loads `member_id`) so that permission checks always have the required data. No change to session loading is required; this is documented for clarity.
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# PDF Generation: Imprintor instead of Chromium
|
||||
|
||||
## Decision
|
||||
|
||||
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`.
|
||||
|
||||
## Rationale (Imprintor over 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.
|
||||
|
||||
## When Chromium would still be warranted
|
||||
|
||||
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.
|
||||
|
||||
## Usage in this project
|
||||
|
||||
Member export as PDF (member lists / reports) and other static, predefined
|
||||
documents (e.g. membership certificates).
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
# Policy Pattern: Bypass vs. HasPermission
|
||||
|
||||
**Date:** 2026-01-22
|
||||
**Status:** Implemented and Tested
|
||||
**Applies to:** User Resource, Member Resource
|
||||
|
||||
## Summary
|
||||
|
||||
For filter-based permissions (`scope :own`, `scope :linked`) we use a **two-tier authorization pattern**:
|
||||
|
||||
1. **Bypass with `expr()` for READ** — handles list queries via `auto_filter`.
|
||||
2. **HasPermission for UPDATE/CREATE/DESTROY** — uses scope from PermissionSets when a record is present.
|
||||
|
||||
This ensures 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:
|
||||
|
||||
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.
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
## The Solution
|
||||
|
||||
Bypass for READ, HasPermission for everything else:
|
||||
|
||||
```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 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
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from user's role and permission set"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Why it 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 |
|
||||
|
||||
### UPDATE is controlled by PermissionSets, not hardcoded
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
### No explicit `forbid_if always()`
|
||||
|
||||
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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Proven by `test/mv/accounts/user_policies_test.exs` ("can update own email"): the update succeeds via `HasPermission` with `scope :own` (not via bypass).
|
||||
|
||||
## 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 action_type(:read) do
|
||||
authorize_if expr(id == ^actor(:id))
|
||||
end
|
||||
```
|
||||
|
||||
PermissionSets: `own_data` / `read_only` / `normal_user` use `scope :own` for read/update; `admin` uses `scope :all`.
|
||||
|
||||
### Member Resource
|
||||
|
||||
```elixir
|
||||
bypass action_type(:read) do
|
||||
authorize_if expr(id == ^actor(:member_id))
|
||||
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`.
|
||||
|
||||
## Technical Deep Dive
|
||||
|
||||
### Why `expr()` in bypass works
|
||||
|
||||
Ash treats `expr()` natively in both 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.
|
||||
|
||||
```elixir
|
||||
# Ash.read(User, actor: user)
|
||||
# Compiled SQL: SELECT * FROM users WHERE id = $1 → [user]
|
||||
```
|
||||
|
||||
### Why HasPermission doesn't trigger auto_filter
|
||||
|
||||
```elixir
|
||||
def strict_check(actor, authorizer, _opts) do
|
||||
case check_permission(...) do
|
||||
{:filter, filter_expr} ->
|
||||
if record do
|
||||
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.
|
||||
{: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`.)
|
||||
|
||||
## 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.**
|
||||
|
||||
## 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`
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -3,8 +3,8 @@
|
|||
**Project:** Mila - Membership Management System
|
||||
**Feature:** Role-Based Access Control (RBAC) with Hardcoded Permission Sets
|
||||
**Version:** 2.0
|
||||
**Last Updated:** 2026-01-13
|
||||
**Status:** ✅ Implemented (2026-01-08, PR #346, closes #345)
|
||||
**Last Updated:** 2025-11-13
|
||||
**Status:** Architecture Design - MVP Approach
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -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, Property, PropertyType, 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)
|
||||
|
||||
---
|
||||
|
|
@ -203,7 +280,7 @@ Contains:
|
|||
Each Permission Set contains:
|
||||
|
||||
**Resources:** List of resource permissions
|
||||
- resource: "Member", "User", "CustomFieldValue", etc.
|
||||
- resource: "Member", "User", "Property", etc.
|
||||
- action: :read, :create, :update, :destroy
|
||||
- scope: :own, :linked, :all
|
||||
- granted: true/false
|
||||
|
|
@ -217,9 +294,7 @@ Each Permission Set contains:
|
|||
**:own** - Only records where id == actor.id
|
||||
- Example: User can read their own User record
|
||||
|
||||
**:linked** - Only records linked to actor via relationships
|
||||
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
||||
- CustomFieldValue: `member_id == actor.member_id` (traverses Member → User relationship)
|
||||
**:linked** - Only records where user_id == actor.id
|
||||
- Example: User can read Member linked to their account
|
||||
|
||||
**:all** - All records without restriction
|
||||
|
|
@ -254,39 +329,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 +484,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.
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 1. Seeds Test Suite — coverage mapping
|
||||
|
||||
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:
|
||||
|
||||
| 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 |
|
||||
|
||||
### 4 retained critical-bootstrap tests (and why)
|
||||
|
||||
These guard deployment-critical invariants that nothing else covers and must stay in the **fast** suite:
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tagging convention: `:slow`
|
||||
|
||||
Tests are split into **fast** (standard CI) and **slow** (run via promotion before merge). A test is tagged `@tag :slow` when **all** of:
|
||||
|
||||
- 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).
|
||||
|
||||
**Never** tag as `:slow`:
|
||||
|
||||
- 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
|
||||
|
||||
Use **`@describetag :slow`** (not `@moduletag`) for describe blocks, so unrelated tests in the same module are not tagged.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 3. Execution model
|
||||
|
||||
| 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 |
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 4. Test organization
|
||||
|
||||
Tests mirror the `lib/` 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Concurrent `create_member` deadlock and deferrable FKs
|
||||
|
||||
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).
|
||||
|
||||
**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.
|
||||
|
||||
**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).
|
||||
|
||||
This deadlock is also a latent **production** risk under concurrent sign-ups; the deferrable-FK fix addresses both.
|
||||
|
||||
### Async-test-safety checklist (members/groups/custom fields)
|
||||
|
||||
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`:
|
||||
|
||||
- **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.
|
||||
|
||||
### StreamData generator pitfall
|
||||
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
## 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`)
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
# Vereinfacht API Integration
|
||||
|
||||
This document describes the current integration with the Vereinfacht (verein.visuel.dev) accounting API for syncing members as finance contacts.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Purpose:** Create and update external finance contacts in Vereinfacht when members are created or updated; support bulk sync for members without a contact ID.
|
||||
- **Configuration:** ENV or Settings: `VEREINFACHT_API_URL`, `VEREINFACHT_API_KEY`, `VEREINFACHT_CLUB_ID`, optional `VEREINFACHT_APP_URL` for contact view links.
|
||||
- **Modules:** `Mv.Vereinfacht` (business logic), `Mv.Vereinfacht.Client` (HTTP client), `Mv.Vereinfacht.Changes.SyncContact` (Ash after_transaction change).
|
||||
|
||||
## API Usage
|
||||
|
||||
### Finding an existing contact by email
|
||||
|
||||
The API supports filtered list requests. Use a single GET instead of paginating:
|
||||
|
||||
- **Endpoint:** `GET /api/v1/finance-contacts?filter[isExternal]=true&filter[email]=<email>`
|
||||
- **Client:** `Mv.Vereinfacht.Client.find_contact_by_email/1` builds this URL (with encoded email) and returns `{:ok, contact_id}` if the first match exists, `{:error, :not_found}` otherwise.
|
||||
- No member fields are required in the app solely for this lookup.
|
||||
|
||||
### Creating a contact
|
||||
|
||||
When creating an external finance contact, the API only requires:
|
||||
|
||||
- **Attributes:** `contactType` (e.g. `"person"`), `isExternal: true`
|
||||
- **Relationship:** `club` (club ID from config)
|
||||
|
||||
Additional attributes (firstName, lastName, email, address, zipCode, city, country) are optional and are sent when present on the member so the contact is filled in. The app does **not** enforce extra required member fields for Vereinfacht; only Settings-based required fields and email apply.
|
||||
|
||||
- **Client:** `Mv.Vereinfacht.Client.create_contact/1` builds the JSON:API body from the member; `Mv.Constants.vereinfacht_required_member_fields/0` is an empty list.
|
||||
|
||||
### Updating a contact
|
||||
|
||||
- **Endpoint:** `PATCH /api/v1/finance-contacts/:id`
|
||||
- **Client:** `Mv.Vereinfacht.Client.update_contact/2` sends current member attributes. The API may still validate presence/format of fields on update.
|
||||
|
||||
## Flow
|
||||
|
||||
1. **Member create/update:** `SyncContact` runs after the transaction. If the member has no `vereinfacht_contact_id`, the client tries `find_contact_by_email(email)`; if found, it updates that contact and stores the ID on the member; otherwise it creates a contact and stores the new ID. If the member already has a contact ID, the client updates the contact.
|
||||
2. **Bulk sync:** “Sync all members without Vereinfacht contact” calls `Vereinfacht.sync_members_without_contact/0`, which loads members with nil/blank `vereinfacht_contact_id` and runs the same create/update flow per member.
|
||||
|
||||
## References
|
||||
|
||||
- **Config:** `Mv.Config` (`vereinfacht_api_url`, `vereinfacht_api_key`, `vereinfacht_club_id`, `vereinfacht_app_url`, `vereinfacht_configured?/0`).
|
||||
- **Constants:** `Mv.Constants.vereinfacht_required_member_fields/0` (empty), `vereinfacht_required_field?/1` (legacy; currently unused in UI or validation).
|
||||
- **Tests:** `test/mv/vereinfacht/`, `test/mv/config_vereinfacht_test.exs`; see `test/mv/vereinfacht/vereinfacht_test_README.md` for scope.
|
||||
- **Roadmap:** Payment/transaction import and deeper integration are tracked in `docs/feature-roadmap.md` and `docs/membership-fee-architecture.md`.
|
||||
|
|
@ -1,15 +1,6 @@
|
|||
defmodule Mv.Accounts do
|
||||
@moduledoc """
|
||||
AshAuthentication specific domain to handle Authentication for users.
|
||||
|
||||
## Resources
|
||||
- `User` - User accounts with authentication methods (password, OIDC)
|
||||
- `Token` - Session tokens for authentication
|
||||
|
||||
## Public API
|
||||
The domain exposes these main actions:
|
||||
- User CRUD: `create_user/1`, `list_users/0`, `update_user/2`, `destroy_user/1`
|
||||
- Authentication: `create_register_with_oidc/1`, `read_sign_in_with_oidc/1`
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
|
@ -24,8 +15,8 @@ defmodule Mv.Accounts do
|
|||
define :list_users, action: :read
|
||||
define :update_user, action: :update_user
|
||||
define :destroy_user, action: :destroy
|
||||
define :create_register_with_oidc, action: :register_with_oidc
|
||||
define :read_sign_in_with_oidc, action: :sign_in_with_oidc
|
||||
define :create_register_with_rauthy, action: :register_with_rauthy
|
||||
define :read_sign_in_with_rauthy, action: :sign_in_with_rauthy
|
||||
end
|
||||
|
||||
resource Mv.Accounts.Token
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
defmodule Mv.Accounts.Token do
|
||||
@moduledoc """
|
||||
AshAuthentication Token Resource for session management.
|
||||
|
||||
This resource is used by AshAuthentication to manage authentication tokens
|
||||
for user sessions. Tokens are automatically created and managed by the
|
||||
authentication system.
|
||||
AshAuthentication specific ressource
|
||||
"""
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
|
|
|
|||
|
|
@ -5,17 +5,9 @@ defmodule Mv.Accounts.User do
|
|||
use Ash.Resource,
|
||||
domain: Mv.Accounts,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication],
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
extensions: [AshAuthentication]
|
||||
|
||||
import Ash.Expr
|
||||
|
||||
alias Ash.Resource.Preparation.Builtins
|
||||
alias Mv.Authorization.Role, as: RoleResource
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.OidcRoleSync
|
||||
|
||||
require Ash.Query
|
||||
# authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "users"
|
||||
|
|
@ -25,16 +17,12 @@ defmodule Mv.Accounts.User do
|
|||
# When a member is deleted, set the user's member_id to NULL
|
||||
# This allows users to continue existing even if their linked member is removed
|
||||
reference :member, on_delete: :nilify
|
||||
|
||||
# When a role is deleted, prevent deletion if users are assigned to it
|
||||
# This protects critical roles from accidental deletion
|
||||
reference :role, on_delete: :restrict
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
AshAuthentication specific: Defines the strategies we want to use for authentication.
|
||||
Currently password and SSO via OIDC (supports any provider: Authentik, Rauthy, Keycloak, etc.)
|
||||
Currently password and SSO with Rauthy as OIDC provider
|
||||
"""
|
||||
authentication do
|
||||
session_identifier Application.compile_env!(:mv, :session_identifier)
|
||||
|
|
@ -58,7 +46,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
|
||||
|
|
@ -66,9 +54,6 @@ defmodule Mv.Accounts.User do
|
|||
auth_method :client_secret_jwt
|
||||
code_verifier true
|
||||
|
||||
# Request email and profile scopes from OIDC provider (required for Authentik, Keycloak, etc.)
|
||||
authorization_params scope: "openid email profile"
|
||||
|
||||
# id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87
|
||||
end
|
||||
|
||||
|
|
@ -76,10 +61,6 @@ defmodule Mv.Accounts.User do
|
|||
identity_field :email
|
||||
hash_provider AshAuthentication.BcryptProvider
|
||||
confirmation_required? false
|
||||
|
||||
resettable do
|
||||
sender Mv.Accounts.User.Senders.SendPasswordResetEmail
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -88,20 +69,14 @@ defmodule Mv.Accounts.User do
|
|||
# Default actions for framework/tooling integration:
|
||||
# - :read -> Standard read used across the app and by admin tooling.
|
||||
# - :destroy-> Standard delete used by admin tooling and maintenance tasks.
|
||||
#
|
||||
#
|
||||
# NOTE: :create is INTENTIONALLY excluded from defaults!
|
||||
# Using a default :create would bypass email-synchronization logic.
|
||||
# Always use one of these explicit create actions instead:
|
||||
# - :create_user (for manual user creation with optional member link)
|
||||
# - :register_with_password (for password-based registration)
|
||||
# - :register_with_oidc (for OIDC-based registration)
|
||||
defaults [:read]
|
||||
|
||||
destroy :destroy do
|
||||
primary? true
|
||||
# Required because custom validation (system actor protection) cannot run atomically
|
||||
require_atomic? false
|
||||
end
|
||||
# - :register_with_rauthy (for OIDC-based registration)
|
||||
defaults [:read, :destroy]
|
||||
|
||||
# Primary generic update action:
|
||||
# - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
|
||||
|
|
@ -112,7 +87,6 @@ defmodule Mv.Accounts.User do
|
|||
# the specialized :update_user action below.
|
||||
update :update do
|
||||
primary? true
|
||||
accept [:email]
|
||||
|
||||
# Required because custom validation functions (email validation, member relationship validation)
|
||||
# cannot be executed atomically. These validations need to query the database and perform
|
||||
|
|
@ -124,8 +98,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
|
||||
|
|
@ -137,8 +109,6 @@ defmodule Mv.Accounts.User do
|
|||
argument :member, :map, allow_nil?: true
|
||||
upsert? true
|
||||
|
||||
# Note: Default role is automatically assigned via attribute default (see attributes block)
|
||||
|
||||
# Manage the member relationship during user creation
|
||||
change manage_relationship(:member, :member,
|
||||
# Look up existing member and relate to it
|
||||
|
|
@ -153,16 +123,13 @@ 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
|
||||
description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one."
|
||||
|
||||
# Accept email and role_id (role_id only used by admins; policy restricts update_user to admins).
|
||||
# member_id is NOT in accept list - use argument :member for relationship management.
|
||||
accept [:email, :role_id]
|
||||
# Only accept email directly - member_id is NOT in accept list
|
||||
# This prevents direct foreign key manipulation, forcing use of manage_relationship
|
||||
accept [:email]
|
||||
# Allow member to be passed as argument for relationship management
|
||||
argument :member, :map, allow_nil?: true
|
||||
|
||||
|
|
@ -188,22 +155,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.
|
||||
# Not protected by system-user validation so bootstrap can run.
|
||||
update :update_internal do
|
||||
accept []
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
# Internal: set role from OIDC group sync (Mv.OidcRoleSync). Bypass policy when context.private.oidc_role_sync.
|
||||
# Same "at least one admin" validation as update_user (see validations where action_is).
|
||||
update :set_role_from_oidc_sync do
|
||||
accept [:role_id]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
# Admin action for direct password changes in admin panel
|
||||
|
|
@ -218,13 +169,6 @@ defmodule Mv.Accounts.User do
|
|||
|
||||
# Use the official Ash Authentication password change
|
||||
change AshAuthentication.Strategy.Password.HashPasswordChange
|
||||
|
||||
# Sync email changes to linked member when email is changed (e.g. form changes both)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
# Action to link an OIDC account to an existing password-only user
|
||||
|
|
@ -241,9 +185,7 @@ defmodule Mv.Accounts.User do
|
|||
oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info)
|
||||
|
||||
# Get the new email from OIDC user_info
|
||||
# Support both "email" (standard OIDC) and "preferred_username" (Rauthy)
|
||||
new_email =
|
||||
Map.get(oidc_user_info, "email") || Map.get(oidc_user_info, "preferred_username")
|
||||
new_email = Map.get(oidc_user_info, "preferred_username")
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.change_attribute(:oidc_id, oidc_id)
|
||||
|
|
@ -262,8 +204,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,9 +213,7 @@ defmodule Mv.Accounts.User do
|
|||
prepare AshAuthentication.Preparations.FilterBySubject
|
||||
end
|
||||
|
||||
read :sign_in_with_oidc do
|
||||
# Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1).
|
||||
get? true
|
||||
read :sign_in_with_rauthy do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
||||
|
|
@ -285,37 +223,14 @@ defmodule Mv.Accounts.User do
|
|||
# linked their account via OIDC. Password-only users (oidc_id = nil)
|
||||
# cannot be accessed via OIDC login without password verification.
|
||||
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
|
||||
|
||||
# Sync role from OIDC groups after sign-in (e.g. admin group → Admin role)
|
||||
# get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each
|
||||
prepare Builtins.after_action(fn query, result, _context ->
|
||||
user_info = Ash.Query.get_argument(query, :user_info) || %{}
|
||||
oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{}
|
||||
|
||||
users =
|
||||
case result do
|
||||
nil -> []
|
||||
u when is_struct(u, __MODULE__) -> [u]
|
||||
list when is_list(list) -> list
|
||||
_ -> []
|
||||
end
|
||||
|
||||
Enum.each(users, fn user ->
|
||||
OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
|
||||
end)
|
||||
|
||||
{:ok, result}
|
||||
end)
|
||||
end
|
||||
|
||||
create :register_with_oidc do
|
||||
create :register_with_rauthy do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
upsert? true
|
||||
# Upsert based on oidc_id (primary match for existing OIDC users)
|
||||
upsert_identity :unique_oidc_id
|
||||
# On upsert, only update email - preserve existing role_id
|
||||
upsert_fields [:email]
|
||||
|
||||
validate &__MODULE__.validate_oidc_id_present/2
|
||||
|
||||
|
|
@ -324,11 +239,8 @@ defmodule Mv.Accounts.User do
|
|||
change fn changeset, _ctx ->
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
|
||||
# Support both "email" (standard OIDC like Authentik, Keycloak) and "preferred_username" (Rauthy)
|
||||
email = user_info["email"] || user_info["preferred_username"]
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.change_attribute(:email, email)
|
||||
|> Ash.Changeset.change_attribute(:email, user_info["preferred_username"])
|
||||
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
|
||||
end
|
||||
|
||||
|
|
@ -338,73 +250,11 @@ defmodule Mv.Accounts.User do
|
|||
# - The LinkOidcAccountLive will auto-link passwordless users without password prompt
|
||||
validate Mv.Accounts.User.Validations.OidcEmailCollision
|
||||
|
||||
# Note: Default role is automatically assigned via attribute default (see attributes block)
|
||||
# upsert_fields [:email] ensures existing users' roles are preserved during upserts
|
||||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
|
||||
# Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated
|
||||
change fn changeset, _ctx ->
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
oauth_tokens = Ash.Changeset.get_argument(changeset, :oauth_tokens) || %{}
|
||||
|
||||
Ash.Changeset.after_action(changeset, fn _cs, record ->
|
||||
Mv.OidcRoleSync.apply_admin_role_from_user_info(record, user_info, oauth_tokens)
|
||||
# Return original record so __metadata__.token (from GenerateTokenChange) is preserved
|
||||
{:ok, record}
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Authorization Policies
|
||||
# Order matters: Most specific policies first, then general permission check
|
||||
policies do
|
||||
# When OIDC-only is active, password sign-in is forbidden (SSO only).
|
||||
policy action(:sign_in_with_password) do
|
||||
forbid_if Mv.Authorization.Checks.OidcOnlyActive
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
# AshAuthentication bypass (registration/login without actor)
|
||||
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||
description "Allow AshAuthentication internal operations (registration, login)"
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
# READ bypass for list queries (scope :own via expr)
|
||||
bypass action_type(:read) do
|
||||
description "Users can always read their own account"
|
||||
authorize_if expr(id == ^actor(:id))
|
||||
end
|
||||
|
||||
# update_user allows :member argument (link/unlink). Only admins may use it to prevent
|
||||
# privilege escalation (own_data could otherwise link to any member and get :linked scope).
|
||||
policy action(:update_user) do
|
||||
description "Only admins can update user with member link/unlink"
|
||||
forbid_unless Mv.Authorization.Checks.ActorIsAdmin
|
||||
authorize_if Mv.Authorization.Checks.ActorIsAdmin
|
||||
end
|
||||
|
||||
# set_role_from_oidc_sync: internal only (called from Mv.OidcRoleSync on registration/sign-in).
|
||||
# Not exposed in code_interface; only allowed when context.private.oidc_role_sync is set.
|
||||
bypass action(:set_role_from_oidc_sync) do
|
||||
description "Internal: OIDC role sync (server-side only)"
|
||||
authorize_if Mv.Authorization.Checks.OidcRoleSyncContext
|
||||
end
|
||||
|
||||
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from user's role and permission set"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
# Default: Ash implicitly forbids if no policy authorizes (fail-closed)
|
||||
end
|
||||
|
||||
# Global validations - applied to all relevant actions
|
||||
validations do
|
||||
# Password strength policy: minimum 8 characters for all password-related actions
|
||||
|
|
@ -412,14 +262,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
|
||||
|
|
@ -436,9 +278,7 @@ defmodule Mv.Accounts.User do
|
|||
changeset2 =
|
||||
{%{}, %{email: :string}}
|
||||
|> Ecto.Changeset.cast(%{email: email_string}, [:email])
|
||||
|> EctoCommons.EmailValidator.validate_email(:email,
|
||||
checks: Mv.Constants.email_validator_checks()
|
||||
)
|
||||
|> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow])
|
||||
|
||||
if changeset2.valid? do
|
||||
:ok
|
||||
|
|
@ -471,78 +311,6 @@ defmodule Mv.Accounts.User do
|
|||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# Last-admin: prevent the only admin from leaving the admin role (at least one admin required).
|
||||
# Only block when the user is leaving admin (target role is not admin). Switching between
|
||||
# two admin roles (e.g. "Admin" and "Superadmin" both with permission_set_name "admin") is allowed.
|
||||
validate fn changeset, _context ->
|
||||
if Ash.Changeset.changing_attribute?(changeset, :role_id) do
|
||||
new_role_id = Ash.Changeset.get_attribute(changeset, :role_id)
|
||||
|
||||
if is_nil(new_role_id) do
|
||||
:ok
|
||||
else
|
||||
current_role_id = changeset.data.role_id
|
||||
|
||||
current_role =
|
||||
Mv.Authorization.Role
|
||||
|> Ash.get!(current_role_id, authorize?: false)
|
||||
|
||||
new_role =
|
||||
Mv.Authorization.Role
|
||||
|> Ash.get!(new_role_id, authorize?: false)
|
||||
|
||||
# Only block when current user is admin and target role is not admin (leaving admin)
|
||||
if current_role.permission_set_name == "admin" and
|
||||
new_role.permission_set_name != "admin" do
|
||||
admin_role_ids =
|
||||
Mv.Authorization.Role
|
||||
|> Ash.Query.for_read(:read)
|
||||
|> Ash.Query.filter(expr(permission_set_name == "admin"))
|
||||
|> Ash.read!(authorize?: false)
|
||||
|> Enum.map(& &1.id)
|
||||
|
||||
# Count only non-system users with admin role (system user is for internal ops)
|
||||
system_email = SystemActor.system_user_email()
|
||||
|
||||
count =
|
||||
__MODULE__
|
||||
|> Ash.Query.for_read(:read)
|
||||
|> Ash.Query.filter(expr(role_id in ^admin_role_ids))
|
||||
|> Ash.Query.filter(expr(email != ^system_email))
|
||||
|> Ash.count!(authorize?: false)
|
||||
|
||||
if count <= 1 do
|
||||
{:error,
|
||||
field: :role_id, message: "At least one user must keep the Admin role."}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:update],
|
||||
where: [action_is([:update_user, :set_role_from_oidc_sync])]
|
||||
|
||||
# Prevent modification of the system actor user (required for internal operations).
|
||||
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
|
||||
validate fn changeset, _context ->
|
||||
if SystemActor.system_user?(changeset.data) do
|
||||
{:error,
|
||||
field: :email,
|
||||
message:
|
||||
"Cannot modify system actor user. This user is required for internal operations."}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:update, :destroy],
|
||||
where: [action_is([:update, :update_user, :admin_set_password, :destroy])]
|
||||
end
|
||||
|
||||
def validate_oidc_id_present(changeset, _context) do
|
||||
|
|
@ -574,15 +342,6 @@ defmodule Mv.Accounts.User do
|
|||
|
||||
attribute :hashed_password, :string, sensitive?: true, allow_nil?: true
|
||||
attribute :oidc_id, :string, allow_nil?: true
|
||||
|
||||
# Role assignment: Explicitly defined to enforce default value
|
||||
# This ensures every user has a role, regardless of creation path
|
||||
# (register_with_password, create_user, seeds, etc.)
|
||||
attribute :role_id, :uuid do
|
||||
allow_nil? false
|
||||
default &__MODULE__.default_role_id/0
|
||||
public? false
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
|
|
@ -590,15 +349,6 @@ defmodule Mv.Accounts.User do
|
|||
# This automatically creates a `member_id` attribute in the User table
|
||||
# The relationship is optional (allow_nil? true by default)
|
||||
belongs_to :member, Mv.Membership.Member
|
||||
|
||||
# 1:1 relationship - User belongs to a Role
|
||||
# We define role_id ourselves (above in attributes) to control default value
|
||||
# Foreign key constraint: on_delete: :restrict (prevents deleting roles assigned to users)
|
||||
belongs_to :role, Mv.Authorization.Role do
|
||||
define_attribute? false
|
||||
source_attribute :role_id
|
||||
allow_nil? false
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
|
|
@ -618,60 +368,4 @@ defmodule Mv.Accounts.User do
|
|||
# forbid_if(always())
|
||||
# end
|
||||
# end
|
||||
|
||||
@doc """
|
||||
Returns the default role ID for new users.
|
||||
|
||||
This function is called automatically when creating a user without an explicit role_id.
|
||||
It fetches the "Mitglied" role from the database without authorization checks
|
||||
(safe during user creation bootstrap phase).
|
||||
|
||||
The result is cached in the process dictionary to avoid repeated database queries
|
||||
during high-volume user creation. The cache is invalidated on application restart.
|
||||
|
||||
## Bootstrap Safety
|
||||
|
||||
Only non-nil values are cached. If the role doesn't exist yet (e.g., before seeds run),
|
||||
`nil` is not cached, allowing subsequent calls to retry after the role is created.
|
||||
This prevents bootstrap issues where a process would be permanently stuck with `nil`
|
||||
if the first call happens before the role exists.
|
||||
|
||||
## Performance Note
|
||||
|
||||
This function makes one database query per process (cached in process dictionary).
|
||||
For very high-volume scenarios, consider using a fixed UUID from Application config
|
||||
instead of querying the database.
|
||||
|
||||
## Returns
|
||||
|
||||
- UUID of the "Mitglied" role if it exists
|
||||
- `nil` if the role doesn't exist (will cause validation error due to `allow_nil? false`)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Accounts.User.default_role_id()
|
||||
"019bf2e2-873a-7712-a7ce-a5a1f90c5f4f"
|
||||
"""
|
||||
@spec default_role_id() :: Ecto.UUID.t() | nil
|
||||
def default_role_id do
|
||||
# Cache in process dictionary to avoid repeated queries
|
||||
# IMPORTANT: Only cache non-nil values to avoid bootstrap issues.
|
||||
# If the role doesn't exist yet (e.g., before seeds run), we don't cache nil
|
||||
# so that subsequent calls can retry after the role is created.
|
||||
case Process.get({__MODULE__, :default_role_id}) do
|
||||
nil ->
|
||||
role_id =
|
||||
case RoleResource.get_mitglied_role() do
|
||||
{:ok, %RoleResource{id: id}} -> id
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
# Only cache non-nil values to allow retry if role is created later
|
||||
if role_id, do: Process.put({__MODULE__, :default_role_id}, role_id)
|
||||
role_id
|
||||
|
||||
cached_role_id ->
|
||||
cached_role_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
@ -45,29 +42,25 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
|||
if email && oidc_id && user_info do
|
||||
# Check if a user with this oidc_id already exists
|
||||
# If yes, this will be an upsert (email update), not a new registration
|
||||
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
existing_oidc_user =
|
||||
case User
|
||||
case Mv.Accounts.User
|
||||
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|
||||
|> Ash.read_one(actor: system_actor) do
|
||||
|> Ash.read_one() do
|
||||
{:ok, user} -> user
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
check_email_collision(email, oidc_id, user_info, existing_oidc_user, system_actor)
|
||||
check_email_collision(email, oidc_id, user_info, existing_oidc_user)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user, system_actor) do
|
||||
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user) 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
|
||||
|> Ash.read_one() do
|
||||
{:ok, nil} ->
|
||||
# No user exists with this email - OK to create new user
|
||||
:ok
|
||||
|
|
@ -167,7 +160,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,9 @@ 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.
|
||||
- `immutable` - If true, custom field values cannot be changed after creation
|
||||
- `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 +29,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
|
||||
|
|
@ -53,9 +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
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
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, :immutable, :required, :show_in_overview]
|
||||
|
||||
create :create do
|
||||
accept [:name, :value_type, :description, :join_description, :required, :show_in_overview]
|
||||
change Mv.Membership.Changes.GenerateSlug
|
||||
accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
|
||||
change Mv.Membership.CustomField.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
|
||||
|
|
@ -108,13 +80,6 @@ defmodule Mv.Membership.CustomField do
|
|||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from user's role"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
|
|
@ -148,14 +113,9 @@ 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 :immutable, :boolean,
|
||||
default: false,
|
||||
allow_nil?: false
|
||||
|
||||
attribute :required, :boolean,
|
||||
default: false,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Mv.Membership.Changes.GenerateSlug do
|
||||
defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
|
||||
@moduledoc """
|
||||
Ash Change that automatically generates a URL-friendly slug from the `name` attribute.
|
||||
|
||||
|
|
@ -14,26 +14,12 @@ defmodule Mv.Membership.Changes.GenerateSlug do
|
|||
- Trims leading/trailing hyphens
|
||||
- Truncates to max 100 characters
|
||||
|
||||
## Usage
|
||||
|
||||
Works for any resource with `name` and `slug` attributes.
|
||||
Used by CustomField and Group resources.
|
||||
|
||||
create :create do
|
||||
accept [:name, :description]
|
||||
change Mv.Membership.Changes.GenerateSlug
|
||||
validate string_length(:slug, min: 1)
|
||||
end
|
||||
|
||||
## Examples
|
||||
|
||||
# Create with automatic slug generation
|
||||
CustomField.create!(%{name: "Mobile Phone"})
|
||||
# => %CustomField{name: "Mobile Phone", slug: "mobile-phone"}
|
||||
|
||||
Group.create!(%{name: "Test Group"})
|
||||
# => %Group{name: "Test Group", slug: "test-group"}
|
||||
|
||||
# German umlauts are converted
|
||||
CustomField.create!(%{name: "Café Müller"})
|
||||
# => %CustomField{name: "Café Müller", slug: "cafe-muller"}
|
||||
|
|
@ -46,7 +32,7 @@ defmodule Mv.Membership.Changes.GenerateSlug do
|
|||
## Implementation Note
|
||||
|
||||
This change only runs on `:create` actions. The slug is immutable by design,
|
||||
as changing slugs would break external references (e.g., CSV imports/exports, URL routes).
|
||||
as changing slugs would break external references (e.g., CSV imports/exports).
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
|
|
@ -61,14 +47,11 @@ defmodule Mv.Membership.Changes.GenerateSlug do
|
|||
## Parameters
|
||||
|
||||
- `changeset` - The Ash changeset
|
||||
- `_opts` - Options passed to the change (unused)
|
||||
- `_context` - Ash context map (unused)
|
||||
|
||||
## Returns
|
||||
|
||||
The changeset with the `:slug` attribute set to the generated slug.
|
||||
"""
|
||||
@impl true
|
||||
def change(changeset, _opts, _context) do
|
||||
# Only generate slug on create, not on update (immutability)
|
||||
if changeset.action_type == :create do
|
||||
|
|
@ -79,9 +62,6 @@ defmodule Mv.Membership.Changes.GenerateSlug do
|
|||
name when is_binary(name) ->
|
||||
slug = generate_slug(name)
|
||||
Ash.Changeset.force_change_attribute(changeset, :slug, slug)
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
else
|
||||
# On update, don't touch the slug (immutable)
|
||||
|
|
@ -100,14 +80,6 @@ defmodule Mv.Membership.Changes.GenerateSlug do
|
|||
- Leading/trailing hyphens removed
|
||||
- Maximum length of 100 characters
|
||||
|
||||
## Parameters
|
||||
|
||||
- `name` - The string to convert to a slug
|
||||
|
||||
## Returns
|
||||
|
||||
A URL-friendly slug string, or empty string if input is invalid.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> generate_slug("Mobile Phone")
|
||||
|
|
@ -132,7 +104,6 @@ defmodule Mv.Membership.Changes.GenerateSlug do
|
|||
"strasse"
|
||||
|
||||
"""
|
||||
@spec generate_slug(String.t()) :: String.t()
|
||||
def generate_slug(name) when is_binary(name) do
|
||||
slug = Slug.slugify(name)
|
||||
|
||||
|
|
@ -39,10 +39,7 @@ defmodule Mv.Membership.CustomFieldValue do
|
|||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
import Ash.Expr
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "custom_field_values"
|
||||
|
|
@ -65,36 +62,6 @@ defmodule Mv.Membership.CustomFieldValue do
|
|||
end
|
||||
end
|
||||
|
||||
# Authorization Policies
|
||||
# Order matters: Most specific policies first, then general permission check
|
||||
# Pattern aligns with User and Member resources (bypass for READ, HasPermission for update/destroy)
|
||||
# Create uses CustomFieldValueCreateScope because Ash cannot apply filters to create actions.
|
||||
policies do
|
||||
# SPECIAL CASE: Users can READ custom field values of their linked member
|
||||
# Bypass needed for list queries (expr triggers auto_filter in Ash)
|
||||
bypass action_type(:read) do
|
||||
description "Users can read custom field values of their linked member"
|
||||
authorize_if expr(member_id == ^actor(:member_id))
|
||||
end
|
||||
|
||||
# CREATE: CustomFieldValueCreateScope (no filter; Ash rejects filters on create)
|
||||
# - :own_data -> create allowed when member_id == actor.member_id (scope :linked)
|
||||
# - :read_only -> no create permission
|
||||
# - :normal_user / :admin -> create allowed (scope :all)
|
||||
policy action_type(:create) do
|
||||
description "CustomFieldValue create allowed by permission set scope"
|
||||
authorize_if Mv.Authorization.Checks.CustomFieldValueCreateScope
|
||||
end
|
||||
|
||||
# READ/UPDATE/DESTROY: HasPermission (scope :linked / :all)
|
||||
policy action_type([:read, :update, :destroy]) do
|
||||
description "Check permissions from user's role and permission set"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
# DEFAULT: Ash implicitly forbids if no policy authorized (fail-closed)
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -1,167 +0,0 @@
|
|||
defmodule Mv.Membership.Group do
|
||||
@moduledoc """
|
||||
Ash resource representing a group that members can belong to.
|
||||
|
||||
## Overview
|
||||
Groups allow organizing members into categories (e.g., "Board Members", "Active Members").
|
||||
Each member can belong to multiple groups, and each group can contain multiple members.
|
||||
|
||||
## Attributes
|
||||
- `name` - Unique group name (required, max 100 chars, case-insensitive uniqueness)
|
||||
- `slug` - URL-friendly identifier (required, max 100 chars, auto-generated from name, immutable)
|
||||
- `description` - Optional description (max 500 chars)
|
||||
|
||||
## Relationships
|
||||
- `has_many :member_groups` - Relationship to MemberGroup join table
|
||||
- `many_to_many :members` - Relationship to Members through MemberGroup
|
||||
|
||||
## Constraints
|
||||
- Name must be unique (case-insensitive, using LOWER(name) in database)
|
||||
- Slug must be unique (case-sensitive, exact match)
|
||||
- Name cannot be null
|
||||
- Slug cannot be null
|
||||
|
||||
## Calculations
|
||||
- `member_count` - Returns the number of members in this group
|
||||
|
||||
## Examples
|
||||
# Create a new group
|
||||
Group.create!(%{name: "Board Members", description: "Members of the board"})
|
||||
# => %Group{name: "Board Members", slug: "board-members", ...}
|
||||
|
||||
# Update group (slug remains unchanged)
|
||||
group = Group.get_by_slug!("board-members")
|
||||
Group.update!(group, %{description: "Updated description"})
|
||||
# => %Group{slug: "board-members", ...} # slug unchanged!
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
postgres do
|
||||
table "groups"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
|
||||
create :create do
|
||||
accept [:name, :description]
|
||||
change Mv.Membership.Changes.GenerateSlug
|
||||
validate string_length(:slug, min: 1)
|
||||
end
|
||||
|
||||
update :update do
|
||||
accept [:name, :description]
|
||||
require_atomic? false
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from role (all can read; normal_user and admin can create/update/destroy)"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate present(:name)
|
||||
|
||||
# Case-insensitive name uniqueness validation
|
||||
validate fn changeset, context ->
|
||||
name = Ash.Changeset.get_attribute(changeset, :name)
|
||||
current_id = Ash.Changeset.get_attribute(changeset, :id)
|
||||
|
||||
if name do
|
||||
check_name_uniqueness(name, current_id, context)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_v7_primary_key :id
|
||||
|
||||
attribute :name, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
|
||||
constraints max_length: 100,
|
||||
trim?: true
|
||||
end
|
||||
|
||||
attribute :slug, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
writable? false
|
||||
|
||||
constraints max_length: 100,
|
||||
trim?: true
|
||||
end
|
||||
|
||||
attribute :description, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
|
||||
constraints max_length: 500,
|
||||
trim?: true
|
||||
end
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :member_groups, Mv.Membership.MemberGroup
|
||||
many_to_many :members, Mv.Membership.Member, through: Mv.Membership.MemberGroup
|
||||
end
|
||||
|
||||
aggregates do
|
||||
count :member_count, :member_groups
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_slug, [:slug]
|
||||
end
|
||||
|
||||
# Private helper function for case-insensitive name uniqueness check
|
||||
# Uses context actor if available (respects policies), falls back to system actor
|
||||
defp check_name_uniqueness(name, exclude_id, context) do
|
||||
# Use context actor if available (respects user permissions), otherwise fall back to system actor
|
||||
actor =
|
||||
case context do
|
||||
%{actor: actor} when not is_nil(actor) -> actor
|
||||
_ -> SystemActor.get_system_actor()
|
||||
end
|
||||
|
||||
query =
|
||||
Mv.Membership.Group
|
||||
|> Ash.Query.filter(fragment("LOWER(?) = LOWER(?)", name, ^name))
|
||||
|> Helpers.query_exclude_id(exclude_id)
|
||||
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.read(query, opts) do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, _} ->
|
||||
{:error, field: :name, message: "has already been taken", value: name}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Name uniqueness validation query failed for group name '#{name}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
defmodule Mv.Membership.JoinNotifier do
|
||||
@moduledoc """
|
||||
Behaviour for sending join-related emails (confirmation, already member, already pending).
|
||||
|
||||
The domain calls this module instead of MvWeb.Emails directly, so the domain layer
|
||||
does not depend on the web layer. The default implementation is set in config
|
||||
(`config :mv, :join_notifier, MvWeb.JoinNotifierImpl`). Tests can override with a mock.
|
||||
"""
|
||||
@callback send_confirmation(email :: String.t(), token :: String.t(), opts :: keyword()) ::
|
||||
{:ok, term()} | {:error, term()}
|
||||
@callback send_already_member(email :: String.t()) :: {:ok, term()} | {:error, term()}
|
||||
@callback send_already_pending(email :: String.t()) :: {:ok, term()} | {:error, term()}
|
||||
end
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
defmodule Mv.Membership.JoinRequest do
|
||||
@moduledoc """
|
||||
Ash resource for public join requests (onboarding, double opt-in).
|
||||
|
||||
A JoinRequest is created on form submit with status `pending_confirmation`, then
|
||||
updated to `submitted` when the user clicks the confirmation link. No User or
|
||||
Member is created in this flow; promotion happens in a later approval step.
|
||||
|
||||
## Public actions (actor: nil)
|
||||
- `submit` (create) – create with token hash and expiry
|
||||
- `get_by_confirmation_token_hash` (read) – lookup by token hash for confirm flow
|
||||
- `confirm` (update) – set status to submitted and invalidate token
|
||||
|
||||
## Schema
|
||||
Typed: email (required), first_name, last_name. Remaining form data in form_data (jsonb).
|
||||
Confirmation: confirmation_token_hash, confirmation_token_expires_at. Audit: submitted_at, etc.
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "join_requests"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
|
||||
create :submit do
|
||||
description "Create a join request (public form submit); stores token hash and expiry"
|
||||
primary? true
|
||||
|
||||
argument :confirmation_token, :string, allow_nil?: false
|
||||
|
||||
accept [:email, :first_name, :last_name, :form_data, :schema_version]
|
||||
|
||||
change Mv.Membership.JoinRequest.Changes.SetConfirmationToken
|
||||
change Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist
|
||||
end
|
||||
|
||||
# Internal/seeding only: create with status submitted (no policy allows; use authorize?: false).
|
||||
create :create_submitted do
|
||||
description "Create a join request with status submitted (seeds, internal use only)"
|
||||
accept [:email, :first_name, :last_name, :form_data, :schema_version]
|
||||
change Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding
|
||||
end
|
||||
|
||||
read :get_by_confirmation_token_hash do
|
||||
description "Find a join request by confirmation token hash (for confirm flow only)"
|
||||
argument :confirmation_token_hash, :string, allow_nil?: false
|
||||
|
||||
filter expr(confirmation_token_hash == ^arg(:confirmation_token_hash))
|
||||
|
||||
prepare build(sort: [inserted_at: :desc], limit: 1)
|
||||
end
|
||||
|
||||
update :confirm do
|
||||
description "Mark join request as submitted and invalidate token (after link click)"
|
||||
primary? true
|
||||
require_atomic? false
|
||||
|
||||
change Mv.Membership.JoinRequest.Changes.ConfirmRequest
|
||||
end
|
||||
|
||||
update :approve do
|
||||
description "Approve a submitted join request and promote to Member"
|
||||
require_atomic? false
|
||||
|
||||
change Mv.Membership.JoinRequest.Changes.ApproveRequest
|
||||
end
|
||||
|
||||
update :reject do
|
||||
description "Reject a submitted join request"
|
||||
require_atomic? false
|
||||
|
||||
change Mv.Membership.JoinRequest.Changes.RejectRequest
|
||||
end
|
||||
|
||||
# Internal: resend confirmation (new token) when user submits form again with same email.
|
||||
# Called from domain with authorize?: false; not exposed to public.
|
||||
update :regenerate_confirmation_token do
|
||||
description "Set new confirmation token and expiry (resend flow)"
|
||||
require_atomic? false
|
||||
|
||||
argument :confirmation_token, :string, allow_nil?: false
|
||||
|
||||
change Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
# Use :strict so unauthorized access returns Forbidden (not empty list).
|
||||
# Default :filter would silently return [] for unauthorized reads instead of Forbidden.
|
||||
default_access_type :strict
|
||||
|
||||
# Public actions: bypass so nil actor is immediately authorized (skips all remaining policies).
|
||||
# Using bypass (not policy) avoids AND-combination with the read policy below.
|
||||
bypass action(:submit) do
|
||||
description "Allow unauthenticated submit (public join form)"
|
||||
authorize_if Mv.Authorization.Checks.ActorIsNil
|
||||
end
|
||||
|
||||
bypass action(:get_by_confirmation_token_hash) do
|
||||
description "Allow unauthenticated lookup by token hash for confirm"
|
||||
authorize_if Mv.Authorization.Checks.ActorIsNil
|
||||
end
|
||||
|
||||
bypass action(:confirm) do
|
||||
description "Allow unauthenticated confirm (confirmation link click)"
|
||||
authorize_if Mv.Authorization.Checks.ActorIsNil
|
||||
end
|
||||
|
||||
# READ: bypass for authorized roles (normal_user, admin).
|
||||
# Uses a SimpleCheck (HasJoinRequestAccess) to avoid HasPermission.auto_filter returning
|
||||
# expr(false), which would silently produce an empty list instead of Forbidden for
|
||||
# unauthorized actors. See docs/policy-bypass-vs-haspermission.md.
|
||||
# Unauthorized actors fall through to no matching policy → Ash default deny (Forbidden).
|
||||
bypass action_type(:read) do
|
||||
description "Allow normal_user and admin to read join requests (SimpleCheck bypass)"
|
||||
authorize_if Mv.Authorization.Checks.HasJoinRequestAccess
|
||||
end
|
||||
|
||||
# Approve/Reject: only actors with JoinRequest update permission
|
||||
policy action(:approve) do
|
||||
description "Allow authenticated users with JoinRequest update permission to approve"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
policy action(:reject) do
|
||||
description "Allow authenticated users with JoinRequest update permission to reject"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
# Format/formatting of email is not validated here; invalid addresses may fail at send time
|
||||
# or can be enforced via an Ash change if needed.
|
||||
validate present(:email), on: [:create]
|
||||
end
|
||||
|
||||
# Attributes are backend-internal for now; set public? true when exposing via AshJsonApi/AshGraphql
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :status, :atom do
|
||||
description "pending_confirmation | submitted | approved | rejected"
|
||||
default :pending_confirmation
|
||||
constraints one_of: [:pending_confirmation, :submitted, :approved, :rejected]
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
attribute :email, :string do
|
||||
description "Email address (required for join form)"
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
attribute :first_name, :string
|
||||
attribute :last_name, :string
|
||||
|
||||
attribute :form_data, :map do
|
||||
description "Additional form fields (jsonb)"
|
||||
end
|
||||
|
||||
attribute :schema_version, :integer do
|
||||
description "Version of join form / member_fields for form_data"
|
||||
end
|
||||
|
||||
attribute :confirmation_token_hash, :string do
|
||||
description "SHA256 hash of confirmation token; raw token only in email link"
|
||||
end
|
||||
|
||||
attribute :confirmation_token_expires_at, :utc_datetime_usec do
|
||||
description "When the confirmation link expires (e.g. 24h)"
|
||||
end
|
||||
|
||||
attribute :confirmation_sent_at, :utc_datetime_usec do
|
||||
description "When the confirmation email was sent"
|
||||
end
|
||||
|
||||
attribute :submitted_at, :utc_datetime_usec do
|
||||
description "When the user confirmed (clicked the link)"
|
||||
end
|
||||
|
||||
attribute :approved_at, :utc_datetime_usec
|
||||
attribute :rejected_at, :utc_datetime_usec
|
||||
attribute :reviewed_by_user_id, :uuid
|
||||
|
||||
attribute :reviewed_by_display, :string do
|
||||
description "Denormalized reviewer display (e.g. email) for UI without loading User"
|
||||
end
|
||||
|
||||
attribute :source, :string
|
||||
|
||||
create_timestamp :inserted_at
|
||||
update_timestamp :updated_at
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :reviewed_by_user, Mv.Accounts.User do
|
||||
define_attribute? false
|
||||
source_attribute :reviewed_by_user_id
|
||||
end
|
||||
end
|
||||
|
||||
# Public helpers (used by SetConfirmationToken change and domain confirm_join_request)
|
||||
|
||||
@doc """
|
||||
Returns the SHA256 hash of the confirmation token (lowercase hex).
|
||||
|
||||
Used when creating a join request (submit) and when confirming by token.
|
||||
Only one implementation ensures algorithm changes stay in sync.
|
||||
"""
|
||||
@spec hash_confirmation_token(String.t()) :: String.t()
|
||||
def hash_confirmation_token(token) when is_binary(token) do
|
||||
:crypto.hash(:sha256, token) |> Base.encode16(case: :lower)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do
|
||||
@moduledoc """
|
||||
Sets the join request to approved and records the reviewer.
|
||||
|
||||
Only transitions from :submitted status. If already approved, returns error
|
||||
(idempotency guard via status validation). Promotion to Member is handled
|
||||
by the domain function approve_join_request/2 after calling this action.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Mv.Membership.JoinRequest.Changes.Helpers
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, context) do
|
||||
current_status = Ash.Changeset.get_data(changeset, :status)
|
||||
|
||||
if current_status == :submitted do
|
||||
reviewed_by_id = Helpers.actor_id(context.actor)
|
||||
reviewed_by_display = Helpers.actor_email(context.actor)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:status, :approved)
|
||||
|> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now())
|
||||
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|
||||
|> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display)
|
||||
else
|
||||
Ash.Changeset.add_error(changeset,
|
||||
field: :status,
|
||||
message: "can only approve a submitted join request (current status: #{current_status})"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
defmodule Mv.Membership.JoinRequest.Changes.ConfirmRequest do
|
||||
@moduledoc """
|
||||
Sets the join request to submitted (confirmation link clicked).
|
||||
|
||||
Used by the confirm action after the user clicks the confirmation link.
|
||||
Only applies when the current status is `:pending_confirmation`, so that
|
||||
direct calls to the confirm action are idempotent and never overwrite
|
||||
:submitted, :approved, or :rejected. Token hash is kept so a second click
|
||||
can still find the record and return success without changing it.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
current_status = Ash.Changeset.get_data(changeset, :status)
|
||||
|
||||
if current_status == :pending_confirmation do
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:status, :submitted)
|
||||
|> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now())
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,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
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,42 +0,0 @@
|
|||
defmodule Mv.Membership.Member.Changes.SetDefaultMembershipFeeType do
|
||||
@moduledoc """
|
||||
Ash change that automatically assigns the default membership fee type to new members
|
||||
if no membership_fee_type_id is explicitly provided.
|
||||
|
||||
This change reads the default_membership_fee_type_id from global settings and
|
||||
assigns it to the member if membership_fee_type_id is nil.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
# Only set default if membership_fee_type_id is not already set
|
||||
current_type_id = Ash.Changeset.get_attribute(changeset, :membership_fee_type_id)
|
||||
|
||||
if is_nil(current_type_id) do
|
||||
apply_default_membership_fee_type(changeset)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_default_membership_fee_type(changeset) do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
if settings.default_membership_fee_type_id do
|
||||
Ash.Changeset.force_change_attribute(
|
||||
changeset,
|
||||
:membership_fee_type_id,
|
||||
settings.default_membership_fee_type_id
|
||||
)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
|
||||
{:error, _error} ->
|
||||
# If settings can't be loaded, continue without default
|
||||
# This prevents member creation from failing if settings are misconfigured
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
defmodule Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil do
|
||||
@moduledoc """
|
||||
When :user argument is present and nil/empty on update_member, unrelate the current user.
|
||||
|
||||
With on_missing: :ignore, manage_relationship does not unrelate when input is nil/[].
|
||||
This change handles explicit unlink (user: nil or user: %{}) by updating the linked
|
||||
User to set member_id = nil. Only runs when the argument key is present (policy
|
||||
ForbidMemberUserLinkUnlessAdmin ensures only admins can pass :user).
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
if unlink_requested?(changeset) do
|
||||
unrelate_current_user(changeset)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp unlink_requested?(changeset) do
|
||||
args = changeset.arguments || %{}
|
||||
|
||||
if Map.has_key?(args, :user) or Map.has_key?(args, "user") do
|
||||
user_arg = Ash.Changeset.get_argument(changeset, :user)
|
||||
user_arg == nil or (is_map(user_arg) and map_size(user_arg) == 0)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
defp unrelate_current_user(changeset) do
|
||||
member = changeset.data
|
||||
actor = Map.get(changeset.context || %{}, :actor)
|
||||
|
||||
case Ash.load(member, :user, domain: Mv.Membership, authorize?: false) do
|
||||
{:ok, %{user: user}} when not is_nil(user) ->
|
||||
# User's :update action only accepts [:email]; use :update_user so
|
||||
# manage_relationship(:member, ..., on_missing: :unrelate) runs and clears member_id.
|
||||
_ =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|
||||
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
|
||||
|
||||
changeset
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
defmodule Mv.Membership.MemberGroup do
|
||||
@moduledoc """
|
||||
Ash resource representing the join table for the many-to-many relationship
|
||||
between Members and Groups.
|
||||
|
||||
## Overview
|
||||
MemberGroup is a join table that links members to groups. It enables the
|
||||
many-to-many relationship where:
|
||||
- A member can belong to multiple groups
|
||||
- A group can contain multiple members
|
||||
|
||||
## Attributes
|
||||
- `member_id` - Foreign key to Member (required)
|
||||
- `group_id` - Foreign key to Group (required)
|
||||
|
||||
## Relationships
|
||||
- `belongs_to :member` - Relationship to Member
|
||||
- `belongs_to :group` - Relationship to Group
|
||||
|
||||
## Constraints
|
||||
- Unique constraint on `(member_id, group_id)` - prevents duplicate memberships
|
||||
- CASCADE delete: Removing member removes all group associations
|
||||
- CASCADE delete: Removing group removes all member associations
|
||||
|
||||
## Examples
|
||||
# Add member to group
|
||||
{:ok, member_group} =
|
||||
Membership.create_member_group(%{member_id: member.id, group_id: group.id})
|
||||
|
||||
# Remove member from group
|
||||
{:ok, [member_group]} =
|
||||
Ash.read(
|
||||
Mv.Membership.MemberGroup
|
||||
|> Ash.Query.filter(member_id == ^member.id and group_id == ^group.id),
|
||||
domain: Mv.Membership
|
||||
)
|
||||
|
||||
:ok = Membership.destroy_member_group(member_group)
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
require Ash.Query
|
||||
|
||||
postgres do
|
||||
table "member_groups"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
|
||||
create :create do
|
||||
accept [:member_id, :group_id]
|
||||
end
|
||||
end
|
||||
|
||||
# Authorization: read uses bypass for :linked (own_data only) then HasPermission for :all;
|
||||
# create/destroy use HasPermission (normal_user + admin only).
|
||||
# Single check: own_data gets filter via auto_filter; admin does not match, gets :all from HasPermission.
|
||||
policies do
|
||||
bypass action_type(:read) do
|
||||
description "own_data: read only member_groups where member_id == actor.member_id"
|
||||
authorize_if {Mv.Authorization.Checks.ReadLinkedForOwnData, member_id_field: :member_id}
|
||||
end
|
||||
|
||||
policy action_type(:read) do
|
||||
description "Check read permission from role (read_only/normal_user/admin :all)"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
policy action_type([:create, :destroy]) do
|
||||
description "Check create/destroy from role (normal_user + admin only)"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate present(:member_id)
|
||||
validate present(:group_id)
|
||||
|
||||
# Prevent duplicate associations
|
||||
validate fn changeset, context ->
|
||||
member_id = Ash.Changeset.get_attribute(changeset, :member_id)
|
||||
group_id = Ash.Changeset.get_attribute(changeset, :group_id)
|
||||
current_id = Ash.Changeset.get_attribute(changeset, :id)
|
||||
|
||||
if member_id && group_id do
|
||||
check_duplicate_association(member_id, group_id, current_id, context)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_v7_primary_key :id
|
||||
|
||||
attribute :member_id, :uuid do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
attribute :group_id, :uuid do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :member, Mv.Membership.Member do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
belongs_to :group, Mv.Membership.Group do
|
||||
allow_nil? false
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_member_group, [:member_id, :group_id]
|
||||
end
|
||||
|
||||
# Private helper function to check for duplicate associations
|
||||
# Uses context actor if available (respects policies), falls back to system actor
|
||||
defp check_duplicate_association(member_id, group_id, exclude_id, context) do
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
# Use context actor if available (respects user permissions), otherwise fall back to system actor
|
||||
actor =
|
||||
case context do
|
||||
%{actor: actor} when not is_nil(actor) -> actor
|
||||
_ -> SystemActor.get_system_actor()
|
||||
end
|
||||
|
||||
query =
|
||||
Mv.Membership.MemberGroup
|
||||
|> Ash.Query.filter(member_id == ^member_id and group_id == ^group_id)
|
||||
|> Helpers.query_exclude_id(exclude_id)
|
||||
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.read(query, opts) do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, _} ->
|
||||
{:error, field: :member_id, message: "Member is already in this group", value: member_id}
|
||||
|
||||
{:error, _reason} ->
|
||||
# Fail-open: if query fails, allow operation to proceed
|
||||
# Database constraint will catch duplicates anyway
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -7,18 +7,13 @@ defmodule Mv.Membership do
|
|||
- `CustomFieldValue` - Dynamic custom field values attached to members
|
||||
- `CustomField` - Schema definitions for custom fields
|
||||
- `Setting` - Global application settings (singleton)
|
||||
- `Group` - Groups that members can belong to
|
||||
- `MemberGroup` - Join table for many-to-many relationship between Members and Groups
|
||||
- `JoinRequest` - Public join form submissions (pending_confirmation → submitted after email confirm)
|
||||
|
||||
## Public API
|
||||
The domain exposes these main actions:
|
||||
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
|
||||
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
|
||||
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/1`, etc.
|
||||
- Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3`
|
||||
- Group management: `create_group/1`, `list_groups/0`, `update_group/2`, `destroy_group/1`
|
||||
- Member-group associations: `create_member_group/1`, `list_member_groups/0`, `destroy_member_group/1`
|
||||
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, etc.
|
||||
- Settings management: `get_settings/0`, `update_settings/2`
|
||||
|
||||
## Admin Interface
|
||||
The domain is configured with AshAdmin for management UI.
|
||||
|
|
@ -26,17 +21,6 @@ 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
|
||||
|
||||
admin do
|
||||
show? true
|
||||
end
|
||||
|
|
@ -69,30 +53,6 @@ defmodule Mv.Membership do
|
|||
# It's only used internally as fallback in get_settings/0
|
||||
# Settings should be created via seed script
|
||||
define :update_settings, action: :update
|
||||
define :update_member_field_visibility, action: :update_member_field_visibility
|
||||
|
||||
define :update_single_member_field_visibility,
|
||||
action: :update_single_member_field_visibility
|
||||
|
||||
define :update_single_member_field, action: :update_single_member_field
|
||||
end
|
||||
|
||||
resource Mv.Membership.Group do
|
||||
define :create_group, action: :create
|
||||
define :list_groups, action: :read
|
||||
define :update_group, action: :update
|
||||
define :destroy_group, action: :destroy
|
||||
end
|
||||
|
||||
resource Mv.Membership.MemberGroup do
|
||||
define :create_member_group, action: :create
|
||||
define :list_member_groups, action: :read
|
||||
define :destroy_member_group, action: :destroy
|
||||
end
|
||||
|
||||
resource Mv.Membership.JoinRequest do
|
||||
# Public submit/confirm and approval domain functions are implemented as custom
|
||||
# functions below to handle cross-resource operations (Member promotion on approve).
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -118,23 +78,14 @@ 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
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
club_name: default_club_name,
|
||||
member_field_visibility: %{"exit_date" => false}
|
||||
})
|
||||
|> Ash.Changeset.for_create(:create, %{club_name: default_club_name})
|
||||
|> Ash.create!(domain: __MODULE__)
|
||||
|> then(fn settings -> {:ok, settings} end)
|
||||
|
||||
|
|
@ -168,736 +119,8 @@ 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
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists only required custom fields.
|
||||
|
||||
This is an optimized version that filters at the database level instead of
|
||||
loading all custom fields and filtering in memory. Requires an actor for
|
||||
authorization (CustomField read policy). Callers must pass `actor:`; no default.
|
||||
|
||||
## Options
|
||||
|
||||
- `:actor` - Required. The actor for authorization (e.g. current user).
|
||||
All roles can read CustomField; actor must have a valid role.
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, required_custom_fields}` - List of required custom fields
|
||||
- `{:error, :missing_actor}` - When actor is nil (caller must pass actor)
|
||||
- `{:error, error}` - Error reading custom fields (e.g. Forbidden)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields(actor: actor)
|
||||
iex> Enum.all?(required_fields, & &1.required)
|
||||
true
|
||||
|
||||
iex> Mv.Membership.list_required_custom_fields(actor: nil)
|
||||
{:error, :missing_actor}
|
||||
"""
|
||||
def list_required_custom_fields(actor: actor) when not is_nil(actor) do
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.filter(expr(required == true))
|
||||
|> Ash.read(domain: __MODULE__, actor: actor)
|
||||
end
|
||||
|
||||
def list_required_custom_fields(actor: nil), do: {:error, :missing_actor}
|
||||
|
||||
@doc """
|
||||
Updates the member field visibility configuration.
|
||||
|
||||
This is a specialized action for updating only the member field visibility settings.
|
||||
It validates that all keys are valid member fields and all values are booleans.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `settings` - The settings record to update
|
||||
- `visibility_config` - A map of member field names (strings) to boolean visibility values
|
||||
(e.g., `%{"street" => false, "house_number" => false}`)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, updated_settings}` - Successfully updated settings
|
||||
- `{:error, error}` - Validation or update error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||
iex> updated.member_field_visibility
|
||||
%{"street" => false, "house_number" => false}
|
||||
|
||||
"""
|
||||
def update_member_field_visibility(settings, visibility_config) do
|
||||
case settings
|
||||
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
||||
member_field_visibility: visibility_config
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Atomically updates a single field in the member field visibility configuration.
|
||||
|
||||
This action uses PostgreSQL's jsonb_set function to atomically update a single key
|
||||
in the JSONB map, preventing lost updates in concurrent scenarios. This is the
|
||||
preferred method for updating individual field visibility settings.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `settings` - The settings record to update
|
||||
- `field` - The member field name as a string (e.g., "street", "house_number")
|
||||
- `show_in_overview` - Boolean value indicating visibility
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, updated_settings}` - Successfully updated settings
|
||||
- `{:error, error}` - Validation or update error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> {:ok, updated} = Mv.Membership.update_single_member_field_visibility(settings, field: "street", show_in_overview: false)
|
||||
iex> updated.member_field_visibility["street"]
|
||||
false
|
||||
|
||||
"""
|
||||
def update_single_member_field_visibility(settings,
|
||||
field: field,
|
||||
show_in_overview: show_in_overview
|
||||
) do
|
||||
case settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Atomically updates visibility and required for a single member field.
|
||||
|
||||
Updates both `member_field_visibility` and `member_field_required` in one
|
||||
operation. Use this when saving from the member field settings form.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `settings` - The settings record to update
|
||||
- `field` - The member field name as a string (e.g., "first_name", "street")
|
||||
- `show_in_overview` - Boolean value indicating visibility in member overview
|
||||
- `required` - Boolean value indicating whether the field is required in member forms
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, updated_settings}` - Successfully updated settings
|
||||
- `{:error, error}` - Validation or update error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> {:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
|
||||
iex> updated.member_field_required["first_name"]
|
||||
true
|
||||
|
||||
"""
|
||||
def update_single_member_field(settings,
|
||||
field: field,
|
||||
show_in_overview: show_in_overview,
|
||||
required: required
|
||||
) do
|
||||
case settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.set_argument(:required, required)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a group by its slug.
|
||||
|
||||
Uses `Ash.Query.filter` to efficiently find a group by its slug.
|
||||
The unique index on `slug` ensures efficient lookup performance.
|
||||
The slug lookup is case-sensitive (exact match required).
|
||||
|
||||
## Parameters
|
||||
|
||||
- `slug` - The slug to search for (case-sensitive)
|
||||
- `opts` - Options including `:actor` for authorization
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, group}` - Found group (with members and member_count loaded)
|
||||
- `{:ok, nil}` - Group not found
|
||||
- `{:error, error}` - Error reading group
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, group} = Mv.Membership.get_group_by_slug("board-members", actor: actor)
|
||||
iex> group.name
|
||||
"Board Members"
|
||||
|
||||
iex> {:ok, nil} = Mv.Membership.get_group_by_slug("non-existent", actor: actor)
|
||||
{:ok, nil}
|
||||
|
||||
"""
|
||||
def get_group_by_slug(slug, opts \\ []) do
|
||||
load = Keyword.get(opts, :load, [])
|
||||
|
||||
require Ash.Query
|
||||
|
||||
query =
|
||||
Mv.Membership.Group
|
||||
|> Ash.Query.filter(slug == ^slug)
|
||||
|> Ash.Query.load(load)
|
||||
|
||||
opts
|
||||
|> Keyword.delete(:load)
|
||||
|> Keyword.put_new(:domain, __MODULE__)
|
||||
|> then(&Ash.read_one(query, &1))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a join request (submit flow) and sends the confirmation email.
|
||||
|
||||
Generates a confirmation token if not provided in attrs (e.g. for tests, pass
|
||||
`:confirmation_token` to get a known token). On success, sends one email with
|
||||
the confirm link to the request email.
|
||||
|
||||
## Options
|
||||
- `:actor` - Must be nil for public submit (policy allows only unauthenticated).
|
||||
|
||||
## Returns
|
||||
- `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent
|
||||
- `{:ok, :notified_already_member}` - Email already a member; notice sent by email only (no request created)
|
||||
- `{:ok, :notified_already_pending}` - Email already has pending/submitted request; notice or resend sent by email only
|
||||
- `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged)
|
||||
- `{:error, error}` - Validation or authorization error
|
||||
"""
|
||||
def submit_join_request(attrs, opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
email = normalize_submit_email(attrs)
|
||||
|
||||
pending =
|
||||
if email != nil and email != "", do: pending_join_request_with_email(email), else: nil
|
||||
|
||||
cond do
|
||||
email != nil and email != "" and member_exists_with_email?(email) ->
|
||||
send_already_member_and_return(email)
|
||||
|
||||
pending != nil ->
|
||||
handle_already_pending(email, pending)
|
||||
|
||||
true ->
|
||||
do_create_join_request(attrs, actor)
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_submit_email(attrs) do
|
||||
raw = attrs["email"] || attrs[:email]
|
||||
if is_binary(raw), do: String.trim(raw), else: nil
|
||||
end
|
||||
|
||||
defp member_exists_with_email?(email) when is_binary(email) do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
opts = [actor: system_actor, domain: __MODULE__]
|
||||
|
||||
case Ash.get(Member, %{email: email}, opts) do
|
||||
{:ok, _member} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp member_exists_with_email?(_), do: false
|
||||
|
||||
defp pending_join_request_with_email(email) when is_binary(email) do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
query =
|
||||
JoinRequest
|
||||
|> Ash.Query.filter(expr(email == ^email and status in [:pending_confirmation, :submitted]))
|
||||
|> Ash.Query.sort(inserted_at: :desc)
|
||||
|> Ash.Query.limit(1)
|
||||
|
||||
case Ash.read_one(query, actor: system_actor, domain: __MODULE__) do
|
||||
{:ok, request} -> request
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp pending_join_request_with_email(_), do: nil
|
||||
|
||||
defp join_notifier do
|
||||
Application.get_env(:mv, :join_notifier, MvWeb.JoinNotifierImpl)
|
||||
end
|
||||
|
||||
defp send_already_member_and_return(email) do
|
||||
case join_notifier().send_already_member(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||
{:ok, :notified_already_member}
|
||||
end
|
||||
|
||||
defp handle_already_pending(email, existing) do
|
||||
if existing.status == :pending_confirmation do
|
||||
resend_confirmation_to_pending(email, existing)
|
||||
else
|
||||
send_already_pending_and_return(email)
|
||||
end
|
||||
end
|
||||
|
||||
defp resend_confirmation_to_pending(email, request) do
|
||||
new_token = generate_confirmation_token()
|
||||
|
||||
case request
|
||||
|> Ash.Changeset.for_update(:regenerate_confirmation_token, %{
|
||||
confirmation_token: new_token
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__, authorize?: false) do
|
||||
{:ok, _updated} ->
|
||||
case join_notifier().send_confirmation(email, new_token, resend: true) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||
{:ok, :notified_already_pending}
|
||||
|
||||
{:error, _} ->
|
||||
# Fallback: do not create duplicate; send generic pending email
|
||||
send_already_pending_and_return(email)
|
||||
end
|
||||
end
|
||||
|
||||
defp send_already_pending_and_return(email) do
|
||||
case join_notifier().send_already_pending(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||
{:ok, :notified_already_pending}
|
||||
end
|
||||
|
||||
defp do_create_join_request(attrs, actor) do
|
||||
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
|
||||
attrs_with_token = Map.put(attrs, :confirmation_token, token)
|
||||
|
||||
case Ash.create(JoinRequest, attrs_with_token,
|
||||
action: :submit,
|
||||
actor: actor,
|
||||
domain: __MODULE__
|
||||
) do
|
||||
{:ok, request} ->
|
||||
case join_notifier().send_confirmation(request.email, token, []) do
|
||||
{:ok, _email} ->
|
||||
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||
{:ok, request}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error(
|
||||
"Join confirmation email failed for #{request.email}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
{:error, :email_delivery_failed}
|
||||
end
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_confirmation_token do
|
||||
32
|
||||
|> :crypto.strong_rand_bytes()
|
||||
|> Base.url_encode64(padding: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms a join request by token (public confirmation link).
|
||||
|
||||
Hashes the token, finds the JoinRequest by confirmation_token_hash, checks that
|
||||
the token has not expired, then updates to status :submitted. Idempotent: if
|
||||
already submitted, approved, or rejected, returns the existing record without changing it.
|
||||
|
||||
## Options
|
||||
- `:actor` - Must be nil for public confirm (policy allows only unauthenticated).
|
||||
|
||||
## Returns
|
||||
- `{:ok, request}` - Updated or already-processed JoinRequest
|
||||
- `{:error, :token_expired}` - Token was found but confirmation_token_expires_at is in the past
|
||||
- `{:error, error}` - Token unknown/invalid or authorization error
|
||||
"""
|
||||
def confirm_join_request(token, opts \\ []) when is_binary(token) do
|
||||
hash = JoinRequest.hash_confirmation_token(token)
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
query =
|
||||
Ash.Query.for_read(JoinRequest, :get_by_confirmation_token_hash, %{
|
||||
confirmation_token_hash: hash
|
||||
})
|
||||
|
||||
case Ash.read_one(query, actor: actor, domain: __MODULE__) do
|
||||
{:ok, nil} ->
|
||||
{:error, NotFoundError.exception(resource: JoinRequest)}
|
||||
|
||||
{:ok, request} ->
|
||||
do_confirm_request(request, actor)
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_confirm_request(request, _actor)
|
||||
when request.status in [:submitted, :approved, :rejected] do
|
||||
{:ok, request}
|
||||
end
|
||||
|
||||
defp do_confirm_request(request, actor) do
|
||||
if expired?(request.confirmation_token_expires_at) do
|
||||
{:error, :token_expired}
|
||||
else
|
||||
request
|
||||
|> Ash.Changeset.for_update(:confirm, %{}, domain: __MODULE__)
|
||||
|> Ash.update(domain: __MODULE__, actor: actor)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns whether the public join form is enabled in global settings.
|
||||
|
||||
Used by the web layer (JoinRequest LiveViews, Layouts, plugs) to decide whether
|
||||
to show join-related UI and to gate access to join request pages.
|
||||
"""
|
||||
@spec join_form_enabled?() :: boolean()
|
||||
def join_form_enabled? do
|
||||
case get_settings() do
|
||||
{:ok, %{join_form_enabled: true}} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the allowlist of fields configured for the public join form.
|
||||
|
||||
Reads the current settings. When the join form is disabled (or no settings exist),
|
||||
returns an empty list. When enabled, returns each configured field as a map with:
|
||||
- `:id` - field identifier string (member field name or custom field UUID)
|
||||
- `:required` - boolean; email is always true
|
||||
- `:type` - `:member_field` or `:custom_field`
|
||||
|
||||
This is the server-side allowlist used by the join form submit action (Subtask 4)
|
||||
to enforce which fields are accepted from user input.
|
||||
|
||||
## Returns
|
||||
|
||||
- `[%{id: String.t(), required: boolean(), type: :member_field | :custom_field}]`
|
||||
- `[]` when join form is disabled or settings are missing
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Membership.get_join_form_allowlist()
|
||||
[%{id: "email", required: true, type: :member_field},
|
||||
%{id: "first_name", required: false, type: :member_field}]
|
||||
|
||||
"""
|
||||
def get_join_form_allowlist do
|
||||
case get_settings() do
|
||||
{:ok, settings} ->
|
||||
if settings.join_form_enabled do
|
||||
build_join_form_allowlist(settings)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp build_join_form_allowlist(settings) do
|
||||
field_ids = settings.join_form_field_ids || []
|
||||
required_config = settings.join_form_field_required || %{}
|
||||
member_field_names = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
Enum.map(field_ids, fn id ->
|
||||
type = if id in member_field_names, do: :member_field, else: :custom_field
|
||||
required = Map.get(required_config, id, false)
|
||||
%{id: id, required: required, type: type}
|
||||
end)
|
||||
end
|
||||
|
||||
defp expired?(nil), do: true
|
||||
defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2: Approval domain functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Lists join requests, optionally filtered by status.
|
||||
|
||||
## Options
|
||||
- `:actor` - Required. The actor for authorization (normal_user or admin).
|
||||
- `:status` - Optional atom to filter by status (default: `:submitted`).
|
||||
Pass `:all` to return requests of all statuses.
|
||||
|
||||
## Returns
|
||||
- `{:ok, list}` - List of JoinRequests
|
||||
- `{:error, error}` - Authorization or query error
|
||||
"""
|
||||
@spec list_join_requests(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()}
|
||||
def list_join_requests(opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
status = Keyword.get(opts, :status, :submitted)
|
||||
|
||||
query =
|
||||
if status == :all do
|
||||
JoinRequest
|
||||
|> Ash.Query.sort(inserted_at: :desc)
|
||||
else
|
||||
JoinRequest
|
||||
|> Ash.Query.filter(expr(status == ^status))
|
||||
|> Ash.Query.sort(inserted_at: :desc)
|
||||
end
|
||||
|
||||
Ash.read(query, actor: actor, domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists join requests with status `:approved` or `:rejected` (history), sorted by most recent first.
|
||||
|
||||
Loads `:reviewed_by_user` for displaying the reviewer. Same authorization as `list_join_requests/1`.
|
||||
|
||||
## Options
|
||||
- `:actor` - Required. The actor for authorization (normal_user or admin).
|
||||
|
||||
## Returns
|
||||
- `{:ok, list}` - List of JoinRequests (approved/rejected only)
|
||||
- `{:error, error}` - Authorization or query error
|
||||
"""
|
||||
@spec list_join_requests_history(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()}
|
||||
def list_join_requests_history(opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
query =
|
||||
JoinRequest
|
||||
|> Ash.Query.filter(expr(status in [:approved, :rejected]))
|
||||
|> Ash.Query.sort(updated_at: :desc)
|
||||
|> Ash.Query.load(:reviewed_by_user)
|
||||
|
||||
Ash.read(query, actor: actor, domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of join requests with status `:submitted` (unprocessed).
|
||||
|
||||
Used e.g. for sidebar indicator. Same authorization as `list_join_requests/1`.
|
||||
|
||||
## Options
|
||||
- `:actor` - Required. The actor for authorization (normal_user or admin).
|
||||
|
||||
## Returns
|
||||
- Non-negative integer (0 on error or when unauthorized).
|
||||
"""
|
||||
@spec count_submitted_join_requests(keyword()) :: non_neg_integer()
|
||||
def count_submitted_join_requests(opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
query = JoinRequest |> Ash.Query.filter(expr(status == :submitted))
|
||||
|
||||
case Ash.count(query, actor: actor, domain: __MODULE__) do
|
||||
{:ok, count} when is_integer(count) and count >= 0 ->
|
||||
count
|
||||
|
||||
{:error, error} ->
|
||||
Logger.debug("count_submitted_join_requests failed: #{inspect(error)}")
|
||||
0
|
||||
|
||||
_ ->
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single JoinRequest by id.
|
||||
|
||||
## Options
|
||||
- `:actor` - Required. The actor for authorization.
|
||||
|
||||
## Returns
|
||||
- `{:ok, request}` - The JoinRequest
|
||||
- `{:ok, nil}` - Not found
|
||||
- `{:error, error}` - Authorization or query error
|
||||
"""
|
||||
@spec get_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t() | nil} | {:error, term()}
|
||||
def get_join_request(id, opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
Ash.get(JoinRequest, id,
|
||||
actor: actor,
|
||||
load: [:reviewed_by_user],
|
||||
not_found_error?: false,
|
||||
domain: __MODULE__
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Approves a join request and promotes it to a Member.
|
||||
|
||||
Finds the JoinRequest by id, calls the :approve action (which sets status to
|
||||
:approved and records the reviewer), then creates a Member from the typed fields
|
||||
and form_data. Idempotency: if the request is already approved, returns an error.
|
||||
|
||||
## Options
|
||||
- `:actor` - Required. The reviewer (normal_user or admin).
|
||||
|
||||
## Returns
|
||||
- `{:ok, approved_request}` - Approved JoinRequest
|
||||
- `{:error, error}` - Status error, authorization error, or Member creation error
|
||||
"""
|
||||
@spec approve_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()}
|
||||
def approve_join_request(id, opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
result =
|
||||
Ash.transact(JoinRequest, fn ->
|
||||
with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__),
|
||||
{:ok, approved} <-
|
||||
request
|
||||
|> Ash.Changeset.for_update(:approve, %{}, actor: actor, domain: __MODULE__)
|
||||
|> Ash.update(actor: actor, domain: __MODULE__),
|
||||
{:ok, _member} <- promote_to_member(approved, actor) do
|
||||
{:ok, approved}
|
||||
end
|
||||
end)
|
||||
|
||||
# Ash.transact returns {:ok, callback_result}; flatten so callers get {:ok, request} | {:error, term()}
|
||||
case result do
|
||||
{:ok, inner} -> inner
|
||||
{:error, _} = err -> err
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Rejects a join request.
|
||||
|
||||
Finds the JoinRequest by id and calls the :reject action (status → :rejected,
|
||||
records reviewer). No Member is created. Returns error if not in :submitted status.
|
||||
|
||||
## Options
|
||||
- `:actor` - Required. The reviewer (normal_user or admin).
|
||||
|
||||
## Returns
|
||||
- `{:ok, rejected_request}` - Rejected JoinRequest
|
||||
- `{:error, error}` - Status error or authorization error
|
||||
"""
|
||||
@spec reject_join_request(String.t(), keyword()) ::
|
||||
{:ok, JoinRequest.t()}
|
||||
| {: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)
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update, attrs)
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,24 +4,11 @@ defmodule Mv.Membership.Setting do
|
|||
|
||||
## Overview
|
||||
Settings is a singleton resource that stores global configuration for the association,
|
||||
such as the club name, branding information, and membership fee settings. There should
|
||||
only ever be one settings record in the database.
|
||||
such as the club name and branding information. There should only ever be one settings
|
||||
record in the database.
|
||||
|
||||
## Attributes
|
||||
- `club_name` - The name of the association/club (required, cannot be empty)
|
||||
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
|
||||
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
|
||||
- `member_field_required` - JSONB map storing which member fields are required in forms
|
||||
(e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional.
|
||||
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
|
||||
- `default_membership_fee_type_id` - Default membership fee type for new members (optional)
|
||||
- `registration_enabled` - Whether direct registration via /register is allowed (default: true)
|
||||
- `join_form_enabled` - Whether the public /join page is active (default: false)
|
||||
- `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is
|
||||
either a member field name string (e.g. "email") or a custom field UUID. Email is always
|
||||
included and always required; normalization enforces this automatically.
|
||||
- `join_form_field_required` - Map of field ID => required boolean for the join form.
|
||||
Email is always forced to true.
|
||||
|
||||
## Singleton Pattern
|
||||
This resource uses a singleton pattern - there should only be one settings record.
|
||||
|
|
@ -33,12 +20,6 @@ defmodule Mv.Membership.Setting do
|
|||
If set, the environment variable value is used as a fallback when no database
|
||||
value exists. Database values always take precedence over environment variables.
|
||||
|
||||
## Membership Fee Settings
|
||||
- `include_joining_cycle`: When true, members pay from their joining cycle. When false,
|
||||
they pay from the next full cycle after joining.
|
||||
- `default_membership_fee_type_id`: The membership fee type automatically assigned to
|
||||
new members. Can be nil if no default is set.
|
||||
|
||||
## Examples
|
||||
|
||||
# Get current settings
|
||||
|
|
@ -47,29 +28,10 @@ defmodule Mv.Membership.Setting do
|
|||
|
||||
# Update club name
|
||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"})
|
||||
|
||||
# Update member field visibility
|
||||
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||
|
||||
# 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,284 +42,25 @@ 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
|
||||
# Settings should normally be created via seed script
|
||||
create :create do
|
||||
accept [
|
||||
:club_name,
|
||||
:member_field_visibility,
|
||||
:member_field_required,
|
||||
:include_joining_cycle,
|
||||
:default_membership_fee_type_id,
|
||||
:vereinfacht_api_url,
|
||||
:vereinfacht_api_key,
|
||||
:vereinfacht_club_id,
|
||||
:vereinfacht_app_url,
|
||||
:oidc_client_id,
|
||||
:oidc_base_url,
|
||||
:oidc_redirect_uri,
|
||||
:oidc_client_secret,
|
||||
:oidc_admin_group_name,
|
||||
:oidc_groups_claim,
|
||||
:oidc_only,
|
||||
:smtp_host,
|
||||
:smtp_port,
|
||||
:smtp_username,
|
||||
:smtp_password,
|
||||
:smtp_ssl,
|
||||
:smtp_from_name,
|
||||
:smtp_from_email,
|
||||
:registration_enabled,
|
||||
:join_form_enabled,
|
||||
:join_form_field_ids,
|
||||
:join_form_field_required
|
||||
]
|
||||
|
||||
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
|
||||
accept [:club_name]
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
require_atomic? false
|
||||
|
||||
accept [
|
||||
:club_name,
|
||||
:member_field_visibility,
|
||||
:member_field_required,
|
||||
:include_joining_cycle,
|
||||
:default_membership_fee_type_id,
|
||||
:vereinfacht_api_url,
|
||||
:vereinfacht_api_key,
|
||||
:vereinfacht_club_id,
|
||||
:vereinfacht_app_url,
|
||||
:oidc_client_id,
|
||||
:oidc_base_url,
|
||||
:oidc_redirect_uri,
|
||||
:oidc_client_secret,
|
||||
:oidc_admin_group_name,
|
||||
:oidc_groups_claim,
|
||||
:oidc_only,
|
||||
:smtp_host,
|
||||
:smtp_port,
|
||||
:smtp_username,
|
||||
:smtp_password,
|
||||
:smtp_ssl,
|
||||
:smtp_from_name,
|
||||
:smtp_from_email,
|
||||
:registration_enabled,
|
||||
:join_form_enabled,
|
||||
:join_form_field_ids,
|
||||
:join_form_field_required
|
||||
]
|
||||
|
||||
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
|
||||
end
|
||||
|
||||
update :update_member_field_visibility do
|
||||
description "Updates the visibility configuration for member fields in the overview"
|
||||
require_atomic? false
|
||||
accept [:member_field_visibility]
|
||||
end
|
||||
|
||||
update :update_single_member_field_visibility do
|
||||
description "Atomically updates a single field in the member_field_visibility JSONB map"
|
||||
require_atomic? false
|
||||
|
||||
argument :field, :string, allow_nil?: false
|
||||
argument :show_in_overview, :boolean, allow_nil?: false
|
||||
|
||||
change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility
|
||||
end
|
||||
|
||||
update :update_single_member_field do
|
||||
description "Atomically updates visibility and required for a single member field"
|
||||
require_atomic? false
|
||||
|
||||
argument :field, :string, allow_nil?: false
|
||||
argument :show_in_overview, :boolean, allow_nil?: false
|
||||
argument :required, :boolean, allow_nil?: false
|
||||
|
||||
change Mv.Membership.Setting.Changes.UpdateSingleMemberField
|
||||
end
|
||||
|
||||
update :update_membership_fee_settings do
|
||||
description "Updates the membership fee configuration"
|
||||
require_atomic? false
|
||||
accept [:include_joining_cycle, :default_membership_fee_type_id]
|
||||
|
||||
change Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId
|
||||
accept [:club_name]
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate present(:club_name), on: [:create, :update]
|
||||
validate string_length(:club_name, min: 1), on: [:create, :update]
|
||||
|
||||
# Validate member_field_visibility map structure and content
|
||||
validate fn changeset, _context ->
|
||||
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
|
||||
|
||||
if visibility && is_map(visibility) do
|
||||
# Validate all values are booleans
|
||||
invalid_values =
|
||||
Enum.filter(visibility, fn {_key, value} ->
|
||||
not is_boolean(value)
|
||||
end)
|
||||
|
||||
# Validate all keys are valid member fields
|
||||
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
invalid_keys =
|
||||
Enum.filter(visibility, fn {key, _value} ->
|
||||
key not in valid_field_strings
|
||||
end)
|
||||
|> Enum.map(fn {key, _value} -> key end)
|
||||
|
||||
cond do
|
||||
not Enum.empty?(invalid_values) ->
|
||||
{:error,
|
||||
field: :member_field_visibility,
|
||||
message: "All values in member_field_visibility must be booleans"}
|
||||
|
||||
not Enum.empty?(invalid_keys) ->
|
||||
{:error,
|
||||
field: :member_field_visibility,
|
||||
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:create, :update]
|
||||
|
||||
# Validate member_field_required map structure and content
|
||||
validate fn changeset, _context ->
|
||||
required_config = Ash.Changeset.get_attribute(changeset, :member_field_required)
|
||||
|
||||
if required_config && is_map(required_config) do
|
||||
invalid_values =
|
||||
Enum.filter(required_config, fn {_key, value} ->
|
||||
not is_boolean(value)
|
||||
end)
|
||||
|
||||
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
invalid_keys =
|
||||
Enum.filter(required_config, fn {key, _value} ->
|
||||
key not in valid_field_strings
|
||||
end)
|
||||
|> Enum.map(fn {key, _value} -> key end)
|
||||
|
||||
cond do
|
||||
not Enum.empty?(invalid_values) ->
|
||||
{:error,
|
||||
field: :member_field_required,
|
||||
message: "All values in member_field_required must be booleans"}
|
||||
|
||||
not Enum.empty?(invalid_keys) ->
|
||||
{:error,
|
||||
field: :member_field_required,
|
||||
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:create, :update]
|
||||
|
||||
# Validate join_form_field_ids: each entry must be a known member field name
|
||||
# or a UUID-format string (custom field ID). Normalization (NormalizeJoinFormSettings
|
||||
# change) runs before validations, so email is already present when this runs.
|
||||
validate fn changeset, _context ->
|
||||
field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids)
|
||||
|
||||
if is_list(field_ids) and field_ids != [] do
|
||||
invalid_ids =
|
||||
Enum.reject(field_ids, fn id ->
|
||||
is_binary(id) and
|
||||
(id in @valid_join_form_member_fields or Regex.match?(@uuid_pattern, id))
|
||||
end)
|
||||
|
||||
if Enum.empty?(invalid_ids) do
|
||||
:ok
|
||||
else
|
||||
{:error,
|
||||
field: :join_form_field_ids,
|
||||
message:
|
||||
"Invalid field identifiers: #{inspect(invalid_ids)}. Use member field names or custom field UUIDs."}
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:create, :update]
|
||||
|
||||
# Validate default_membership_fee_type_id exists if set
|
||||
validate fn changeset, context ->
|
||||
fee_type_id =
|
||||
Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
|
||||
|
||||
if fee_type_id do
|
||||
# Check existence only; action is already restricted by policy (e.g. admin).
|
||||
opts = [domain: Mv.MembershipFees, authorize?: false]
|
||||
|
||||
case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id, opts) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
|
||||
{:error,
|
||||
field: :default_membership_fee_type_id,
|
||||
message: "Membership fee type not found"}
|
||||
|
||||
{:error, err} ->
|
||||
# Log unexpected errors (DB timeout, connection errors, etc.)
|
||||
require Logger
|
||||
|
||||
Logger.warning(
|
||||
"Unexpected error when validating default_membership_fee_type_id: #{inspect(err)}"
|
||||
)
|
||||
|
||||
# Return generic error to user
|
||||
{:error,
|
||||
field: :default_membership_fee_type_id,
|
||||
message: "Could not validate membership fee type"}
|
||||
end
|
||||
else
|
||||
# Optional, can be nil
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:create, :update]
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
|
@ -372,190 +75,6 @@ defmodule Mv.Membership.Setting do
|
|||
min_length: 1
|
||||
]
|
||||
|
||||
attribute :member_field_visibility, :map,
|
||||
allow_nil?: true,
|
||||
public?: true,
|
||||
description:
|
||||
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
|
||||
|
||||
attribute :member_field_required, :map,
|
||||
allow_nil?: true,
|
||||
public?: true,
|
||||
description:
|
||||
"Configuration for which member fields are required in forms (JSONB map). Keys are member field names (strings), values are booleans. Email is always required."
|
||||
|
||||
# Membership fee settings
|
||||
attribute :include_joining_cycle, :boolean do
|
||||
allow_nil? false
|
||||
default true
|
||||
public? true
|
||||
description "Whether to include the joining cycle in membership fee generation"
|
||||
end
|
||||
|
||||
attribute :default_membership_fee_type_id, :uuid do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Default membership fee type ID for new members"
|
||||
end
|
||||
|
||||
# Vereinfacht accounting software integration (can be overridden by ENV)
|
||||
attribute :vereinfacht_api_url, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Vereinfacht API base URL (e.g. https://api.verein.visuel.dev/api/v1)"
|
||||
end
|
||||
|
||||
attribute :vereinfacht_api_key, :string do
|
||||
allow_nil? true
|
||||
public? false
|
||||
description "Vereinfacht API key (Bearer token)"
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
attribute :vereinfacht_club_id, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Vereinfacht club ID for multi-tenancy"
|
||||
end
|
||||
|
||||
attribute :vereinfacht_app_url, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
|
||||
description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
|
||||
end
|
||||
|
||||
# OIDC authentication (can be overridden by ENV)
|
||||
attribute :oidc_client_id, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "OIDC client ID (e.g. from OIDC_CLIENT_ID)"
|
||||
end
|
||||
|
||||
attribute :oidc_base_url, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "OIDC provider base URL (e.g. from OIDC_BASE_URL)"
|
||||
end
|
||||
|
||||
attribute :oidc_redirect_uri, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "OIDC redirect URI for callback (e.g. from OIDC_REDIRECT_URI)"
|
||||
end
|
||||
|
||||
attribute :oidc_client_secret, :string do
|
||||
allow_nil? true
|
||||
public? false
|
||||
description "OIDC client secret (e.g. from OIDC_CLIENT_SECRET)"
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
attribute :oidc_admin_group_name, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "OIDC group name that maps to Admin role (e.g. from OIDC_ADMIN_GROUP_NAME)"
|
||||
end
|
||||
|
||||
attribute :oidc_groups_claim, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "JWT claim name for group list (e.g. from OIDC_GROUPS_CLAIM, default 'groups')"
|
||||
end
|
||||
|
||||
attribute :oidc_only, :boolean do
|
||||
allow_nil? false
|
||||
default false
|
||||
public? true
|
||||
|
||||
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
|
||||
end
|
||||
|
||||
# SMTP configuration (can be overridden by ENV)
|
||||
attribute :smtp_host, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "SMTP server hostname (e.g. smtp.example.com)"
|
||||
end
|
||||
|
||||
attribute :smtp_port, :integer do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "SMTP server port (e.g. 587 for TLS, 465 for SSL, 25 for plain)"
|
||||
end
|
||||
|
||||
attribute :smtp_username, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "SMTP authentication username"
|
||||
end
|
||||
|
||||
attribute :smtp_password, :string do
|
||||
allow_nil? true
|
||||
public? false
|
||||
description "SMTP authentication password (sensitive)"
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
attribute :smtp_ssl, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "SMTP TLS/SSL mode: 'tls', 'ssl', or 'none'"
|
||||
end
|
||||
|
||||
attribute :smtp_from_name, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
|
||||
description "Display name for the transactional email sender (e.g. 'Mila'). Overrides MAIL_FROM_NAME env."
|
||||
end
|
||||
|
||||
attribute :smtp_from_email, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
|
||||
description "Email address for the transactional email sender. Must be owned by the SMTP user. Overrides MAIL_FROM_EMAIL env."
|
||||
end
|
||||
|
||||
# Authentication: direct registration toggle
|
||||
attribute :registration_enabled, :boolean do
|
||||
allow_nil? false
|
||||
default true
|
||||
public? true
|
||||
|
||||
description "When true, users can register via /register; when false, only sign-in and join form remain available."
|
||||
end
|
||||
|
||||
# Join form (Beitrittsformular) settings
|
||||
attribute :join_form_enabled, :boolean do
|
||||
allow_nil? false
|
||||
default false
|
||||
public? true
|
||||
|
||||
description "When true, the public /join page is active and new members can submit a request."
|
||||
end
|
||||
|
||||
attribute :join_form_field_ids, {:array, :string} do
|
||||
allow_nil? true
|
||||
default []
|
||||
public? true
|
||||
|
||||
description "Ordered list of field IDs shown on the join form. Each entry is a member field name (e.g. 'email') or a custom field UUID. Email is always present after normalization."
|
||||
end
|
||||
|
||||
attribute :join_form_field_required, :map do
|
||||
allow_nil? true
|
||||
public? true
|
||||
|
||||
description "Map of field ID => required boolean for the join form. Email is always true after normalization."
|
||||
end
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
relationships do
|
||||
# Optional relationship to the default membership fee type
|
||||
# Note: We use manual FK (default_membership_fee_type_id attribute) instead of belongs_to
|
||||
# to avoid circular dependency between Membership and MembershipFees domains
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,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,19 +0,0 @@
|
|||
defmodule Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId do
|
||||
@moduledoc """
|
||||
Ash change that normalizes empty strings to nil for default_membership_fee_type_id.
|
||||
|
||||
HTML forms submit empty select values as empty strings (""), but the database
|
||||
expects nil for optional UUID fields. This change converts "" to nil.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
default_fee_type_id = Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
|
||||
|
||||
if default_fee_type_id == "" do
|
||||
Ash.Changeset.force_change_attribute(changeset, :default_membership_fee_type_id, nil)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
defmodule Mv.Membership.Setting.Changes.NormalizeJoinFormSettings do
|
||||
@moduledoc """
|
||||
Ash change that normalizes join form field settings before persist.
|
||||
|
||||
Applied on create and update actions whenever join form attributes are present.
|
||||
|
||||
Rules enforced:
|
||||
- Email is always added to join_form_field_ids if not already present.
|
||||
- Email is always marked as required (true) in join_form_field_required.
|
||||
- Keys in join_form_field_required that are not in join_form_field_ids are dropped.
|
||||
|
||||
Only runs when join_form_field_ids is being changed; if only
|
||||
join_form_field_required changes, normalization still uses the current
|
||||
(possibly changed) field_ids to strip orphaned required flags.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
changing_ids? = Ash.Changeset.changing_attribute?(changeset, :join_form_field_ids)
|
||||
changing_required? = Ash.Changeset.changing_attribute?(changeset, :join_form_field_required)
|
||||
|
||||
if changing_ids? or changing_required? do
|
||||
normalize(changeset)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize(changeset) do
|
||||
field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids)
|
||||
required_config = Ash.Changeset.get_attribute(changeset, :join_form_field_required)
|
||||
|
||||
field_ids = normalize_field_ids(field_ids)
|
||||
required_config = normalize_required(field_ids, required_config)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:join_form_field_ids, field_ids)
|
||||
|> Ash.Changeset.force_change_attribute(:join_form_field_required, required_config)
|
||||
end
|
||||
|
||||
defp normalize_field_ids(nil), do: ["email"]
|
||||
|
||||
defp normalize_field_ids(ids) when is_list(ids) do
|
||||
if "email" in ids do
|
||||
ids
|
||||
else
|
||||
["email" | ids]
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_field_ids(_), do: ["email"]
|
||||
|
||||
defp normalize_required(field_ids, required_config) do
|
||||
base = if is_map(required_config), do: required_config, else: %{}
|
||||
|
||||
base
|
||||
|> Map.take(field_ids)
|
||||
|> Map.put("email", true)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,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
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
|
||||
@moduledoc """
|
||||
Ash change that atomically updates a single field in the member_field_visibility JSONB map.
|
||||
|
||||
This change uses PostgreSQL's jsonb_set function to atomically update a single key
|
||||
in the JSONB map, preventing lost updates in concurrent scenarios.
|
||||
|
||||
## Arguments
|
||||
- `field` - The member field name as a string (e.g., "street", "house_number")
|
||||
- `show_in_overview` - Boolean value indicating visibility
|
||||
|
||||
## Example
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility,
|
||||
%{},
|
||||
arguments: %{field: "street", show_in_overview: false}
|
||||
)
|
||||
|> Ash.update(domain: Mv.Membership)
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias 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) do
|
||||
add_after_action(changeset, field, show_in_overview)
|
||||
else
|
||||
{:error, updated_changeset} -> updated_changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp get_and_validate_field(changeset) do
|
||||
case Ash.Changeset.get_argument(changeset, :field) do
|
||||
nil ->
|
||||
{:error,
|
||||
add_error(changeset,
|
||||
field: :member_field_visibility,
|
||||
message: "field argument is required"
|
||||
)}
|
||||
|
||||
field ->
|
||||
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
if field in valid_fields do
|
||||
{:ok, field}
|
||||
else
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: :member_field_visibility,
|
||||
message: "Invalid member field: #{field}"
|
||||
)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_and_validate_boolean(changeset, arg_name) do
|
||||
case Ash.Changeset.get_argument(changeset, arg_name) do
|
||||
nil ->
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: :member_field_visibility,
|
||||
message: "#{arg_name} argument is required"
|
||||
)}
|
||||
|
||||
value when is_boolean(value) ->
|
||||
{:ok, value}
|
||||
|
||||
_ ->
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: :member_field_visibility,
|
||||
message: "#{arg_name} must be a boolean"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp add_error(changeset, opts) do
|
||||
Ash.Changeset.add_error(changeset, opts)
|
||||
end
|
||||
|
||||
defp add_after_action(changeset, field, show_in_overview) do
|
||||
# Use after_action to execute atomic SQL update
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
|
||||
# Use PostgreSQL jsonb_set for atomic update
|
||||
# jsonb_set(target, path, new_value, create_missing?)
|
||||
# path is an array: ['field_name']
|
||||
# new_value must be JSON: to_jsonb(boolean)
|
||||
sql = """
|
||||
UPDATE settings
|
||||
SET member_field_visibility = jsonb_set(
|
||||
COALESCE(member_field_visibility, '{}'::jsonb),
|
||||
ARRAY[$1::text],
|
||||
to_jsonb($2::boolean),
|
||||
true
|
||||
)
|
||||
WHERE id = $3
|
||||
RETURNING member_field_visibility
|
||||
"""
|
||||
|
||||
# Convert UUID string to binary for PostgreSQL
|
||||
uuid_binary = Ecto.UUID.dump!(settings.id)
|
||||
|
||||
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"
|
||||
)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
defmodule Mv.Membership.SettingsCache do
|
||||
@moduledoc """
|
||||
Process-based cache for global settings to avoid repeated DB reads on hot paths
|
||||
(e.g. RegistrationEnabled validation, Layouts.public_page, Plugs).
|
||||
|
||||
Uses a short TTL (default 60 seconds). Cache is invalidated on every settings
|
||||
update so that changes take effect quickly. If no settings process exists
|
||||
(e.g. in tests), get/1 falls back to direct read.
|
||||
"""
|
||||
use GenServer
|
||||
|
||||
@default_ttl_seconds 60
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns cached settings or fetches and caches them. Uses TTL; invalidate on update.
|
||||
"""
|
||||
def get do
|
||||
case Process.whereis(__MODULE__) do
|
||||
nil ->
|
||||
# No cache process (e.g. test) – read directly
|
||||
do_fetch()
|
||||
|
||||
_pid ->
|
||||
GenServer.call(__MODULE__, :get, 10_000)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Invalidates the cache so the next get/0 will refetch from the database.
|
||||
Call after update_settings and any other path that mutates settings.
|
||||
"""
|
||||
def invalidate do
|
||||
case Process.whereis(__MODULE__) do
|
||||
nil -> :ok
|
||||
_pid -> GenServer.cast(__MODULE__, :invalidate)
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
ttl = Keyword.get(opts, :ttl_seconds, @default_ttl_seconds)
|
||||
state = %{ttl_seconds: ttl, cached: nil, expires_at: nil}
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get, _from, state) do
|
||||
now = System.monotonic_time(:second)
|
||||
expired? = state.expires_at == nil or state.expires_at <= now
|
||||
|
||||
{result, new_state} =
|
||||
if expired? do
|
||||
fetch_and_cache(now, state)
|
||||
else
|
||||
{{:ok, state.cached}, state}
|
||||
end
|
||||
|
||||
{:reply, result, new_state}
|
||||
end
|
||||
|
||||
defp fetch_and_cache(now, state) do
|
||||
case do_fetch() do
|
||||
{:ok, settings} = ok ->
|
||||
expires = now + state.ttl_seconds
|
||||
{ok, %{state | cached: settings, expires_at: expires}}
|
||||
|
||||
err ->
|
||||
result = if state.cached, do: {:ok, state.cached}, else: err
|
||||
{result, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast(:invalidate, state) do
|
||||
{:noreply, %{state | cached: nil, expires_at: nil}}
|
||||
end
|
||||
|
||||
defp do_fetch do
|
||||
Mv.Membership.get_settings_uncached()
|
||||
end
|
||||
end
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
||||
@moduledoc """
|
||||
Ash change module that automatically calculates and sets the membership_fee_start_date.
|
||||
|
||||
## Logic
|
||||
|
||||
1. Only executes if `membership_fee_start_date` is not manually set
|
||||
2. Requires both `join_date` and `membership_fee_type_id` to be present
|
||||
3. Reads `include_joining_cycle` setting from global Settings
|
||||
4. Reads `interval` from the assigned `membership_fee_type`
|
||||
5. Calculates the start date:
|
||||
- If `include_joining_cycle = true`: First day of the joining cycle
|
||||
- If `include_joining_cycle = false`: First day of the next cycle after joining
|
||||
|
||||
## Usage
|
||||
|
||||
In a Member action:
|
||||
|
||||
create :create_member do
|
||||
# ...
|
||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
||||
end
|
||||
|
||||
The change module handles all prerequisite checks internally (join_date, membership_fee_type_id).
|
||||
If any required data is missing, the changeset is returned unchanged with a warning logged.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, context) do
|
||||
# Only calculate if membership_fee_start_date is not already set
|
||||
if has_start_date?(changeset) do
|
||||
changeset
|
||||
else
|
||||
calculate_and_set_start_date(changeset, context)
|
||||
end
|
||||
end
|
||||
|
||||
# Check if membership_fee_start_date is already set (either in changeset or data)
|
||||
defp has_start_date?(changeset) do
|
||||
# Check if it's being set in this changeset
|
||||
case Ash.Changeset.fetch_change(changeset, :membership_fee_start_date) do
|
||||
{:ok, date} when not is_nil(date) ->
|
||||
true
|
||||
|
||||
_ ->
|
||||
# Check if it already exists in the data (for updates)
|
||||
case changeset.data do
|
||||
%{membership_fee_start_date: date} when not is_nil(date) -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_and_set_start_date(changeset, context) do
|
||||
actor = Map.get(context || %{}, :actor)
|
||||
opts = if actor, do: [actor: actor], else: []
|
||||
|
||||
with {:ok, join_date} <- get_join_date(changeset),
|
||||
{:ok, membership_fee_type_id} <- get_membership_fee_type_id(changeset),
|
||||
{:ok, interval} <- get_interval(membership_fee_type_id, opts),
|
||||
{:ok, include_joining_cycle} <- get_include_joining_cycle() do
|
||||
start_date = calculate_start_date(join_date, interval, include_joining_cycle)
|
||||
Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date)
|
||||
else
|
||||
{:error, :join_date_not_set} ->
|
||||
# Missing join_date is expected for partial creates
|
||||
changeset
|
||||
|
||||
{:error, :membership_fee_type_not_set} ->
|
||||
# Missing membership_fee_type_id is expected for partial creates
|
||||
changeset
|
||||
|
||||
{:error, :membership_fee_type_not_found} ->
|
||||
# This is a data integrity error - membership_fee_type_id references non-existent type
|
||||
# Return changeset error to fail the action
|
||||
Ash.Changeset.add_error(
|
||||
changeset,
|
||||
field: :membership_fee_type_id,
|
||||
message: "not found"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_join_date(changeset) do
|
||||
# First check the changeset for changes
|
||||
case Ash.Changeset.fetch_change(changeset, :join_date) do
|
||||
{:ok, date} when not is_nil(date) ->
|
||||
{:ok, date}
|
||||
|
||||
_ ->
|
||||
# Then check existing data
|
||||
case changeset.data do
|
||||
%{join_date: date} when not is_nil(date) -> {:ok, date}
|
||||
_ -> {:error, :join_date_not_set}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_membership_fee_type_id(changeset) do
|
||||
# First check the changeset for changes
|
||||
case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do
|
||||
{:ok, id} when not is_nil(id) ->
|
||||
{:ok, id}
|
||||
|
||||
_ ->
|
||||
# Then check existing data
|
||||
case changeset.data do
|
||||
%{membership_fee_type_id: id} when not is_nil(id) -> {:ok, id}
|
||||
_ -> {:error, :membership_fee_type_not_set}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_interval(membership_fee_type_id, opts) do
|
||||
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id, opts) do
|
||||
{:ok, %{interval: interval}} -> {:ok, interval}
|
||||
{:error, _} -> {:error, :membership_fee_type_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_include_joining_cycle do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, %{include_joining_cycle: include}} -> {:ok, include}
|
||||
{:error, _} -> {:ok, true}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the membership fee start date based on join date, interval, and settings.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `join_date` - The date the member joined
|
||||
- `interval` - The billing interval (:monthly, :quarterly, :half_yearly, :yearly)
|
||||
- `include_joining_cycle` - Whether to include the joining cycle
|
||||
|
||||
## Returns
|
||||
|
||||
The calculated start date (first day of the appropriate cycle).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> calculate_start_date(~D[2024-03-15], :yearly, true)
|
||||
~D[2024-01-01]
|
||||
|
||||
iex> calculate_start_date(~D[2024-03-15], :yearly, false)
|
||||
~D[2025-01-01]
|
||||
|
||||
iex> calculate_start_date(~D[2024-03-15], :quarterly, true)
|
||||
~D[2024-01-01]
|
||||
|
||||
iex> calculate_start_date(~D[2024-03-15], :quarterly, false)
|
||||
~D[2024-04-01]
|
||||
|
||||
"""
|
||||
@spec calculate_start_date(Date.t(), CalendarCycles.interval(), boolean()) :: Date.t()
|
||||
def calculate_start_date(join_date, interval, include_joining_cycle) do
|
||||
if include_joining_cycle do
|
||||
# Start date is the first day of the joining cycle
|
||||
CalendarCycles.calculate_cycle_start(join_date, interval)
|
||||
else
|
||||
# Start date is the first day of the next cycle after joining
|
||||
join_cycle_start = CalendarCycles.calculate_cycle_start(join_date, interval)
|
||||
CalendarCycles.next_cycle_start(join_cycle_start, interval)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
|
||||
@moduledoc """
|
||||
Validates that membership fee type changes only allow same-interval types.
|
||||
|
||||
Prevents changing from yearly to monthly, etc. (MVP constraint).
|
||||
|
||||
## Usage
|
||||
|
||||
In a Member action:
|
||||
|
||||
update :update_member do
|
||||
# ...
|
||||
change Mv.MembershipFees.Changes.ValidateSameInterval
|
||||
end
|
||||
|
||||
The change module only executes when `membership_fee_type_id` is being changed.
|
||||
If the new type has a different interval than the current type, a validation error is returned.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, context) do
|
||||
if changing_membership_fee_type?(changeset) do
|
||||
validate_interval_match(changeset, context)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
# Check if membership_fee_type_id is being changed
|
||||
defp changing_membership_fee_type?(changeset) do
|
||||
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
|
||||
end
|
||||
|
||||
# Validate that the new type has the same interval as the current type
|
||||
defp validate_interval_match(changeset, context) do
|
||||
current_type_id = get_current_type_id(changeset)
|
||||
new_type_id = get_new_type_id(changeset)
|
||||
actor = Map.get(context || %{}, :actor)
|
||||
|
||||
cond do
|
||||
# If no current type, allow any change (first assignment)
|
||||
is_nil(current_type_id) ->
|
||||
changeset
|
||||
|
||||
# If new type is nil, reject the change (membership_fee_type_id is required)
|
||||
is_nil(new_type_id) ->
|
||||
add_nil_type_error(changeset)
|
||||
|
||||
# Both types exist - validate intervals match
|
||||
true ->
|
||||
validate_intervals_match(changeset, current_type_id, new_type_id, actor)
|
||||
end
|
||||
end
|
||||
|
||||
# Validates that intervals match when both types exist
|
||||
defp validate_intervals_match(changeset, current_type_id, new_type_id, actor) do
|
||||
case get_intervals(current_type_id, new_type_id, actor) do
|
||||
{:ok, current_interval, new_interval} ->
|
||||
if current_interval == new_interval do
|
||||
changeset
|
||||
else
|
||||
add_interval_mismatch_error(changeset, current_interval, new_interval)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
# Fail closed: If we can't load the types, reject the change
|
||||
# This prevents inconsistent data states
|
||||
add_type_validation_error(changeset, reason)
|
||||
end
|
||||
end
|
||||
|
||||
# Get current type ID from changeset data
|
||||
defp get_current_type_id(changeset) do
|
||||
case changeset.data do
|
||||
%{membership_fee_type_id: type_id} -> type_id
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Get new type ID from changeset
|
||||
defp get_new_type_id(changeset) do
|
||||
case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do
|
||||
{:ok, type_id} -> type_id
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Get intervals for both types (actor required for authorization when resource has policies)
|
||||
defp get_intervals(current_type_id, new_type_id, actor) do
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
opts = if actor, do: [actor: actor], else: []
|
||||
|
||||
case {
|
||||
Ash.get(MembershipFeeType, current_type_id, opts),
|
||||
Ash.get(MembershipFeeType, new_type_id, opts)
|
||||
} do
|
||||
{{:ok, current_type}, {:ok, new_type}} ->
|
||||
{:ok, current_type.interval, new_type.interval}
|
||||
|
||||
_ ->
|
||||
{:error, :type_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
# Add validation error for interval mismatch
|
||||
defp add_interval_mismatch_error(changeset, current_interval, new_interval) do
|
||||
current_interval_name = format_interval(current_interval)
|
||||
new_interval_name = format_interval(new_interval)
|
||||
|
||||
message =
|
||||
"Cannot change membership fee type: current type uses #{current_interval_name} interval, " <>
|
||||
"new type uses #{new_interval_name} interval. Only same-interval changes are allowed."
|
||||
|
||||
Ash.Changeset.add_error(
|
||||
changeset,
|
||||
field: :membership_fee_type_id,
|
||||
message: message
|
||||
)
|
||||
end
|
||||
|
||||
# Add validation error when types cannot be loaded
|
||||
defp add_type_validation_error(changeset, _reason) do
|
||||
message =
|
||||
"Could not validate membership fee type intervals. " <>
|
||||
"The current or new membership fee type no longer exists. " <>
|
||||
"This may indicate a data consistency issue."
|
||||
|
||||
Ash.Changeset.add_error(
|
||||
changeset,
|
||||
field: :membership_fee_type_id,
|
||||
message: message
|
||||
)
|
||||
end
|
||||
|
||||
# Add validation error when trying to set membership_fee_type_id to nil
|
||||
defp add_nil_type_error(changeset) do
|
||||
message = "Cannot remove membership fee type. A membership fee type is required."
|
||||
|
||||
Ash.Changeset.add_error(
|
||||
changeset,
|
||||
field: :membership_fee_type_id,
|
||||
message: message
|
||||
)
|
||||
end
|
||||
|
||||
# Format interval atom to human-readable string
|
||||
defp format_interval(:monthly), do: "monthly"
|
||||
defp format_interval(:quarterly), do: "quarterly"
|
||||
defp format_interval(:half_yearly), do: "half-yearly"
|
||||
defp format_interval(:yearly), do: "yearly"
|
||||
defp format_interval(interval), do: to_string(interval)
|
||||
end
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||
@moduledoc """
|
||||
Ash resource representing an individual membership fee cycle for a member.
|
||||
|
||||
## Overview
|
||||
MembershipFeeCycle represents a single billing cycle for a member. Each cycle
|
||||
tracks the payment status and amount for a specific time period.
|
||||
|
||||
## Attributes
|
||||
- `cycle_start` - Start date of the billing cycle (aligned to calendar boundaries)
|
||||
- `amount` - The fee amount for this cycle (stored for audit trail)
|
||||
- `status` - Payment status: unpaid, paid, or suspended
|
||||
- `notes` - Optional notes for this cycle
|
||||
|
||||
## Design Decisions
|
||||
- **No cycle_end field**: Calculated from cycle_start + interval (from fee type)
|
||||
- **Amount stored per cycle**: Preserves historical amounts when fee type changes
|
||||
- **Calendar-aligned cycles**: All cycles start on calendar boundaries
|
||||
|
||||
## Relationships
|
||||
- `belongs_to :member` - The member this cycle belongs to
|
||||
- `belongs_to :membership_fee_type` - The fee type for this cycle
|
||||
|
||||
## Constraints
|
||||
- Unique constraint on (member_id, cycle_start) - one cycle per period per member
|
||||
- CASCADE delete when member is deleted
|
||||
- RESTRICT delete on membership_fee_type if cycles exist
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.MembershipFees,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "membership_fee_cycles"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
resource do
|
||||
description "Individual membership fee cycle for a member"
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
|
||||
create :create do
|
||||
primary? true
|
||||
accept [:cycle_start, :amount, :status, :notes, :member_id, :membership_fee_type_id]
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
accept [:status, :notes, :amount]
|
||||
end
|
||||
|
||||
update :mark_as_paid do
|
||||
description "Mark cycle as paid"
|
||||
require_atomic? false
|
||||
accept [:notes]
|
||||
|
||||
change fn changeset, _context ->
|
||||
Ash.Changeset.force_change_attribute(changeset, :status, :paid)
|
||||
end
|
||||
end
|
||||
|
||||
update :mark_as_suspended do
|
||||
description "Mark cycle as suspended"
|
||||
require_atomic? false
|
||||
accept [:notes]
|
||||
|
||||
change fn changeset, _context ->
|
||||
Ash.Changeset.force_change_attribute(changeset, :status, :suspended)
|
||||
end
|
||||
end
|
||||
|
||||
update :mark_as_unpaid do
|
||||
description "Mark cycle as unpaid (for error correction)"
|
||||
require_atomic? false
|
||||
accept [:notes]
|
||||
|
||||
change fn changeset, _context ->
|
||||
Ash.Changeset.force_change_attribute(changeset, :status, :unpaid)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# READ: bypass for own_data (:linked) then HasPermission for :all; create/update/destroy: HasPermission only.
|
||||
policies do
|
||||
bypass action_type(:read) do
|
||||
description "own_data: read only cycles where member_id == actor.member_id"
|
||||
authorize_if {Mv.Authorization.Checks.ReadLinkedForOwnData, member_id_field: :member_id}
|
||||
end
|
||||
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from role (all read; normal_user and admin create/update/destroy)"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_v7_primary_key :id
|
||||
|
||||
attribute :cycle_start, :date do
|
||||
allow_nil? false
|
||||
public? true
|
||||
description "Start date of the billing cycle"
|
||||
end
|
||||
|
||||
attribute :amount, :decimal do
|
||||
allow_nil? false
|
||||
public? true
|
||||
|
||||
description "Fee amount for this cycle (stored for audit trail, non-negative, max 2 decimal places)"
|
||||
|
||||
constraints min: 0, scale: 2
|
||||
end
|
||||
|
||||
attribute :status, :atom do
|
||||
allow_nil? false
|
||||
public? true
|
||||
default :unpaid
|
||||
description "Payment status of this cycle"
|
||||
constraints one_of: [:unpaid, :paid, :suspended]
|
||||
end
|
||||
|
||||
attribute :notes, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Optional notes for this cycle"
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :member, Mv.Membership.Member do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
belongs_to :membership_fee_type, Mv.MembershipFees.MembershipFeeType do
|
||||
allow_nil? false
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_cycle_per_member, [:member_id, :cycle_start]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
defmodule Mv.MembershipFees.MembershipFeeType do
|
||||
@moduledoc """
|
||||
Ash resource representing a membership fee type definition.
|
||||
|
||||
## Overview
|
||||
MembershipFeeType defines the different types of membership fees that can be
|
||||
assigned to members. Each type has a fixed interval (billing cycle) and a
|
||||
default amount.
|
||||
|
||||
## Attributes
|
||||
- `name` - Unique name for the fee type (e.g., "Standard", "Reduced", "Family")
|
||||
- `amount` - The fee amount in the default currency (decimal)
|
||||
- `interval` - Billing interval: monthly, quarterly, half_yearly, or yearly
|
||||
- `description` - Optional description for the fee type
|
||||
|
||||
## Immutability
|
||||
The `interval` field is immutable after creation. This prevents complex
|
||||
migration scenarios when changing billing cycles. To change intervals,
|
||||
create a new fee type and migrate members.
|
||||
|
||||
## Relationships
|
||||
- `has_many :members` - Members assigned to this fee type
|
||||
- `has_many :membership_fee_cycles` - All cycles using this fee type
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.MembershipFees,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "membership_fee_types"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
resource do
|
||||
description "Membership fee type definition with interval and amount"
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read]
|
||||
|
||||
create :create do
|
||||
primary? true
|
||||
accept [:name, :amount, :interval, :description]
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
# require_atomic? false because validation queries (member/cycle counts) are not atomic
|
||||
# DB constraints serve as the final safeguard if data changes between validation and update
|
||||
require_atomic? false
|
||||
# Note: interval is NOT in accept list - it's immutable after creation
|
||||
accept [:name, :amount, :description]
|
||||
end
|
||||
|
||||
destroy :destroy do
|
||||
primary? true
|
||||
|
||||
# require_atomic? false because validation queries (member/cycle/settings counts) are not atomic
|
||||
# DB constraints serve as the final safeguard if data changes between validation and delete
|
||||
require_atomic? false
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from role (all can read, only admin can create/update/destroy)"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
# Prevent interval changes after creation
|
||||
validate fn changeset, _context ->
|
||||
if Ash.Changeset.changing_attribute?(changeset, :interval) do
|
||||
case changeset.data do
|
||||
# Creating new resource, interval can be set
|
||||
nil ->
|
||||
:ok
|
||||
|
||||
_existing ->
|
||||
{:error,
|
||||
field: :interval, message: "Interval cannot be changed after creation"}
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:update]
|
||||
|
||||
# Prevent deletion if assigned to members
|
||||
validate fn changeset, _context ->
|
||||
if changeset.action_type == :destroy do
|
||||
require Ash.Query
|
||||
|
||||
# Integrity check: count members without authorization (systemic operation)
|
||||
member_count =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|
||||
|> Ash.count!(authorize?: false)
|
||||
|
||||
if member_count > 0 do
|
||||
{:error,
|
||||
message:
|
||||
"Cannot delete membership fee type: #{member_count} member(s) are assigned to it"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:destroy]
|
||||
|
||||
# Prevent deletion if cycles exist
|
||||
validate fn changeset, _context ->
|
||||
if changeset.action_type == :destroy do
|
||||
require Ash.Query
|
||||
|
||||
# Integrity check: count cycles without authorization (systemic operation)
|
||||
cycle_count =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|
||||
|> Ash.count!(authorize?: false)
|
||||
|
||||
if cycle_count > 0 do
|
||||
{:error,
|
||||
message:
|
||||
"Cannot delete membership fee type: #{cycle_count} cycle(s) reference it"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:destroy]
|
||||
|
||||
# Prevent deletion if used as default in settings
|
||||
validate fn changeset, _context ->
|
||||
if changeset.action_type == :destroy do
|
||||
require Ash.Query
|
||||
|
||||
# Integrity check: count settings without authorization (systemic operation)
|
||||
setting_count =
|
||||
Mv.Membership.Setting
|
||||
|> Ash.Query.filter(default_membership_fee_type_id == ^changeset.data.id)
|
||||
|> Ash.count!(authorize?: false)
|
||||
|
||||
if setting_count > 0 do
|
||||
{:error,
|
||||
message: "Cannot delete membership fee type: it's used as default in settings"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:destroy]
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_v7_primary_key :id
|
||||
|
||||
attribute :name, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
description "Unique name for the membership fee type"
|
||||
end
|
||||
|
||||
attribute :amount, :decimal do
|
||||
allow_nil? false
|
||||
public? true
|
||||
description "Fee amount in default currency (non-negative, max 2 decimal places)"
|
||||
constraints min: 0, scale: 2
|
||||
end
|
||||
|
||||
attribute :interval, :atom do
|
||||
allow_nil? false
|
||||
public? true
|
||||
description "Billing interval (immutable after creation)"
|
||||
constraints one_of: [:monthly, :quarterly, :half_yearly, :yearly]
|
||||
end
|
||||
|
||||
attribute :description, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Optional description for the fee type"
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
|
||||
has_many :members, Mv.Membership.Member
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_name, [:name]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
defmodule Mv.MembershipFees do
|
||||
@moduledoc """
|
||||
Ash Domain for membership fee management.
|
||||
|
||||
## Resources
|
||||
- `MembershipFeeType` - Defines membership fee types with intervals and amounts
|
||||
- `MembershipFeeCycle` - Individual membership fee cycles per member
|
||||
|
||||
## Public API
|
||||
The domain exposes these main actions:
|
||||
- MembershipFeeType CRUD: `create_membership_fee_type/1`, `list_membership_fee_types/0`, `update_membership_fee_type/2`, `destroy_membership_fee_type/1`
|
||||
- MembershipFeeCycle CRUD: `create_membership_fee_cycle/1`, `list_membership_fee_cycles/0`, `update_membership_fee_cycle/2`, `destroy_membership_fee_cycle/1`
|
||||
|
||||
Note: LiveViews may use direct Ash calls instead of these domain functions for performance or flexibility.
|
||||
|
||||
## Overview
|
||||
This domain handles the complete membership fee lifecycle including:
|
||||
- Fee type definitions (monthly, quarterly, half-yearly, yearly)
|
||||
- Individual fee cycles for each member
|
||||
- Payment status tracking (unpaid, paid, suspended)
|
||||
|
||||
## Architecture Decisions
|
||||
- `interval` field on MembershipFeeType is immutable after creation
|
||||
- `cycle_end` is calculated, not stored (from cycle_start + interval)
|
||||
- `amount` is stored per cycle for audit trail when prices change
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
||||
admin do
|
||||
show? true
|
||||
end
|
||||
|
||||
resources do
|
||||
resource Mv.MembershipFees.MembershipFeeType do
|
||||
define :create_membership_fee_type, action: :create
|
||||
define :list_membership_fee_types, action: :read
|
||||
define :update_membership_fee_type, action: :update
|
||||
define :destroy_membership_fee_type, action: :destroy
|
||||
end
|
||||
|
||||
resource Mv.MembershipFees.MembershipFeeCycle do
|
||||
define :create_membership_fee_cycle, action: :create
|
||||
define :list_membership_fee_cycles, action: :read
|
||||
define :update_membership_fee_cycle, action: :update
|
||||
define :destroy_membership_fee_cycle, action: :destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,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
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
|
|||
"""
|
||||
use Ash.Resource.Validation
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Validates email uniqueness across linked User-Member pairs.
|
||||
|
||||
|
|
@ -75,30 +73,23 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
|
|||
end
|
||||
|
||||
defp check_email_uniqueness(email, exclude_member_id) do
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.filter(email == ^to_string(email))
|
||||
|> Mv.Helpers.query_exclude_id(exclude_member_id)
|
||||
|> maybe_exclude_id(exclude_member_id)
|
||||
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
opts = Helpers.ash_actor_opts(system_actor)
|
||||
|
||||
case Ash.read(query, opts) do
|
||||
case Ash.read(query) do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, _} ->
|
||||
{:error, field: :email, message: "is already used by another member", value: email}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Email uniqueness validation query failed for user email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
|
||||
)
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_exclude_id(query, nil), do: query
|
||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,40 +5,19 @@ 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,
|
||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Mv.PubSub},
|
||||
{AshAuthentication.Supervisor, otp_app: :my},
|
||||
# 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
|
||||
|
|
|
|||
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