Compare commits
3 commits
main
...
validation
| Author | SHA1 | Date | |
|---|---|---|---|
| 156cdb24d0 | |||
| 967a89b18d | |||
| 0f5d3d7fdd |
334 changed files with 1846 additions and 77980 deletions
|
|
@ -89,7 +89,7 @@
|
|||
# If you don't want TODO comments to cause `mix credo` to fail, just
|
||||
# set this value to 0 (zero).
|
||||
#
|
||||
{Credo.Check.Design.TagTODO, [exit_status: 0]},
|
||||
{Credo.Check.Design.TagTODO, [exit_status: 2]},
|
||||
|
||||
#
|
||||
## Readability Checks
|
||||
|
|
@ -158,11 +158,11 @@
|
|||
{Credo.Check.Warning.UnusedRegexOperation, []},
|
||||
{Credo.Check.Warning.UnusedStringOperation, []},
|
||||
{Credo.Check.Warning.UnusedTupleOperation, []},
|
||||
{Credo.Check.Warning.WrongTestFileExtension, []},
|
||||
# Module documentation check (enabled after adding @moduledoc to all modules)
|
||||
{Credo.Check.Readability.ModuleDoc, []}
|
||||
{Credo.Check.Warning.WrongTestFileExtension, []}
|
||||
],
|
||||
disabled: [
|
||||
# Checks disabled by the Mitgliederverwaltung Team
|
||||
{Credo.Check.Readability.ModuleDoc, []},
|
||||
#
|
||||
# Checks scheduled for next check update (opt-in for now)
|
||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||
|
|
|
|||
93
.drone.yml
93
.drone.yml
|
|
@ -4,7 +4,7 @@ name: check
|
|||
|
||||
services:
|
||||
- name: postgres
|
||||
image: docker.io/library/postgres:18.1
|
||||
image: docker.io/library/postgres:17.2
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
|
@ -14,26 +14,6 @@ trigger:
|
|||
- 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:
|
||||
|
|
@ -53,11 +33,9 @@ steps:
|
|||
- mix hex.audit
|
||||
# Provide hints for improving code quality
|
||||
- mix credo
|
||||
# Check that translations are up to date
|
||||
- mix gettext.extract --check-up-to-date
|
||||
|
||||
- name: wait_for_postgres
|
||||
image: docker.io/library/postgres:18.1
|
||||
image: docker.io/library/postgres:17.2
|
||||
commands:
|
||||
# Wait for postgres to become available
|
||||
- |
|
||||
|
|
@ -77,7 +55,6 @@ steps:
|
|||
environment:
|
||||
MIX_ENV: test
|
||||
TEST_POSTGRES_HOST: postgres
|
||||
TEST_POSTGRES_PORT: 5432
|
||||
commands:
|
||||
# Install hex package manager
|
||||
- mix local.hex --force
|
||||
|
|
@ -86,69 +63,6 @@ steps:
|
|||
# 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
|
||||
|
|
@ -158,6 +72,7 @@ trigger:
|
|||
event:
|
||||
- cron
|
||||
- custom
|
||||
- push
|
||||
branch:
|
||||
- main
|
||||
|
||||
|
|
@ -166,7 +81,7 @@ environment:
|
|||
|
||||
steps:
|
||||
- name: renovate
|
||||
image: renovate/renovate:42.81
|
||||
image: renovate/renovate:40.22
|
||||
environment:
|
||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||
RENOVATE_TOKEN:
|
||||
|
|
|
|||
19
.env.example
19
.env.example
|
|
@ -1,19 +0,0 @@
|
|||
# Production Environment Variables for docker-compose.prod.yml
|
||||
# Copy this file to .env and fill in the actual values
|
||||
|
||||
# Required: Phoenix secrets (generate with: mix phx.gen.secret)
|
||||
SECRET_KEY_BASE=changeme-run-mix-phx.gen.secret
|
||||
TOKEN_SIGNING_SECRET=changeme-run-mix-phx.gen.secret
|
||||
|
||||
# Required: Hostname for URL generation
|
||||
PHX_HOST=localhost
|
||||
|
||||
# Recommended: Association settings
|
||||
ASSOCIATION_NAME="Sportsclub XYZ"
|
||||
|
||||
# Optional: 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/rauthy/callback
|
||||
# OIDC_CLIENT_SECRET=your-rauthy-client-secret
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
---
|
||||
name: 'Bug Tempate'
|
||||
about: 'This template is for bugs!'
|
||||
title: '[BUG]: '
|
||||
ref: 'main'
|
||||
labels:
|
||||
- bug
|
||||
---
|
||||
|
||||
<!--- Provide a general summary of the issue in the Title above -->
|
||||
|
||||
## Expected Behavior
|
||||
<!--- Tell us what should happen -->
|
||||
|
||||
## Current Behavior
|
||||
<!--- Tell us what happens instead of the expected behavior -->
|
||||
|
||||
## Possible Solution
|
||||
<!--- Not obligatory, but suggest a fix/reason for the bug, -->
|
||||
|
||||
## Steps to Reproduce
|
||||
<!--- reproduce this bug. Include code to reproduce, if relevant -->
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
4.
|
||||
|
||||
|
||||
## Detailed Description
|
||||
<!--- Provide a detailed description of the change or addition you are proposing -->
|
||||
|
||||
## Possible Implementation
|
||||
<!--- Not obligatory, but suggest an idea for implementing addition or change -->
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
---
|
||||
name: 'Feature Template'
|
||||
about: 'This template is for new features!'
|
||||
title: '[FEATURE]: '
|
||||
ref: 'main'
|
||||
labels:
|
||||
- feature
|
||||
---
|
||||
## Description
|
||||
<!--- Describe the goal and desired output --->
|
||||
<!--- Add the estimated effort as label --->
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] AC 1
|
||||
- [ ] AC 2
|
||||
|
||||
## External or internal Dependencies
|
||||
<!-- List any dependencies --->
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
---
|
||||
name: 'New Pull-Request'
|
||||
about: 'This is the template for a new Pull Request'
|
||||
title: 'WIP: '
|
||||
ref: 'main'
|
||||
labels:
|
||||
---
|
||||
|
||||
## Description of the implemented changes
|
||||
The changes were:
|
||||
- [ ] Bugfixing
|
||||
- [ ] New Feature
|
||||
- [ ] Breaking Change
|
||||
- [ ] Refactoring
|
||||
|
||||
<!--- Describe the goal of the PR in a few words -->
|
||||
|
||||
|
||||
## What has been changed?
|
||||
<!--- List the things you changed -->
|
||||
|
||||
## Definition of Done
|
||||
### Code Quality
|
||||
- [ ] No new technical depths
|
||||
- [ ] Linting passed
|
||||
- [ ] Documentation is added were needed
|
||||
|
||||
### Accessibility
|
||||
- [ ] New elements are properly defined with html-tags
|
||||
- [ ] Colour contrast follows WCAG criteria
|
||||
- [ ] Aria labels are added when needed
|
||||
- [ ] Everything is accessible by keyboard
|
||||
- [ ] Tab-Order is comprehensible
|
||||
- [ ] All interactive elements have a visible focus
|
||||
|
||||
### Testing
|
||||
- [ ] Tests for new code are written
|
||||
- [ ] All tests pass
|
||||
- [ ] axe-core dev tools show no critical or major issues
|
||||
|
||||
|
||||
## Additional Notes
|
||||
<!--- Add any additional information for the reviewers here -->
|
||||
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
[
|
||||
import_deps: [
|
||||
:ash_authentication_phoenix,
|
||||
:ash_admin,
|
||||
:ash_postgres,
|
||||
:ash_authentication,
|
||||
:ash_phoenix,
|
||||
:ash,
|
||||
:reactor,
|
||||
|
|
|
|||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -35,13 +35,3 @@ mv-*.tar
|
|||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
|
||||
.cursor
|
||||
|
||||
# Ignore the .env file with env variables
|
||||
.env
|
||||
|
||||
.elixir_ls/
|
||||
|
||||
# Docker secrets directory (generated by `just init-secrets`)
|
||||
/secrets/
|
||||
notes.md
|
||||
|
|
|
|||
10
.igniter.exs
10
.igniter.exs
|
|
@ -1,10 +0,0 @@
|
|||
# This is a configuration file for igniter.
|
||||
# For option documentation, see https://hexdocs.pm/igniter/Igniter.Project.IgniterConfig.html
|
||||
# To keep it up to date, use `mix igniter.setup`
|
||||
[
|
||||
module_location: :outside_matching_folder,
|
||||
extensions: [{Igniter.Extensions.Phoenix, []}],
|
||||
deps_location: :last_list_literal,
|
||||
source_folders: ["lib", "test/support"],
|
||||
dont_move_files: [~r"lib/mix"]
|
||||
]
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
elixir 1.18.3-otp-27
|
||||
erlang 27.3.4
|
||||
just 1.46.0
|
||||
erlang 27.3
|
||||
just 1.40.0
|
||||
|
|
|
|||
26
CHANGELOG.md
26
CHANGELOG.md
|
|
@ -1,26 +0,0 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- 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)
|
||||
|
||||
### 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
|
||||
|
||||
2578
CODE_GUIDELINES.md
2578
CODE_GUIDELINES.md
File diff suppressed because it is too large
Load diff
10
Dockerfile
10
Dockerfile
|
|
@ -14,7 +14,7 @@
|
|||
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
|
||||
ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
|
||||
|
||||
FROM ${BUILDER_IMAGE} AS builder
|
||||
FROM ${BUILDER_IMAGE} as builder
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && apt-get install -y build-essential git \
|
||||
|
|
@ -70,9 +70,9 @@ RUN apt-get update -y && \
|
|||
# Set the locale
|
||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LANGUAGE=en_US:en
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
ENV LANG en_US.UTF-8
|
||||
ENV LANGUAGE en_US:en
|
||||
ENV LC_ALL en_US.UTF-8
|
||||
|
||||
WORKDIR "/app"
|
||||
RUN chown nobody /app
|
||||
|
|
@ -90,4 +90,4 @@ USER nobody
|
|||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||
# ENTRYPOINT ["/tini", "--"]
|
||||
|
||||
ENTRYPOINT ["/app/bin/docker-entrypoint.sh"]
|
||||
CMD ["/app/bin/server"]
|
||||
|
|
|
|||
52
Justfile
52
Justfile
|
|
@ -1,8 +1,3 @@
|
|||
set dotenv-load := true
|
||||
set export := true
|
||||
|
||||
MIX_QUIET := "1"
|
||||
|
||||
run: install-dependencies start-database migrate-database seed-database
|
||||
mix phx.server
|
||||
|
||||
|
|
@ -14,7 +9,6 @@ migrate-database:
|
|||
|
||||
reset-database:
|
||||
mix ash.reset
|
||||
MIX_ENV=test mix ash.reset
|
||||
|
||||
seed-database:
|
||||
mix run priv/repo/seeds.exs
|
||||
|
|
@ -24,25 +18,18 @@ start-database:
|
|||
|
||||
ci-dev: lint audit test
|
||||
|
||||
gettext:
|
||||
mix gettext.extract
|
||||
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
|
||||
|
||||
lint:
|
||||
mix format --check-formatted
|
||||
mix compile --warnings-as-errors
|
||||
mix credo
|
||||
# Check that all German translations are filled (UI must be in German)
|
||||
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
|
||||
mix gettext.extract --check-up-to-date
|
||||
|
||||
audit:
|
||||
mix sobelow --config
|
||||
mix deps.audit
|
||||
mix hex.audit
|
||||
|
||||
test *args: install-dependencies start-database
|
||||
mix test {{args}}
|
||||
test:
|
||||
mix test
|
||||
|
||||
format:
|
||||
mix format
|
||||
|
|
@ -84,38 +71,3 @@ regen-migrations migration_name commit_hash='':
|
|||
|
||||
# Generate a fresh migration
|
||||
mix ash.codegen --name "{{migration_name}}"
|
||||
|
||||
# Remove all build artifacts
|
||||
clean:
|
||||
mix clean
|
||||
rm -rf .elixir_ls
|
||||
rm -rf _build
|
||||
|
||||
# Remove Git merge conflict markers from gettext files
|
||||
remove-gettext-conflicts:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
find priv/gettext -type f -exec sed -i '/^<<<<<<</d; /^=======$/d; /^>>>>>>>/d; /^%%%%%%%/d; /^++++++/d; s/^+//; s/^-//' {} \;
|
||||
|
||||
# Production environment commands
|
||||
# ================================
|
||||
|
||||
# Initialize secrets directory with generated secrets (only if not exists)
|
||||
init-prod-secrets:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ -d "secrets" ]; then
|
||||
echo "Secrets directory already exists. Skipping generation."
|
||||
exit 0
|
||||
fi
|
||||
echo "Creating secrets directory and generating secrets..."
|
||||
mkdir -p secrets
|
||||
mix phx.gen.secret > secrets/secret_key_base.txt
|
||||
mix phx.gen.secret > secrets/token_signing_secret.txt
|
||||
openssl rand -base64 32 | tr -d '\n' > secrets/db_password.txt
|
||||
touch secrets/oidc_client_secret.txt
|
||||
echo "Secrets generated in ./secrets/"
|
||||
|
||||
# Start production environment with Docker Compose
|
||||
start-prod: init-prod-secrets
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
|
|
|
|||
662
LICENSE
662
LICENSE
|
|
@ -1,662 +0,0 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
|
||||
Copyright (C) {{ year }} {{ organization }}
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
298
README.md
298
README.md
|
|
@ -1,298 +1,2 @@
|
|||
# Mila
|
||||
# mitgliederverwaltung
|
||||
|
||||
**Mila** — simple, usable, self-hostable membership management for small to mid-sized clubs.
|
||||
|
||||
[](https://drone.dev.local-it.cloud/local-it/mitgliederverwaltung)
|
||||

|
||||
|
||||
## 🚧 Project Status
|
||||
|
||||
⚠️ **Early development** — not production-ready. Expect breaking changes.
|
||||
Contributions and feedback are welcome!
|
||||
|
||||
## ✨ Overview
|
||||
|
||||
Mila is a free and open-source membership management tool designed for real club needs.
|
||||
It is **self-hosting friendly**, aims for **accessibility and GDPR compliance**, and focuses on **usability** instead of feature overload.
|
||||
|
||||
## 💡 Why Mila?
|
||||
|
||||
Most membership tools for clubs are either:
|
||||
|
||||
* **Too complex** — overloaded with features small and mid-sized clubs don’t need
|
||||
* **Too expensive** — hidden fees, closed ecosystems, vendor lock-in
|
||||
* **Too rigid** — no way to adapt fields, processes, or roles to your club’s reality
|
||||
|
||||
**Mila** is different:
|
||||
|
||||
* **Simple**: Focused on what clubs really need — members, dues, communication.
|
||||
* **Usable**: Clean, accessible UI, GDPR-compliant, designed with everyday volunteers in mind.
|
||||
* **Flexible**: Customize what data you collect about members, role-based permissions, and self-service for members.
|
||||
* **Truly open**: 100% free and open source — no lock-in, transparent code, self-host friendly.
|
||||
|
||||
Our philosophy: **software should help people spend less time on administration and more time on their community.**
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||

|
||||
*This is how Mila might look in action.*
|
||||
|
||||
## 🔑 Features
|
||||
|
||||
- ✅ Manage member data with ease
|
||||
- 🚧 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.)
|
||||
- 🚧 Self-service & online application
|
||||
- 🚧 Accessibility, GDPR, usability improvements
|
||||
- 🚧 Email sending
|
||||
|
||||
## 🚀 Quick Start (Development)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
We recommend using **[asdf](https://asdf-vm.com/)** for managing tool versions.
|
||||
- Tested with: `asdf 0.16.5`
|
||||
- Required versions are documented in `.tool-versions` in this repo
|
||||
|
||||
<details>
|
||||
<summary>Install system dependencies (Debian/Ubuntu)</summary>
|
||||
|
||||
```bash
|
||||
# Debian 12
|
||||
apt-get -y install build-essential autoconf m4 libncurses-dev libwxgtk3.2-dev libwxgtk-webview3.2-dev libgl1-mesa-dev libglu1-mesa-dev libpng-dev libssh-dev unixodbc-dev xsltproc fop libxml2-utils openjdk-17-jdk icu-devtools bison flex pkg-config
|
||||
|
||||
# Ubuntu 24
|
||||
apt-get -y install build-essential autoconf m4 libwxgtk3.2-dev libwxgtk-webview3.2-dev libgl1-mesa-dev libglu1-mesa-dev libpng-dev libssh-dev unixodbc-dev xsltproc fop libxml2-utils libncurses-dev openjdk-11-jdk icu-devtools bison flex libreadline-dev
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Install asdf</summary>
|
||||
|
||||
```bash
|
||||
mkdir ~/.asdf
|
||||
cd ~/.asdf
|
||||
wget https://github.com/asdf-vm/asdf/releases/download/v0.16.5/asdf-v0.16.5-linux-amd64.tar.gz
|
||||
tar -xvf asdf-v0.16.5-linux-amd64.tar.gz
|
||||
ln -s ~/.asdf/asdf ~/.local/bin/asdf
|
||||
```
|
||||
|
||||
Then follow the official “Shell Configuration” steps in the asdf docs.
|
||||
|
||||
*Fish example* (`~/.config/fish/config.fish`):
|
||||
|
||||
```fish
|
||||
asdf completion fish > ~/.config/fish/completions/asdf.fish
|
||||
set -gx PATH "$HOME/.asdf/shims" $PATH
|
||||
```
|
||||
|
||||
*Bash example* (`~/.bash_profile` and `~/.bashrc`):
|
||||
|
||||
```bash
|
||||
export PATH="${ASDF_DATA_DIR:-$HOME/.asdf}/shims:$PATH"
|
||||
. <(asdf completion bash)
|
||||
```
|
||||
</details>
|
||||
|
||||
### Install project dependencies
|
||||
|
||||
```bash
|
||||
git clone https://git.local-it.org/local-it/mitgliederverwaltung.git mila
|
||||
cd mila
|
||||
asdf install
|
||||
|
||||
# Inside the repo folder:
|
||||
mix local.hex
|
||||
mix archive.install hex phx_new
|
||||
```
|
||||
|
||||
> Note: running `mix local.hex` must be done inside the repo folder,
|
||||
> because `.tool-versions` defines the Erlang/Elixir versions.
|
||||
|
||||
### Run the app
|
||||
|
||||
1. Copy env file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Set OIDC_CLIENT_SECRET inside .env
|
||||
```
|
||||
|
||||
2. Start everything (database, Mailcrab, Rauthy, app):
|
||||
```bash
|
||||
just run
|
||||
```
|
||||
|
||||
3. Services will be available at:
|
||||
- App: <http://localhost:4000>
|
||||
- Mail UI: <http://localhost:1080>
|
||||
- Postgres: `localhost:5000`
|
||||
|
||||
## 🔐 Testing SSO locally
|
||||
|
||||
Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance is provided.
|
||||
|
||||
1. `just run`
|
||||
2. go to [localhost:8080](http://localhost:8080), go to the Admin area
|
||||
3. Login with "admin@localhost" and password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in docker-compose.yml
|
||||
4. add client from the admin panel
|
||||
- Client ID: mv
|
||||
- redirect uris: http://localhost:4000/auth/user/rauthy/callback
|
||||
- Authorization Flows: authorization_code
|
||||
- allowed origins: http://localhost:4000
|
||||
- access/id token algortihm: RS256 (EDDSA did not work for me, found just few infos in the ashauthentication docs)
|
||||
5. copy client secret to `.env` file
|
||||
6. abort and run `just run` again
|
||||
|
||||
Now you can log in to Mila via OIDC!
|
||||
|
||||
### OIDC with other providers (Authentik, Keycloak, etc.)
|
||||
|
||||
Mila works with any OIDC-compliant provider. The internal strategy is named `:rauthy`, but this is just a name — it works with any provider.
|
||||
|
||||
**Important:** The redirect URI must always end with `/auth/user/rauthy/callback`.
|
||||
|
||||
Example for Authentik:
|
||||
1. Create an OAuth2/OpenID Provider in Authentik
|
||||
2. Set the redirect URI to: `https://your-domain.com/auth/user/rauthy/callback`
|
||||
3. Configure environment variables:
|
||||
```bash
|
||||
DOMAIN=your-domain.com # or PHX_HOST=your-domain.com
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_BASE_URL=https://auth.example.com/application/o/your-app
|
||||
OIDC_CLIENT_SECRET=your-client-secret # or use OIDC_CLIENT_SECRET_FILE
|
||||
```
|
||||
|
||||
The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/callback` if not explicitly set.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
- **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
|
||||
|
||||
**Tech Stack Overview:**
|
||||
- **Backend:** Elixir + Phoenix + Ash Framework
|
||||
- **Frontend:** Phoenix LiveView + Tailwind CSS + DaisyUI
|
||||
- **Database:** PostgreSQL
|
||||
- **Auth:** AshAuthentication (OIDC + password)
|
||||
|
||||
**Code Structure:**
|
||||
- `lib/accounts/` & `lib/membership/` — Ash resources and domains
|
||||
- `lib/mv_web/` — Phoenix controllers, LiveViews, components
|
||||
- `assets/` — Tailwind, JavaScript, static files
|
||||
|
||||
📚 **Full tech stack details:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
|
||||
📖 **Implementation history:** See [`docs/development-progress-log.md`](docs/development-progress-log.md)
|
||||
🗄️ **Database schema:** See [`docs/database-schema-readme.md`](docs/database-schema-readme.md)
|
||||
|
||||
## 🧑💻 Development
|
||||
|
||||
**Common commands:**
|
||||
```bash
|
||||
just run # Start full dev environment
|
||||
just test # Run test suite
|
||||
just lint # Code style checks
|
||||
just audit # Security audits
|
||||
just reset-database # Reset local DB
|
||||
```
|
||||
|
||||
📚 **Full development guidelines:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
|
||||
|
||||
## 📦 Production Deployment
|
||||
|
||||
### Local Production Testing
|
||||
|
||||
For testing the production Docker build locally:
|
||||
|
||||
1. **Generate secrets:**
|
||||
```bash
|
||||
mix phx.gen.secret # for SECRET_KEY_BASE
|
||||
mix phx.gen.secret # for TOKEN_SIGNING_SECRET
|
||||
```
|
||||
|
||||
2. **Create `.env` file:**
|
||||
```bash
|
||||
# Copy template and edit
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
# Required variables:
|
||||
SECRET_KEY_BASE=<your-generated-secret>
|
||||
TOKEN_SIGNING_SECRET=<your-generated-secret>
|
||||
DOMAIN=localhost # or PHX_HOST=localhost
|
||||
|
||||
# Optional OIDC configuration:
|
||||
# OIDC_CLIENT_ID=mv
|
||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||
# OIDC_CLIENT_SECRET=<from-your-oidc-provider>
|
||||
# OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/rauthy/callback
|
||||
|
||||
# Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars):
|
||||
# SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base
|
||||
# TOKEN_SIGNING_SECRET_FILE=/run/secrets/token_signing_secret
|
||||
# OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
|
||||
# DATABASE_URL_FILE=/run/secrets/database_url
|
||||
# DATABASE_PASSWORD_FILE=/run/secrets/database_password
|
||||
```
|
||||
|
||||
3. **Start development environment** (for Rauthy):
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. **Start production environment:**
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up
|
||||
```
|
||||
|
||||
5. **Database migrations run automatically** on app start. For manual migration:
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate"
|
||||
```
|
||||
|
||||
6. **Access the production app:**
|
||||
- Production App: http://localhost:4001
|
||||
- Uses same Rauthy instance as dev (localhost:8080)
|
||||
|
||||
**Note:** The local production setup uses `network_mode: host` to share localhost with the development Rauthy instance. For real production deployment, configure an external OIDC provider and remove `network_mode: host`.
|
||||
|
||||
### Real Production Deployment
|
||||
|
||||
For actual production deployment:
|
||||
|
||||
1. **Use an external OIDC provider** (not the local Rauthy)
|
||||
2. **Update `docker-compose.prod.yml`:**
|
||||
- Remove `network_mode: host`
|
||||
- Set `OIDC_BASE_URL` to your production OIDC provider
|
||||
- Configure proper Docker networks
|
||||
3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik)
|
||||
4. **Use secure secrets management** — All sensitive environment variables support a `_FILE` suffix for Docker secrets (e.g., `SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base`). See `docker-compose.prod.yml` for an example setup with Docker secrets.
|
||||
5. **Configure database backups**
|
||||
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions!
|
||||
- Open issues and PRs in this repo.
|
||||
- Please follow existing code style and conventions.
|
||||
- Expect breaking changes while the project is in early development.
|
||||
|
||||
## 📄 License
|
||||
|
||||
**License: AGPLv3**
|
||||
See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 📬 Contact
|
||||
|
||||
- Issues: [GitLab Issues](https://git.local-it.org/local-it/mitgliederverwaltung/-/issues)
|
||||
- Community links: coming soon.
|
||||
|
|
|
|||
|
|
@ -1,311 +1,5 @@
|
|||
/* See the Tailwind configuration guide for advanced usage
|
||||
https://tailwindcss.com/docs/configuration */
|
||||
|
||||
@import "tailwindcss";
|
||||
@source "../css";
|
||||
@source "../js";
|
||||
@source "../../lib/mv_web";
|
||||
|
||||
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
|
||||
The heroicons installation itself is managed by your mix.exs */
|
||||
@plugin "../vendor/heroicons";
|
||||
|
||||
/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with:
|
||||
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
|
||||
Make sure to look at the daisyUI changelog: https://daisyui.com/docs/changelog/ */
|
||||
@plugin "../vendor/daisyui" {
|
||||
themes: false;
|
||||
}
|
||||
|
||||
/* daisyUI theme plugin. You can update this file by fetching the latest version with:
|
||||
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
|
||||
We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
|
||||
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
|
||||
@plugin "../vendor/daisyui-theme" {
|
||||
name: "dark";
|
||||
default: 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);
|
||||
--color-base-300: oklch(20.15% 0.012 254.09);
|
||||
--color-base-content: oklch(97.807% 0.029 256.847);
|
||||
--color-primary: oklch(58% 0.233 277.117);
|
||||
--color-primary-content: oklch(96% 0.018 272.314);
|
||||
--color-secondary: oklch(58% 0.233 277.117);
|
||||
--color-secondary-content: oklch(96% 0.018 272.314);
|
||||
--color-accent: oklch(60% 0.25 292.717);
|
||||
--color-accent-content: oklch(96% 0.016 293.756);
|
||||
--color-neutral: oklch(37% 0.044 257.287);
|
||||
--color-neutral-content: oklch(98% 0.003 247.858);
|
||||
--color-info: oklch(58% 0.158 241.966);
|
||||
--color-info-content: oklch(97% 0.013 236.62);
|
||||
--color-success: oklch(60% 0.118 184.704);
|
||||
--color-success-content: oklch(98% 0.014 180.72);
|
||||
--color-warning: oklch(66% 0.179 58.318);
|
||||
--color-warning-content: oklch(98% 0.022 95.277);
|
||||
--color-error: oklch(58% 0.253 17.585);
|
||||
--color-error-content: oklch(96% 0.015 12.422);
|
||||
--radius-selector: 0.25rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
--size-selector: 0.21875rem;
|
||||
--size-field: 0.21875rem;
|
||||
--border: 1.5px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@plugin "../vendor/daisyui-theme" {
|
||||
name: "light";
|
||||
default: true;
|
||||
prefersdark: false;
|
||||
color-scheme: "light";
|
||||
--color-base-100: oklch(98% 0 0);
|
||||
--color-base-200: oklch(96% 0.001 286.375);
|
||||
--color-base-300: oklch(92% 0.004 286.32);
|
||||
--color-base-content: oklch(21% 0.006 285.885);
|
||||
--color-primary: oklch(70% 0.213 47.604);
|
||||
--color-primary-content: oklch(98% 0.016 73.684);
|
||||
--color-secondary: oklch(55% 0.027 264.364);
|
||||
--color-secondary-content: oklch(98% 0.002 247.839);
|
||||
--color-accent: oklch(0% 0 0);
|
||||
--color-accent-content: oklch(100% 0 0);
|
||||
--color-neutral: oklch(44% 0.017 285.786);
|
||||
--color-neutral-content: oklch(98% 0 0);
|
||||
--color-info: oklch(62% 0.214 259.815);
|
||||
--color-info-content: oklch(97% 0.014 254.604);
|
||||
--color-success: oklch(70% 0.14 182.503);
|
||||
--color-success-content: oklch(98% 0.014 180.72);
|
||||
--color-warning: oklch(66% 0.179 58.318);
|
||||
--color-warning-content: oklch(98% 0.022 95.277);
|
||||
--color-error: oklch(58% 0.253 17.585);
|
||||
--color-error-content: oklch(96% 0.015 12.422);
|
||||
--radius-selector: 0.25rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
--size-selector: 0.21875rem;
|
||||
--size-field: 0.21875rem;
|
||||
--border: 1.5px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* Add variants based on LiveView classes */
|
||||
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
|
||||
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
|
||||
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
|
||||
|
||||
/* Make LiveView wrapper divs transparent for layout */
|
||||
[data-phx-session] { display: contents }
|
||||
|
||||
/* ============================================
|
||||
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;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
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 {
|
||||
@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 {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@import "tailwindcss/base";
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
/* This file is for your main application CSS */
|
||||
|
|
|
|||
266
assets/js/app.js
266
assets/js/app.js
|
|
@ -23,106 +23,9 @@ import {LiveSocket} from "phoenix_live_view"
|
|||
import topbar from "../vendor/topbar"
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
|
||||
// Hooks for LiveView components
|
||||
let Hooks = {}
|
||||
|
||||
// CopyToClipboard hook: Copies text to clipboard when triggered by server event
|
||||
Hooks.CopyToClipboard = {
|
||||
mounted() {
|
||||
this.handleEvent("copy_to_clipboard", ({text}) => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).catch(err => {
|
||||
console.error("Clipboard write failed:", err)
|
||||
})
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = text
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-999999px"
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand("copy")
|
||||
} catch (err) {
|
||||
console.error("Fallback clipboard copy failed:", err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ComboBox hook: Prevents form submission when Enter is pressed in dropdown
|
||||
Hooks.ComboBox = {
|
||||
mounted() {
|
||||
this.handleKeyDown = (e) => {
|
||||
const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true"
|
||||
|
||||
if (e.key === "Enter" && isDropdownOpen) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
this.el.addEventListener("keydown", this.handleKeyDown)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener("keydown", this.handleKeyDown)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
},
|
||||
|
||||
setSidebarState(expanded) {
|
||||
// Convert boolean to string for consistency
|
||||
const expandedStr = expanded ? 'true' : 'false'
|
||||
|
||||
// Update data-attribute (CSS reacts to this)
|
||||
this.el.dataset.sidebarExpanded = expandedStr
|
||||
|
||||
// Persist to localStorage
|
||||
localStorage.setItem('sidebar-expanded', expandedStr)
|
||||
|
||||
// Update ARIA for accessibility
|
||||
const toggleBtn = document.getElementById('sidebar-toggle')
|
||||
if (toggleBtn) {
|
||||
toggleBtn.setAttribute('aria-expanded', expandedStr)
|
||||
}
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
// Cleanup
|
||||
delete window.toggleSidebar
|
||||
}
|
||||
}
|
||||
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {_csrf_token: csrfToken},
|
||||
hooks: Hooks
|
||||
})
|
||||
|
||||
// Listen for custom events from LiveView
|
||||
window.addEventListener("phx:set-input-value", (e) => {
|
||||
const {id, value} = e.detail
|
||||
const input = document.getElementById(id)
|
||||
if (input) {
|
||||
input.value = value
|
||||
}
|
||||
params: {_csrf_token: csrfToken}
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
@ -139,170 +42,3 @@ liveSocket.connect()
|
|||
// >> liveSocket.disableLatencySim()
|
||||
window.liveSocket = liveSocket
|
||||
|
||||
// Sidebar accessibility improvements
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const drawerToggle = document.getElementById("mobile-drawer")
|
||||
const sidebarToggle = document.getElementById("sidebar-toggle")
|
||||
const sidebar = document.getElementById("main-sidebar")
|
||||
|
||||
if (!drawerToggle || !sidebarToggle || !sidebar) return
|
||||
|
||||
// Manage tabindex for sidebar elements based on open/closed state
|
||||
const updateSidebarTabIndex = (isOpen) => {
|
||||
// Find all potentially focusable elements (including those with tabindex="-1")
|
||||
const allFocusableElements = sidebar.querySelectorAll(
|
||||
'a[href], button, select, input:not([type="hidden"]), [tabindex]'
|
||||
)
|
||||
|
||||
allFocusableElements.forEach(el => {
|
||||
// Skip the overlay button
|
||||
if (el.closest('.drawer-overlay')) return
|
||||
|
||||
if (isOpen) {
|
||||
// Remove tabindex="-1" to make focusable when open
|
||||
if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') === '-1') {
|
||||
el.removeAttribute('tabindex')
|
||||
}
|
||||
} else {
|
||||
// Set tabindex="-1" to remove from tab order when closed
|
||||
if (!el.hasAttribute('tabindex')) {
|
||||
el.setAttribute('tabindex', '-1')
|
||||
} else if (el.getAttribute('tabindex') !== '-1') {
|
||||
// Store original tabindex in data attribute before setting to -1
|
||||
if (!el.hasAttribute('data-original-tabindex')) {
|
||||
el.setAttribute('data-original-tabindex', el.getAttribute('tabindex'))
|
||||
}
|
||||
el.setAttribute('tabindex', '-1')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Find first focusable element in sidebar
|
||||
// Priority: first navigation link (menuitem) > other links > other focusable elements
|
||||
const getFirstFocusableElement = () => {
|
||||
// First, try to find the first navigation link (menuitem)
|
||||
const firstNavLink = sidebar.querySelector('a[href][role="menuitem"]:not([tabindex="-1"])')
|
||||
if (firstNavLink && !firstNavLink.closest('.drawer-overlay')) {
|
||||
return firstNavLink
|
||||
}
|
||||
|
||||
// Fallback: any navigation link
|
||||
const firstLink = sidebar.querySelector('a[href]:not([tabindex="-1"])')
|
||||
if (firstLink && !firstLink.closest('.drawer-overlay')) {
|
||||
return firstLink
|
||||
}
|
||||
|
||||
// Last resort: any other focusable element
|
||||
const focusableSelectors = [
|
||||
'button:not([tabindex="-1"]):not([disabled])',
|
||||
'select:not([tabindex="-1"]):not([disabled])',
|
||||
'input:not([tabindex="-1"]):not([disabled]):not([type="hidden"])',
|
||||
'[tabindex]:not([tabindex="-1"])'
|
||||
]
|
||||
|
||||
for (const selector of focusableSelectors) {
|
||||
const element = sidebar.querySelector(selector)
|
||||
if (element && !element.closest('.drawer-overlay')) {
|
||||
return element
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Update aria-expanded when drawer state changes
|
||||
const updateAriaExpanded = () => {
|
||||
const isOpen = drawerToggle.checked
|
||||
sidebarToggle.setAttribute("aria-expanded", isOpen.toString())
|
||||
|
||||
// Update dropdown aria-expanded if present
|
||||
const userMenuButton = sidebar.querySelector('button[aria-haspopup="true"]')
|
||||
if (userMenuButton) {
|
||||
const dropdown = userMenuButton.closest('.dropdown')
|
||||
const isDropdownOpen = dropdown?.classList.contains('dropdown-open')
|
||||
if (userMenuButton) {
|
||||
userMenuButton.setAttribute("aria-expanded", (isDropdownOpen || false).toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for changes to the drawer checkbox
|
||||
drawerToggle.addEventListener("change", () => {
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ const path = require("path")
|
|||
|
||||
module.exports = {
|
||||
content: [
|
||||
"../deps/ash_authentication_phoenix/**/*.*ex",
|
||||
"./js/**/*.js",
|
||||
"../lib/mv_web.ex",
|
||||
"../lib/mv_web/**/*.*ex"
|
||||
|
|
|
|||
124
assets/vendor/daisyui-theme.js
vendored
124
assets/vendor/daisyui-theme.js
vendored
File diff suppressed because one or more lines are too long
1021
assets/vendor/daisyui.js
vendored
1021
assets/vendor/daisyui.js
vendored
File diff suppressed because one or more lines are too long
43
assets/vendor/heroicons.js
vendored
43
assets/vendor/heroicons.js
vendored
|
|
@ -1,43 +0,0 @@
|
|||
const plugin = require("tailwindcss/plugin")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
module.exports = plugin(function({matchComponents, theme}) {
|
||||
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized")
|
||||
let values = {}
|
||||
let icons = [
|
||||
["", "/24/outline"],
|
||||
["-solid", "/24/solid"],
|
||||
["-mini", "/20/solid"],
|
||||
["-micro", "/16/solid"]
|
||||
]
|
||||
icons.forEach(([suffix, dir]) => {
|
||||
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
|
||||
let name = path.basename(file, ".svg") + suffix
|
||||
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
|
||||
})
|
||||
})
|
||||
matchComponents({
|
||||
"hero": ({name, fullPath}) => {
|
||||
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
|
||||
content = encodeURIComponent(content)
|
||||
let size = theme("spacing.6")
|
||||
if (name.endsWith("-mini")) {
|
||||
size = theme("spacing.5")
|
||||
} else if (name.endsWith("-micro")) {
|
||||
size = theme("spacing.4")
|
||||
}
|
||||
return {
|
||||
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
|
||||
"-webkit-mask": `var(--hero-${name})`,
|
||||
"mask": `var(--hero-${name})`,
|
||||
"mask-repeat": "no-repeat",
|
||||
"background-color": "currentColor",
|
||||
"vertical-align": "middle",
|
||||
"display": "inline-block",
|
||||
"width": size,
|
||||
"height": size
|
||||
}
|
||||
}
|
||||
}, {values})
|
||||
})
|
||||
37
assets/vendor/topbar.js
vendored
37
assets/vendor/topbar.js
vendored
|
|
@ -1,12 +1,39 @@
|
|||
/**
|
||||
* @license MIT
|
||||
* topbar 3.0.0
|
||||
* http://buunguyen.github.io/topbar
|
||||
* Copyright (c) 2024 Buu Nguyen
|
||||
* topbar 2.0.0, 2023-02-04
|
||||
* https://buunguyen.github.io/topbar
|
||||
* Copyright (c) 2021 Buu Nguyen
|
||||
*/
|
||||
(function (window, document) {
|
||||
"use strict";
|
||||
|
||||
// https://gist.github.com/paulirish/1579671
|
||||
(function () {
|
||||
var lastTime = 0;
|
||||
var vendors = ["ms", "moz", "webkit", "o"];
|
||||
for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
|
||||
window.requestAnimationFrame =
|
||||
window[vendors[x] + "RequestAnimationFrame"];
|
||||
window.cancelAnimationFrame =
|
||||
window[vendors[x] + "CancelAnimationFrame"] ||
|
||||
window[vendors[x] + "CancelRequestAnimationFrame"];
|
||||
}
|
||||
if (!window.requestAnimationFrame)
|
||||
window.requestAnimationFrame = function (callback, element) {
|
||||
var currTime = new Date().getTime();
|
||||
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
|
||||
var id = window.setTimeout(function () {
|
||||
callback(currTime + timeToCall);
|
||||
}, timeToCall);
|
||||
lastTime = currTime + timeToCall;
|
||||
return id;
|
||||
};
|
||||
if (!window.cancelAnimationFrame)
|
||||
window.cancelAnimationFrame = function (id) {
|
||||
clearTimeout(id);
|
||||
};
|
||||
})();
|
||||
|
||||
var canvas,
|
||||
currentProgress,
|
||||
showing,
|
||||
|
|
@ -61,6 +88,7 @@
|
|||
style.zIndex = 100001;
|
||||
style.display = "none";
|
||||
if (options.className) canvas.classList.add(options.className);
|
||||
document.body.appendChild(canvas);
|
||||
addEvent(window, "resize", repaint);
|
||||
},
|
||||
topbar = {
|
||||
|
|
@ -73,11 +101,10 @@
|
|||
if (delay) {
|
||||
if (delayTimerId) return;
|
||||
delayTimerId = setTimeout(() => topbar.show(), delay);
|
||||
} else {
|
||||
} else {
|
||||
showing = true;
|
||||
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
|
||||
if (!canvas) createCanvas();
|
||||
if (!canvas.parentElement) document.body.appendChild(canvas);
|
||||
canvas.style.opacity = 1;
|
||||
canvas.style.display = "block";
|
||||
topbar.progress(0);
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ config :spark,
|
|||
config :mv,
|
||||
ecto_repos: [Mv.Repo],
|
||||
generators: [timestamp_type: :utc_datetime],
|
||||
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization]
|
||||
ash_domains: [Mv.Membership]
|
||||
|
||||
# Configures the endpoint
|
||||
config :mv, MvWeb.Endpoint,
|
||||
|
|
@ -76,35 +76,27 @@ config :esbuild,
|
|||
version: "0.17.11",
|
||||
mv: [
|
||||
args:
|
||||
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
|
||||
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
|
||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||
]
|
||||
|
||||
# Configure tailwind (the version is required)
|
||||
config :tailwind,
|
||||
version: "4.0.9",
|
||||
version: "3.4.3",
|
||||
mv: [
|
||||
args: ~w(
|
||||
--input=assets/css/app.css
|
||||
--output=priv/static/assets/css/app.css
|
||||
--config=tailwind.config.js
|
||||
--input=css/app.css
|
||||
--output=../priv/static/assets/app.css
|
||||
),
|
||||
cd: Path.expand("..", __DIR__)
|
||||
cd: Path.expand("../assets", __DIR__)
|
||||
]
|
||||
|
||||
# Configures Elixir's Logger
|
||||
config :logger, :default_formatter,
|
||||
config :logger, :console,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ config :mv, Mv.Repo,
|
|||
# The watchers configuration can be used to run external
|
||||
# watchers to your application. For example, we can use it
|
||||
# to bundle .js and .css sources.
|
||||
# Binding to loopback ipv4 address prevents access from other machines.
|
||||
config :mv, MvWeb.Endpoint,
|
||||
# Binding to loopback ipv4 address prevents access from other machines.
|
||||
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
|
||||
http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")],
|
||||
http: [ip: {127, 0, 0, 1}, port: 4000],
|
||||
check_origin: false,
|
||||
code_reloader: true,
|
||||
debug_errors: true,
|
||||
|
|
@ -56,11 +56,10 @@ config :mv, MvWeb.Endpoint,
|
|||
# Watch static and templates for browser reloading.
|
||||
config :mv, MvWeb.Endpoint,
|
||||
live_reload: [
|
||||
web_console_logger: true,
|
||||
patterns: [
|
||||
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
|
||||
~r"priv/gettext/.*(po)$",
|
||||
~r"lib/mv_web/(?:controllers|live|components|router)/?.*\.(ex|heex)$"
|
||||
~r"lib/mv_web/(controllers|live|components)/.*(ex|heex)$"
|
||||
]
|
||||
]
|
||||
|
||||
|
|
@ -68,7 +67,7 @@ config :mv, MvWeb.Endpoint,
|
|||
config :mv, dev_routes: true
|
||||
|
||||
# Do not include metadata nor timestamps in development logs
|
||||
config :logger, :default_formatter, format: "[$level] $message\n"
|
||||
config :logger, :console, format: "[$level] $message\n"
|
||||
|
||||
# Set a higher stacktrace during development. Avoid configuring such
|
||||
# in production as building large stacktraces may be expensive.
|
||||
|
|
@ -78,28 +77,10 @@ config :phoenix, :stacktrace_depth, 20
|
|||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
config :phoenix_live_view,
|
||||
# Include HEEx debug annotations as HTML comments in rendered markup.
|
||||
# Changing this configuration will require mix clean and a full recompile.
|
||||
# Include HEEx debug annotations as HTML comments in rendered markup
|
||||
debug_heex_annotations: true,
|
||||
debug_tags_location: true,
|
||||
# Enable helpful, but potentially expensive runtime checks
|
||||
enable_expensive_runtime_checks: true
|
||||
|
||||
# Disable swoosh api client as it is only required for production adapters.
|
||||
config :swoosh, :api_client, false
|
||||
|
||||
config :mv, :secret_key_base, "ryn7D6ssmIHQFWIks2sFiTGATgwwAR1+3bN8p7fy6qVtB8qnxOuk1uyAwHz1Q8WB"
|
||||
|
||||
# Signing Secret for Authentication
|
||||
config :mv, :token_signing_secret, "IwUwi65TrEeExwBXXFPGm2I7889NsL"
|
||||
|
||||
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
|
||||
|
||||
config :mv, :require_token_presence_for_authentication, true
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import Config
|
|||
config :mv, MvWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
|
||||
|
||||
# Configures Swoosh API Client
|
||||
config :swoosh, api_client: Swoosh.ApiClient.Req
|
||||
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Mv.Finch
|
||||
|
||||
# Disable Swoosh Local Memory Storage
|
||||
config :swoosh, local: false
|
||||
|
|
@ -16,16 +16,5 @@ config :swoosh, local: false
|
|||
# Do not print debug messages in production
|
||||
config :logger, level: :info
|
||||
|
||||
# AshAuthentication production configuration
|
||||
# These must be set at compile-time (not in runtime.exs) because
|
||||
# Application.compile_env!/3 is used in lib/accounts/user.ex
|
||||
config :mv, :session_identifier, :jti
|
||||
|
||||
config :mv, :require_token_presence_for_authentication, true
|
||||
|
||||
# Token signing secret - using a placeholder that MUST be overridden
|
||||
# at runtime via environment variable in config/runtime.exs
|
||||
config :mv, :token_signing_secret, "REPLACE_ME_AT_RUNTIME"
|
||||
|
||||
# Runtime production configuration, including reading
|
||||
# of environment variables, is done on config/runtime.exs.
|
||||
|
|
|
|||
|
|
@ -7,75 +7,6 @@ import Config
|
|||
# any compile-time configuration in here, as it won't be applied.
|
||||
# The block below contains prod specific runtime configuration.
|
||||
|
||||
# Helper function to read environment variables with Docker secrets support.
|
||||
# Supports the _FILE suffix pattern: if VAR_FILE is set, reads the value from
|
||||
# that file path. Otherwise falls back to VAR directly.
|
||||
# VAR_FILE takes priority and must contain the full absolute path to the secret file.
|
||||
get_env_or_file = fn var_name, default ->
|
||||
file_var = "#{var_name}_FILE"
|
||||
|
||||
case System.get_env(file_var) do
|
||||
nil ->
|
||||
System.get_env(var_name, default)
|
||||
|
||||
file_path ->
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
String.trim_trailing(content)
|
||||
|
||||
{:error, reason} ->
|
||||
raise """
|
||||
Failed to read secret from file specified in #{file_var}="#{file_path}".
|
||||
Error: #{inspect(reason)}
|
||||
"""
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Same as get_env_or_file but raises if the value is not set
|
||||
get_env_or_file! = fn var_name, error_message ->
|
||||
case get_env_or_file.(var_name, nil) do
|
||||
nil -> raise error_message
|
||||
value -> value
|
||||
end
|
||||
end
|
||||
|
||||
# Build database URL from individual components or use DATABASE_URL directly.
|
||||
# Supports both approaches:
|
||||
# 1. DATABASE_URL (or DATABASE_URL_FILE) - full connection URL
|
||||
# 2. Separate vars: DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD (or _FILE), DATABASE_NAME, DATABASE_PORT
|
||||
build_database_url = fn ->
|
||||
case get_env_or_file.("DATABASE_URL", nil) do
|
||||
nil ->
|
||||
# Build URL from separate components
|
||||
host =
|
||||
System.get_env("DATABASE_HOST") ||
|
||||
raise "DATABASE_HOST is required when DATABASE_URL is not set"
|
||||
|
||||
user =
|
||||
System.get_env("DATABASE_USER") ||
|
||||
raise "DATABASE_USER is required when DATABASE_URL is not set"
|
||||
|
||||
password =
|
||||
get_env_or_file!.("DATABASE_PASSWORD", """
|
||||
DATABASE_PASSWORD or DATABASE_PASSWORD_FILE is required when DATABASE_URL is not set.
|
||||
""")
|
||||
|
||||
database =
|
||||
System.get_env("DATABASE_NAME") ||
|
||||
raise "DATABASE_NAME is required when DATABASE_URL is not set"
|
||||
|
||||
port = System.get_env("DATABASE_PORT", "5432")
|
||||
|
||||
# URL-encode the password to handle special characters
|
||||
encoded_password = URI.encode_www_form(password)
|
||||
"ecto://#{user}:#{encoded_password}@#{host}:#{port}/#{database}"
|
||||
|
||||
url ->
|
||||
url
|
||||
end
|
||||
end
|
||||
|
||||
# ## Using releases
|
||||
#
|
||||
# If you use `mix release`, you need to explicitly enable the server
|
||||
|
|
@ -90,7 +21,12 @@ if System.get_env("PHX_SERVER") do
|
|||
end
|
||||
|
||||
if config_env() == :prod do
|
||||
database_url = build_database_url.()
|
||||
database_url =
|
||||
System.get_env("DATABASE_URL") ||
|
||||
raise """
|
||||
environment variable DATABASE_URL is missing.
|
||||
For example: ecto://USER:PASS@HOST/DATABASE
|
||||
"""
|
||||
|
||||
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
|
||||
|
||||
|
|
@ -105,81 +41,29 @@ if config_env() == :prod do
|
|||
# want to use a different value for prod and you most likely don't want
|
||||
# to check this value into version control, so we use an environment
|
||||
# variable instead.
|
||||
# Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets.
|
||||
secret_key_base =
|
||||
get_env_or_file!.("SECRET_KEY_BASE", """
|
||||
environment variable SECRET_KEY_BASE (or SECRET_KEY_BASE_FILE) is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
""")
|
||||
|
||||
# PHX_HOST or DOMAIN can be used to set the host for the application.
|
||||
# DOMAIN is commonly used in deployment environments (e.g., Portainer templates).
|
||||
host =
|
||||
System.get_env("PHX_HOST") ||
|
||||
System.get_env("DOMAIN") ||
|
||||
raise "Please define the PHX_HOST or DOMAIN environment variable."
|
||||
System.get_env("SECRET_KEY_BASE") ||
|
||||
raise """
|
||||
environment variable SECRET_KEY_BASE is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
"""
|
||||
|
||||
host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable."
|
||||
port = String.to_integer(System.get_env("PORT") || "4000")
|
||||
|
||||
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||
|
||||
# OIDC configuration (works with any OIDC provider: Authentik, Rauthy, Keycloak, etc.)
|
||||
# Note: The strategy is named :rauthy internally, but works with any OIDC provider.
|
||||
# The redirect_uri callback path is always /auth/user/rauthy/callback regardless of provider.
|
||||
#
|
||||
# Supports OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE for Docker secrets.
|
||||
# OIDC_CLIENT_SECRET is required only if OIDC is being used (indicated by explicit OIDC env vars).
|
||||
oidc_base_url = System.get_env("OIDC_BASE_URL")
|
||||
oidc_client_id = System.get_env("OIDC_CLIENT_ID")
|
||||
oidc_in_use = not is_nil(oidc_base_url) or not is_nil(oidc_client_id)
|
||||
|
||||
client_secret =
|
||||
if oidc_in_use do
|
||||
get_env_or_file!.("OIDC_CLIENT_SECRET", """
|
||||
environment variable OIDC_CLIENT_SECRET (or OIDC_CLIENT_SECRET_FILE) is missing.
|
||||
This is required when OIDC authentication is configured (OIDC_BASE_URL or OIDC_CLIENT_ID is set).
|
||||
""")
|
||||
else
|
||||
get_env_or_file.("OIDC_CLIENT_SECRET", nil)
|
||||
end
|
||||
|
||||
# Build redirect_uri: use OIDC_REDIRECT_URI if set, otherwise build from host.
|
||||
# Uses HTTPS since production runs behind TLS termination.
|
||||
default_redirect_uri = "https://#{host}/auth/user/rauthy/callback"
|
||||
|
||||
config :mv, :rauthy,
|
||||
client_id: oidc_client_id || "mv",
|
||||
base_url: oidc_base_url || "http://localhost:8080/auth/v1",
|
||||
client_secret: client_secret,
|
||||
redirect_uri: System.get_env("OIDC_REDIRECT_URI") || default_redirect_uri
|
||||
|
||||
# Token signing secret from environment variable
|
||||
# This overrides the placeholder value set in prod.exs
|
||||
# Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets.
|
||||
token_signing_secret =
|
||||
get_env_or_file!.("TOKEN_SIGNING_SECRET", """
|
||||
environment variable TOKEN_SIGNING_SECRET (or TOKEN_SIGNING_SECRET_FILE) is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
""")
|
||||
|
||||
config :mv, :token_signing_secret, token_signing_secret
|
||||
|
||||
config :mv, MvWeb.Endpoint,
|
||||
url: [host: host, port: 443, scheme: "https"],
|
||||
http: [
|
||||
# Bind on all IPv4 interfaces.
|
||||
# Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only.
|
||||
# Enable IPv6 and bind on all interfaces.
|
||||
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
|
||||
ip: {0, 0, 0, 0},
|
||||
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||
ip: {0, 0, 0, 0, 0, 0, 0, 0},
|
||||
port: port
|
||||
],
|
||||
secret_key_base: secret_key_base,
|
||||
# Allow connections from localhost and 127.0.0.1
|
||||
check_origin: [
|
||||
"//#{host}",
|
||||
"//localhost:#{port}",
|
||||
"//127.0.0.1:#{port}"
|
||||
]
|
||||
secret_key_base: secret_key_base
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
|
|
@ -225,7 +109,7 @@ if config_env() == :prod do
|
|||
# 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:
|
||||
# Swoosh supports Hackney and Finch out of the box:
|
||||
#
|
||||
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
|
||||
#
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ config :mv, Mv.Repo,
|
|||
username: "postgres",
|
||||
password: "postgres",
|
||||
hostname: System.get_env("TEST_POSTGRES_HOST", "localhost"),
|
||||
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() * 2
|
||||
|
|
@ -36,16 +35,3 @@ config :phoenix, :plug_init_mode, :runtime
|
|||
# Enable helpful, but potentially expensive runtime checks
|
||||
config :phoenix_live_view,
|
||||
enable_expensive_runtime_checks: true
|
||||
|
||||
# Token signing secret for AshAuthentication tests
|
||||
config :mv, :token_signing_secret, "test_secret_key_for_ash_authentication_tokens"
|
||||
|
||||
# AshAuthentication test-specific configuration
|
||||
# In Tests we don't need token presence, but in other envs its recommended
|
||||
config :mv, :session_identifier, :unsafe
|
||||
|
||||
config :mv, :require_token_presence_for_authentication, false
|
||||
|
||||
# Enable SQL Sandbox for async LiveView tests
|
||||
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
|
||||
config :mv, :sql_sandbox, true
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
services:
|
||||
app:
|
||||
image: git.local-it.org/local-it/mitgliederverwaltung:latest
|
||||
container_name: mv-prod-app
|
||||
ports:
|
||||
- "4001:4001"
|
||||
environment:
|
||||
# Database configuration using separate variables
|
||||
# Use Docker service name for internal networking
|
||||
DATABASE_HOST: "db-prod"
|
||||
DATABASE_PORT: "5432"
|
||||
DATABASE_USER: "postgres"
|
||||
DATABASE_NAME: "mv_prod"
|
||||
DATABASE_PASSWORD_FILE: "/run/secrets/db_password"
|
||||
# Phoenix secrets via Docker secrets
|
||||
SECRET_KEY_BASE_FILE: "/run/secrets/secret_key_base"
|
||||
TOKEN_SIGNING_SECRET_FILE: "/run/secrets/token_signing_secret"
|
||||
PHX_HOST: "${PHX_HOST:-localhost}"
|
||||
PORT: "4001"
|
||||
PHX_SERVER: "true"
|
||||
# Rauthy OIDC config - use host.docker.internal to reach host services
|
||||
OIDC_CLIENT_ID: "mv"
|
||||
OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1"
|
||||
OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret"
|
||||
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
|
||||
secrets:
|
||||
- db_password
|
||||
- secret_key_base
|
||||
- token_signing_secret
|
||||
- oidc_client_secret
|
||||
depends_on:
|
||||
- db-prod
|
||||
restart: unless-stopped
|
||||
|
||||
db-prod:
|
||||
image: postgres:18.1-alpine
|
||||
container_name: mv-prod-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
|
||||
POSTGRES_DB: mv_prod
|
||||
secrets:
|
||||
- db_password
|
||||
volumes:
|
||||
- postgres_data_prod:/var/lib/postgresql/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:
|
||||
|
|
@ -1,52 +1,24 @@
|
|||
networks:
|
||||
local:
|
||||
rauthy-dev:
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:18.1-alpine
|
||||
image: postgres:17.2-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: mv_dev
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- type: volume
|
||||
source: postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
volume:
|
||||
nocopy: true
|
||||
ports:
|
||||
- "5000:5432"
|
||||
networks:
|
||||
- local
|
||||
|
||||
mailcrab:
|
||||
image: marlonb/mailcrab:latest
|
||||
ports:
|
||||
- "1080:1080"
|
||||
networks:
|
||||
- rauthy-dev
|
||||
|
||||
rauthy:
|
||||
container_name: rauthy-dev
|
||||
image: ghcr.io/sebadob/rauthy:0.33.4
|
||||
environment:
|
||||
- LOCAL_TEST=true
|
||||
- SMTP_URL=mailcrab
|
||||
- SMTP_PORT=1025
|
||||
- SMTP_DANGER_INSECURE=true
|
||||
- LISTEN_SCHEME=http
|
||||
- PUB_URL=localhost:8080
|
||||
- BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345
|
||||
# Disable strict IP validation to allow access from multiple Docker networks
|
||||
- SESSION_VALIDATE_IP=false
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- mailcrab
|
||||
- db
|
||||
networks:
|
||||
- rauthy-dev
|
||||
- local
|
||||
volumes:
|
||||
- rauthy-data:/app/data
|
||||
networks:
|
||||
local:
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
rauthy-data:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,686 +0,0 @@
|
|||
# CSV Member Import v1 - Implementation Plan
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** 2025-01-XX
|
||||
**Status:** Ready for Implementation
|
||||
**Related Documents:**
|
||||
- [Feature Roadmap](./feature-roadmap.md) - Overall feature planning
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview & Scope](#overview--scope)
|
||||
- [UX Flow](#ux-flow)
|
||||
- [CSV Specification](#csv-specification)
|
||||
- [Technical Design Notes](#technical-design-notes)
|
||||
- [Implementation Issues](#implementation-issues)
|
||||
- [Rollout & Risks](#rollout--risks)
|
||||
|
||||
---
|
||||
|
||||
## Overview & Scope
|
||||
|
||||
### What We're Building
|
||||
|
||||
A **basic CSV member import feature** that allows administrators to upload a CSV file and import new members into the system. This is a **v1 minimal implementation** focused on establishing the import structure without advanced features.
|
||||
|
||||
**Core Functionality (v1 Minimal):**
|
||||
- Upload CSV file via LiveView file upload
|
||||
- Parse CSV with bilingual header support for core member fields (English/German)
|
||||
- Auto-detect delimiter (`;` or `,`) using header recognition
|
||||
- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`)
|
||||
- **Import custom field values** - Map CSV columns to existing custom fields by name (unknown custom field columns will be ignored with a warning)
|
||||
- Validate each row (required field: `email`)
|
||||
- Create members via Ash resource (one-by-one, **no background jobs**, processed in chunks of 200 rows via LiveView messages)
|
||||
- Display import results: success count, error count, and error details
|
||||
- Provide static CSV templates (EN/DE)
|
||||
|
||||
**Key Constraints (v1):**
|
||||
- ✅ **Admin-only feature**
|
||||
- ✅ **No upsert** (create only)
|
||||
- ✅ **No deduplication** (duplicate emails fail and show as errors)
|
||||
- ✅ **No mapping wizard** (fixed header mapping via bilingual variants)
|
||||
- ✅ **No background jobs** (progress via LiveView `handle_info`)
|
||||
- ✅ **Best-effort import** (row-by-row, no rollback)
|
||||
- ✅ **UI-only error display** (no error CSV export)
|
||||
- ✅ **Safety limits** (10 MB, 1,000 rows, chunks of 200)
|
||||
|
||||
### Out of Scope (v1)
|
||||
|
||||
**Deferred to Future Versions:**
|
||||
- ❌ Upsert/update existing members
|
||||
- ❌ Advanced deduplication strategies
|
||||
- ❌ Column mapping wizard UI
|
||||
- ❌ Background job processing (Oban/GenStage)
|
||||
- ❌ Transactional all-or-nothing import
|
||||
- ❌ Error CSV export/download
|
||||
- ❌ Batch validation preview before import
|
||||
- ❌ Dynamic template generation
|
||||
- ❌ Import history/audit log
|
||||
- ❌ Import templates for other entities
|
||||
|
||||
---
|
||||
|
||||
## UX Flow
|
||||
|
||||
### Access & Location
|
||||
|
||||
**Entry Point:**
|
||||
- **Location:** Global Settings page (`/settings`)
|
||||
- **UI Element:** New section "Import Members (CSV)" below "Custom Fields" section
|
||||
- **Access Control:** Admin-only (enforced at LiveView event level, not entire `/settings` route)
|
||||
|
||||
### User Journey
|
||||
|
||||
1. **Navigate to Global Settings**
|
||||
2. **Access Import Section**
|
||||
- **Important notice:** Custom fields should be created in Mila before importing CSV files with custom field columns (unknown columns will be ignored with a warning)
|
||||
- Upload area (drag & drop or file picker)
|
||||
- Template download links (English / German)
|
||||
- Help text explaining CSV format and custom field requirements
|
||||
3. **Ensure Custom Fields Exist (if importing custom fields)**
|
||||
- Navigate to Custom Fields section and create required custom fields
|
||||
- Note the name/identifier for each custom field (used as CSV header)
|
||||
4. **Download Template (Optional)**
|
||||
5. **Prepare CSV File**
|
||||
- Include custom field columns using the custom field name as header (e.g., `membership_number`, `birth_date`)
|
||||
6. **Upload CSV**
|
||||
7. **Start Import**
|
||||
- Runs server-side via LiveView messages (may take up to ~30 seconds for large files)
|
||||
- Warning messages if custom field columns reference non-existent custom fields (columns will be ignored)
|
||||
8. **View Results**
|
||||
- Success count
|
||||
- Error count
|
||||
- First 50 errors, each with:
|
||||
- **CSV line number** (header is line 1, first data record begins at line 2)
|
||||
- Error message
|
||||
- Field name (if applicable)
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **File too large:** Flash error before upload starts
|
||||
- **Too many rows:** Flash error before import starts
|
||||
- **Invalid CSV format:** Error shown in results
|
||||
- **Partial success:** Results show both success and error counts
|
||||
|
||||
---
|
||||
|
||||
## CSV Specification
|
||||
|
||||
### Delimiter
|
||||
|
||||
**Recommended:** Semicolon (`;`)
|
||||
**Supported:** `;` and `,`
|
||||
|
||||
**Auto-Detection (Header Recognition):**
|
||||
- Remove UTF-8 BOM *first*
|
||||
- Extract header record and try parsing with both delimiters
|
||||
- For each delimiter, count how many recognized headers are present (via normalized variants)
|
||||
- Choose delimiter with higher recognition; prefer `;` if tied
|
||||
- If neither yields recognized headers, default to `;`
|
||||
|
||||
### Quoting Rules
|
||||
|
||||
- Fields may be quoted with double quotes (`"`)
|
||||
- Escaped quotes: `""` inside quoted field represents a single `"`
|
||||
- **v1 assumption:** CSV records do **not** contain embedded newlines inside quoted fields. (If they do, parsing may fail or line numbers may be inaccurate.)
|
||||
|
||||
### Column Headers
|
||||
|
||||
**v1 Supported Fields:**
|
||||
|
||||
**Core Member Fields:**
|
||||
- `first_name` / `Vorname` (optional)
|
||||
- `last_name` / `Nachname` (optional)
|
||||
- `email` / `E-Mail` (required)
|
||||
- `street` / `Straße` (optional)
|
||||
- `postal_code` / `PLZ` / `Postleitzahl` (optional)
|
||||
- `city` / `Stadt` (optional)
|
||||
|
||||
**Custom Fields:**
|
||||
- Any custom field column using the custom field's **name** as the header (e.g., `membership_number`, `birth_date`)
|
||||
- **Important:** Custom fields must be created in Mila before importing. The CSV header must match the custom field name exactly (same normalization as member fields).
|
||||
- **Behavior:** If the CSV contains custom field columns that don't exist in Mila, a warning message will be shown and those columns will be ignored during import.
|
||||
|
||||
**Member Field Header Mapping:**
|
||||
|
||||
| Canonical Field | English Variants | German Variants |
|
||||
|---|---|---|
|
||||
| `first_name` | `first_name`, `firstname` | `Vorname`, `vorname` |
|
||||
| `last_name` | `last_name`, `lastname`, `surname` | `Nachname`, `nachname`, `Familienname` |
|
||||
| `email` | `email`, `e-mail`, `e_mail` | `E-Mail`, `e-mail`, `e_mail` |
|
||||
| `street` | `street`, `address` | `Straße`, `strasse`, `Strasse` |
|
||||
| `postal_code` | `postal_code`, `zip`, `postcode` | `PLZ`, `plz`, `Postleitzahl`, `postleitzahl` |
|
||||
| `city` | `city`, `town` | `Stadt`, `stadt`, `Ort` |
|
||||
|
||||
**Header Normalization (used consistently for both input headers AND mapping variants):**
|
||||
- Trim whitespace
|
||||
- Convert to lowercase
|
||||
- Normalize Unicode: `ß` → `ss` (e.g., `Straße` → `strasse`)
|
||||
- Replace hyphens/whitespace with underscores: `E-Mail` → `e_mail`, `phone number` → `phone_number`
|
||||
- Collapse multiple underscores: `e__mail` → `e_mail`
|
||||
- Case-insensitive matching
|
||||
|
||||
**Unknown columns:** ignored (no error)
|
||||
|
||||
**Required fields:** `email`
|
||||
|
||||
**Custom Field Columns:**
|
||||
- Custom field columns are identified by matching the normalized CSV header to the custom field `name` (not slug)
|
||||
- Same normalization rules apply as for member fields (trim, lowercase, Unicode normalization, underscore replacement)
|
||||
- Unknown custom field columns (non-existent names) will be ignored with a warning message
|
||||
|
||||
### CSV Template Files
|
||||
|
||||
**Location:**
|
||||
- `priv/static/templates/member_import_en.csv`
|
||||
- `priv/static/templates/member_import_de.csv`
|
||||
|
||||
**Content:**
|
||||
- Header row with required + common optional fields
|
||||
- **Note:** Custom field columns are not included in templates by default (users add them based on their custom field configuration)
|
||||
- One example row
|
||||
- Uses semicolon delimiter (`;`)
|
||||
- UTF-8 encoding **with BOM** (Excel compatibility)
|
||||
|
||||
**Template Access:**
|
||||
- Templates are static files in `priv/static/templates/`
|
||||
- Served at:
|
||||
- `/templates/member_import_en.csv`
|
||||
- `/templates/member_import_de.csv`
|
||||
- In LiveView, link them using Phoenix static path helpers (e.g. `~p` or `Routes.static_path/2`, depending on Phoenix version).
|
||||
|
||||
**Example Usage in LiveView Templates:**
|
||||
|
||||
```heex
|
||||
<!-- Using ~p sigil (Phoenix 1.7+) -->
|
||||
<.link href={~p"/templates/member_import_en.csv"} download>
|
||||
<%= gettext("Download English Template") %>
|
||||
</.link>
|
||||
|
||||
<.link href={~p"/templates/member_import_de.csv"} download>
|
||||
<%= gettext("Download German Template") %>
|
||||
</.link>
|
||||
|
||||
<!-- Alternative: Using Routes.static_path/2 -->
|
||||
<.link href={Routes.static_path(MvWeb.Endpoint, "/templates/member_import_en.csv")} download>
|
||||
<%= gettext("Download English Template") %>
|
||||
</.link>
|
||||
```
|
||||
|
||||
**Note:** The `templates` directory must be included in `MvWeb.static_paths()` (configured in `lib/mv_web.ex`) for the files to be served.
|
||||
|
||||
### File Limits
|
||||
|
||||
- **Max file size:** 10 MB
|
||||
- **Max rows:** 1,000 rows (excluding header)
|
||||
- **Processing:** chunks of 200 (via LiveView messages)
|
||||
- **Encoding:** UTF-8 (BOM handled)
|
||||
|
||||
---
|
||||
|
||||
## Technical Design Notes
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ LiveView UI │ (GlobalSettingsLive or component)
|
||||
│ - Upload area │
|
||||
│ - Progress │
|
||||
│ - Results │
|
||||
└────────┬────────┘
|
||||
│ prepare
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ Import Service │ (Mv.Membership.Import.MemberCSV)
|
||||
│ - parse + map + limit checks│ -> returns import_state
|
||||
│ - process_chunk(chunk) │ -> returns chunk results
|
||||
└────────┬────────────────────┘
|
||||
│ create
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Ash Resource │ (Mv.Membership.Member)
|
||||
│ - Create │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Technology Stack
|
||||
|
||||
- **Phoenix LiveView:** file upload via `allow_upload/3`
|
||||
- **NimbleCSV:** CSV parsing (add explicit dependency if missing)
|
||||
- **Ash Resource:** member creation via `Membership.create_member/1`
|
||||
- **Gettext:** bilingual UI/error messages
|
||||
|
||||
### Module Structure
|
||||
|
||||
**New Modules:**
|
||||
- `lib/mv/membership/import/member_csv.ex` - import orchestration + chunk processing + custom field handling
|
||||
- `lib/mv/membership/import/csv_parser.ex` - delimiter detection + parsing + BOM handling
|
||||
- `lib/mv/membership/import/header_mapper.ex` - normalization + header mapping (core fields + custom fields)
|
||||
|
||||
**Modified Modules:**
|
||||
- `lib/mv_web/live/global_settings_live.ex` - render import section, handle upload/events/messages
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Upload:** LiveView receives file via `allow_upload`
|
||||
2. **Consume:** `consume_uploaded_entries/3` reads file content
|
||||
3. **Prepare:** `MemberCSV.prepare/2`
|
||||
- Strip BOM
|
||||
- Detect delimiter (header recognition)
|
||||
- Parse header + rows
|
||||
- Map headers to canonical fields (core member fields)
|
||||
- **Query existing custom fields and map custom field columns by name** (using same normalization as member fields)
|
||||
- **Warn about unknown custom field columns** (non-existent names will be ignored with warning)
|
||||
- Early abort if required headers missing
|
||||
- Row count check
|
||||
- Return `import_state` containing chunks, column_map, and custom_field_map
|
||||
4. **Process:** LiveView drives chunk processing via `handle_info`
|
||||
- For each chunk: validate + create member + create custom field values + collect errors
|
||||
5. **Results:** LiveView shows progress + final summary
|
||||
|
||||
### Types & Key Consistency
|
||||
|
||||
- **Raw CSV parsing:** returns headers as list of strings, and rows **with csv line numbers**
|
||||
- **Header mapping:** operates on normalized strings; mapping table variants are normalized once
|
||||
- **Ash attrs:** built as atom-keyed map (`%{first_name: ..., ...}`)
|
||||
|
||||
### Error Model
|
||||
|
||||
```elixir
|
||||
%{
|
||||
csv_line_number: 5, # physical line number in the CSV file
|
||||
field: :email, # optional
|
||||
message: "is not a valid email"
|
||||
}
|
||||
```
|
||||
|
||||
### CSV Line Numbers (Important)
|
||||
|
||||
To keep error reporting user-friendly and accurate, **row errors must reference the physical line number in the original file**, even if empty lines are skipped.
|
||||
|
||||
**Design decision:** the parser returns rows as:
|
||||
|
||||
```elixir
|
||||
rows :: [{csv_line_number :: pos_integer(), row_map :: map()}]
|
||||
```
|
||||
|
||||
Downstream logic must **not** recompute line numbers from row indexes.
|
||||
|
||||
### Authorization
|
||||
|
||||
**Enforcement points:**
|
||||
1. **LiveView event level:** check admin permission in `handle_event("start_import", ...)`
|
||||
2. **UI level:** render import section only for admin users
|
||||
3. **Static templates:** public assets (no authorization needed)
|
||||
|
||||
Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string checks where possible.
|
||||
|
||||
### Safety Limits
|
||||
|
||||
- File size enforced by `allow_upload` (`max_file_size`)
|
||||
- Row count enforced in `MemberCSV.prepare/2` before processing starts
|
||||
- Chunking is done via **LiveView `handle_info` loop** (sequential, cooperative scheduling)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Issues
|
||||
|
||||
### Issue #1: CSV Specification & Static Template Files
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Goal:** Define CSV contract and add static templates.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Finalize header mapping variants
|
||||
- [ ] Document normalization rules
|
||||
- [ ] Document delimiter detection strategy
|
||||
- [ ] Create templates in `priv/static/templates/` (UTF-8 with BOM)
|
||||
- [ ] Document template URLs and how to link them from LiveView
|
||||
- [ ] Document line number semantics (physical CSV line numbers)
|
||||
|
||||
**Definition of Done:**
|
||||
- [ ] Templates open cleanly in Excel/LibreOffice
|
||||
- [ ] CSV spec section complete
|
||||
|
||||
---
|
||||
|
||||
### Issue #2: Import Service Module Skeleton
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Goal:** Create service API and error types.
|
||||
|
||||
**API (recommended):**
|
||||
- `prepare/2` — parse + map + limit checks, returns import_state
|
||||
- `process_chunk/3` — process one chunk (pure-ish), returns per-chunk results
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Create `lib/mv/membership/import/member_csv.ex`
|
||||
- [ ] Define public function: `prepare/2 (file_content, opts \\ [])`
|
||||
- [ ] Define public function: `process_chunk/3 (chunk_rows_with_lines, column_map, opts \\ [])`
|
||||
- [ ] Define error struct: `%MemberCSV.Error{csv_line_number: integer, field: atom | nil, message: String.t}`
|
||||
- [ ] Document module + API
|
||||
|
||||
---
|
||||
|
||||
### Issue #3: CSV Parsing + Delimiter Auto-Detection + BOM Handling
|
||||
|
||||
**Dependencies:** Issue #2
|
||||
|
||||
**Goal:** Parse CSV robustly with correct delimiter detection and BOM handling.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Verify/add NimbleCSV dependency (`{:nimble_csv, "~> 1.0"}`)
|
||||
- [ ] Create `lib/mv/membership/import/csv_parser.ex`
|
||||
- [ ] Implement `strip_bom/1` and apply it **before** any header handling
|
||||
- [ ] Handle `\r\n` and `\n` line endings (trim `\r` on header record)
|
||||
- [ ] Detect delimiter via header recognition (try `;` and `,`)
|
||||
- [ ] Parse CSV and return:
|
||||
- `headers :: [String.t()]`
|
||||
- `rows :: [{csv_line_number, [String.t()]}]` or directly `[{csv_line_number, row_map}]`
|
||||
- [ ] Skip completely empty records (but preserve correct physical line numbers)
|
||||
- [ ] Return `{:ok, headers, rows}` or `{:error, reason}`
|
||||
|
||||
**Definition of Done:**
|
||||
- [ ] BOM handling works (Excel exports)
|
||||
- [ ] Delimiter detection works reliably
|
||||
- [ ] Rows carry correct `csv_line_number`
|
||||
|
||||
---
|
||||
|
||||
### Issue #4: Header Normalization + Per-Header Mapping (No Language Detection)
|
||||
|
||||
**Dependencies:** Issue #3
|
||||
|
||||
**Goal:** Map each header individually to canonical fields (normalized comparison).
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Create `lib/mv/membership/import/header_mapper.ex`
|
||||
- [ ] Implement `normalize_header/1`
|
||||
- [ ] Normalize mapping variants once and compare normalized strings
|
||||
- [ ] Build `column_map` (canonical field -> column index)
|
||||
- [ ] **Early abort if required headers missing** (`email`)
|
||||
- [ ] Ignore unknown columns (member fields only)
|
||||
- [ ] **Separate custom field column detection** (by name, with normalization)
|
||||
|
||||
**Definition of Done:**
|
||||
- [ ] English/German headers map correctly
|
||||
- [ ] Missing required columns fails fast
|
||||
|
||||
---
|
||||
|
||||
### Issue #5: Validation (Required Fields) + Error Formatting
|
||||
|
||||
**Dependencies:** Issue #4
|
||||
|
||||
**Goal:** Validate each row and return structured, translatable errors.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Implement `validate_row/3 (row_map, csv_line_number, opts)`
|
||||
- [ ] Required field presence (`email`)
|
||||
- [ ] Email format validation (EctoCommons.EmailValidator)
|
||||
- [ ] Trim values before validation
|
||||
- [ ] Gettext-backed error messages
|
||||
|
||||
---
|
||||
|
||||
### Issue #6: Persistence via Ash Create + Per-Row Error Capture (Chunked Processing)
|
||||
|
||||
**Dependencies:** Issue #5
|
||||
|
||||
**Goal:** Create members and capture errors per row with correct CSV line numbers.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Implement `process_chunk/3` in service:
|
||||
- Input: `[{csv_line_number, row_map}]`
|
||||
- Validate + create sequentially
|
||||
- Collect counts + first 50 errors (per import overall; LiveView enforces cap across chunks)
|
||||
- [ ] Implement Ash error formatter helper:
|
||||
- Convert `Ash.Error.Invalid` into `%MemberCSV.Error{}`
|
||||
- Prefer field-level errors where possible (attach `field` atom)
|
||||
- Handle unique email constraint error as user-friendly message
|
||||
- [ ] Map row_map to Ash attrs (`%{first_name: ..., ...}`)
|
||||
|
||||
**Important:** **Do not recompute line numbers** in this layer—use the ones provided by the parser.
|
||||
|
||||
---
|
||||
|
||||
### Issue #7: Admin Global Settings LiveView UI (Upload + Start Import + Results + Template Links)
|
||||
|
||||
**Dependencies:** Issue #6
|
||||
|
||||
**Goal:** UI section with upload, progress, results, and template links.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Render import section only for admins
|
||||
- [ ] **Add prominent UI notice about custom fields:**
|
||||
- Display alert/info box: "Custom fields must be created in Mila before importing CSV files with custom field columns"
|
||||
- Explain: "Use the custom field name as the CSV column header (same normalization as member fields applies)"
|
||||
- Add link to custom fields management section
|
||||
- [ ] Configure `allow_upload/3`:
|
||||
- `.csv` only, `max_entries: 1`, `max_file_size: 10MB`, `auto_upload: false`
|
||||
- [ ] `handle_event("start_import", ...)`:
|
||||
- Admin permission check
|
||||
- Consume upload -> read file content
|
||||
- Call `MemberCSV.prepare/2`
|
||||
- Store `import_state` in assigns (chunks + column_map + metadata)
|
||||
- Initialize progress assigns
|
||||
- `send(self(), {:process_chunk, 0})`
|
||||
- [ ] `handle_info({:process_chunk, idx}, socket)`:
|
||||
- Fetch chunk from `import_state`
|
||||
- Call `MemberCSV.process_chunk/3`
|
||||
- Merge counts/errors into progress assigns (cap errors at 50 overall)
|
||||
- Schedule next chunk (or finish and show results)
|
||||
- [ ] Results UI:
|
||||
- Success count
|
||||
- Failure count
|
||||
- Error list (line number + message + field)
|
||||
- **Warning messages for unknown custom field columns** (non-existent names) shown in results
|
||||
|
||||
**Template links:**
|
||||
- Link `/templates/member_import_en.csv` and `/templates/member_import_de.csv` via Phoenix static path helpers.
|
||||
|
||||
---
|
||||
|
||||
### Issue #8: Authorization + Limits
|
||||
|
||||
**Dependencies:** None (can be parallelized)
|
||||
|
||||
**Goal:** Ensure admin-only access and enforce limits.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Admin check in start import event handler
|
||||
- [ ] File size enforced in upload config
|
||||
- [ ] Row limit enforced in `MemberCSV.prepare/2` (max_rows from config)
|
||||
- [ ] Configuration:
|
||||
```elixir
|
||||
config :mv, csv_import: [
|
||||
max_file_size_mb: 10,
|
||||
max_rows: 1000
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue #9: End-to-End LiveView Tests + Fixtures
|
||||
|
||||
**Dependencies:** Issue #7 and #8
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Fixtures:
|
||||
- valid EN/DE (core fields only)
|
||||
- valid with custom fields
|
||||
- invalid
|
||||
- unknown custom field name (non-existent, should show warning)
|
||||
- too many rows (1,001)
|
||||
- BOM + `;` delimiter fixture
|
||||
- fixture with empty line(s) to validate correct line numbers
|
||||
- [ ] LiveView tests:
|
||||
- admin sees section, non-admin does not
|
||||
- upload + start import
|
||||
- success + error rendering
|
||||
- row limit + file size errors
|
||||
- custom field import success
|
||||
- custom field import warning (non-existent name, column ignored)
|
||||
|
||||
---
|
||||
|
||||
### Issue #10: Documentation Polish (Inline Help Text + Docs)
|
||||
|
||||
**Dependencies:** Issue #9
|
||||
|
||||
**Tasks:**
|
||||
- [ ] UI help text + translations
|
||||
- [ ] CHANGELOG entry
|
||||
- [ ] Ensure moduledocs/docs
|
||||
|
||||
---
|
||||
|
||||
### Issue #11: Custom Field Import
|
||||
|
||||
**Dependencies:** Issue #6 (Persistence)
|
||||
|
||||
**Priority:** High (Core v1 Feature)
|
||||
|
||||
**Goal:** Support importing custom field values from CSV columns. Custom fields should exist in Mila before import for best results.
|
||||
|
||||
**Important Requirements:**
|
||||
- **Custom fields should be created in Mila first** - Unknown custom field columns will be ignored with a warning message
|
||||
- CSV headers for custom fields must match the custom field **name** exactly (same normalization as member fields applies)
|
||||
- Custom field values are validated according to the custom field type (string, integer, boolean, date, email)
|
||||
- Unknown custom field columns (non-existent names) will be ignored with a warning - import continues
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Extend `header_mapper.ex` to detect custom field columns by name (using same normalization as member fields)
|
||||
- [ ] Query existing custom fields during `prepare/2` to map custom field columns
|
||||
- [ ] Collect unknown custom field columns and add warning messages (don't fail import)
|
||||
- [ ] Map custom field CSV values to `CustomFieldValue` creation in `process_chunk/3`
|
||||
- [ ] Handle custom field type validation (string, integer, boolean, date, email)
|
||||
- [ ] Create `CustomFieldValue` records linked to members during import
|
||||
- [ ] Update error messages to include custom field validation errors
|
||||
- [ ] Add UI help text explaining custom field requirements:
|
||||
- "Custom fields must be created in Mila before importing"
|
||||
- "Use the custom field name as the CSV column header (same normalization as member fields)"
|
||||
- Link to custom fields management section
|
||||
- [ ] Update CSV templates documentation to explain custom field columns
|
||||
- [ ] Add tests for custom field import (valid, invalid name, type validation, warning for unknown)
|
||||
|
||||
**Definition of Done:**
|
||||
- [ ] Custom field columns are recognized by name (with normalization)
|
||||
- [ ] Warning messages shown for unknown custom field columns (import continues)
|
||||
- [ ] Custom field values are created and linked to members
|
||||
- [ ] Type validation works for all custom field types
|
||||
- [ ] UI clearly explains custom field requirements
|
||||
- [ ] Tests cover custom field import scenarios (including warning for unknown names)
|
||||
|
||||
---
|
||||
|
||||
## Rollout & Risks
|
||||
|
||||
### Rollout Strategy
|
||||
- Dev → Staging → Production (with anonymized real-world CSV tests)
|
||||
|
||||
### Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|---|---:|---:|---|
|
||||
| Large import timeout | High | Medium | 10 MB + 1,000 rows, chunking via `handle_info` |
|
||||
| Encoding issues | Medium | Medium | BOM stripping, templates with BOM |
|
||||
| Invalid CSV format | Medium | High | Clear errors + templates |
|
||||
| Duplicate emails | Low | High | Ash constraint error -> user-friendly message |
|
||||
| Performance (no background jobs) | Medium | Low | Small limits, sequential chunk processing |
|
||||
| Admin access bypass | High | Low | Event-level auth + UI hiding |
|
||||
| Data corruption | High | Low | Per-row validation + best-effort |
|
||||
|
||||
---
|
||||
|
||||
## Appendix
|
||||
|
||||
### Module File Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── mv/
|
||||
│ └── membership/
|
||||
│ └── import/
|
||||
│ ├── member_csv.ex # prepare + process_chunk
|
||||
│ ├── csv_parser.ex # delimiter detection + parsing + BOM handling
|
||||
│ └── header_mapper.ex # normalization + header mapping
|
||||
└── mv_web/
|
||||
└── live/
|
||||
└── global_settings_live.ex # add import section + LV message loop
|
||||
|
||||
priv/
|
||||
└── static/
|
||||
└── templates/
|
||||
├── member_import_en.csv
|
||||
└── member_import_de.csv
|
||||
|
||||
test/
|
||||
├── mv/
|
||||
│ └── membership/
|
||||
│ └── import/
|
||||
│ ├── member_csv_test.exs
|
||||
│ ├── csv_parser_test.exs
|
||||
│ └── header_mapper_test.exs
|
||||
└── fixtures/
|
||||
├── member_import_en.csv
|
||||
├── member_import_de.csv
|
||||
├── member_import_invalid.csv
|
||||
├── member_import_large.csv
|
||||
└── member_import_empty_lines.csv
|
||||
```
|
||||
|
||||
### Example Usage (LiveView)
|
||||
|
||||
```elixir
|
||||
def handle_event("start_import", _params, socket) do
|
||||
assert_admin!(socket.assigns.current_user)
|
||||
|
||||
[{_name, content}] =
|
||||
consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry ->
|
||||
{:ok, File.read!(path)}
|
||||
end)
|
||||
|
||||
case Mv.Membership.Import.MemberCSV.prepare(content) do
|
||||
{:ok, import_state} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_state, import_state)
|
||||
|> assign(:import_progress, %{processed: 0, inserted: 0, failed: 0, errors: []})
|
||||
|> assign(:importing?, true)
|
||||
|
||||
send(self(), {:process_chunk, 0})
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, reason)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({:process_chunk, idx}, socket) do
|
||||
%{chunks: chunks, column_map: column_map} = socket.assigns.import_state
|
||||
|
||||
case Enum.at(chunks, idx) do
|
||||
nil ->
|
||||
{:noreply, assign(socket, importing?: false)}
|
||||
|
||||
chunk_rows_with_lines ->
|
||||
{:ok, chunk_result} =
|
||||
Mv.Membership.Import.MemberCSV.process_chunk(chunk_rows_with_lines, column_map)
|
||||
|
||||
socket = merge_progress(socket, chunk_result) # caps errors at 50 overall
|
||||
|
||||
send(self(), {:process_chunk, idx + 1})
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**End of Implementation Plan**
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
# Performance Analysis: Custom Fields in Search Vector
|
||||
|
||||
## Current Implementation
|
||||
|
||||
The search vector includes custom field values via database triggers that:
|
||||
1. Aggregate all custom field values for a member
|
||||
2. Extract values from JSONB format
|
||||
3. Add them to the search_vector with weight 'C'
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### 1. Trigger Performance on Member Updates
|
||||
|
||||
**Current Implementation:**
|
||||
- `members_search_vector_trigger()` executes a subquery on every INSERT/UPDATE:
|
||||
```sql
|
||||
SELECT string_agg(...) FROM custom_field_values WHERE member_id = NEW.id
|
||||
```
|
||||
|
||||
**Performance Impact:**
|
||||
- ✅ **Good:** Index on `member_id` exists (`custom_field_values_member_id_idx`)
|
||||
- ✅ **Good:** Subquery only runs for the affected member
|
||||
- ⚠️ **Potential Issue:** With many custom fields per member (e.g., 50+), aggregation could be slower
|
||||
- ⚠️ **Potential Issue:** JSONB extraction (`value->>'_union_value'`) is relatively fast but adds overhead
|
||||
|
||||
**Expected Performance:**
|
||||
- **Small scale (< 10 custom fields per member):** Negligible impact (< 5ms per operation)
|
||||
- **Medium scale (10-30 custom fields):** Minor impact (5-20ms per operation)
|
||||
- **Large scale (30+ custom fields):** Noticeable impact (20-50ms+ per operation)
|
||||
|
||||
### 2. Trigger Performance on Custom Field Value Changes
|
||||
|
||||
**Current Implementation:**
|
||||
- `update_member_search_vector_from_custom_field_value()` executes on every INSERT/UPDATE/DELETE on `custom_field_values`
|
||||
- **Optimized:** Only fetches required member fields (not full record) to reduce overhead
|
||||
- **Optimized:** Skips re-aggregation on UPDATE if value hasn't actually changed
|
||||
- Aggregates all custom field values, then updates member search_vector
|
||||
|
||||
**Performance Impact:**
|
||||
- ✅ **Good:** Index on `member_id` ensures fast lookup
|
||||
- ✅ **Optimized:** Only required fields are fetched (first_name, last_name, email, etc.) instead of full record
|
||||
- ✅ **Optimized:** UPDATE operations that don't change the value skip expensive re-aggregation (early return)
|
||||
- ⚠️ **Note:** Re-aggregation is still necessary when values change (required for search_vector consistency)
|
||||
- ⚠️ **Critical:** Bulk operations (e.g., importing 1000 members with custom fields) will trigger this for each row
|
||||
|
||||
**Expected Performance:**
|
||||
- **Single operation (value changed):** 3-10ms per custom field value change (improved from 5-15ms)
|
||||
- **Single operation (value unchanged):** <1ms (early return, no aggregation)
|
||||
- **Bulk operations:** Could be slow (consider disabling trigger temporarily)
|
||||
|
||||
### 3. Search Vector Size
|
||||
|
||||
**Current Constraints:**
|
||||
- String values: max 10,000 characters per custom field
|
||||
- No limit on number of custom fields per member
|
||||
- tsvector has no explicit size limit, but very large vectors can cause issues
|
||||
|
||||
**Potential Issues:**
|
||||
- **Theoretical maximum:** If a member has 100 custom fields with 10,000 char strings each, the aggregated text could be ~1MB
|
||||
- **Practical concern:** Very large search vectors (> 100KB) can slow down:
|
||||
- Index updates (GIN index maintenance)
|
||||
- Search queries (tsvector operations)
|
||||
- Trigger execution time
|
||||
|
||||
**Recommendation:**
|
||||
- Monitor search_vector size in production
|
||||
- Consider limiting total custom field content per member if needed
|
||||
- PostgreSQL can handle large tsvectors, but performance degrades gradually
|
||||
|
||||
### 4. Initial Migration Performance
|
||||
|
||||
**Current Implementation:**
|
||||
- Updates ALL members in a single transaction:
|
||||
```sql
|
||||
UPDATE members m SET search_vector = ... (subquery for each member)
|
||||
```
|
||||
|
||||
**Performance Impact:**
|
||||
- ⚠️ **Potential Issue:** With 10,000+ members, this could take minutes
|
||||
- ⚠️ **Potential Issue:** Single transaction locks the members table
|
||||
- ⚠️ **Potential Issue:** If migration fails, entire rollback required
|
||||
|
||||
**Recommendation:**
|
||||
- For large datasets (> 10,000 members), consider:
|
||||
- Batch updates (e.g., 1000 members at a time)
|
||||
- Run during maintenance window
|
||||
- Monitor progress
|
||||
|
||||
### 5. Search Query Performance
|
||||
|
||||
**Current Implementation:**
|
||||
- Full-text search uses GIN index on `search_vector` (fast)
|
||||
- Additional LIKE queries on `custom_field_values` for substring matching:
|
||||
```sql
|
||||
EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = id AND ... LIKE ...)
|
||||
```
|
||||
|
||||
**Performance Impact:**
|
||||
- ✅ **Good:** GIN index on `search_vector` is very fast
|
||||
- ⚠️ **Potential Issue:** LIKE queries on JSONB are not indexed (sequential scan)
|
||||
- ⚠️ **Potential Issue:** EXISTS subquery runs for every search, even if search_vector match is found
|
||||
- ⚠️ **Potential Issue:** With many custom fields, the LIKE queries could be slow
|
||||
|
||||
**Expected Performance:**
|
||||
- **With GIN index match:** Very fast (< 10ms for typical queries)
|
||||
- **Without GIN index match (fallback to LIKE):** Slower (10-100ms depending on data size)
|
||||
- **Worst case:** Sequential scan of all custom_field_values for all members
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Short-term (Current Implementation)
|
||||
|
||||
1. **Monitor Performance:**
|
||||
- Add logging for trigger execution time
|
||||
- Monitor search_vector size distribution
|
||||
- Track search query performance
|
||||
|
||||
2. **Index Verification:**
|
||||
- Ensure `custom_field_values_member_id_idx` exists and is used
|
||||
- Verify GIN index on `search_vector` is maintained
|
||||
|
||||
3. **Bulk Operations:**
|
||||
- For bulk imports, consider temporarily disabling the custom_field_values trigger
|
||||
- Re-enable and update search_vectors in batch after import
|
||||
|
||||
### Medium-term Optimizations
|
||||
|
||||
1. **✅ Optimize Trigger Function (FULLY IMPLEMENTED):**
|
||||
- ✅ Only fetch required member fields instead of full record (reduces overhead)
|
||||
- ✅ Skip re-aggregation on UPDATE if value hasn't actually changed (early return optimization)
|
||||
|
||||
2. **Limit Search Vector Size:**
|
||||
- Truncate very long custom field values (e.g., first 1000 chars)
|
||||
- Add warning if aggregated text exceeds threshold
|
||||
|
||||
3. **Optimize LIKE Queries:**
|
||||
- Consider adding a generated column for searchable text
|
||||
- Or use a materialized view for custom field search
|
||||
|
||||
### Long-term Considerations
|
||||
|
||||
1. **Alternative Approaches:**
|
||||
- Separate search index table for custom fields
|
||||
- Use Elasticsearch or similar for advanced search
|
||||
- Materialized view for search optimization
|
||||
|
||||
2. **Scaling Strategy:**
|
||||
- If performance becomes an issue with 100+ custom fields per member:
|
||||
- Consider limiting which custom fields are searchable
|
||||
- Use a separate search service
|
||||
- Implement search result caching
|
||||
|
||||
## Performance Benchmarks (Estimated)
|
||||
|
||||
Based on typical PostgreSQL performance:
|
||||
|
||||
| Scenario | Members | Custom Fields/Member | Expected Impact |
|
||||
|----------|---------|---------------------|-----------------|
|
||||
| Small | < 1,000 | < 10 | Negligible (< 5ms per operation) |
|
||||
| Medium | 1,000-10,000 | 10-30 | Minor (5-20ms per operation) |
|
||||
| Large | 10,000-100,000 | 30-50 | Noticeable (20-50ms per operation) |
|
||||
| Very Large | > 100,000 | 50+ | Significant (50-200ms+ per operation) |
|
||||
|
||||
## Monitoring Queries
|
||||
|
||||
```sql
|
||||
-- Check search_vector size distribution
|
||||
SELECT
|
||||
pg_size_pretty(octet_length(search_vector::text)) as size,
|
||||
COUNT(*) as member_count
|
||||
FROM members
|
||||
WHERE search_vector IS NOT NULL
|
||||
GROUP BY octet_length(search_vector::text)
|
||||
ORDER BY octet_length(search_vector::text) DESC
|
||||
LIMIT 20;
|
||||
|
||||
-- Check average custom fields per member
|
||||
SELECT
|
||||
AVG(custom_field_count) as avg_custom_fields,
|
||||
MAX(custom_field_count) as max_custom_fields
|
||||
FROM (
|
||||
SELECT member_id, COUNT(*) as custom_field_count
|
||||
FROM custom_field_values
|
||||
GROUP BY member_id
|
||||
) subq;
|
||||
|
||||
-- Check trigger execution time (requires pg_stat_statements)
|
||||
SELECT
|
||||
mean_exec_time,
|
||||
calls,
|
||||
query
|
||||
FROM pg_stat_statements
|
||||
WHERE query LIKE '%members_search_vector_trigger%'
|
||||
ORDER BY mean_exec_time DESC;
|
||||
```
|
||||
|
||||
## Code Quality Improvements (Post-Review)
|
||||
|
||||
### Refactored Search Implementation
|
||||
|
||||
The search query has been refactored for better maintainability and clarity:
|
||||
|
||||
**Before:** Single large OR-chain with mixed search types (hard to maintain)
|
||||
|
||||
**After:** Modular functions grouped by search type:
|
||||
- `build_fts_filter/1` - Full-text search (highest priority, fastest)
|
||||
- `build_substring_filter/2` - Substring matching on structured fields
|
||||
- `build_custom_field_filter/1` - Custom field value search (JSONB LIKE)
|
||||
- `build_fuzzy_filter/2` - Trigram/fuzzy matching for names and streets
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Clear separation of concerns
|
||||
- ✅ Easier to maintain and test
|
||||
- ✅ Better documentation of search priority
|
||||
- ✅ Easier to optimize individual search types
|
||||
|
||||
**Search Priority Order:**
|
||||
1. **FTS (Full-Text Search)** - Fastest, uses GIN index on search_vector
|
||||
2. **Substring** - For structured fields (postal_code, phone_number, etc.)
|
||||
3. **Custom Fields** - JSONB LIKE queries (fallback for substring matching)
|
||||
4. **Fuzzy Matching** - Trigram similarity for names and streets
|
||||
|
||||
## Conclusion
|
||||
|
||||
The current implementation is **well-optimized for typical use cases** (< 30 custom fields per member, < 10,000 members). For larger scales, monitoring and potential optimizations may be needed.
|
||||
|
||||
**Key Strengths:**
|
||||
- Indexed lookups (member_id index)
|
||||
- Efficient GIN index for search
|
||||
- Trigger-based automatic updates
|
||||
- Modular, maintainable search code structure
|
||||
|
||||
**Key Weaknesses:**
|
||||
- LIKE queries on JSONB (not indexed)
|
||||
- Re-aggregation on every custom field change (necessary for consistency)
|
||||
- Potential size issues with many/large custom fields
|
||||
- Substring searches (contains/ILIKE) not index-optimized
|
||||
|
||||
**Recent Optimizations:**
|
||||
- ✅ Trigger function optimized to fetch only required fields (reduces overhead by ~30-50%)
|
||||
- ✅ Early return on UPDATE when value hasn't changed (skips expensive re-aggregation, <1ms vs 3-10ms)
|
||||
- ✅ Improved performance for custom field value updates (3-10ms vs 5-15ms when value changes)
|
||||
|
||||
|
|
@ -1,533 +0,0 @@
|
|||
# DaisyUI Drawer Pattern - Standard Implementation
|
||||
|
||||
This document describes the standard DaisyUI drawer pattern for implementing responsive sidebars. It covers mobile overlay drawers, desktop persistent sidebars, and their combination.
|
||||
|
||||
## Core Concept
|
||||
|
||||
DaisyUI's drawer component uses a **checkbox-based toggle mechanism** combined with CSS to create accessible, responsive sidebars without custom JavaScript.
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **`drawer`** - Container element
|
||||
2. **`drawer-toggle`** - Hidden checkbox that controls open/close state
|
||||
3. **`drawer-content`** - Main content area
|
||||
4. **`drawer-side`** - Sidebar content (menu, navigation)
|
||||
5. **`drawer-overlay`** - Optional overlay for mobile (closes drawer on click)
|
||||
|
||||
## HTML Structure
|
||||
|
||||
```html
|
||||
<div class="drawer">
|
||||
<!-- Hidden checkbox controls the drawer state -->
|
||||
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="drawer-content">
|
||||
<!-- Page content goes here -->
|
||||
<label for="my-drawer" class="btn btn-primary">Open drawer</label>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar content -->
|
||||
<div class="drawer-side">
|
||||
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<ul class="menu p-4 w-80 min-h-full bg-base-200 text-base-content">
|
||||
<!-- Sidebar content goes here -->
|
||||
<li><a>Sidebar Item 1</a></li>
|
||||
<li><a>Sidebar Item 2</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## How drawer-toggle Works
|
||||
|
||||
### Mechanism
|
||||
|
||||
The `drawer-toggle` is a **hidden checkbox** that serves as the state controller:
|
||||
|
||||
```html
|
||||
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
|
||||
```
|
||||
|
||||
### Toggle Behavior
|
||||
|
||||
1. **Label Connection**: Any `<label for="my-drawer">` element can toggle the drawer
|
||||
2. **Checkbox State**:
|
||||
- `checked` → drawer is open
|
||||
- `unchecked` → drawer is closed
|
||||
3. **CSS Targeting**: DaisyUI uses CSS sibling selectors to show/hide the drawer based on checkbox state
|
||||
4. **Accessibility**: Native checkbox provides keyboard accessibility (Space/Enter to toggle)
|
||||
|
||||
### Toggle Examples
|
||||
|
||||
```html
|
||||
<!-- Button to open drawer -->
|
||||
<label for="my-drawer" class="btn btn-primary drawer-button">
|
||||
Open Menu
|
||||
</label>
|
||||
|
||||
<!-- Close button inside drawer -->
|
||||
<label for="my-drawer" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</label>
|
||||
|
||||
<!-- Overlay to close (click outside) -->
|
||||
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
```
|
||||
|
||||
## Mobile Drawer (Overlay)
|
||||
|
||||
### Characteristics
|
||||
|
||||
- Drawer slides in from the side (usually left)
|
||||
- Overlays the main content
|
||||
- Dark overlay (drawer-overlay) behind drawer
|
||||
- Clicking overlay closes the drawer
|
||||
- Typically used on mobile/tablet screens
|
||||
|
||||
### Implementation
|
||||
|
||||
```html
|
||||
<div class="drawer">
|
||||
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<div class="drawer-content">
|
||||
<!-- Toggle button in header -->
|
||||
<div class="navbar bg-base-100">
|
||||
<div class="flex-none">
|
||||
<label for="mobile-drawer" class="btn btn-square btn-ghost">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<a class="btn btn-ghost text-xl">My App</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="p-4">
|
||||
<h1>Main Content</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-side">
|
||||
<!-- Overlay - clicking it closes the drawer -->
|
||||
<label for="mobile-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
|
||||
<!-- Sidebar menu -->
|
||||
<ul class="menu p-4 w-80 min-h-full bg-base-200">
|
||||
<li><a>Home</a></li>
|
||||
<li><a>About</a></li>
|
||||
<li><a>Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Styling Notes
|
||||
|
||||
- **Width**: Default `w-80` (320px), adjust with Tailwind width utilities
|
||||
- **Background**: Use DaisyUI color classes like `bg-base-200`
|
||||
- **Height**: Always use `min-h-full` to ensure full height
|
||||
- **Padding**: Add `p-4` or similar for inner spacing
|
||||
|
||||
## Desktop Sidebar (Persistent)
|
||||
|
||||
### Characteristics
|
||||
|
||||
- Always visible (no overlay)
|
||||
- Does not overlay main content
|
||||
- Main content adjusts to sidebar width
|
||||
- No toggle button needed
|
||||
- Used on desktop screens
|
||||
|
||||
### Implementation with drawer-open
|
||||
|
||||
```html
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input id="desktop-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<div class="drawer-content">
|
||||
<!-- Main content -->
|
||||
<div class="p-4">
|
||||
<h1>Main Content</h1>
|
||||
<p>The sidebar is always visible on desktop (lg and above)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-side">
|
||||
<!-- No overlay needed for persistent sidebar -->
|
||||
<label for="desktop-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
|
||||
<!-- Sidebar menu -->
|
||||
<ul class="menu p-4 w-80 min-h-full bg-base-200">
|
||||
<li><a>Dashboard</a></li>
|
||||
<li><a>Settings</a></li>
|
||||
<li><a>Profile</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### How drawer-open Works
|
||||
|
||||
The `drawer-open` class forces the drawer to be **permanently open**:
|
||||
|
||||
```html
|
||||
<div class="drawer drawer-open">
|
||||
```
|
||||
|
||||
- Drawer is always visible
|
||||
- Cannot be toggled closed
|
||||
- `drawer-toggle` checkbox is ignored
|
||||
- `drawer-overlay` is not shown
|
||||
- Main content automatically shifts to accommodate sidebar width
|
||||
|
||||
### Responsive Usage
|
||||
|
||||
Use Tailwind breakpoint modifiers for responsive behavior:
|
||||
|
||||
```html
|
||||
<!-- Open on large screens and above -->
|
||||
<div class="drawer lg:drawer-open">
|
||||
|
||||
<!-- Open on medium screens and above -->
|
||||
<div class="drawer md:drawer-open">
|
||||
|
||||
<!-- Open on extra-large screens and above -->
|
||||
<div class="drawer xl:drawer-open">
|
||||
```
|
||||
|
||||
## Combined Mobile + Desktop Pattern (Recommended)
|
||||
|
||||
This is the **most common pattern** for responsive applications: mobile overlay + desktop persistent.
|
||||
|
||||
### Complete Implementation
|
||||
|
||||
```html
|
||||
<div class="drawer lg:drawer-open">
|
||||
<!-- Checkbox for mobile toggle -->
|
||||
<input id="app-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<div class="drawer-content flex flex-col">
|
||||
<!-- Navbar with mobile menu button -->
|
||||
<div class="navbar bg-base-100 lg:hidden">
|
||||
<div class="flex-none">
|
||||
<label for="app-drawer" class="btn btn-square btn-ghost">
|
||||
<!-- Hamburger icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<a class="btn btn-ghost text-xl">My App</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex-1 p-6">
|
||||
<h1 class="text-3xl font-bold mb-4">Welcome</h1>
|
||||
<p>This is the main content area.</p>
|
||||
<p>On mobile (< lg): sidebar is hidden, hamburger menu visible</p>
|
||||
<p>On desktop (≥ lg): sidebar is persistent, hamburger menu hidden</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-side">
|
||||
<!-- Overlay only shows on mobile -->
|
||||
<label for="app-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
|
||||
<!-- Sidebar navigation -->
|
||||
<aside class="bg-base-200 w-80 min-h-full">
|
||||
<!-- Logo/Header area -->
|
||||
<div class="p-4 font-bold text-xl border-b border-base-300">
|
||||
My App Logo
|
||||
</div>
|
||||
|
||||
<!-- Navigation menu -->
|
||||
<ul class="menu p-4">
|
||||
<li><a>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</a></li>
|
||||
<li><a>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Documents
|
||||
</a></li>
|
||||
<li><a>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Settings
|
||||
</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Behavior Breakdown
|
||||
|
||||
#### On Mobile (< 1024px / < lg)
|
||||
1. Sidebar is hidden by default
|
||||
2. Hamburger button visible in navbar
|
||||
3. Clicking hamburger opens sidebar as overlay
|
||||
4. Clicking overlay or close button closes sidebar
|
||||
5. Sidebar slides in from left with animation
|
||||
|
||||
#### On Desktop (≥ 1024px / ≥ lg)
|
||||
1. `lg:drawer-open` keeps sidebar permanently visible
|
||||
2. Hamburger button hidden via `lg:hidden`
|
||||
3. Sidebar takes up fixed width (320px)
|
||||
4. Main content area adjusts automatically
|
||||
5. No overlay, no toggle needed
|
||||
|
||||
## Tailwind Breakpoints Reference
|
||||
|
||||
```css
|
||||
/* Default (mobile-first) */
|
||||
/* < 640px */
|
||||
|
||||
sm: /* ≥ 640px */
|
||||
md: /* ≥ 768px */
|
||||
lg: /* ≥ 1024px */ ← Common desktop breakpoint
|
||||
xl: /* ≥ 1280px */
|
||||
2xl: /* ≥ 1536px */
|
||||
```
|
||||
|
||||
## Key Classes Summary
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `drawer` | Main container |
|
||||
| `drawer-toggle` | Hidden checkbox for state control |
|
||||
| `drawer-content` | Main content area |
|
||||
| `drawer-side` | Sidebar container |
|
||||
| `drawer-overlay` | Clickable overlay (closes drawer) |
|
||||
| `drawer-open` | Forces drawer to stay open |
|
||||
| `drawer-end` | Positions drawer on the right side |
|
||||
| `lg:drawer-open` | Opens drawer on large screens only |
|
||||
|
||||
## Positioning Variants
|
||||
|
||||
### Left Side Drawer (Default)
|
||||
|
||||
```html
|
||||
<div class="drawer">
|
||||
<!-- Drawer appears on the left -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Right Side Drawer
|
||||
|
||||
```html
|
||||
<div class="drawer drawer-end">
|
||||
<!-- Drawer appears on the right -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Accessibility
|
||||
- Always include `aria-label` on overlay: `<label for="drawer" aria-label="close sidebar" class="drawer-overlay"></label>`
|
||||
- Use semantic HTML (`<nav>`, `<aside>`)
|
||||
- Ensure keyboard navigation works (native checkbox provides this)
|
||||
|
||||
### 2. Responsive Design
|
||||
- Use `lg:drawer-open` for desktop persistence
|
||||
- Hide mobile toggle button on desktop: `lg:hidden`
|
||||
- Adjust sidebar width for mobile if needed: `w-64 md:w-80`
|
||||
|
||||
### 3. Performance
|
||||
- DaisyUI drawer is pure CSS (no JavaScript needed)
|
||||
- Animations are handled by CSS transitions
|
||||
- No performance overhead
|
||||
|
||||
### 4. Styling
|
||||
- Use DaisyUI theme colors: `bg-base-200`, `text-base-content`
|
||||
- Maintain consistent spacing: `p-4`, `gap-2`
|
||||
- Use DaisyUI menu component for navigation: `<ul class="menu">`
|
||||
|
||||
### 5. Content Structure
|
||||
```html
|
||||
<div class="drawer-content flex flex-col">
|
||||
<!-- Navbar (if needed) -->
|
||||
<div class="navbar">...</div>
|
||||
|
||||
<!-- Main content with flex-1 to fill space -->
|
||||
<div class="flex-1 p-6">
|
||||
<!-- Your content -->
|
||||
</div>
|
||||
|
||||
<!-- Footer (if needed) -->
|
||||
<footer>...</footer>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Drawer with Close Button
|
||||
|
||||
```html
|
||||
<div class="drawer-side">
|
||||
<label for="drawer" class="drawer-overlay"></label>
|
||||
<aside class="bg-base-200 w-80 min-h-full relative">
|
||||
<!-- Close button (mobile only) -->
|
||||
<label for="drawer" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 lg:hidden">✕</label>
|
||||
|
||||
<!-- Sidebar content -->
|
||||
<ul class="menu p-4 pt-12">
|
||||
<li><a>Item 1</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Pattern 2: Drawer with User Profile
|
||||
|
||||
```html
|
||||
<aside class="bg-base-200 w-80 min-h-full flex flex-col">
|
||||
<!-- Logo -->
|
||||
<div class="p-4 font-bold text-xl">My App</div>
|
||||
|
||||
<!-- Navigation (flex-1 to push footer down) -->
|
||||
<ul class="menu flex-1 p-4">
|
||||
<li><a>Dashboard</a></li>
|
||||
<li><a>Settings</a></li>
|
||||
</ul>
|
||||
|
||||
<!-- User profile footer -->
|
||||
<div class="p-4 border-t border-base-300">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="avatar">
|
||||
<div class="w-10 rounded-full">
|
||||
<img src="/avatar.jpg" alt="User" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">John Doe</div>
|
||||
<div class="text-sm opacity-70">john@example.com</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
```
|
||||
|
||||
### Pattern 3: Nested Menu with Submenu
|
||||
|
||||
```html
|
||||
<ul class="menu p-4 w-80 min-h-full bg-base-200">
|
||||
<li><a>Dashboard</a></li>
|
||||
|
||||
<!-- Submenu -->
|
||||
<li>
|
||||
<details>
|
||||
<summary>Products</summary>
|
||||
<ul>
|
||||
<li><a>Electronics</a></li>
|
||||
<li><a>Clothing</a></li>
|
||||
<li><a>Books</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<li><a>Settings</a></li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Drawer doesn't open on mobile
|
||||
**Solution**: Check that:
|
||||
1. Checkbox `id` matches label `for` attribute
|
||||
2. Checkbox has class `drawer-toggle`
|
||||
3. You're not using `drawer-open` on mobile breakpoints
|
||||
|
||||
### Issue: Drawer overlaps content on desktop
|
||||
**Solution**:
|
||||
- Remove `drawer-open` or use responsive variant `lg:drawer-open`
|
||||
- Ensure you want overlay behavior, not persistent sidebar
|
||||
|
||||
### Issue: Overlay not clickable
|
||||
**Solution**:
|
||||
- Ensure overlay label has correct `for` attribute
|
||||
- Check that overlay is not behind other elements (z-index)
|
||||
|
||||
### Issue: Content jumps when drawer opens
|
||||
**Solution**:
|
||||
- Add `flex flex-col` to `drawer-content`
|
||||
- Ensure drawer-side width is fixed (e.g., `w-80`)
|
||||
|
||||
## Migration from Custom Solutions
|
||||
|
||||
If migrating from a custom sidebar implementation:
|
||||
|
||||
### Replace custom JavaScript
|
||||
❌ Before:
|
||||
```javascript
|
||||
function toggleDrawer() {
|
||||
document.getElementById('sidebar').classList.toggle('open');
|
||||
}
|
||||
```
|
||||
|
||||
✅ After:
|
||||
```html
|
||||
<input id="drawer" type="checkbox" class="drawer-toggle" />
|
||||
<label for="drawer">Toggle</label>
|
||||
```
|
||||
|
||||
### Replace custom CSS
|
||||
❌ Before:
|
||||
```css
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
```
|
||||
|
||||
✅ After:
|
||||
```html
|
||||
<div class="drawer">
|
||||
<!-- DaisyUI handles all transitions -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Replace media query logic
|
||||
❌ Before:
|
||||
```css
|
||||
@media (min-width: 1024px) {
|
||||
.sidebar { display: block; }
|
||||
.toggle-button { display: none; }
|
||||
}
|
||||
```
|
||||
|
||||
✅ After:
|
||||
```html
|
||||
<div class="drawer lg:drawer-open">
|
||||
<label for="drawer" class="lg:hidden">Toggle</label>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
The DaisyUI drawer pattern provides:
|
||||
|
||||
✅ **Zero JavaScript** - Pure CSS solution
|
||||
✅ **Accessible** - Built-in keyboard support via checkbox
|
||||
✅ **Responsive** - Easy mobile/desktop variants with Tailwind
|
||||
✅ **Themeable** - Uses DaisyUI theme colors
|
||||
✅ **Flexible** - Supports left/right positioning
|
||||
✅ **Standard** - No custom CSS needed
|
||||
|
||||
**Recommended approach**: Use `lg:drawer-open` for desktop with hidden mobile toggle for best responsive experience.
|
||||
|
||||
|
||||
|
|
@ -1,470 +0,0 @@
|
|||
# Database Schema Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a comprehensive overview of the Mila Membership Management System database schema.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- **DBML File:** [`database_schema.dbml`](./database_schema.dbml)
|
||||
- **Visualize Online:**
|
||||
- [dbdiagram.io](https://dbdiagram.io) - Upload the DBML file
|
||||
- [dbdocs.io](https://dbdocs.io) - Generate interactive documentation
|
||||
|
||||
## Schema Statistics
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| **Tables** | 5 |
|
||||
| **Domains** | 2 (Accounts, Membership) |
|
||||
| **Relationships** | 3 |
|
||||
| **Indexes** | 15+ |
|
||||
| **Triggers** | 1 (Full-text search) |
|
||||
|
||||
## Tables Overview
|
||||
|
||||
### Accounts Domain
|
||||
|
||||
#### `users`
|
||||
- **Purpose:** User authentication and session management
|
||||
- **Rows (Estimated):** Low to Medium (typically 10-50% of members)
|
||||
- **Key Features:**
|
||||
- Dual authentication (Password + OIDC)
|
||||
- Optional 1:1 link to members
|
||||
- Email as source of truth when linked
|
||||
|
||||
#### `tokens`
|
||||
- **Purpose:** JWT token storage for AshAuthentication
|
||||
- **Rows (Estimated):** Medium to High (multiple tokens per user)
|
||||
- **Key Features:**
|
||||
- Token lifecycle management
|
||||
- Revocation support
|
||||
- Multiple token purposes
|
||||
|
||||
### Membership Domain
|
||||
|
||||
#### `members`
|
||||
- **Purpose:** Club member master data
|
||||
- **Rows (Estimated):** High (core entity)
|
||||
- **Key Features:**
|
||||
- Complete member profile
|
||||
- Full-text search via tsvector
|
||||
- Bidirectional email sync with users
|
||||
- Flexible address and contact data
|
||||
|
||||
#### `custom_field_values`
|
||||
- **Purpose:** Dynamic custom member attributes
|
||||
- **Rows (Estimated):** Variable (N per member)
|
||||
- **Key Features:**
|
||||
- Union type value storage (JSONB)
|
||||
- Multiple data types supported
|
||||
- One custom field value per custom field per member
|
||||
|
||||
#### `custom_fields`
|
||||
- **Purpose:** Schema definitions for custom_field_values
|
||||
- **Rows (Estimated):** Low (admin-defined)
|
||||
- **Key Features:**
|
||||
- Type definitions
|
||||
- Immutable and required flags
|
||||
- Centralized custom field management
|
||||
|
||||
## Key Relationships
|
||||
|
||||
```
|
||||
User (0..1) ←→ (0..1) Member
|
||||
↓
|
||||
Tokens (N)
|
||||
|
||||
Member (1) → (N) Properties
|
||||
↓
|
||||
CustomField (1)
|
||||
```
|
||||
|
||||
### Relationship Details
|
||||
|
||||
1. **User ↔ Member (Optional 1:1, both sides optional)**
|
||||
- A User can have 0 or 1 Member (`user.member_id` can be NULL)
|
||||
- A Member can have 0 or 1 User (optional `has_one` relationship)
|
||||
- Both entities can exist independently
|
||||
- Email synchronization when linked (User.email is source of truth)
|
||||
- `ON DELETE SET NULL` on user side (User preserved when Member deleted)
|
||||
|
||||
2. **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)
|
||||
|
||||
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)
|
||||
- After linking: Changes sync bidirectionally
|
||||
- Validation prevents email conflicts
|
||||
|
||||
### Authentication Strategies
|
||||
- **Password:** Email + hashed_password
|
||||
- **OIDC:** Email + oidc_id (Rauthy provider)
|
||||
- At least one method required per user
|
||||
|
||||
### Member Constraints
|
||||
- First name and last name required (min 1 char)
|
||||
- Email unique, validated format (5-254 chars)
|
||||
- Join date cannot be in future
|
||||
- Exit date must be after join date
|
||||
- Phone: `+?[0-9\- ]{6,20}`
|
||||
- Postal code: 5 digits
|
||||
|
||||
### CustomFieldValue System
|
||||
- Maximum one custom field value per custom field per member
|
||||
- Value stored as union type in JSONB
|
||||
- Supported types: string, integer, boolean, date, email
|
||||
- Types can be marked as immutable or required
|
||||
|
||||
## Indexes
|
||||
|
||||
### Performance Indexes
|
||||
|
||||
**members:**
|
||||
- `search_vector` (GIN) - Full-text search (tsvector)
|
||||
- `first_name` (GIN trgm) - Fuzzy search on first name
|
||||
- `last_name` (GIN trgm) - Fuzzy search on last name
|
||||
- `email` (GIN trgm) - Fuzzy search on email
|
||||
- `city` (GIN trgm) - Fuzzy search on city
|
||||
- `street` (GIN trgm) - Fuzzy search on street
|
||||
- `notes` (GIN trgm) - Fuzzy search on notes
|
||||
- `email` (B-tree) - Exact email lookups
|
||||
- `last_name` (B-tree) - Name sorting
|
||||
- `join_date` (B-tree) - Date filtering
|
||||
- `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:** `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
|
||||
- **Weight C:** phone_number, city, street, house_number, postal_code, custom_field_values
|
||||
- **Weight D (lowest):** join_date, exit_date
|
||||
|
||||
### 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 phone_number, city, etc.)
|
||||
- The search vector is automatically updated when custom field values are created, updated, or deleted via database triggers
|
||||
|
||||
### Usage Example
|
||||
```sql
|
||||
SELECT * FROM members
|
||||
WHERE search_vector @@ to_tsquery('simple', 'john & doe');
|
||||
```
|
||||
|
||||
## Fuzzy Search (Trigram-based)
|
||||
|
||||
### Implementation
|
||||
- **Extension:** `pg_trgm` (PostgreSQL Trigram)
|
||||
- **Index Type:** GIN with `gin_trgm_ops` operator class
|
||||
- **Similarity Threshold:** 0.2 (default, configurable)
|
||||
- **Added:** November 2025 (PR #187, closes #162)
|
||||
|
||||
### How It Works
|
||||
Fuzzy search combines multiple search strategies:
|
||||
1. **Full-text search** - Primary filter using tsvector
|
||||
2. **Trigram similarity** - `similarity(field, query) > threshold`
|
||||
3. **Word similarity** - `word_similarity(query, field) > threshold`
|
||||
4. **Substring matching** - `LIKE` and `ILIKE` for exact substrings
|
||||
5. **Modulo operator** - `query % field` for quick similarity check
|
||||
|
||||
### Indexed Fields for Fuzzy Search
|
||||
- `first_name` - GIN trigram index
|
||||
- `last_name` - GIN trigram index
|
||||
- `email` - GIN trigram index
|
||||
- `city` - GIN trigram index
|
||||
- `street` - GIN trigram index
|
||||
- `notes` - GIN trigram index
|
||||
|
||||
### Usage Example (Ash Action)
|
||||
```elixir
|
||||
# In LiveView or context
|
||||
Member.fuzzy_search(Member, query: "john", similarity_threshold: 0.2)
|
||||
|
||||
# Or using Ash Query directly
|
||||
Member
|
||||
|> Ash.Query.for_read(:search, %{query: "john", similarity_threshold: 0.2})
|
||||
|> Mv.Membership.read!()
|
||||
```
|
||||
|
||||
### Usage Example (SQL)
|
||||
```sql
|
||||
-- Trigram similarity search
|
||||
SELECT * FROM members
|
||||
WHERE similarity(first_name, 'john') > 0.2
|
||||
OR similarity(last_name, 'doe') > 0.2
|
||||
ORDER BY similarity(first_name, 'john') DESC;
|
||||
|
||||
-- Word similarity (better for partial matches)
|
||||
SELECT * FROM members
|
||||
WHERE word_similarity('john', first_name) > 0.2;
|
||||
|
||||
-- Quick similarity check with % operator
|
||||
SELECT * FROM members
|
||||
WHERE 'john' % first_name;
|
||||
```
|
||||
|
||||
### Performance Considerations
|
||||
- **GIN indexes** speed up trigram operations significantly
|
||||
- **Similarity threshold** of 0.2 balances precision and recall
|
||||
- **Combined approach** (FTS + trigram) provides best results
|
||||
- Lower threshold = more results but less specific
|
||||
|
||||
## Database Extensions
|
||||
|
||||
### Required PostgreSQL Extensions
|
||||
|
||||
1. **uuid-ossp**
|
||||
- Purpose: UUID generation functions
|
||||
- Used for: `gen_random_uuid()`, `uuid_generate_v7()`
|
||||
|
||||
2. **citext**
|
||||
- Purpose: Case-insensitive text type
|
||||
- Used for: `users.email` (case-insensitive email matching)
|
||||
|
||||
3. **pg_trgm**
|
||||
- Purpose: Trigram-based fuzzy text search and similarity matching
|
||||
- Used for: Fuzzy member search with similarity scoring
|
||||
- Operators: `%` (similarity), `word_similarity()`, `similarity()`
|
||||
- Added in: Migration `20251001141005_add_trigram_to_members.exs`
|
||||
|
||||
### Installation
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "citext";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Ash Migrations
|
||||
This project uses Ash Framework's migration system:
|
||||
|
||||
```bash
|
||||
# Generate new migration
|
||||
mix ash.codegen --name add_new_feature
|
||||
|
||||
# Apply migrations
|
||||
mix ash.setup
|
||||
|
||||
# Rollback migrations
|
||||
mix ash_postgres.rollback -n 1
|
||||
```
|
||||
|
||||
### Migration Files Location
|
||||
```
|
||||
priv/repo/migrations/
|
||||
├── 20250421101957_initialize_extensions_1.exs
|
||||
├── 20250528163901_initial_migration.exs
|
||||
├── 20250617090641_member_fields.exs
|
||||
├── 20250620110850_add_accounts_domain.exs
|
||||
├── 20250912085235_AddSearchVectorToMembers.exs
|
||||
├── 20250926180341_add_unique_email_to_members.exs
|
||||
├── 20251001141005_add_trigram_to_members.exs
|
||||
└── 20251016130855_add_constraints_for_user_member_and_property.exs
|
||||
```
|
||||
|
||||
## Data Integrity
|
||||
|
||||
### Foreign Key Behaviors
|
||||
|
||||
| Relationship | On Delete | Rationale |
|
||||
|--------------|-----------|-----------|
|
||||
| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted |
|
||||
| `custom_field_values.member_id → members.id` | CASCADE | Delete custom_field_values with member |
|
||||
| `custom_field_values.custom_field_id → custom_fields.id` | RESTRICT | Prevent deletion of types in use |
|
||||
|
||||
### Validation Layers
|
||||
|
||||
1. **Database Level:**
|
||||
- CHECK constraints
|
||||
- NOT NULL constraints
|
||||
- UNIQUE indexes
|
||||
- Foreign key constraints
|
||||
|
||||
2. **Application Level (Ash):**
|
||||
- Custom validators
|
||||
- Email format validation (EctoCommons.EmailValidator)
|
||||
- Business rule validation
|
||||
- Cross-entity validation
|
||||
|
||||
3. **UI Level:**
|
||||
- Client-side form validation
|
||||
- Real-time feedback
|
||||
- Error messages
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Query Patterns
|
||||
|
||||
**High Frequency:**
|
||||
- Member search (uses GIN index on search_vector)
|
||||
- Member list with filters (uses indexes on join_date, 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, 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:** 2025-11-13
|
||||
**Schema Version:** 1.1
|
||||
**Database:** PostgreSQL 17.6 (dev) / 16 (prod)
|
||||
|
||||
|
|
@ -1,502 +0,0 @@
|
|||
// Mila - Membership Management System
|
||||
// Database Schema Documentation
|
||||
//
|
||||
// This file can be used with:
|
||||
// - https://dbdiagram.io
|
||||
// - https://dbdocs.io
|
||||
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
|
||||
//
|
||||
// Version: 1.3
|
||||
// Last Updated: 2025-12-11
|
||||
|
||||
Project mila_membership_management {
|
||||
database_type: 'PostgreSQL'
|
||||
Note: '''
|
||||
# Mila Membership Management System
|
||||
|
||||
A membership management application for small to mid-sized clubs.
|
||||
|
||||
## Key Features:
|
||||
- User authentication (OIDC + Password with secure account linking)
|
||||
- Member management with flexible custom fields
|
||||
- Bidirectional email synchronization between users and members
|
||||
- Full-text search capabilities (tsvector)
|
||||
- Fuzzy search with trigram matching (pg_trgm)
|
||||
- GDPR-compliant data management
|
||||
|
||||
## Domains:
|
||||
- **Accounts**: User authentication and session management
|
||||
- **Membership**: Club member data and custom fields
|
||||
- **MembershipFees**: Membership fee types and billing cycles
|
||||
|
||||
## Required PostgreSQL Extensions:
|
||||
- uuid-ossp (UUID generation)
|
||||
- citext (case-insensitive text)
|
||||
- pg_trgm (trigram-based fuzzy search)
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ACCOUNTS DOMAIN
|
||||
// ============================================
|
||||
|
||||
Table users {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
|
||||
email citext [not null, unique, note: 'Email address (case-insensitive) - source of truth when linked to member']
|
||||
hashed_password text [null, note: 'Bcrypt-hashed password (null for OIDC-only users)']
|
||||
oidc_id text [null, unique, note: 'External OIDC identifier from authentication provider (e.g., Rauthy)']
|
||||
member_id uuid [null, unique, note: 'Optional 1:1 link to member record']
|
||||
|
||||
indexes {
|
||||
email [unique, name: 'users_unique_email_index']
|
||||
oidc_id [unique, name: 'users_unique_oidc_id_index']
|
||||
member_id [unique, name: 'users_unique_member_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**User Authentication Table**
|
||||
|
||||
Handles user login accounts with two authentication strategies:
|
||||
1. Password-based authentication (email + hashed_password)
|
||||
2. OIDC/SSO authentication (email + oidc_id)
|
||||
|
||||
**Relationship with Members:**
|
||||
- Optional 1:1 relationship with members table (0..1 ↔ 0..1)
|
||||
- A user can have 0 or 1 member (user.member_id can be NULL)
|
||||
- A member can have 0 or 1 user (optional has_one relationship)
|
||||
- Both entities can exist independently
|
||||
- When linked, user.email is the source of truth
|
||||
- Email changes sync bidirectionally between user ↔ member
|
||||
|
||||
**Constraints:**
|
||||
- At least one auth method required (password OR oidc_id)
|
||||
- Email must be unique across all users
|
||||
- OIDC ID must be unique if present
|
||||
- Member can only be linked to one user (enforced by unique index)
|
||||
|
||||
**Deletion Behavior:**
|
||||
- When member is deleted → user.member_id set to NULL (user preserved)
|
||||
- When user is deleted → member.user relationship cleared (member preserved)
|
||||
'''
|
||||
}
|
||||
|
||||
Table tokens {
|
||||
jti text [pk, not null, note: 'JWT ID - unique token identifier']
|
||||
subject text [not null, note: 'Token subject (usually user ID)']
|
||||
purpose text [not null, note: 'Token purpose (e.g., "access", "refresh", "password_reset")']
|
||||
expires_at timestamp [not null, note: 'Token expiration timestamp (UTC)']
|
||||
extra_data jsonb [null, note: 'Additional token metadata']
|
||||
created_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
|
||||
updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
|
||||
|
||||
indexes {
|
||||
subject [name: 'tokens_subject_idx', note: 'For user token lookups']
|
||||
expires_at [name: 'tokens_expires_at_idx', note: 'For token cleanup queries']
|
||||
purpose [name: 'tokens_purpose_idx', note: 'For purpose-based queries']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**AshAuthentication Token Management**
|
||||
|
||||
Stores JWT tokens for authentication and authorization.
|
||||
|
||||
**Token Purposes:**
|
||||
- `access`: Short-lived access tokens for API requests
|
||||
- `refresh`: Long-lived tokens for obtaining new access tokens
|
||||
- `password_reset`: Temporary tokens for password reset flow
|
||||
- `email_confirmation`: Temporary tokens for email verification
|
||||
|
||||
**Token Lifecycle:**
|
||||
- Tokens are created during login/registration
|
||||
- Can be revoked by deleting the record
|
||||
- Expired tokens should be cleaned up periodically
|
||||
- `store_all_tokens? true` enables token tracking
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MEMBERSHIP DOMAIN
|
||||
// ============================================
|
||||
|
||||
Table members {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key (sortable by creation time)']
|
||||
first_name text [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)']
|
||||
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']
|
||||
search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
|
||||
membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type']
|
||||
membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated']
|
||||
|
||||
indexes {
|
||||
email [unique, name: 'members_unique_email_index']
|
||||
search_vector [type: gin, name: 'members_search_vector_idx', note: 'GIN index for full-text search (tsvector)']
|
||||
first_name [type: gin, name: 'members_first_name_trgm_idx', note: 'GIN trigram index for fuzzy search']
|
||||
last_name [type: gin, name: 'members_last_name_trgm_idx', note: 'GIN trigram index for fuzzy search']
|
||||
email [type: gin, name: 'members_email_trgm_idx', note: 'GIN trigram index for fuzzy search']
|
||||
city [type: gin, name: 'members_city_trgm_idx', note: 'GIN trigram index for fuzzy search']
|
||||
street [type: gin, name: 'members_street_trgm_idx', note: 'GIN trigram index for fuzzy search']
|
||||
notes [type: gin, name: 'members_notes_trgm_idx', note: 'GIN trigram index for fuzzy search']
|
||||
email [name: 'members_email_idx', note: 'B-tree index for exact lookups']
|
||||
last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting']
|
||||
join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters']
|
||||
(paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL']
|
||||
membership_fee_type_id [name: 'members_membership_fee_type_id_index', note: 'B-tree index for fee type lookups']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Club Member Master Data**
|
||||
|
||||
Core entity for membership management containing:
|
||||
- Personal information (name, email)
|
||||
- Contact details (phone, address)
|
||||
- Membership status (join/exit dates, payment status)
|
||||
- Additional notes
|
||||
|
||||
**Email Synchronization:**
|
||||
When a member is linked to a user:
|
||||
- User.email is the source of truth (overwrites member.email on link)
|
||||
- Subsequent changes to either email sync bidirectionally
|
||||
- Validates that email is not already used by another unlinked user
|
||||
|
||||
**Search Capabilities:**
|
||||
1. Full-Text Search (tsvector):
|
||||
- `search_vector` is auto-updated via trigger
|
||||
- Weighted fields: first_name (A), last_name (A), email (B), notes (B)
|
||||
- GIN index for fast text search
|
||||
|
||||
2. Fuzzy Search (pg_trgm):
|
||||
- Trigram-based similarity matching
|
||||
- 6 GIN trigram indexes on searchable fields
|
||||
- Configurable similarity threshold (default 0.2)
|
||||
- Supports typos and partial matches
|
||||
|
||||
**Relationships:**
|
||||
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
|
||||
- 1:N with custom_field_values (custom dynamic fields)
|
||||
- Optional N:1 with membership_fee_types - assigned fee type
|
||||
- 1:N with membership_fee_cycles - billing history
|
||||
|
||||
**Validation Rules:**
|
||||
- first_name, last_name: min 1 character
|
||||
- email: 5-254 characters, valid email format
|
||||
- join_date: cannot be in future
|
||||
- exit_date: must be after join_date (if both present)
|
||||
- phone_number: matches pattern ^\+?[0-9\- ]{6,20}$
|
||||
- postal_code: exactly 5 digits
|
||||
'''
|
||||
}
|
||||
|
||||
Table custom_field_values {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
|
||||
value jsonb [null, note: 'Union type value storage (format: {type: "string", value: "example"})']
|
||||
member_id uuid [not null, note: 'Link to member']
|
||||
custom_field_id uuid [not null, note: 'Link to custom field definition']
|
||||
|
||||
indexes {
|
||||
(member_id, custom_field_id) [unique, name: 'custom_field_values_unique_custom_field_per_member_index', note: 'One custom field value per custom field per member']
|
||||
member_id [name: 'custom_field_values_member_id_idx']
|
||||
custom_field_id [name: 'custom_field_values_custom_field_id_idx']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Dynamic Custom Member Field Values**
|
||||
|
||||
Provides flexible, extensible attributes for members beyond the fixed schema.
|
||||
|
||||
**Value Storage:**
|
||||
- Stored as JSONB map with type discrimination
|
||||
- Format: `{type: "string|integer|boolean|date|email", value: <actual_value>}`
|
||||
- Allows multiple data types in single column
|
||||
|
||||
**Supported Types:**
|
||||
- `string`: Text data
|
||||
- `integer`: Numeric data
|
||||
- `boolean`: True/False flags
|
||||
- `date`: Date values
|
||||
- `email`: Validated email addresses
|
||||
|
||||
**Constraints:**
|
||||
- Each member can have only ONE custom field value per custom field
|
||||
- Custom field values are deleted when member is deleted (CASCADE)
|
||||
- Custom field cannot be deleted if custom field values exist (RESTRICT)
|
||||
|
||||
**Use Cases:**
|
||||
- Custom membership numbers
|
||||
- Additional contact methods
|
||||
- Club-specific attributes
|
||||
- Flexible data model without schema migrations
|
||||
'''
|
||||
}
|
||||
|
||||
Table custom_fields {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
|
||||
name text [not null, unique, note: 'CustomFieldValue name/identifier (e.g., "membership_number")']
|
||||
slug text [not null, unique, note: 'URL-friendly, immutable identifier (e.g., "membership-number"). Auto-generated from name.']
|
||||
value_type text [not null, note: 'Data type: string | integer | boolean | date | email']
|
||||
description text [null, note: 'Human-readable description']
|
||||
immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation']
|
||||
required boolean [not null, default: false, note: 'If true, all members must have this custom field']
|
||||
|
||||
indexes {
|
||||
name [unique, name: 'custom_fields_unique_name_index']
|
||||
slug [unique, name: 'custom_fields_unique_slug_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**CustomFieldValue Type Definitions**
|
||||
|
||||
Defines the schema and behavior for custom member custom_field_values.
|
||||
|
||||
**Attributes:**
|
||||
- `name`: Unique identifier for the custom field
|
||||
- `slug`: URL-friendly, human-readable identifier (auto-generated, immutable)
|
||||
- `value_type`: Enforces data type consistency
|
||||
- `description`: Documentation for users/admins
|
||||
- `immutable`: Prevents changes after initial creation (e.g., membership numbers)
|
||||
- `required`: Enforces that all members must have this custom field
|
||||
|
||||
**Slug Generation:**
|
||||
- Automatically generated from `name` on creation
|
||||
- Immutable after creation (does not change when name is updated)
|
||||
- Lowercase, spaces replaced with hyphens, special characters removed
|
||||
- UTF-8 support (ä → a, ß → ss, etc.)
|
||||
- Used for human-readable identifiers (CSV export/import, API, etc.)
|
||||
- Examples: "Mobile Phone" → "mobile-phone", "Café Müller" → "cafe-muller"
|
||||
|
||||
**Constraints:**
|
||||
- `value_type` must be one of: string, integer, boolean, date, email
|
||||
- `name` must be unique across all custom fields
|
||||
- `slug` must be unique across all custom fields
|
||||
- `slug` cannot be empty (validated on creation)
|
||||
- Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT)
|
||||
|
||||
**Examples:**
|
||||
- Membership Number (string, immutable, required) → slug: "membership-number"
|
||||
- Emergency Contact (string, mutable, optional) → slug: "emergency-contact"
|
||||
- Certified Trainer (boolean, mutable, optional) → slug: "certified-trainer"
|
||||
- Certification Date (date, immutable, optional) → slug: "certification-date"
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MEMBERSHIP_FEES DOMAIN
|
||||
// ============================================
|
||||
|
||||
Table membership_fee_types {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
|
||||
name text [not null, unique, note: 'Unique name for the fee type (e.g., "Standard", "Reduced")']
|
||||
amount numeric(10,2) [not null, note: 'Fee amount in default currency (CHECK: >= 0)']
|
||||
interval text [not null, note: 'Billing interval (CHECK: IN monthly, quarterly, half_yearly, yearly) - immutable']
|
||||
description text [null, note: 'Optional description for the fee type']
|
||||
|
||||
indexes {
|
||||
name [unique, name: 'membership_fee_types_unique_name_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Membership Fee Type Definitions**
|
||||
|
||||
Defines the different types of membership fees with fixed billing intervals.
|
||||
|
||||
**Attributes:**
|
||||
- `name`: Unique identifier for the fee type
|
||||
- `amount`: Default fee amount (stored per cycle for audit trail)
|
||||
- `interval`: Billing cycle - immutable after creation
|
||||
- `description`: Optional documentation
|
||||
|
||||
**Interval Values:**
|
||||
- `monthly`: 1st to last day of month
|
||||
- `quarterly`: 1st of Jan/Apr/Jul/Oct to last day of quarter
|
||||
- `half_yearly`: 1st of Jan/Jul to last day of half
|
||||
- `yearly`: Jan 1 to Dec 31
|
||||
|
||||
**Immutability:**
|
||||
The `interval` field cannot be changed after creation to prevent
|
||||
complex migration scenarios. Create a new fee type to change intervals.
|
||||
|
||||
**Relationships:**
|
||||
- 1:N with members - members assigned to this fee type
|
||||
- 1:N with membership_fee_cycles - all cycles using this fee type
|
||||
|
||||
**Deletion Behavior:**
|
||||
- ON DELETE RESTRICT: Cannot delete if members or cycles reference it
|
||||
'''
|
||||
}
|
||||
|
||||
Table membership_fee_cycles {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
|
||||
cycle_start date [not null, note: 'Start date of the billing cycle']
|
||||
amount numeric(10,2) [not null, note: 'Fee amount for this cycle (CHECK: >= 0)']
|
||||
status text [not null, default: 'unpaid', note: 'Payment status (CHECK: IN unpaid, paid, suspended)']
|
||||
notes text [null, note: 'Optional notes for this cycle']
|
||||
member_id uuid [not null, note: 'FK to members - the member this cycle belongs to']
|
||||
membership_fee_type_id uuid [not null, note: 'FK to membership_fee_types - fee type for this cycle']
|
||||
|
||||
indexes {
|
||||
member_id [name: 'membership_fee_cycles_member_id_index']
|
||||
membership_fee_type_id [name: 'membership_fee_cycles_membership_fee_type_id_index']
|
||||
status [name: 'membership_fee_cycles_status_index']
|
||||
cycle_start [name: 'membership_fee_cycles_cycle_start_index']
|
||||
(member_id, cycle_start) [unique, name: 'membership_fee_cycles_unique_cycle_per_member_index', note: 'One cycle per member per cycle_start']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Individual Membership Fee Cycles**
|
||||
|
||||
Represents a single billing cycle for a member with payment tracking.
|
||||
|
||||
**Design Decisions:**
|
||||
- `cycle_end` is NOT stored - calculated from cycle_start + interval
|
||||
- `amount` is stored per cycle to preserve historical values when fee type amount changes
|
||||
- Cycles are aligned to calendar boundaries
|
||||
|
||||
**Status Values:**
|
||||
- `unpaid`: Payment pending (default)
|
||||
- `paid`: Payment received
|
||||
- `suspended`: Payment suspended (e.g., hardship case)
|
||||
|
||||
**Constraints:**
|
||||
- Unique: One cycle per member per cycle_start date
|
||||
- member_id: Required (belongs_to)
|
||||
- membership_fee_type_id: Required (belongs_to)
|
||||
|
||||
**Relationships:**
|
||||
- N:1 with members - the member this cycle belongs to
|
||||
- N:1 with membership_fee_types - the fee type for this cycle
|
||||
|
||||
**Deletion Behavior:**
|
||||
- ON DELETE CASCADE (member_id): Cycles deleted when member deleted
|
||||
- ON DELETE RESTRICT (membership_fee_type_id): Cannot delete fee type if cycles exist
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RELATIONSHIPS
|
||||
// ============================================
|
||||
|
||||
// Optional 1:1 User ↔ Member Link
|
||||
// - A user can have 0 or 1 linked member (optional)
|
||||
// - A member can have 0 or 1 linked user (optional)
|
||||
// - Both can exist independently
|
||||
// - ON DELETE SET NULL: User preserved when member deleted
|
||||
// - Email Synchronization: When linking occurs, user.email becomes source of truth
|
||||
Ref: users.member_id - members.id [delete: set null]
|
||||
|
||||
// Member → Properties (1:N)
|
||||
// - One member can have multiple custom_field_values
|
||||
// - Each custom field value belongs to exactly one member
|
||||
// - ON DELETE CASCADE: Properties deleted when member deleted
|
||||
// - UNIQUE constraint: One custom field value per custom field per member
|
||||
Ref: custom_field_values.member_id > members.id [delete: cascade]
|
||||
|
||||
// CustomFieldValue → CustomField (N:1)
|
||||
// - Many custom_field_values can reference one custom field
|
||||
// - CustomFieldValue type defines the schema/behavior
|
||||
// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist
|
||||
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict]
|
||||
|
||||
// Member → MembershipFeeType (N:1)
|
||||
// - Many members can be assigned to one fee type
|
||||
// - Optional relationship (member can have no fee type)
|
||||
// - ON DELETE RESTRICT: Cannot delete fee type if members are assigned
|
||||
Ref: members.membership_fee_type_id > membership_fee_types.id [delete: restrict]
|
||||
|
||||
// MembershipFeeCycle → Member (N:1)
|
||||
// - Many cycles belong to one member
|
||||
// - ON DELETE CASCADE: Cycles deleted when member deleted
|
||||
Ref: membership_fee_cycles.member_id > members.id [delete: cascade]
|
||||
|
||||
// MembershipFeeCycle → MembershipFeeType (N:1)
|
||||
// - Many cycles reference one fee type
|
||||
// - ON DELETE RESTRICT: Cannot delete fee type if cycles reference it
|
||||
Ref: membership_fee_cycles.membership_fee_type_id > membership_fee_types.id [delete: restrict]
|
||||
|
||||
// ============================================
|
||||
// ENUMS
|
||||
// ============================================
|
||||
|
||||
// Valid data types for custom field values
|
||||
// Determines how CustomFieldValue.value is interpreted
|
||||
Enum custom_field_value_type {
|
||||
string [note: 'Text data']
|
||||
integer [note: 'Numeric data']
|
||||
boolean [note: 'True/False flags']
|
||||
date [note: 'Date values']
|
||||
email [note: 'Validated email addresses']
|
||||
}
|
||||
|
||||
// Token purposes for different authentication flows
|
||||
Enum token_purpose {
|
||||
access [note: 'Short-lived access tokens']
|
||||
refresh [note: 'Long-lived refresh tokens']
|
||||
password_reset [note: 'Password reset tokens']
|
||||
email_confirmation [note: 'Email verification tokens']
|
||||
}
|
||||
|
||||
// Billing interval for membership fee types
|
||||
Enum membership_fee_interval {
|
||||
monthly [note: '1st to last day of month']
|
||||
quarterly [note: '1st of Jan/Apr/Jul/Oct to last day of quarter']
|
||||
half_yearly [note: '1st of Jan/Jul to last day of half']
|
||||
yearly [note: 'Jan 1 to Dec 31']
|
||||
}
|
||||
|
||||
// Payment status for membership fee cycles
|
||||
Enum membership_fee_status {
|
||||
unpaid [note: 'Payment pending (default)']
|
||||
paid [note: 'Payment received']
|
||||
suspended [note: 'Payment suspended']
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TABLE GROUPS
|
||||
// ============================================
|
||||
|
||||
TableGroup accounts_domain {
|
||||
users
|
||||
tokens
|
||||
|
||||
Note: '''
|
||||
**Accounts Domain**
|
||||
|
||||
Handles user authentication and session management using AshAuthentication.
|
||||
Supports multiple authentication strategies (Password, OIDC).
|
||||
'''
|
||||
}
|
||||
|
||||
TableGroup membership_domain {
|
||||
members
|
||||
custom_field_values
|
||||
custom_fields
|
||||
|
||||
Note: '''
|
||||
**Membership Domain**
|
||||
|
||||
Core business logic for club membership management.
|
||||
Supports flexible, extensible member data model.
|
||||
'''
|
||||
}
|
||||
|
||||
TableGroup membership_fees_domain {
|
||||
membership_fee_types
|
||||
membership_fee_cycles
|
||||
|
||||
Note: '''
|
||||
**Membership Fees Domain**
|
||||
|
||||
Handles membership fee management including:
|
||||
- Fee type definitions with intervals
|
||||
- Individual billing cycles per member
|
||||
- Payment status tracking
|
||||
'''
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,49 +0,0 @@
|
|||
## Core Rules
|
||||
|
||||
1. **User.email is source of truth** - Always overrides member email when linking
|
||||
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
|
||||
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
|
||||
4. **Sync is bidirectional**: User ↔ Member (but User always wins on link)
|
||||
|
||||
---
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Action: Create/Update/Link Entity with Email X
|
||||
│
|
||||
├─ Does Email X violate DB constraint (same table)?
|
||||
│ └─ YES → ❌ FAIL (two users or two members with same email)
|
||||
│
|
||||
├─ Is Entity currently linked? (or being linked?)
|
||||
│ │
|
||||
│ ├─ NO (unlinked entity)
|
||||
│ │ └─ ✅ SUCCESS (no custom validation)
|
||||
│ │
|
||||
│ └─ YES (linked or linking)
|
||||
│ │
|
||||
│ ├─ Action: Update Linked User Email
|
||||
│ │ ├─ Email used by other member? → ❌ FAIL (validation)
|
||||
│ │ └─ Email unique? → ✅ SUCCESS + sync to member
|
||||
│ │
|
||||
│ ├─ Action: Update Linked Member Email
|
||||
│ │ ├─ Email used by other user? → ❌ FAIL (validation)
|
||||
│ │ └─ Email unique? → ✅ SUCCESS + sync to user
|
||||
│ │
|
||||
│ ├─ Action: Link User to Member (both directions)
|
||||
│ │ ├─ User email used by other member? → ❌ FAIL (validation)
|
||||
│ │ └─ Otherwise → ✅ SUCCESS + override member email
|
||||
|
||||
```
|
||||
|
||||
## Sync Triggers
|
||||
|
||||
| Action | Sync Direction | When |
|
||||
|--------|---------------|------|
|
||||
| Update linked user email | User → Member | Email changed |
|
||||
| Update linked member email | Member → User | Email changed |
|
||||
| Link user to member | User → Member | Always (override) |
|
||||
| Link member to user | User → Member | Always (override) |
|
||||
| Unlink | None | Emails stay as-is |
|
||||
|
||||
|
||||
|
|
@ -1,762 +0,0 @@
|
|||
# Feature Roadmap & Implementation Plan
|
||||
|
||||
**Project:** Mila - Membership Management System
|
||||
**Last Updated:** 2025-11-10
|
||||
**Status:** Planning Phase
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Phase 1: Feature Area Breakdown](#phase-1-feature-area-breakdown)
|
||||
2. [Phase 2: API Endpoint Definition](#phase-2-api-endpoint-definition)
|
||||
3. [Phase 3: Implementation Task Creation](#phase-3-implementation-task-creation)
|
||||
4. [Phase 4: Task Organization and Prioritization](#phase-4-task-organization-and-prioritization)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Feature Area Breakdown
|
||||
|
||||
### Feature Areas
|
||||
|
||||
#### 1. **Authentication & Authorization** 🔐
|
||||
|
||||
**Current State:**
|
||||
- ✅ OIDC authentication (Rauthy)
|
||||
- ✅ Password-based authentication
|
||||
- ✅ User sessions and tokens
|
||||
- ✅ Basic authentication flows
|
||||
- ✅ **OIDC account linking with password verification** (PR #192, closes #171)
|
||||
- ✅ **Secure OIDC email collision handling** (PR #192)
|
||||
- ✅ **Automatic linking for passwordless users** (PR #192)
|
||||
|
||||
**Closed Issues:**
|
||||
- ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13)
|
||||
|
||||
**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:**
|
||||
- [#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]
|
||||
|
||||
---
|
||||
|
||||
#### 2. **Member Management** 👥
|
||||
|
||||
**Current State:**
|
||||
- ✅ Member CRUD operations
|
||||
- ✅ Member profile with personal data
|
||||
- ✅ Address management
|
||||
- ✅ Membership status tracking
|
||||
- ✅ Full-text search (PostgreSQL tsvector)
|
||||
- ✅ **Fuzzy search with trigram matching** (PR #187, closes #162)
|
||||
- ✅ **Combined FTS + trigram search** (PR #187)
|
||||
- ✅ **6 GIN trigram indexes** for fuzzy matching (PR #187)
|
||||
- ✅ Sorting by basic fields
|
||||
- ✅ User-Member linking (optional 1:1)
|
||||
- ✅ Email synchronization between User and Member
|
||||
- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230)
|
||||
|
||||
**Closed Issues:**
|
||||
- ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12)
|
||||
|
||||
**Open Issues:**
|
||||
- [#169](https://git.local-it.org/local-it/mitgliederverwaltung/issues/169) - Allow combined creation of Users/Members (M, Low priority)
|
||||
- [#168](https://git.local-it.org/local-it/mitgliederverwaltung/issues/168) - Allow user-member association in edit/create views (M, High priority)
|
||||
- [#165](https://git.local-it.org/local-it/mitgliederverwaltung/issues/165) - Pagination for list of members (S, Low priority)
|
||||
- [#160](https://git.local-it.org/local-it/mitgliederverwaltung/issues/160) - Implement clear icon in searchbar (S, Low priority)
|
||||
- [#154](https://git.local-it.org/local-it/mitgliederverwaltung/issues/154) - Concept advanced search (Low priority, needs refinement)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Advanced filters (date ranges, multiple criteria)
|
||||
- ❌ Pagination (currently all members loaded)
|
||||
- ❌ Bulk operations (bulk delete, bulk update)
|
||||
- ❌ Member import/export (CSV, Excel)
|
||||
- ❌ Member profile photos/avatars
|
||||
- ❌ Member history/audit log
|
||||
- ❌ Duplicate detection
|
||||
|
||||
---
|
||||
|
||||
#### 3. **Custom Fields (CustomFieldValue System)** 🔧
|
||||
|
||||
**Current State:**
|
||||
- ✅ CustomFieldValue types (string, integer, boolean, date, email)
|
||||
- ✅ CustomFieldValue type management
|
||||
- ✅ Dynamic custom field value assignment to members
|
||||
- ✅ Union type storage (JSONB)
|
||||
- ✅ Default field visibility configuration
|
||||
|
||||
**Closed Issues:**
|
||||
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S)
|
||||
- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M)
|
||||
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Remove birthday field from default configuration (S) - Closed 2025-12-02
|
||||
|
||||
**Open Issues:**
|
||||
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
|
||||
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Field groups/categories
|
||||
- ❌ Conditional fields (show field X if field Y = value)
|
||||
- ❌ Field validation rules (min/max, regex patterns)
|
||||
- ❌ Required custom fields
|
||||
- ❌ Multi-select fields
|
||||
- ❌ File upload fields
|
||||
- ❌ Sorting by custom fields
|
||||
- ❌ Searching by custom fields
|
||||
|
||||
---
|
||||
|
||||
#### 4. **User Management** 👤
|
||||
|
||||
**Current State:**
|
||||
- ✅ User CRUD operations
|
||||
- ✅ User list view
|
||||
- ✅ User profile view
|
||||
- ✅ Admin password setting
|
||||
- ✅ User-Member relationship
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ User roles assignment UI
|
||||
- ❌ User permissions management
|
||||
- ❌ User activity log
|
||||
- ❌ User invitation system
|
||||
- ❌ User onboarding flow
|
||||
- ❌ Self-service profile editing
|
||||
- ❌ Password change flow
|
||||
|
||||
---
|
||||
|
||||
#### 5. **Navigation & UX** 🧭
|
||||
|
||||
**Current State:**
|
||||
- ✅ Basic navigation structure
|
||||
- ✅ Navbar with profile button
|
||||
- ✅ Member list as landing page
|
||||
- ✅ Breadcrumbs (basic)
|
||||
|
||||
**Open Issues:**
|
||||
- [#188](https://git.local-it.org/local-it/mitgliederverwaltung/issues/188) - Check if searching just on typing is accessible (S, Low priority)
|
||||
- [#174](https://git.local-it.org/local-it/mitgliederverwaltung/issues/174) - Accessibility - aria-sort in tables (S, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Dashboard/Home page
|
||||
- ❌ Quick actions menu
|
||||
- ❌ Recent activity widget
|
||||
- ❌ Keyboard shortcuts
|
||||
- ❌ Mobile navigation
|
||||
- ❌ Context-sensitive help
|
||||
- ❌ Onboarding tooltips
|
||||
|
||||
---
|
||||
|
||||
#### 6. **Internationalization (i18n)** 🌍
|
||||
|
||||
**Current State:**
|
||||
- ✅ Gettext integration
|
||||
- ✅ German translations
|
||||
- ✅ English translations
|
||||
- ✅ Translation files for auth, errors, default
|
||||
|
||||
**Open Issues:**
|
||||
- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low)
|
||||
- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Language switcher UI
|
||||
- ❌ User-specific language preferences
|
||||
- ❌ Date/time localization
|
||||
- ❌ Number formatting (currency, decimals)
|
||||
- ❌ Complete translation coverage
|
||||
- ❌ RTL support (future)
|
||||
|
||||
---
|
||||
|
||||
#### 7. **Payment & Fees Management** 💰
|
||||
|
||||
**Current State:**
|
||||
- ✅ Basic "paid" boolean field on members
|
||||
- ✅ **UI Mock-ups for Membership Fee Types & Settings** (2025-12-02)
|
||||
- ⚠️ No payment tracking
|
||||
|
||||
**Open Issues:**
|
||||
- [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority)
|
||||
- [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Membership Fee Mockup Pages (Preview)
|
||||
|
||||
**Mock-Up Pages (Non-Functional Preview):**
|
||||
- `/membership_fee_types` - Membership Fee Types Management
|
||||
- `/membership_fee_settings` - Global Membership Fee Settings
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Membership fee configuration
|
||||
- ❌ Payment records/transactions
|
||||
- ❌ Payment history per member
|
||||
- ❌ Payment reminders
|
||||
- ❌ Payment status tracking (pending, paid, overdue)
|
||||
- ❌ Invoice generation
|
||||
- ❌ vereinfacht.digital API integration
|
||||
- ❌ SEPA direct debit support
|
||||
- ❌ Payment reports
|
||||
|
||||
**Related Milestones:**
|
||||
- Import transactions via vereinfacht API
|
||||
|
||||
---
|
||||
|
||||
#### 8. **Admin Panel & Configuration** ⚙️
|
||||
|
||||
**Current State:**
|
||||
- ✅ AshAdmin integration (basic)
|
||||
- ⚠️ 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)
|
||||
|
||||
**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
|
||||
|
||||
**Related Milestones:**
|
||||
- As Admin I can configure settings globally
|
||||
|
||||
---
|
||||
|
||||
#### 9. **Communication & Notifications** 📧
|
||||
|
||||
**Current State:**
|
||||
- ✅ Swoosh mailer integration
|
||||
- ✅ Email confirmation (via AshAuthentication)
|
||||
- ✅ Password reset emails (via AshAuthentication)
|
||||
- ⚠️ No member communication features
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Email broadcast to members
|
||||
- ❌ Email templates (customizable)
|
||||
- ❌ Email to member groups/filters
|
||||
|
||||
---
|
||||
|
||||
#### 10. **Reporting & Analytics** 📊
|
||||
|
||||
**Current State:**
|
||||
- ❌ No reporting features
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Member statistics dashboard
|
||||
- ❌ Membership growth charts
|
||||
- ❌ Payment reports
|
||||
- ❌ Custom report builder
|
||||
- ❌ Export to PDF/CSV/Excel
|
||||
- ❌ Scheduled reports
|
||||
- ❌ Data visualization
|
||||
|
||||
---
|
||||
|
||||
#### 11. **Data Import/Export** 📥📤
|
||||
|
||||
**Current State:**
|
||||
- ✅ Seed data script
|
||||
- ⚠️ No user-facing import/export
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ CSV import for members
|
||||
- ❌ Excel import for members
|
||||
- ❌ Import validation and preview
|
||||
- ❌ Import error handling
|
||||
- ❌ Bulk data export
|
||||
- ❌ Backup export
|
||||
- ❌ Data migration tools
|
||||
|
||||
---
|
||||
|
||||
#### 12. **Testing & Quality Assurance** 🧪
|
||||
|
||||
**Current State:**
|
||||
- ✅ ExUnit test suite
|
||||
- ✅ Unit tests for resources
|
||||
- ✅ Integration tests for email sync
|
||||
- ✅ LiveView tests
|
||||
- ✅ Component tests
|
||||
- ✅ CI/CD pipeline (Drone)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ E2E tests (browser automation)
|
||||
- ❌ Performance testing
|
||||
- ❌ Load testing
|
||||
- ❌ Security penetration testing
|
||||
- ❌ Accessibility testing automation
|
||||
- ❌ Visual regression testing
|
||||
- ❌ Test coverage reporting
|
||||
|
||||
---
|
||||
|
||||
#### 13. **Infrastructure & DevOps** 🚀
|
||||
|
||||
**Current State:**
|
||||
- ✅ Docker Compose for development
|
||||
- ✅ Production Dockerfile
|
||||
- ✅ Drone CI/CD pipeline
|
||||
- ✅ Renovate for dependency updates
|
||||
- ⚠️ No staging environment
|
||||
|
||||
**Open Issues:**
|
||||
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Staging environment
|
||||
- ❌ Automated deployment
|
||||
- ❌ Database backup automation
|
||||
- ❌ Monitoring and alerting
|
||||
- ❌ Error tracking (Sentry, etc.)
|
||||
- ❌ Log aggregation
|
||||
- ❌ Health checks and uptime monitoring
|
||||
|
||||
**Related Milestones:**
|
||||
- We have a staging environment
|
||||
- We implement security measures
|
||||
|
||||
---
|
||||
|
||||
#### 14. **Security & Compliance** 🔒
|
||||
|
||||
**Current State:**
|
||||
- ✅ OIDC authentication
|
||||
- ✅ Password hashing (bcrypt)
|
||||
- ✅ CSRF protection
|
||||
- ✅ SQL injection prevention (Ecto)
|
||||
- ✅ Sobelow security scans
|
||||
- ✅ Dependency auditing
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Role-based access control (see #1)
|
||||
- ❌ Audit logging
|
||||
- ❌ GDPR compliance features (data export, deletion)
|
||||
- ❌ Session management (timeout, concurrent sessions)
|
||||
- ❌ Rate limiting
|
||||
- ❌ IP whitelisting/blacklisting
|
||||
- ❌ Security headers configuration
|
||||
- ❌ Data retention policies
|
||||
|
||||
**Related Milestones:**
|
||||
- We implement security measures
|
||||
|
||||
---
|
||||
|
||||
#### 15. **Accessibility & Usability** ♿
|
||||
|
||||
**Current State:**
|
||||
- ✅ Semantic HTML
|
||||
- ✅ Basic ARIA labels
|
||||
- ⚠️ Needs comprehensive audit
|
||||
|
||||
**Open Issues:**
|
||||
- [#188](https://git.local-it.org/local-it/mitgliederverwaltung/issues/188) - Check if searching just on typing is accessible (S, Low priority)
|
||||
- [#174](https://git.local-it.org/local-it/mitgliederverwaltung/issues/174) - Accessibility - aria-sort in tables (S, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Comprehensive accessibility audit (WCAG 2.1 Level AA)
|
||||
- ❌ Keyboard navigation improvements
|
||||
- ❌ Screen reader optimization
|
||||
- ❌ High contrast mode
|
||||
- ❌ Font size adjustments
|
||||
- ❌ Focus management
|
||||
- ❌ Skip links
|
||||
- ❌ Error announcements
|
||||
|
||||
---
|
||||
|
||||
### Feature Area Summary
|
||||
|
||||
| Feature Area | Current Status | Priority | Complexity |
|
||||
|--------------|----------------|----------|------------|
|
||||
| **Authentication & Authorization** | 60% complete | **High** | Medium |
|
||||
| **Member Management** | 85% complete | **High** | Low-Medium |
|
||||
| **Custom Fields** | 50% complete | **High** | Medium |
|
||||
| **User Management** | 60% complete | Medium | Low |
|
||||
| **Navigation & UX** | 50% complete | Medium | Low |
|
||||
| **Internationalization** | 70% complete | Low | Low |
|
||||
| **Payment & Fees** | 5% complete | **High** | High |
|
||||
| **Admin Panel** | 20% complete | Medium | Medium |
|
||||
| **Communication** | 30% complete | Medium | Medium |
|
||||
| **Reporting** | 0% complete | Medium | Medium-High |
|
||||
| **Import/Export** | 10% complete | Low | Medium |
|
||||
| **Testing & QA** | 60% complete | Medium | Low-Medium |
|
||||
| **Infrastructure** | 70% complete | Medium | Medium |
|
||||
| **Security** | 50% complete | **High** | Medium-High |
|
||||
| **Accessibility** | 40% complete | Medium | Medium |
|
||||
|
||||
---
|
||||
|
||||
### Open Milestones (From Issues)
|
||||
|
||||
1. ✅ **Ich kann einen neuen Kontakt anlegen** (Closed)
|
||||
2. ✅ **I can search through the list of members - fulltext** (Closed) - #162 implemented (Fuzzy Search), #154 needs refinement
|
||||
3. 🔄 **I can sort the list of members for specific fields** (Open) - Related: #153
|
||||
4. 🔄 **We have a intuitive navigation structure** (Open)
|
||||
5. 🔄 **We have different roles and permissions** (Open) - Related: #191, #190, #151
|
||||
6. 🔄 **As Admin I can configure settings globally** (Open)
|
||||
7. ✅ **Accounts & Logins** (Partially closed) - #171 implemented (OIDC linking), #169/#168 still open
|
||||
8. 🔄 **I can add custom fields** (Open) - Related: #194, #157, #161
|
||||
9. 🔄 **Import transactions via vereinfacht API** (Open) - Related: #156
|
||||
10. 🔄 **We have a staging environment** (Open)
|
||||
11. 🔄 **We implement security measures** (Open)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: API Endpoint Definition
|
||||
|
||||
### Endpoint Types
|
||||
|
||||
Since this is a **Phoenix LiveView** application with **Ash Framework**, we have three types of endpoints:
|
||||
|
||||
1. **LiveView Endpoints** - Mount points and event handlers
|
||||
2. **HTTP Controller Endpoints** - Traditional REST-style endpoints
|
||||
3. **Ash Resource Actions** - Backend data layer API
|
||||
|
||||
### Authentication Requirements Legend
|
||||
|
||||
- 🔓 **Public** - No authentication required
|
||||
- 🔐 **Authenticated** - Requires valid user session
|
||||
- 👤 **User Role** - Requires specific user role
|
||||
- 🛡️ **Admin Only** - Requires admin privileges
|
||||
|
||||
---
|
||||
|
||||
### 1. Authentication & Authorization Endpoints
|
||||
|
||||
#### HTTP Controller Endpoints
|
||||
|
||||
| Method | Route | Purpose | Auth | Request | Response |
|
||||
|--------|-------|---------|------|---------|----------|
|
||||
| `GET` | `/auth/user/password/sign_in` | Show password login form | 🔓 | - | HTML form |
|
||||
| `POST` | `/auth/user/password/sign_in` | Submit password login | 🔓 | `{email, password}` | Redirect + session cookie |
|
||||
| `GET` | `/auth/user/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}` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
---
|
||||
|
||||
**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,723 +0,0 @@
|
|||
# Membership Fees - Technical Architecture
|
||||
|
||||
**Project:** Mila - Membership Management System
|
||||
**Feature:** Membership Fee Management
|
||||
**Version:** 1.0
|
||||
**Last Updated:** 2025-11-27
|
||||
**Status:** Architecture Design - Ready for Implementation
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines the technical architecture for the Membership Fees system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details.
|
||||
|
||||
**Related Documents:**
|
||||
|
||||
- [membership-fee-overview.md](./membership-fee-overview.md) - Business logic and requirements
|
||||
- [database-schema-readme.md](./database-schema-readme.md) - Database documentation
|
||||
- [database_schema.dbml](./database_schema.dbml) - Database schema definition
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture Principles](#architecture-principles)
|
||||
2. [Domain Structure](#domain-structure)
|
||||
3. [Data Architecture](#data-architecture)
|
||||
4. [Business Logic Architecture](#business-logic-architecture)
|
||||
5. [Integration Points](#integration-points)
|
||||
6. [Acceptance Criteria](#acceptance-criteria)
|
||||
7. [Testing Strategy](#testing-strategy)
|
||||
8. [Security Considerations](#security-considerations)
|
||||
9. [Performance Considerations](#performance-considerations)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
### Core Design Decisions
|
||||
|
||||
1. **Single Responsibility:**
|
||||
- Each module has one clear responsibility
|
||||
- Cycle generation separated from status management
|
||||
- Calendar logic isolated in dedicated module
|
||||
|
||||
2. **No Redundancy:**
|
||||
- No `cycle_end` field (calculated from `cycle_start` + `interval`)
|
||||
- No `interval_type` field (read from `membership_fee_type.interval`)
|
||||
- Eliminates data inconsistencies
|
||||
|
||||
3. **Immutability Where Important:**
|
||||
- `membership_fee_type.interval` cannot be changed after creation
|
||||
- Prevents complex migration scenarios
|
||||
- Enforced via Ash change validation
|
||||
|
||||
4. **Historical Accuracy:**
|
||||
- `amount` stored per cycle for audit trail
|
||||
- Enables tracking of membership fee changes over time
|
||||
- Old cycles retain original amounts
|
||||
|
||||
5. **Calendar-Based Cycles:**
|
||||
- All cycles aligned to calendar boundaries
|
||||
- Simplifies date calculations
|
||||
- Predictable cycle generation
|
||||
|
||||
---
|
||||
|
||||
## Domain Structure
|
||||
|
||||
### Ash Domain: `Mv.MembershipFees`
|
||||
|
||||
**Purpose:** Encapsulates all membership fee-related resources and logic
|
||||
|
||||
**Resources:**
|
||||
|
||||
- `MembershipFeeType` - Membership fee type definitions (admin-managed)
|
||||
- `MembershipFeeCycle` - Individual membership fee cycles per member
|
||||
|
||||
**Extensions:**
|
||||
|
||||
- Member resource extended with membership fee fields
|
||||
|
||||
### Module Organization
|
||||
|
||||
```
|
||||
lib/
|
||||
├── membership_fees/
|
||||
│ ├── membership_fees.ex # Ash domain definition
|
||||
│ ├── membership_fee_type.ex # MembershipFeeType resource
|
||||
│ ├── membership_fee_cycle.ex # MembershipFeeCycle resource
|
||||
│ └── changes/
|
||||
│ ├── prevent_interval_change.ex # Validates interval immutability
|
||||
│ ├── set_membership_fee_start_date.ex # Auto-sets start date
|
||||
│ └── validate_same_interval.ex # Validates interval match on type change
|
||||
├── mv/
|
||||
│ └── membership_fees/
|
||||
│ ├── cycle_generator.ex # Cycle generation algorithm
|
||||
│ └── calendar_cycles.ex # Calendar cycle calculations
|
||||
└── membership/
|
||||
└── member.ex # Extended with membership fee relationships
|
||||
```
|
||||
|
||||
### Separation of Concerns
|
||||
|
||||
**Domain Layer (Ash Resources):**
|
||||
|
||||
- Data validation
|
||||
- Relationship management
|
||||
- Policy enforcement
|
||||
- Action definitions
|
||||
|
||||
**Business Logic Layer (`Mv.MembershipFees`):**
|
||||
|
||||
- Cycle generation algorithm
|
||||
- Calendar calculations
|
||||
- Date boundary handling
|
||||
- Status transitions
|
||||
|
||||
**UI Layer (LiveView):**
|
||||
|
||||
- User interaction
|
||||
- Display logic
|
||||
- Authorization checks
|
||||
- Form handling
|
||||
|
||||
---
|
||||
|
||||
## Data Architecture
|
||||
|
||||
### Database Schema Extensions
|
||||
|
||||
**See:** [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation.
|
||||
|
||||
### New Tables
|
||||
|
||||
1. **`membership_fee_types`**
|
||||
- Purpose: Define membership fee types with fixed intervals
|
||||
- Key Constraint: `interval` field immutable after creation
|
||||
- Relationships: has_many members, has_many membership_fee_cycles
|
||||
|
||||
2. **`membership_fee_cycles`**
|
||||
- Purpose: Individual membership fee cycles for members
|
||||
- Key Design: NO `cycle_end` or `interval_type` fields (calculated)
|
||||
- Relationships: belongs_to member, belongs_to membership_fee_type
|
||||
- Composite uniqueness: One cycle per member per cycle_start
|
||||
|
||||
### Member Table Extensions
|
||||
|
||||
**Fields Added:**
|
||||
|
||||
- `membership_fee_type_id` (FK, NOT NULL with default from settings)
|
||||
- `membership_fee_start_date` (Date, nullable)
|
||||
|
||||
**Existing Fields Used:**
|
||||
|
||||
- `join_date` - For calculating membership fee start
|
||||
- `exit_date` - For limiting cycle generation
|
||||
- These fields must remain member fields and should not be replaced by custom fields in the future
|
||||
|
||||
### Settings Integration
|
||||
|
||||
**Global Settings:**
|
||||
|
||||
- `membership_fees.include_joining_cycle` (Boolean)
|
||||
- `membership_fees.default_membership_fee_type_id` (UUID)
|
||||
|
||||
**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource)
|
||||
|
||||
### Foreign Key Behaviors
|
||||
|
||||
| Relationship | On Delete | Rationale |
|
||||
|--------------|-----------|-----------|
|
||||
| `membership_fee_cycles.member_id → members.id` | CASCADE | Remove membership fee cycles when member deleted |
|
||||
| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if cycles exist |
|
||||
| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if assigned to members |
|
||||
|
||||
---
|
||||
|
||||
## Business Logic Architecture
|
||||
|
||||
### Cycle Generation System
|
||||
|
||||
**Component:** `Mv.MembershipFees.CycleGenerator`
|
||||
|
||||
**Responsibilities:**
|
||||
|
||||
- Calculate which cycles should exist for a member
|
||||
- Generate missing cycles
|
||||
- Respect membership_fee_start_date and exit_date boundaries
|
||||
- Skip existing cycles (idempotent)
|
||||
- Use PostgreSQL advisory locks per member to prevent race conditions
|
||||
|
||||
**Triggers:**
|
||||
|
||||
1. Member membership fee type assigned (via Ash change)
|
||||
2. Member created with membership fee type (via Ash change)
|
||||
3. Scheduled job runs (daily/weekly cron)
|
||||
4. Admin manual regeneration (UI action)
|
||||
|
||||
**Algorithm Steps:**
|
||||
|
||||
1. Retrieve member with membership fee type and dates
|
||||
2. Determine generation start point:
|
||||
- If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
|
||||
- If cycles exist: Start from the cycle AFTER the last existing one
|
||||
3. Generate all cycle starts from the determined start point to today (or `exit_date`)
|
||||
4. Create new cycles with current membership fee type's amount
|
||||
5. Use PostgreSQL advisory locks per member to prevent race conditions
|
||||
|
||||
**Edge Case Handling:**
|
||||
|
||||
- If membership_fee_start_date is NULL: Calculate from join_date + global setting
|
||||
- If exit_date is set: Stop generation at exit_date
|
||||
- If membership fee type changes: Handled separately by regeneration logic
|
||||
- **Gap Handling:** If cycles were explicitly deleted (gaps exist), they are NOT recreated.
|
||||
The generator always continues from the cycle AFTER the last existing cycle, regardless of gaps.
|
||||
|
||||
### Calendar Cycle Calculations
|
||||
|
||||
**Component:** `Mv.MembershipFees.CalendarCycles`
|
||||
|
||||
**Responsibilities:**
|
||||
|
||||
- Calculate cycle boundaries based on interval type
|
||||
- Determine current cycle
|
||||
- Determine last completed cycle
|
||||
- Calculate cycle_end from cycle_start + interval
|
||||
|
||||
**Functions (high-level):**
|
||||
|
||||
- `calculate_cycle_start/3` - Given date and interval, find cycle start
|
||||
- `calculate_cycle_end/2` - Given cycle_start and interval, calculate end
|
||||
- `next_cycle_start/2` - Given cycle_start and interval, find next
|
||||
- `is_current_cycle?/2` - Check if cycle contains today
|
||||
- `is_last_completed_cycle?/2` - Check if cycle just ended
|
||||
|
||||
**Interval Logic:**
|
||||
|
||||
- **Monthly:** Start = 1st of month, End = last day of month
|
||||
- **Quarterly:** Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter
|
||||
- **Half-yearly:** Start = 1st of half (Jan/Jul), End = last day of half
|
||||
- **Yearly:** Start = Jan 1st, End = Dec 31st
|
||||
|
||||
### Status Management
|
||||
|
||||
**Component:** Ash actions on `MembershipFeeCycle`
|
||||
|
||||
**Status Transitions:**
|
||||
|
||||
- Simple state machine: unpaid ↔ paid ↔ suspended
|
||||
- No complex validation (all transitions allowed)
|
||||
- Permissions checked via Ash policies
|
||||
|
||||
**Actions Required:**
|
||||
|
||||
- `mark_as_paid` - Set status to :paid
|
||||
- `mark_as_suspended` - Set status to :suspended
|
||||
- `mark_as_unpaid` - Set status to :unpaid (error correction)
|
||||
|
||||
**Bulk Operations:**
|
||||
|
||||
- `bulk_mark_as_paid` - Mark multiple cycles as paid (efficiency)
|
||||
- low priority, can be a future issue
|
||||
|
||||
### Membership Fee Type Change Handling
|
||||
|
||||
**Component:** Ash change on `Member.membership_fee_type_id`
|
||||
|
||||
**Validation:**
|
||||
|
||||
- Check if new type has same interval as old type
|
||||
- If different: Reject change (MVP constraint)
|
||||
- If same: Allow change
|
||||
|
||||
**Side Effects on Allowed Change:**
|
||||
|
||||
1. Keep all existing cycles unchanged
|
||||
2. Find future unpaid cycles
|
||||
3. Delete future unpaid cycles
|
||||
4. Regenerate cycles with new membership_fee_type_id and amount
|
||||
|
||||
**Implementation Pattern:**
|
||||
|
||||
- Use Ash change module to validate
|
||||
- Use after_action hook to trigger regeneration synchronously
|
||||
- Regeneration runs in the same transaction as the member update to ensure atomicity
|
||||
- CycleGenerator uses advisory locks and transactions internally to prevent race conditions
|
||||
|
||||
**Validation Behavior:**
|
||||
|
||||
- Fail-closed: If membership fee types cannot be loaded during validation, the change is rejected with a validation error
|
||||
- Nil assignment prevention: Attempts to set membership_fee_type_id to nil are rejected when a current type exists
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Member Resource Integration
|
||||
|
||||
**Extension Points:**
|
||||
|
||||
1. Add fields via migration
|
||||
2. Add relationships (belongs_to, has_many)
|
||||
3. Add calculations (current_cycle_status, overdue_count)
|
||||
4. Add changes (auto-set membership_fee_start_date, validate interval)
|
||||
|
||||
**Backward Compatibility:**
|
||||
|
||||
- New fields nullable or with defaults
|
||||
- Existing members get default membership fee type from settings
|
||||
- No breaking changes to existing member functionality
|
||||
|
||||
### Settings System Integration
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Store two global settings
|
||||
- Provide UI for admin to modify
|
||||
- Default values if not set
|
||||
- Validation (e.g., default membership fee type must exist)
|
||||
|
||||
**Access Pattern:**
|
||||
|
||||
- Read settings during cycle generation
|
||||
- Read settings during member creation
|
||||
- Write settings only via admin UI
|
||||
|
||||
### Permission System Integration
|
||||
|
||||
**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
|
||||
|
||||
**Required Permissions:**
|
||||
|
||||
- `MembershipFeeType.create/update/destroy` - Admin only
|
||||
- `MembershipFeeType.read` - Admin, Treasurer, Board
|
||||
- `MembershipFeeCycle.update` (status changes) - Admin, Treasurer
|
||||
- `MembershipFeeCycle.read` - Admin, Treasurer, Board, Own member
|
||||
|
||||
**Policy Patterns:**
|
||||
|
||||
- Use existing HasPermission check
|
||||
- Leverage existing roles (Admin, Kassenwart)
|
||||
- Member can read own cycles (linked via member_id)
|
||||
|
||||
### LiveView Integration
|
||||
|
||||
**New LiveViews Required:**
|
||||
|
||||
1. MembershipFeeType index/form (admin)
|
||||
2. MembershipFeeCycle table component (member detail view)
|
||||
3. Settings form section (admin)
|
||||
4. Member list column (membership fee status)
|
||||
|
||||
**Existing LiveViews to Extend:**
|
||||
|
||||
- Member detail view: Add membership fees section
|
||||
- Member list view: Add status column
|
||||
- Settings page: Add membership fees section
|
||||
|
||||
**Authorization Helpers:**
|
||||
|
||||
- Use existing `can?/3` helper for UI conditionals
|
||||
- Check permissions before showing actions
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### MembershipFeeType Resource
|
||||
|
||||
**AC-MFT-1:** Admin can create membership fee type with name, amount, interval, description
|
||||
**AC-MFT-2:** Interval field is immutable after creation (validation error on change attempt)
|
||||
**AC-MFT-3:** Admin can update name, amount, description (but not interval)
|
||||
**AC-MFT-4:** Cannot delete membership fee type if assigned to members
|
||||
**AC-MFT-5:** Cannot delete membership fee type if cycles exist referencing it
|
||||
**AC-MFT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly
|
||||
|
||||
### MembershipFeeCycle Resource
|
||||
|
||||
**AC-MFC-1:** Cycle has cycle_start, status, amount, notes, member_id, membership_fee_type_id
|
||||
**AC-MFC-2:** cycle_end is calculated, not stored
|
||||
**AC-MFC-3:** Status defaults to :unpaid
|
||||
**AC-MFC-4:** One cycle per member per cycle_start (uniqueness constraint)
|
||||
**AC-MFC-5:** Amount is set at generation time from membership_fee_type.amount
|
||||
**AC-MFC-6:** Cycles cascade delete when member deleted
|
||||
**AC-MFC-7:** Admin/Treasurer can change status
|
||||
**AC-MFC-8:** Member can read own cycles
|
||||
|
||||
### Member Extensions
|
||||
|
||||
**AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default)
|
||||
**AC-M-2:** Member has membership_fee_start_date field (nullable)
|
||||
**AC-M-3:** New members get default membership fee type from global setting
|
||||
**AC-M-4:** membership_fee_start_date auto-set based on join_date and global setting
|
||||
**AC-M-5:** Admin can manually override membership_fee_start_date
|
||||
**AC-M-6:** Cannot change to membership fee type with different interval (MVP)
|
||||
|
||||
### Cycle Generation
|
||||
|
||||
**AC-CG-1:** Cycles generated when member gets membership fee type
|
||||
**AC-CG-2:** Cycles generated when member created (via change hook)
|
||||
**AC-CG-3:** Scheduled job generates missing cycles daily
|
||||
**AC-CG-4:** Generation respects membership_fee_start_date
|
||||
**AC-CG-5:** Generation stops at exit_date if member exited
|
||||
**AC-CG-6:** Generation is idempotent (skips existing cycles)
|
||||
**AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year)
|
||||
**AC-CG-8:** Amount comes from membership_fee_type at generation time
|
||||
|
||||
### Calendar Logic
|
||||
|
||||
**AC-CL-1:** Monthly cycles: 1st to last day of month
|
||||
**AC-CL-2:** Quarterly cycles: 1st of Jan/Apr/Jul/Oct to last day of quarter
|
||||
**AC-CL-3:** Half-yearly cycles: 1st of Jan/Jul to last day of half
|
||||
**AC-CL-4:** Yearly cycles: Jan 1 to Dec 31
|
||||
**AC-CL-5:** cycle_end calculated correctly for all interval types
|
||||
**AC-CL-6:** Current cycle determined correctly based on today's date
|
||||
**AC-CL-7:** Last completed cycle determined correctly
|
||||
|
||||
### Membership Fee Type Change
|
||||
|
||||
**AC-TC-1:** Can change to type with same interval
|
||||
**AC-TC-2:** Cannot change to type with different interval (error message)
|
||||
**AC-TC-3:** On allowed change: future unpaid cycles regenerated
|
||||
**AC-TC-4:** On allowed change: paid/suspended cycles unchanged
|
||||
**AC-TC-5:** On allowed change: amount updated to new type's amount
|
||||
**AC-TC-6:** Change is atomic (transaction) - ✅ Implemented: Regeneration runs synchronously in the same transaction as the member update
|
||||
|
||||
### Settings
|
||||
|
||||
**AC-S-1:** Global setting: include_joining_cycle (boolean, default true)
|
||||
**AC-S-2:** Global setting: default_membership_fee_type_id (UUID, required)
|
||||
**AC-S-3:** Admin can modify settings via UI
|
||||
**AC-S-4:** Settings validated (e.g., default membership fee type must exist)
|
||||
**AC-S-5:** Settings applied to new members immediately
|
||||
|
||||
### UI - Member List
|
||||
|
||||
**AC-UI-ML-1:** New column shows membership fee status
|
||||
**AC-UI-ML-2:** Default: Shows last completed cycle status
|
||||
**AC-UI-ML-3:** Optional: Toggle to show current cycle status
|
||||
**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended)
|
||||
**AC-UI-ML-5:** Filter: Unpaid in last cycle
|
||||
**AC-UI-ML-6:** Filter: Unpaid in current cycle
|
||||
|
||||
### UI - Member Detail
|
||||
|
||||
**AC-UI-MD-1:** Membership fees section shows all cycles
|
||||
**AC-UI-MD-2:** Table columns: Cycle, Interval, Amount, Status, Actions
|
||||
**AC-UI-MD-3:** Checkbox per cycle for bulk marking (low prio)
|
||||
**AC-UI-MD-4:** "Mark selected as paid" button
|
||||
**AC-UI-MD-5:** Dropdown to change membership fee type (same interval only)
|
||||
**AC-UI-MD-6:** Warning if different interval selected
|
||||
**AC-UI-MD-7:** Only show actions if user has permission
|
||||
|
||||
### UI - Membership Fee Types Admin
|
||||
|
||||
**AC-UI-CTA-1:** List all membership fee types
|
||||
**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count
|
||||
**AC-UI-CTA-3:** Create new membership fee type form
|
||||
**AC-UI-CTA-4:** Edit form: Name, Amount, Description editable
|
||||
**AC-UI-CTA-5:** Edit form: Interval grayed out (not editable)
|
||||
**AC-UI-CTA-6:** Warning on amount change (explain impact)
|
||||
**AC-UI-CTA-7:** Cannot delete if members assigned
|
||||
**AC-UI-CTA-8:** Only admin can access
|
||||
|
||||
### UI - Settings Admin
|
||||
|
||||
**AC-UI-SA-1:** Membership fees section in settings
|
||||
**AC-UI-SA-2:** Dropdown to select default membership fee type
|
||||
**AC-UI-SA-3:** Checkbox: Include joining cycle
|
||||
**AC-UI-SA-4:** Explanatory text with examples
|
||||
**AC-UI-SA-5:** Save button with validation
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Testing
|
||||
|
||||
**Cycle Generator Tests:**
|
||||
|
||||
- Correct cycle_start calculation for all interval types
|
||||
- Correct cycle count from start to end date
|
||||
- Respects membership_fee_start_date boundary
|
||||
- Respects exit_date boundary
|
||||
- Skips existing cycles (idempotent)
|
||||
- Does not fill gaps when cycles were deleted
|
||||
- Handles edge dates (year boundaries, leap years)
|
||||
|
||||
**Calendar Cycles Tests:**
|
||||
|
||||
- Cycle boundaries correct for all intervals
|
||||
- cycle_end calculation correct
|
||||
- Current cycle detection
|
||||
- Last completed cycle detection
|
||||
- Next cycle calculation
|
||||
|
||||
**Validation Tests:**
|
||||
|
||||
- Interval immutability enforced
|
||||
- Same interval validation on type change
|
||||
- Status transitions allowed
|
||||
- Uniqueness constraints enforced
|
||||
|
||||
### Integration Testing
|
||||
|
||||
**Cycle Generation Flow:**
|
||||
|
||||
- Member creation triggers generation
|
||||
- Type assignment triggers generation
|
||||
- Type change regenerates future cycles
|
||||
- Scheduled job generates missing cycles
|
||||
- Left member stops generation
|
||||
|
||||
**Status Management Flow:**
|
||||
|
||||
- Mark single cycle as paid
|
||||
- Bulk mark multiple cycles (low prio)
|
||||
- Status transitions work
|
||||
- Permissions enforced
|
||||
|
||||
**Membership Fee Type Management:**
|
||||
|
||||
- Create type
|
||||
- Update amount (regeneration triggered)
|
||||
- Cannot update interval
|
||||
- Cannot delete if in use
|
||||
|
||||
### LiveView Testing
|
||||
|
||||
**Member List:**
|
||||
|
||||
- Status column displays correctly
|
||||
- Toggle between last/current works
|
||||
- Filters work correctly
|
||||
- Color coding applied
|
||||
|
||||
**Member Detail:**
|
||||
|
||||
- Cycles table displays all cycles
|
||||
- Checkboxes work
|
||||
- Bulk marking works (low prio)
|
||||
- Membership fee type change validation works
|
||||
- Actions only shown with permission
|
||||
|
||||
**Admin UI:**
|
||||
|
||||
- Type CRUD works
|
||||
- Settings save correctly
|
||||
- Validations display errors
|
||||
- Only authorized users can access
|
||||
|
||||
### Edge Case Testing
|
||||
|
||||
**Interval Change Attempt:**
|
||||
|
||||
- Error message displayed
|
||||
- No data modified
|
||||
- User can cancel/choose different type
|
||||
|
||||
**Exit with Unpaid:**
|
||||
|
||||
- Warning shown
|
||||
- Option to suspend offered
|
||||
- Exit completes correctly
|
||||
|
||||
**Amount Change:**
|
||||
|
||||
- Warning displayed
|
||||
- Only future unpaid regenerated
|
||||
- Historical cycles unchanged
|
||||
|
||||
**Date Boundaries:**
|
||||
|
||||
- Today = cycle start handled
|
||||
- Today = cycle end handled
|
||||
- Leap year handled
|
||||
|
||||
### Performance Testing
|
||||
|
||||
**Cycle Generation:**
|
||||
|
||||
- Generate 10 years of monthly cycles: < 100ms
|
||||
- Generate for 1000 members: < 5 seconds
|
||||
- Idempotent check efficient (no full scan)
|
||||
|
||||
**Member List Query:**
|
||||
|
||||
- With status column: < 200ms for 1000 members
|
||||
- Filters applied efficiently
|
||||
- No N+1 queries
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authorization
|
||||
|
||||
**Permissions Required:**
|
||||
|
||||
- Membership fee type management: Admin only
|
||||
- Membership fee cycle status changes: Admin + Treasurer
|
||||
- View all cycles: Admin + Treasurer + Board
|
||||
- View own cycles: All authenticated users
|
||||
|
||||
**Policy Enforcement:**
|
||||
|
||||
- All actions protected by Ash policies
|
||||
- UI shows/hides based on permissions
|
||||
- Backend validates permissions (never trust UI alone)
|
||||
|
||||
### Data Integrity
|
||||
|
||||
**Validation Layers:**
|
||||
|
||||
1. Database constraints (NOT NULL, UNIQUE, CHECK)
|
||||
2. Ash validations (business rules)
|
||||
3. UI validations (user experience)
|
||||
|
||||
**Immutability Protection:**
|
||||
|
||||
- Interval change prevented at multiple layers
|
||||
- Cycle amounts immutable (audit trail)
|
||||
- Settings changes logged (future)
|
||||
|
||||
### Audit Trail
|
||||
|
||||
**Tracked Information:**
|
||||
|
||||
- Cycle status changes (who, when) - future enhancement
|
||||
- Membership fee type amount changes (implicit via cycle amounts)
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Database Indexes
|
||||
|
||||
**Required Indexes:**
|
||||
|
||||
- `membership_fee_cycles(member_id)` - For member cycle lookups
|
||||
- `membership_fee_cycles(membership_fee_type_id)` - For type queries
|
||||
- `membership_fee_cycles(status)` - For unpaid filters
|
||||
- `membership_fee_cycles(cycle_start)` - For date range queries
|
||||
- `membership_fee_cycles(member_id, cycle_start)` - Composite unique index
|
||||
- `members(membership_fee_type_id)` - For type membership count
|
||||
|
||||
### Query Optimization
|
||||
|
||||
**Preloading:**
|
||||
|
||||
- Load membership_fee_type with cycles (avoid N+1)
|
||||
- Load cycles when displaying member detail
|
||||
- Use Ash's load for efficient preloading
|
||||
|
||||
**Calculated Fields:**
|
||||
|
||||
- cycle_end calculated on-demand (not stored)
|
||||
- current_cycle_status calculated when needed
|
||||
- Use Ash calculations for lazy evaluation
|
||||
|
||||
**Pagination:**
|
||||
|
||||
- Cycle list paginated if > 50 cycles
|
||||
- Member list already paginated
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
**No caching needed in MVP:**
|
||||
|
||||
- Membership fee types rarely change
|
||||
- Cycle queries are fast
|
||||
- Settings read infrequently
|
||||
|
||||
**Future caching if needed:**
|
||||
|
||||
- Cache settings in application memory
|
||||
- Cache membership fee types list
|
||||
- Invalidate on change
|
||||
|
||||
### Scheduled Job Performance
|
||||
|
||||
**Cycle Generation Job:**
|
||||
|
||||
- Run daily or weekly (not hourly)
|
||||
- Batch members (process 100 at a time)
|
||||
- Skip members with no changes
|
||||
- Log failures for retry
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Phase 2: Interval Change Support
|
||||
|
||||
**Architecture Changes:**
|
||||
|
||||
- Add logic to handle cycle overlaps
|
||||
- Calculate prorata amounts if needed
|
||||
- More complex validation
|
||||
- Migration path for existing cycles
|
||||
|
||||
### Phase 3: Payment Details
|
||||
|
||||
**Architecture Changes:**
|
||||
|
||||
- Add PaymentTransaction resource
|
||||
- Link transactions to cycles
|
||||
- Support multiple payments per cycle
|
||||
- Reconciliation logic
|
||||
|
||||
### Phase 4: vereinfacht.digital Integration
|
||||
|
||||
**Architecture Changes:**
|
||||
|
||||
- External API client module
|
||||
- Webhook handling for transactions
|
||||
- Automatic matching logic
|
||||
- Manual review interface
|
||||
|
||||
---
|
||||
|
||||
**End of Architecture Document**
|
||||
|
|
@ -1,531 +0,0 @@
|
|||
# Membership Fees - Overview
|
||||
|
||||
**Project:** Mila - Membership Management System
|
||||
**Feature:** Membership Fee Management
|
||||
**Version:** 1.0
|
||||
**Last Updated:** 2025-11-27
|
||||
**Status:** Concept - Ready for Review
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
This document provides a comprehensive overview of the Membership Fees system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format.
|
||||
|
||||
**For detailed implementation:** See [membership-fee-implementation-plan.md](./membership-fee-implementation-plan.md) (created after concept iterations)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Core Principle](#core-principle)
|
||||
2. [Terminology](#terminology)
|
||||
3. [Data Model](#data-model)
|
||||
4. [Business Logic](#business-logic)
|
||||
5. [UI/UX Design](#uiux-design)
|
||||
6. [Edge Cases](#edge-cases)
|
||||
7. [Technical Integration](#technical-integration)
|
||||
8. [Implementation Scope](#implementation-scope)
|
||||
|
||||
---
|
||||
|
||||
## Core Principle
|
||||
|
||||
**Maximum Simplicity:**
|
||||
|
||||
- Minimal complexity
|
||||
- Clear data model without redundancies
|
||||
- Intuitive operation
|
||||
- Calendar cycle-based (Month/Quarter/Half-Year/Year)
|
||||
|
||||
---
|
||||
|
||||
## Terminology
|
||||
|
||||
### German ↔ English
|
||||
|
||||
**Core Entities:**
|
||||
|
||||
- Beitragsart ↔ Membership Fee Type
|
||||
- Beitragszyklus ↔ Membership Fee Cycle
|
||||
- Mitgliedsbeitrag ↔ Membership Fee
|
||||
|
||||
**Status:**
|
||||
|
||||
- bezahlt ↔ paid
|
||||
- unbezahlt ↔ unpaid
|
||||
- ausgesetzt ↔ suspended / waived
|
||||
|
||||
**Intervals (Frequenz / Payment Frequency):**
|
||||
|
||||
- monatlich ↔ monthly
|
||||
- quartalsweise ↔ quarterly
|
||||
- halbjährlich ↔ half-yearly / semi-annually
|
||||
- jährlich ↔ yearly / annually
|
||||
|
||||
**UI Elements:**
|
||||
|
||||
- "Letzter Zyklus" ↔ "Last Cycle" (e.g., 2023 when in 2024)
|
||||
- "Aktueller Zyklus" ↔ "Current Cycle" (e.g., 2024)
|
||||
- "Als bezahlt markieren" ↔ "Mark as paid"
|
||||
- "Aussetzen" ↔ "Suspend" / "Waive"
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### Membership Fee Type (MembershipFeeType)
|
||||
|
||||
```
|
||||
- id (UUID)
|
||||
- name (String) - e.g., "Regular", "Reduced", "Student"
|
||||
- amount (Decimal) - Membership fee amount in Euro
|
||||
- interval (Enum) - :monthly, :quarterly, :half_yearly, :yearly
|
||||
- description (Text, optional)
|
||||
```
|
||||
|
||||
**Important:**
|
||||
|
||||
- `interval` is **IMMUTABLE** after creation!
|
||||
- Admin can only change `name`, `amount`, `description`
|
||||
- On change: Future unpaid cycles regenerated with new amount
|
||||
|
||||
### Membership Fee Cycle (MembershipFeeCycle)
|
||||
|
||||
```
|
||||
- id (UUID)
|
||||
- member_id (FK → members.id)
|
||||
- membership_fee_type_id (FK → membership_fee_types.id)
|
||||
- cycle_start (Date) - Calendar cycle start (01.01., 01.04., 01.07., 01.10., etc.)
|
||||
- status (Enum) - :unpaid (default), :paid, :suspended
|
||||
- amount (Decimal) - Membership fee amount at generation time (history when type changes)
|
||||
- notes (Text, optional) - Admin notes
|
||||
```
|
||||
|
||||
**Important:**
|
||||
|
||||
- **NO** `cycle_end` - calculated from `cycle_start` + `interval`
|
||||
- **NO** `interval_type` - read from `membership_fee_type.interval`
|
||||
- Avoids redundancy and inconsistencies!
|
||||
|
||||
**Calendar Cycle Logic:**
|
||||
|
||||
- Monthly: 01.01. - 31.01., 01.02. - 28./29.02., etc.
|
||||
- Quarterly: 01.01. - 31.03., 01.04. - 30.06., 01.07. - 30.09., 01.10. - 31.12.
|
||||
- Half-yearly: 01.01. - 30.06., 01.07. - 31.12.
|
||||
- Yearly: 01.01. - 31.12.
|
||||
|
||||
### Member (Extensions)
|
||||
|
||||
```
|
||||
- membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings)
|
||||
- membership_fee_start_date (Date, nullable) - When to start generating membership fees
|
||||
- exit_date (Date, nullable) - Exit date (existing)
|
||||
```
|
||||
|
||||
**Logic for membership_fee_start_date:**
|
||||
|
||||
- Auto-set based on global setting `include_joining_cycle`
|
||||
- If `include_joining_cycle = true`: First day of joining month/quarter/year
|
||||
- If `include_joining_cycle = false`: First day of NEXT cycle after joining
|
||||
- Can be manually overridden by admin
|
||||
|
||||
**NO** `include_joining_cycle` field on Member - unnecessary due to `membership_fee_start_date`!
|
||||
|
||||
### Global Settings
|
||||
|
||||
```
|
||||
key: "membership_fees.include_joining_cycle"
|
||||
value: Boolean (Default: true)
|
||||
|
||||
key: "membership_fees.default_membership_fee_type_id"
|
||||
value: UUID (Required) - Default membership fee type for new members
|
||||
```
|
||||
|
||||
**Meaning include_joining_cycle:**
|
||||
|
||||
- `true`: Joining cycle is included (member pays from joining cycle)
|
||||
- `false`: Only from next full cycle after joining
|
||||
|
||||
**Meaning of default membership fee type setting:**
|
||||
|
||||
- Every new member automatically gets this membership fee type
|
||||
- Must be configured in admin settings
|
||||
- Prevents: Members without membership fee type
|
||||
|
||||
---
|
||||
|
||||
## Business Logic
|
||||
|
||||
### Cycle Generation
|
||||
|
||||
**Triggers:**
|
||||
|
||||
- Member gets membership fee type assigned (also during member creation)
|
||||
- New cycle begins (Cron job daily/weekly)
|
||||
- Admin requests manual regeneration
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
Use PostgreSQL advisory locks per member to prevent race conditions
|
||||
|
||||
1. Get `member.membership_fee_start_date` and member's membership fee type
|
||||
2. Determine generation start point:
|
||||
- If NO cycles exist: Start from `membership_fee_start_date`
|
||||
- If cycles exist: Start from the cycle AFTER the last existing one
|
||||
3. Generate cycles until today (or `exit_date` if present):
|
||||
- Use the interval to generate the cycles
|
||||
- **Note:** If cycles were explicitly deleted (gaps exist), they are NOT recreated.
|
||||
The generator always continues from the cycle AFTER the last existing cycle.
|
||||
4. Set `amount` to current membership fee type's amount
|
||||
|
||||
**Example (Yearly):**
|
||||
|
||||
```
|
||||
Joining date: 15.03.2023
|
||||
include_joining_cycle: true
|
||||
→ membership_fee_start_date: 01.01.2023
|
||||
|
||||
Generated cycles:
|
||||
- 01.01.2023 - 31.12.2023 (joining cycle)
|
||||
- 01.01.2024 - 31.12.2024
|
||||
- 01.01.2025 - 31.12.2025 (current year)
|
||||
```
|
||||
|
||||
**Example (Quarterly):**
|
||||
|
||||
```
|
||||
Joining date: 15.03.2023
|
||||
include_joining_cycle: false
|
||||
→ membership_fee_start_date: 01.04.2023
|
||||
|
||||
Generated cycles:
|
||||
- 01.04.2023 - 30.06.2023 (first full quarter)
|
||||
- 01.07.2023 - 30.09.2023
|
||||
- 01.10.2023 - 31.12.2023
|
||||
- 01.01.2024 - 31.03.2024
|
||||
- ...
|
||||
```
|
||||
|
||||
### Status Transitions
|
||||
|
||||
```
|
||||
unpaid → paid
|
||||
unpaid → suspended
|
||||
paid → unpaid
|
||||
suspended → paid
|
||||
suspended → unpaid
|
||||
```
|
||||
|
||||
**Permissions:**
|
||||
|
||||
- Admin + Treasurer (Kassenwart) can change status
|
||||
- Uses existing permission system
|
||||
|
||||
### Membership Fee Type Change
|
||||
|
||||
**MVP - Same Cycle Only:**
|
||||
|
||||
- Member can only choose membership fee type with **same cycle**
|
||||
- Example: From "Regular (yearly)" to "Reduced (yearly)" ✓
|
||||
- Example: From "Regular (yearly)" to "Reduced (monthly)" ✗
|
||||
|
||||
**Logic on Change:**
|
||||
|
||||
1. Check: New membership fee type has same interval
|
||||
2. If yes: Set `member.membership_fee_type_id`
|
||||
3. Future **unpaid** cycles: Delete and regenerate with new amount
|
||||
4. Paid/suspended cycles: Remain unchanged (historical amount)
|
||||
|
||||
**Future - Different Intervals:**
|
||||
|
||||
- Enable interval switching (e.g., yearly → monthly)
|
||||
- More complex logic for cycle overlaps
|
||||
- Needs additional validation
|
||||
|
||||
### Member Exit
|
||||
|
||||
**Logic:**
|
||||
|
||||
- Cycles only generated until `member.exit_date`
|
||||
- Existing cycles remain visible
|
||||
- Unpaid exit cycle can be marked as "suspended"
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
Exit: 15.08.2024
|
||||
Yearly cycle: 01.01.2024 - 31.12.2024
|
||||
|
||||
→ Cycle 2024 is shown (Status: unpaid)
|
||||
→ Admin can set to "suspended"
|
||||
→ No cycles for 2025+ generated
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI/UX Design
|
||||
|
||||
### Member List View
|
||||
|
||||
**New Column: "Membership Fee Status"**
|
||||
|
||||
**Default Display (Last Cycle):**
|
||||
|
||||
- Shows status of **last completed** cycle
|
||||
- Example in 2024: Shows membership fee for 2023
|
||||
- Color coding:
|
||||
- Green: paid ✓
|
||||
- Red: unpaid ✗
|
||||
- Gray: suspended ⊘
|
||||
|
||||
**Optional: Show Current Cycle**
|
||||
|
||||
- Toggle: "Show current cycle" (2024)
|
||||
- Admin decides what to display
|
||||
|
||||
**Filters:**
|
||||
|
||||
- "Unpaid membership fees in last cycle"
|
||||
- "Unpaid membership fees in current cycle"
|
||||
|
||||
### Member Detail View
|
||||
|
||||
**Section: "Membership Fees"**
|
||||
|
||||
**Membership Fee Type Assignment:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Membership Fee Type: [Dropdown] │
|
||||
│ ⚠ Only types with same interval │
|
||||
│ can be selected │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Cycle Table:**
|
||||
|
||||
```
|
||||
┌───────────────┬──────────┬────────┬──────────┬─────────┐
|
||||
│ Cycle │ Interval │ Amount │ Status │ Action │
|
||||
├───────────────┼──────────┼────────┼──────────┼─────────┤
|
||||
│ 01.01.2023- │ Yearly │ 50 € │ ☑ Paid │ │
|
||||
│ 31.12.2023 │ │ │ │ │
|
||||
├───────────────┼──────────┼────────┼──────────┼─────────┤
|
||||
│ 01.01.2024- │ Yearly │ 60 € │ ☐ Open │ [Mark │
|
||||
│ 31.12.2024 │ │ │ │ as paid]│
|
||||
├───────────────┼──────────┼────────┼──────────┼─────────┤
|
||||
│ 01.01.2025- │ Yearly │ 60 € │ ☐ Open │ [Mark │
|
||||
│ 31.12.2025 │ │ │ │ as paid]│
|
||||
└───────────────┴──────────┴────────┴──────────┴─────────┘
|
||||
|
||||
Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended
|
||||
```
|
||||
|
||||
**Quick Marking:**
|
||||
|
||||
- Checkbox in each row for fast marking
|
||||
- Button: "Mark selected as paid/unpaid/suspended"
|
||||
- Bulk action for multiple cycles
|
||||
|
||||
### Admin: Membership Fee Types Management
|
||||
|
||||
**List:**
|
||||
|
||||
```
|
||||
┌────────────┬──────────┬──────────┬────────────┬─────────┐
|
||||
│ Name │ Amount │ Interval │ Members │ Actions │
|
||||
├────────────┼──────────┼──────────┼────────────┼─────────┤
|
||||
│ Regular │ 60 € │ Yearly │ 45 │ [Edit] │
|
||||
│ Reduced │ 30 € │ Yearly │ 12 │ [Edit] │
|
||||
│ Student │ 20 € │ Monthly │ 8 │ [Edit] │
|
||||
└────────────┴──────────┴──────────┴────────────┴─────────┘
|
||||
```
|
||||
|
||||
**Edit:**
|
||||
|
||||
- Name: ✓ editable
|
||||
- Amount: ✓ editable
|
||||
- Description: ✓ editable
|
||||
- Interval: ✗ **NOT** editable (grayed out)
|
||||
|
||||
**Warning on Amount Change:**
|
||||
|
||||
```
|
||||
⚠ Change amount to 65 €?
|
||||
|
||||
Impact:
|
||||
- 45 members affected
|
||||
- Future unpaid cycles will be generated with 65 €
|
||||
- Already paid cycles remain with old amount
|
||||
|
||||
[Cancel] [Confirm]
|
||||
```
|
||||
|
||||
### Admin: Settings
|
||||
|
||||
**Membership Fee Configuration:**
|
||||
|
||||
```
|
||||
Default Membership Fee Type: [Dropdown: Membership Fee Types]
|
||||
|
||||
Selected: "Regular (60 €, Yearly)"
|
||||
|
||||
This membership fee type is automatically assigned to all new members.
|
||||
Can be changed individually per member.
|
||||
|
||||
---
|
||||
|
||||
☐ Include joining cycle
|
||||
|
||||
When active:
|
||||
Members pay from the cycle of their joining.
|
||||
|
||||
Example (Yearly):
|
||||
Joining: 15.03.2023
|
||||
→ Pays from 2023
|
||||
|
||||
When inactive:
|
||||
Members pay from the next full cycle.
|
||||
|
||||
Example (Yearly):
|
||||
Joining: 15.03.2023
|
||||
→ Pays from 2024
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### 1. Membership Fee Type Change with Different Interval
|
||||
|
||||
**MVP:** Blocked (only same interval allowed)
|
||||
|
||||
**UI:**
|
||||
|
||||
```
|
||||
Error: Interval change not possible
|
||||
|
||||
Current membership fee type: "Regular (Yearly)"
|
||||
Selected membership fee type: "Student (Monthly)"
|
||||
|
||||
Changing the interval is currently not possible.
|
||||
Please select a membership fee type with interval "Yearly".
|
||||
|
||||
[OK]
|
||||
```
|
||||
|
||||
**Future:**
|
||||
|
||||
- Allow interval switching
|
||||
- Calculate overlaps
|
||||
- Generate new cycles without duplicates
|
||||
|
||||
### 2. Exit with Unpaid Membership Fees
|
||||
|
||||
**Scenario:**
|
||||
|
||||
```
|
||||
Member exits: 15.08.2024
|
||||
Yearly cycle 2024: unpaid
|
||||
```
|
||||
|
||||
**UI Notice on Exit: (Low Prio)**
|
||||
|
||||
```
|
||||
⚠ Unpaid membership fees present
|
||||
|
||||
This member has 1 unpaid cycle(s):
|
||||
- 2024: 60 € (unpaid)
|
||||
|
||||
Do you want to continue?
|
||||
|
||||
[ ] Mark membership fee as "suspended"
|
||||
[Cancel] [Confirm Exit]
|
||||
```
|
||||
|
||||
### 3. Multiple Unpaid Cycles
|
||||
|
||||
**Scenario:** Member hasn't paid for 2 years
|
||||
|
||||
**Display:**
|
||||
|
||||
```
|
||||
┌───────────────┬──────────┬────────┬──────────┬─────────┐
|
||||
│ 2023 │ Yearly │ 50 € │ ☐ Open │ [✓] │
|
||||
│ 2024 │ Yearly │ 60 € │ ☐ Open │ [✓] │
|
||||
│ 2025 │ Yearly │ 60 € │ ☐ Open │ [ ] │
|
||||
└───────────────┴──────────┴────────┴──────────┴─────────┘
|
||||
|
||||
[Mark selected as paid/unpaid/suspended] (2 selected)
|
||||
```
|
||||
|
||||
### 4. Amount Changes
|
||||
|
||||
**Scenario:**
|
||||
|
||||
```
|
||||
2023: Regular = 50 €
|
||||
2024: Regular = 60 € (increase)
|
||||
```
|
||||
|
||||
**Result:**
|
||||
|
||||
- Cycle 2023: Saved with 50 € (history)
|
||||
- Cycle 2024: Generated with 60 € (current)
|
||||
- Both cycles show correct historical amount
|
||||
|
||||
### 5. Date Boundaries
|
||||
|
||||
**Problem:** What if today = 01.01.2025?
|
||||
|
||||
**Solution:**
|
||||
|
||||
- Current cycle (2025) is generated
|
||||
- Status: unpaid (open)
|
||||
- Shown in overview
|
||||
|
||||
---
|
||||
|
||||
## Implementation Scope
|
||||
|
||||
### MVP (Phase 1)
|
||||
|
||||
**Included:**
|
||||
|
||||
- ✓ Membership fee types (CRUD)
|
||||
- ✓ Automatic cycle generation
|
||||
- ✓ Status management (paid/unpaid/suspended)
|
||||
- ✓ Member overview with membership fee status
|
||||
- ✓ Cycle view per member
|
||||
- ✓ Quick checkbox marking
|
||||
- ✓ Bulk actions
|
||||
- ✓ Amount history
|
||||
- ✓ Same-interval type change
|
||||
- ✓ Default membership fee type
|
||||
- ✓ Joining cycle configuration
|
||||
|
||||
**NOT Included:**
|
||||
|
||||
- ✗ Interval change (only same interval)
|
||||
- ✗ Payment details (date, method)
|
||||
- ✗ Automatic integration (vereinfacht.digital)
|
||||
- ✗ Prorata calculation
|
||||
- ✗ Reports/statistics
|
||||
- ✗ Reminders/dunning (manual via filters)
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
**Phase 2:**
|
||||
|
||||
- Payment details (date, amount, method)
|
||||
- Interval change for future unpaid cycles
|
||||
- Manual vereinfacht.digital links per member
|
||||
- Extended filter options
|
||||
|
||||
**Phase 3:**
|
||||
|
||||
- Automated vereinfacht.digital integration
|
||||
- Automatic payment matching
|
||||
- SEPA integration
|
||||
- Advanced reports
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
# OIDC Account Linking Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This feature implements secure account linking between password-based accounts and OIDC authentication. When a user attempts to log in via OIDC with an email that already exists as a password-only account, the system requires password verification before linking the accounts.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Components
|
||||
|
||||
#### 1. Security Fix: `lib/accounts/user.ex`
|
||||
|
||||
**Change**: The `sign_in_with_rauthy` action now filters by `oidc_id` instead of `email`.
|
||||
|
||||
```elixir
|
||||
read :sign_in_with_rauthy do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
||||
# SECURITY: Filter by oidc_id, NOT by email!
|
||||
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
|
||||
end
|
||||
```
|
||||
|
||||
**Why**: Prevents OIDC users from bypassing password authentication and taking over existing accounts.
|
||||
|
||||
#### 2. Custom Error: `lib/accounts/user/errors/password_verification_required.ex`
|
||||
|
||||
Custom error raised when OIDC login conflicts with existing password account.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `user_id`: ID of the existing user
|
||||
- `oidc_user_info`: OIDC user information for account linking
|
||||
|
||||
#### 3. Validation: `lib/accounts/user/validations/oidc_email_collision.ex`
|
||||
|
||||
Validates email uniqueness during OIDC registration.
|
||||
|
||||
**Scenarios**:
|
||||
|
||||
1. **User exists with matching `oidc_id`**: Allow (upsert)
|
||||
2. **User exists without `oidc_id`** (password-protected OR passwordless): Raise `PasswordVerificationRequired`
|
||||
- The `LinkOidcAccountLive` will auto-link passwordless users without password prompt
|
||||
- Password-protected users must verify their password
|
||||
3. **User exists with different `oidc_id`**: Hard error (cannot link multiple OIDC providers)
|
||||
4. **No user exists**: Allow (new user creation)
|
||||
|
||||
#### 4. Account Linking Action: `lib/accounts/user.ex`
|
||||
|
||||
```elixir
|
||||
update :link_oidc_id do
|
||||
description "Links an OIDC ID to an existing user after password verification"
|
||||
accept []
|
||||
argument :oidc_id, :string, allow_nil?: false
|
||||
argument :oidc_user_info, :map, allow_nil?: false
|
||||
# ... implementation
|
||||
end
|
||||
```
|
||||
|
||||
**Features**:
|
||||
|
||||
- Links `oidc_id` to existing user
|
||||
- Updates email if it differs from OIDC provider
|
||||
- Syncs email changes to linked member
|
||||
|
||||
#### 5. Controller: `lib/mv_web/controllers/auth_controller.ex`
|
||||
|
||||
Refactored for better complexity and maintainability.
|
||||
|
||||
**Key improvements**:
|
||||
|
||||
- Reduced cyclomatic complexity from 11 to below 9
|
||||
- Better separation of concerns with helper functions
|
||||
- Comprehensive documentation
|
||||
|
||||
**Flow**:
|
||||
|
||||
1. Detects `PasswordVerificationRequired` error
|
||||
2. Stores OIDC info in session
|
||||
3. Redirects to account linking page
|
||||
|
||||
#### 6. LiveView: `lib/mv_web/live/auth/link_oidc_account_live.ex`
|
||||
|
||||
Interactive UI for password verification and account linking.
|
||||
|
||||
**Flow**:
|
||||
|
||||
1. Retrieves OIDC info from session
|
||||
2. **Auto-links passwordless users** immediately (no password prompt)
|
||||
3. Displays password verification form for password-protected users
|
||||
4. Verifies password using AshAuthentication
|
||||
5. Links OIDC account on success
|
||||
6. Redirects to complete OIDC login
|
||||
7. **Logs all security-relevant events** (successful/failed linking attempts)
|
||||
|
||||
### Locale Persistence
|
||||
|
||||
**Problem**: Locale was lost on logout (session cleared).
|
||||
|
||||
**Solution**: Store locale in persistent cookie (1 year TTL) with security flags.
|
||||
|
||||
**Changes**:
|
||||
|
||||
- `lib/mv_web/locale_controller.ex`: Sets locale cookie with `http_only` and `secure` flags
|
||||
- `lib/mv_web/router.ex`: Reads locale from cookie if session empty
|
||||
|
||||
**Security Features**:
|
||||
- `http_only: true` - Cookie not accessible via JavaScript (XSS protection)
|
||||
- `secure: true` - Cookie only transmitted over HTTPS in production
|
||||
- `same_site: "Lax"` - CSRF protection
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. OIDC ID Matching
|
||||
|
||||
- **Before**: Matched by email (vulnerable to account takeover)
|
||||
- **After**: Matched by `oidc_id` (secure)
|
||||
|
||||
### 2. Account Linking Flow
|
||||
|
||||
- Password verification required before linking (for password-protected users)
|
||||
- Passwordless users are auto-linked immediately (secure, as they have no password)
|
||||
- OIDC info stored in session (not in URL/query params)
|
||||
- CSRF protection on all forms
|
||||
- All linking attempts logged for audit trail
|
||||
|
||||
### 3. Email Updates
|
||||
|
||||
- Email updates from OIDC provider are applied during linking
|
||||
- Email changes sync to linked member (if exists)
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
- Internal errors are logged but not exposed to users (prevents information disclosure)
|
||||
- User-friendly error messages shown in UI
|
||||
- Security-relevant events logged with appropriate levels:
|
||||
- `Logger.info` for successful operations
|
||||
- `Logger.warning` for failed authentication attempts
|
||||
- `Logger.error` for system errors
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Scenario 1: New OIDC User
|
||||
|
||||
```elixir
|
||||
# User signs in with OIDC for the first time
|
||||
# → New user created with oidc_id
|
||||
```
|
||||
|
||||
### Scenario 2: Existing OIDC User
|
||||
|
||||
```elixir
|
||||
# User with oidc_id signs in via OIDC
|
||||
# → Matched by oidc_id, email updated if changed
|
||||
```
|
||||
|
||||
### Scenario 3: Password User + OIDC Login
|
||||
|
||||
```elixir
|
||||
# User with password account tries OIDC login
|
||||
# → PasswordVerificationRequired raised
|
||||
# → Redirected to /auth/link-oidc-account
|
||||
# → User enters password
|
||||
# → Password verified and logged
|
||||
# → oidc_id linked to account
|
||||
# → Successful linking logged
|
||||
# → Redirected to complete OIDC login
|
||||
```
|
||||
|
||||
### Scenario 4: Passwordless User + OIDC Login
|
||||
|
||||
```elixir
|
||||
# User without password (invited user) tries OIDC login
|
||||
# → PasswordVerificationRequired raised
|
||||
# → Redirected to /auth/link-oidc-account
|
||||
# → System detects passwordless user
|
||||
# → oidc_id automatically linked (no password prompt)
|
||||
# → Auto-linking logged
|
||||
# → Redirected to complete OIDC login
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Custom Actions
|
||||
|
||||
#### `link_oidc_id`
|
||||
|
||||
Links an OIDC ID to existing user after password verification.
|
||||
|
||||
**Arguments**:
|
||||
|
||||
- `oidc_id` (required): OIDC sub/id from provider
|
||||
- `oidc_user_info` (required): Full OIDC user info map
|
||||
|
||||
**Returns**: Updated user with linked `oidc_id`
|
||||
|
||||
**Side Effects**:
|
||||
|
||||
- Updates email if different from OIDC provider
|
||||
- Syncs email to linked member (if exists)
|
||||
|
||||
## References
|
||||
|
||||
- [AshAuthentication Documentation](https://hexdocs.pm/ash_authentication)
|
||||
- [OIDC Specification](https://openid.net/specs/openid-connect-core-1_0.html)
|
||||
- [Security Best Practices for Account Linking](https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html)
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,508 +0,0 @@
|
|||
# Roles and Permissions - Architecture Overview
|
||||
|
||||
**Project:** Mila - Membership Management System
|
||||
**Feature:** Role-Based Access Control (RBAC) with Hardcoded Permission Sets
|
||||
**Version:** 2.0
|
||||
**Last Updated:** 2025-11-13
|
||||
**Status:** Architecture Design - MVP Approach
|
||||
|
||||
---
|
||||
|
||||
## Purpose of This Document
|
||||
|
||||
This document provides a high-level, conceptual overview of the Roles and Permissions architecture without code examples. It is designed for quick understanding of architectural decisions and concepts.
|
||||
|
||||
**For detailed technical implementation:** See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Requirements Summary](#requirements-summary)
|
||||
3. [Evaluated Approaches](#evaluated-approaches)
|
||||
4. [Selected Architecture](#selected-architecture)
|
||||
5. [Permission System Design](#permission-system-design)
|
||||
6. [User-Member Linking Strategy](#user-member-linking-strategy)
|
||||
7. [Field-Level Permissions Strategy](#field-level-permissions-strategy)
|
||||
8. [Migration Strategy](#migration-strategy)
|
||||
9. [Related Documents](#related-documents)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Mila membership management system requires a flexible authorization system that controls:
|
||||
- **Who** can access **what** resources
|
||||
- **Which** pages users can view
|
||||
- **How** users interact with their own vs. others' data
|
||||
|
||||
### Key Design Principles
|
||||
|
||||
1. **Simplicity First:** Start with hardcoded permissions for fast MVP delivery
|
||||
2. **Performance:** No database queries for permission checks in MVP
|
||||
3. **Clear Migration Path:** Easy upgrade to database-backed permissions when needed
|
||||
4. **Security:** Explicit action-based authorization with no ambiguity
|
||||
5. **Maintainability:** Permission logic reviewable in Git, testable as pure functions
|
||||
|
||||
### Core Concepts
|
||||
|
||||
**Permission Set:** Defines a collection of permissions (e.g., "read_only", "admin")
|
||||
|
||||
**Role:** A named job function that references one Permission Set (e.g., "Vorstand" uses "read_only")
|
||||
|
||||
**User:** Each user has exactly one Role, inheriting that Role's Permission Set
|
||||
|
||||
**Scope:** Defines the breadth of access - "own" (only own data), "linked" (data connected to user), "all" (everything)
|
||||
|
||||
---
|
||||
|
||||
## Evaluated Approaches
|
||||
|
||||
During the design phase, we evaluated multiple implementation approaches to find the optimal balance between simplicity, performance, and future extensibility.
|
||||
|
||||
### Approach 1: JSONB in Roles Table
|
||||
|
||||
Store all permissions as a single JSONB column directly in the roles table.
|
||||
|
||||
**Advantages:**
|
||||
- Simplest database schema (single table)
|
||||
- Very flexible structure
|
||||
- No additional tables needed
|
||||
- Fast to implement
|
||||
|
||||
**Disadvantages:**
|
||||
- Poor queryability (can't efficiently filter by specific permissions)
|
||||
- No referential integrity
|
||||
- Difficult to validate structure
|
||||
- Hard to audit permission changes
|
||||
- Can't leverage database indexes effectively
|
||||
|
||||
**Verdict:** Rejected - Poor queryability makes it unsuitable for complex permission logic.
|
||||
|
||||
---
|
||||
|
||||
### Approach 2: Normalized Database Tables
|
||||
|
||||
Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization.
|
||||
|
||||
**Advantages:**
|
||||
- Fully queryable with SQL
|
||||
- Runtime configurable permissions
|
||||
- Strong referential integrity
|
||||
- Easy to audit changes
|
||||
- Can index for performance
|
||||
|
||||
**Disadvantages:**
|
||||
- Complex database schema (4+ tables)
|
||||
- DB queries required for every permission check
|
||||
- Requires ETS cache for performance
|
||||
- Needs admin UI for permission management
|
||||
- Longer implementation time (4-5 weeks)
|
||||
- Overkill for fixed set of 4 permission sets
|
||||
|
||||
**Verdict:** Deferred to Phase 3 - Excellent for runtime configuration but too complex for MVP.
|
||||
|
||||
---
|
||||
|
||||
### Approach 3: Custom Authorizer
|
||||
|
||||
Implement a custom Ash Authorizer from scratch instead of using Ash Policies.
|
||||
|
||||
**Advantages:**
|
||||
- Complete control over authorization logic
|
||||
- Can implement any custom behavior
|
||||
- Not constrained by Ash Policy DSL
|
||||
|
||||
**Disadvantages:**
|
||||
- Significantly more code to write and maintain
|
||||
- Loses benefits of Ash's declarative policies
|
||||
- Harder to test than built-in policy system
|
||||
- Mixes declarative and imperative approaches
|
||||
- Must reimplement filter generation for queries
|
||||
- Higher bug risk
|
||||
|
||||
**Verdict:** Rejected - Too much custom code, reduces maintainability and loses Ash ecosystem benefits.
|
||||
|
||||
---
|
||||
|
||||
### Approach 4: Simple Role Enum
|
||||
|
||||
Add a simple `:role` enum field directly on User resource with hardcoded checks in each policy.
|
||||
|
||||
**Advantages:**
|
||||
- Very simple to implement (< 1 week)
|
||||
- No extra tables needed
|
||||
- Fast performance
|
||||
- Easy to understand
|
||||
|
||||
**Disadvantages:**
|
||||
- No separation between roles and permissions
|
||||
- Can't add new roles without code changes
|
||||
- No dynamic permission configuration
|
||||
- Not extensible to field-level permissions
|
||||
- Violates separation of concerns (role = job function, not permission set)
|
||||
- Difficult to maintain as requirements grow
|
||||
|
||||
**Verdict:** Rejected - Too inflexible, doesn't meet requirement for configurable permissions and role separation.
|
||||
|
||||
---
|
||||
|
||||
### Approach 5: Hardcoded Permissions with Migration Path (SELECTED for MVP)
|
||||
|
||||
Permission Sets hardcoded in Elixir module, only Roles table in database.
|
||||
|
||||
**Advantages:**
|
||||
- Fast implementation (2-3 weeks vs 4-5 weeks)
|
||||
- Maximum performance (zero DB queries, < 1 microsecond)
|
||||
- Simple to test (pure functions)
|
||||
- Code-reviewable permissions (visible in Git)
|
||||
- No migration needed for existing data
|
||||
- Clearly defined 4 permission sets as required
|
||||
- Clear migration path to database-backed solution (Phase 3)
|
||||
- Maintains separation of roles and permission sets
|
||||
|
||||
**Disadvantages:**
|
||||
- Permissions not editable at runtime (only role assignment possible)
|
||||
- New permissions require code deployment
|
||||
- Not suitable if permissions change frequently (> 1x/week)
|
||||
- Limited to the 4 predefined permission sets
|
||||
|
||||
**Why Selected:**
|
||||
- MVP requirement is for 4 fixed permission sets (not custom ones)
|
||||
- No stated requirement for runtime permission editing
|
||||
- Performance is critical for authorization checks
|
||||
- Fast time-to-market (2-3 weeks)
|
||||
- Clear upgrade path when runtime configuration becomes necessary
|
||||
|
||||
**Migration Path:**
|
||||
When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module.
|
||||
|
||||
---
|
||||
|
||||
## Requirements Summary
|
||||
|
||||
### Four Predefined Permission Sets
|
||||
|
||||
1. **own_data** - Access only to own user account and linked member profile
|
||||
2. **read_only** - Read access to all members and custom fields
|
||||
3. **normal_user** - Create/Read/Update members and full CRUD on custom fields (no member deletion for safety)
|
||||
4. **admin** - Unrestricted access to all resources including user management
|
||||
|
||||
### Example Roles
|
||||
|
||||
- **Mitglied (Member)** - Uses "own_data" permission set, default role
|
||||
- **Vorstand (Board)** - Uses "read_only" permission set
|
||||
- **Kassenwart (Treasurer)** - Uses "normal_user" permission set
|
||||
- **Buchhaltung (Accounting)** - Uses "read_only" permission set
|
||||
- **Admin** - Uses "admin" permission set
|
||||
|
||||
### Authorization Levels
|
||||
|
||||
**Resource Level (MVP):**
|
||||
- Controls create, read, update, destroy actions on resources
|
||||
- Resources: Member, User, CustomFieldValue, CustomField, Role
|
||||
|
||||
**Page Level (MVP):**
|
||||
- Controls access to LiveView pages
|
||||
- Example: "/members/new" requires Member.create permission
|
||||
|
||||
**Field Level (Phase 2 - Future):**
|
||||
- Controls read/write access to specific fields
|
||||
- Example: Only Treasurer can see payment_history field
|
||||
|
||||
### Special Cases
|
||||
|
||||
1. **Own Credentials:** Users can always edit their own email and password
|
||||
2. **Linked Member Email:** Only admins can edit email of members linked to users
|
||||
3. **User-Member Linking:** Only admins can link/unlink users to members (except self-service creation)
|
||||
|
||||
---
|
||||
|
||||
## Selected Architecture
|
||||
|
||||
### Conceptual Model
|
||||
|
||||
```
|
||||
Elixir Module: PermissionSets
|
||||
↓ (defines)
|
||||
Permission Set (:own_data, :read_only, :normal_user, :admin)
|
||||
↓ (referenced by)
|
||||
Role (stored in DB: "Vorstand" → "read_only")
|
||||
↓ (assigned to)
|
||||
User (each user has one role_id)
|
||||
```
|
||||
|
||||
### Database Schema (MVP)
|
||||
|
||||
**Single Table: roles**
|
||||
|
||||
Contains:
|
||||
- id (UUID)
|
||||
- name (e.g., "Vorstand")
|
||||
- description
|
||||
- permission_set_name (String: "own_data", "read_only", "normal_user", "admin")
|
||||
- is_system_role (boolean, protects critical roles)
|
||||
|
||||
**No Permission Tables:** Permission Sets are hardcoded in Elixir module.
|
||||
|
||||
### Why This Approach?
|
||||
|
||||
**Fast Implementation:** 2-3 weeks instead of 4-5 weeks
|
||||
|
||||
**Maximum Performance:**
|
||||
- Zero database queries for permission checks
|
||||
- Pure function calls (< 1 microsecond)
|
||||
- No caching needed
|
||||
|
||||
**Code Review:**
|
||||
- Permissions visible in Git diffs
|
||||
- Easy to review changes
|
||||
- No accidental runtime modifications
|
||||
|
||||
**Clear Upgrade Path:**
|
||||
- Phase 1 (MVP): Hardcoded
|
||||
- Phase 2: Add field-level permissions
|
||||
- Phase 3: Migrate to database-backed with admin UI
|
||||
|
||||
**Meets Requirements:**
|
||||
- Four predefined permission sets ✓
|
||||
- Dynamic role creation ✓ (Roles in DB)
|
||||
- Role-to-user assignment ✓
|
||||
- No requirement for runtime permission changes stated
|
||||
|
||||
---
|
||||
|
||||
## Permission System Design
|
||||
|
||||
### Permission Structure
|
||||
|
||||
Each Permission Set contains:
|
||||
|
||||
**Resources:** List of resource permissions
|
||||
- resource: "Member", "User", "CustomFieldValue", etc.
|
||||
- action: :read, :create, :update, :destroy
|
||||
- scope: :own, :linked, :all
|
||||
- granted: true/false
|
||||
|
||||
**Pages:** List of accessible page paths
|
||||
- Examples: "/", "/members", "/members/:id/edit"
|
||||
- "*" for admin (all pages)
|
||||
|
||||
### Scope Definitions
|
||||
|
||||
**:own** - Only records where id == actor.id
|
||||
- Example: User can read their own User record
|
||||
|
||||
**:linked** - Only records linked to actor via relationships
|
||||
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
||||
- CustomFieldValue: `member_id == actor.member_id` (traverses Member → User relationship)
|
||||
- Example: User can read Member linked to their account
|
||||
|
||||
**:all** - All records without restriction
|
||||
- Example: Admin can read all Members
|
||||
|
||||
### How Authorization Works
|
||||
|
||||
1. User attempts action on resource (e.g., read Member)
|
||||
2. System loads user's role from database
|
||||
3. Role contains permission_set_name string
|
||||
4. PermissionSets module returns permissions for that set
|
||||
5. Custom Policy Check evaluates permissions against action
|
||||
6. Access granted or denied based on scope
|
||||
|
||||
### Custom Policy Check
|
||||
|
||||
A reusable Ash Policy Check that:
|
||||
- Reads user's permission_set_name from their role
|
||||
- Calls PermissionSets.get_permissions/1
|
||||
- Matches resource + action against permissions list
|
||||
- Applies scope filters (own/linked/all)
|
||||
- Returns authorized, forbidden, or filtered query
|
||||
|
||||
---
|
||||
|
||||
## User-Member Linking Strategy
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Users need to create member profiles for themselves (self-service), but only admins should be able to:
|
||||
- Link existing members to users
|
||||
- Unlink members from users
|
||||
- Create members pre-linked to arbitrary users
|
||||
|
||||
### Selected Approach: Separate Ash Actions
|
||||
|
||||
Instead of complex field-level validation, we use action-based authorization.
|
||||
|
||||
### Actions on Member Resource
|
||||
|
||||
**1. create_member_for_self** (All authenticated users)
|
||||
- Automatically sets user_id = actor.id
|
||||
- User cannot specify different user_id
|
||||
- UI: "Create My Profile" button
|
||||
|
||||
**2. create_member** (Admin only)
|
||||
- Can set user_id to any user or leave unlinked
|
||||
- Full flexibility for admin
|
||||
- UI: Admin member management form
|
||||
|
||||
**3. link_member_to_user** (Admin only)
|
||||
- Updates existing member to set user_id
|
||||
- Connects unlinked member to user account
|
||||
|
||||
**4. unlink_member_from_user** (Admin only)
|
||||
- Sets user_id to nil
|
||||
- Disconnects member from user account
|
||||
|
||||
**5. update** (Permission-based)
|
||||
- Normal updates (name, address, etc.)
|
||||
- user_id NOT in accept list (prevents manipulation)
|
||||
- Available to users with Member.update permission
|
||||
|
||||
### Why Separate Actions?
|
||||
|
||||
**Explicit Semantics:** Each action has clear, single purpose
|
||||
|
||||
**Server-Side Security:** user_id set by server, not client input
|
||||
|
||||
**Better UX:** Different UI flows for different use cases
|
||||
|
||||
**Simple Policies:** Authorization at action level, not field level
|
||||
|
||||
**Easy Testing:** Each action independently testable
|
||||
|
||||
---
|
||||
|
||||
## Field-Level Permissions Strategy
|
||||
|
||||
### Status: Phase 2 (Future Implementation)
|
||||
|
||||
Field-level permissions are NOT implemented in MVP but have a clear strategy defined.
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Some scenarios require field-level control:
|
||||
- **Read restrictions:** Hide payment_history from certain roles
|
||||
- **Write restrictions:** Only treasurer can edit payment fields
|
||||
- **Complexity:** Ash Policies work at resource level, not field level
|
||||
|
||||
### Selected Strategy
|
||||
|
||||
**For Read Restrictions:**
|
||||
Use Ash Calculations or Custom Preparations
|
||||
- Calculations: Dynamically compute field based on permissions
|
||||
- Preparations: Filter select to only allowed fields
|
||||
- Field returns nil or "[Hidden]" if unauthorized
|
||||
|
||||
**For Write Restrictions:**
|
||||
Use Custom Validations
|
||||
- Validate changeset against field permissions
|
||||
- Similar to existing linked-member email validation
|
||||
- Return error if field modification not allowed
|
||||
|
||||
### Why This Strategy?
|
||||
|
||||
**Leverages Ash Features:** Uses built-in mechanisms, not custom authorizer
|
||||
|
||||
**Performance:** Calculations are lazy, Preparations run once per query
|
||||
|
||||
**Maintainable:** Clear validation logic, standard Ash patterns
|
||||
|
||||
**Extensible:** Easy to add new field restrictions
|
||||
|
||||
### Implementation Timeline
|
||||
|
||||
**Phase 1 (MVP):** No field-level permissions
|
||||
|
||||
**Phase 2:** Extend PermissionSets to include field permissions, implement Calculations/Validations
|
||||
|
||||
**Phase 3:** If migrating to database, add permission_set_fields table
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: MVP with Hardcoded Permissions (2-3 weeks)
|
||||
|
||||
**What's Included:**
|
||||
- Roles table in database
|
||||
- PermissionSets Elixir module with 4 predefined sets
|
||||
- Custom Policy Check reading from module
|
||||
- UI Authorization Helpers for LiveView
|
||||
- Admin UI for role management (create, assign, delete roles)
|
||||
|
||||
**Limitations:**
|
||||
- Permissions not editable at runtime
|
||||
- New permissions require code deployment
|
||||
- Only 4 permission sets available
|
||||
|
||||
**Benefits:**
|
||||
- Fast implementation
|
||||
- Maximum performance
|
||||
- Simple testing and review
|
||||
|
||||
### Phase 2: Field-Level Permissions (Future, 2-3 weeks)
|
||||
|
||||
**When Needed:** Business requires field-level restrictions
|
||||
|
||||
**Implementation:**
|
||||
- Extend PermissionSets module with :fields key
|
||||
- Add Ash Calculations for read restrictions
|
||||
- Add custom validations for write restrictions
|
||||
- Update UI Helpers
|
||||
|
||||
**Migration:** No database changes, pure code additions
|
||||
|
||||
### Phase 3: Database-Backed Permissions (Future, 3-4 weeks)
|
||||
|
||||
**When Needed:** Runtime permission configuration required
|
||||
|
||||
**Implementation:**
|
||||
- Create permission tables in database
|
||||
- Seed script to migrate hardcoded permissions
|
||||
- Update PermissionSets module to query database
|
||||
- Add ETS cache for performance
|
||||
- Build admin UI for permission management
|
||||
|
||||
**Migration:** Seamless, no changes to existing Policies or UI code
|
||||
|
||||
### Decision Matrix: When to Migrate?
|
||||
|
||||
| Scenario | Recommended Phase |
|
||||
|----------|-------------------|
|
||||
| MVP with 4 fixed permission sets | Phase 1 |
|
||||
| Need field-level restrictions | Phase 2 |
|
||||
| Permission changes < 1x/month | Stay Phase 1 |
|
||||
| Need runtime permission config | Phase 3 |
|
||||
| Custom permission sets needed | Phase 3 |
|
||||
| Permission changes > 1x/week | Phase 3 |
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
**This Document (Overview):** High-level concepts, no code examples
|
||||
|
||||
**[roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md):** Complete technical specification with code examples
|
||||
|
||||
**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Detailed implementation plan with TDD approach
|
||||
|
||||
**[CODE_GUIDELINES.md](../CODE_GUIDELINES.md):** Project coding standards
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The selected architecture uses **hardcoded Permission Sets in Elixir** for the MVP, providing:
|
||||
- **Speed:** 2-3 weeks implementation vs 4-5 weeks
|
||||
- **Performance:** Zero database queries for authorization
|
||||
- **Clarity:** Permissions in Git, reviewable and testable
|
||||
- **Flexibility:** Clear migration path to database-backed system
|
||||
|
||||
**User-Member linking** uses **separate Ash Actions** for clarity and security.
|
||||
|
||||
**Field-level permissions** have a **defined strategy** (Calculations + Validations) for Phase 2 implementation.
|
||||
|
||||
The approach balances pragmatism for MVP delivery with extensibility for future requirements.
|
||||
|
||||
|
|
@ -1,747 +0,0 @@
|
|||
# Sidebar Analysis - Current State
|
||||
|
||||
**Erstellt:** 2025-12-16
|
||||
**Status:** Analyse für Neuimplementierung
|
||||
**Autor:** Cursor AI Assistant
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Die aktuelle Sidebar-Implementierung verwendet **nicht existierende Custom-CSS-Variants** (`is-drawer-close:` und `is-drawer-open:`), was zu einer defekten Implementierung führt. Die Sidebar ist strukturell basierend auf DaisyUI's Drawer-Komponente, aber die responsive und state-basierte Funktionalität ist nicht funktionsfähig.
|
||||
|
||||
**Kritisches Problem:** Die im Code verwendeten Variants `is-drawer-close:*` und `is-drawer-open:*` sind **nicht in Tailwind konfiguriert**, was bedeutet, dass diese Klassen beim Build ignoriert werden.
|
||||
|
||||
---
|
||||
|
||||
## 1. Dateien-Übersicht
|
||||
|
||||
### 1.1 Hauptdateien
|
||||
|
||||
| Datei | Zweck | Zeilen | Status |
|
||||
|-------|-------|--------|--------|
|
||||
| `lib/mv_web/components/layouts/sidebar.ex` | Sidebar-Komponente (Elixir) | 198 | ⚠️ Verwendet nicht existierende Variants |
|
||||
| `lib/mv_web/components/layouts/navbar.ex` | Navbar mit Sidebar-Toggle | 48 | ✅ Funktional |
|
||||
| `lib/mv_web/components/layouts.ex` | Layout-Wrapper mit Drawer | 121 | ✅ Funktional |
|
||||
| `assets/js/app.js` | JavaScript für Sidebar-Interaktivität | 272 | ✅ Umfangreiche Accessibility-Logik |
|
||||
| `assets/css/app.css` | CSS-Konfiguration | 103 | ⚠️ Keine Drawer-Variants definiert |
|
||||
| `assets/tailwind.config.js` | Tailwind-Konfiguration | 75 | ⚠️ Keine Drawer-Variants definiert |
|
||||
|
||||
### 1.2 Verwandte Dateien
|
||||
|
||||
- `lib/mv_web/components/layouts/root.html.heex` - Root-Layout (minimal, keine Sidebar-Logik)
|
||||
- `priv/static/images/logo.svg` - Logo (wird vermutlich für Sidebar benötigt)
|
||||
|
||||
---
|
||||
|
||||
## 2. Aktuelle Struktur
|
||||
|
||||
### 2.1 HTML-Struktur (DaisyUI Drawer Pattern)
|
||||
|
||||
```html
|
||||
<!-- In layouts.ex -->
|
||||
<div class="drawer">
|
||||
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<div class="drawer-content">
|
||||
<!-- Navbar mit Toggle-Button -->
|
||||
<navbar with sidebar-toggle button />
|
||||
|
||||
<!-- Hauptinhalt -->
|
||||
<main>...</main>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="drawer-side">
|
||||
<button class="drawer-overlay" onclick="close drawer"></button>
|
||||
<nav id="main-sidebar">
|
||||
<!-- Navigation Items -->
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Bewertung:** ✅ Korrekte DaisyUI Drawer-Struktur
|
||||
|
||||
### 2.2 Sidebar-Komponente (`sidebar.ex`)
|
||||
|
||||
**Struktur:**
|
||||
```elixir
|
||||
defmodule MvWeb.Layouts.Sidebar do
|
||||
attr :current_user, :map
|
||||
attr :club_name, :string
|
||||
|
||||
def sidebar(assigns) do
|
||||
# Rendert Sidebar mit Navigation, Locale-Selector, Theme-Toggle, User-Menu
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Hauptelemente:**
|
||||
1. **Drawer Overlay** - Button zum Schließen (Mobile)
|
||||
2. **Navigation Container** (`<nav id="main-sidebar">`)
|
||||
3. **Menü-Items** - Members, Users, Contributions (nested), Settings
|
||||
4. **Footer-Bereich** - Locale-Selector, Theme-Toggle, User-Menu
|
||||
|
||||
---
|
||||
|
||||
## 3. Custom CSS Variants - KRITISCHES PROBLEM
|
||||
|
||||
### 3.1 Verwendete Variants im Code
|
||||
|
||||
Die Sidebar verwendet folgende Custom-Variants **extensiv**:
|
||||
|
||||
```elixir
|
||||
# Beispiele aus sidebar.ex
|
||||
"is-drawer-close:overflow-visible"
|
||||
"is-drawer-close:w-14 is-drawer-open:w-64"
|
||||
"is-drawer-close:hidden"
|
||||
"is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
||||
"is-drawer-close:w-auto"
|
||||
"is-drawer-close:justify-center"
|
||||
"is-drawer-close:dropdown-end"
|
||||
```
|
||||
|
||||
**Gefundene Verwendungen:**
|
||||
- `is-drawer-close:` - 13 Instanzen in sidebar.ex
|
||||
- `is-drawer-open:` - 1 Instanz in sidebar.ex
|
||||
|
||||
### 3.2 Definition der Variants
|
||||
|
||||
**❌ NICHT GEFUNDEN in:**
|
||||
- `assets/css/app.css` - Enthält nur `phx-*-loading` Variants
|
||||
- `assets/tailwind.config.js` - Enthält nur `phx-*-loading` Variants
|
||||
|
||||
**Fazit:** Diese Variants existieren **nicht** und werden beim Tailwind-Build **ignoriert**!
|
||||
|
||||
### 3.3 Vorhandene Variants
|
||||
|
||||
Nur folgende Custom-Variants sind tatsächlich definiert:
|
||||
|
||||
```css
|
||||
/* In app.css (Tailwind CSS 4.x Syntax) */
|
||||
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
|
||||
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
|
||||
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
|
||||
```
|
||||
|
||||
```javascript
|
||||
/* In tailwind.config.js (Tailwind 3.x Kompatibilität) */
|
||||
plugin(({addVariant}) => addVariant("phx-click-loading", [...])),
|
||||
plugin(({addVariant}) => addVariant("phx-submit-loading", [...])),
|
||||
plugin(({addVariant}) => addVariant("phx-change-loading", [...])),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. JavaScript-Implementierung
|
||||
|
||||
### 4.1 Übersicht
|
||||
|
||||
Die JavaScript-Implementierung ist **sehr umfangreich** und fokussiert auf Accessibility:
|
||||
|
||||
**Datei:** `assets/js/app.js` (Zeilen 106-270)
|
||||
|
||||
**Hauptfunktionalitäten:**
|
||||
1. ✅ Tabindex-Management für fokussierbare Elemente
|
||||
2. ✅ ARIA-Attribut-Management (`aria-expanded`)
|
||||
3. ✅ Keyboard-Navigation (Enter, Space, Escape)
|
||||
4. ✅ Focus-Management beim Öffnen/Schließen
|
||||
5. ✅ Dropdown-Integration
|
||||
|
||||
### 4.2 Wichtige JavaScript-Funktionen
|
||||
|
||||
#### 4.2.1 Tabindex-Management
|
||||
|
||||
```javascript
|
||||
const updateSidebarTabIndex = (isOpen) => {
|
||||
const allFocusableElements = sidebar.querySelectorAll(
|
||||
'a[href], button, select, input:not([type="hidden"]), [tabindex]'
|
||||
)
|
||||
|
||||
allFocusableElements.forEach(el => {
|
||||
if (isOpen) {
|
||||
// Make focusable when open
|
||||
el.removeAttribute('tabindex')
|
||||
} else {
|
||||
// Remove from tab order when closed
|
||||
el.setAttribute('tabindex', '-1')
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Zweck:** Verhindert, dass Nutzer mit Tab zu unsichtbaren Sidebar-Elementen springen können.
|
||||
|
||||
#### 4.2.2 ARIA-Expanded Management
|
||||
|
||||
```javascript
|
||||
const updateAriaExpanded = () => {
|
||||
const isOpen = drawerToggle.checked
|
||||
sidebarToggle.setAttribute("aria-expanded", isOpen.toString())
|
||||
}
|
||||
```
|
||||
|
||||
**Zweck:** Informiert Screen-Reader über den Sidebar-Status.
|
||||
|
||||
#### 4.2.3 Focus-Management
|
||||
|
||||
```javascript
|
||||
const getFirstFocusableElement = () => {
|
||||
// Priority: navigation link > other links > other focusable
|
||||
const firstNavLink = sidebar.querySelector('a[href][role="menuitem"]')
|
||||
// ... fallback logic
|
||||
}
|
||||
|
||||
// On open: focus first element
|
||||
// On close: focus toggle button
|
||||
```
|
||||
|
||||
**Zweck:** Logische Fokus-Reihenfolge für Keyboard-Navigation.
|
||||
|
||||
#### 4.2.4 Keyboard-Shortcuts
|
||||
|
||||
```javascript
|
||||
// ESC to close
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && drawerToggle.checked) {
|
||||
drawerToggle.checked = false
|
||||
sidebarToggle.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// Enter/Space on toggle button
|
||||
sidebarToggle.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
// Toggle drawer and manage focus
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 4.3 LiveView Hooks
|
||||
|
||||
**Definierte Hooks:**
|
||||
```javascript
|
||||
Hooks.CopyToClipboard = { ... } // Clipboard-Funktionalität
|
||||
Hooks.ComboBox = { ... } // Dropdown-Prävention bei Enter
|
||||
```
|
||||
|
||||
**Sidebar-spezifisch:** Keine Hooks, nur native DOM-Events.
|
||||
|
||||
---
|
||||
|
||||
## 5. DaisyUI Dependencies
|
||||
|
||||
### 5.1 Verwendete DaisyUI-Komponenten
|
||||
|
||||
| Komponente | Verwendung | Klassen |
|
||||
|------------|-----------|---------|
|
||||
| **Drawer** | Basis-Layout | `drawer`, `drawer-toggle`, `drawer-side`, `drawer-content`, `drawer-overlay` |
|
||||
| **Menu** | Navigation | `menu`, `menu-title`, `w-64` |
|
||||
| **Button** | Toggle, User-Menu | `btn`, `btn-ghost`, `btn-square`, `btn-circle` |
|
||||
| **Avatar** | User-Menu | `avatar`, `avatar-placeholder` |
|
||||
| **Dropdown** | User-Menu | `dropdown`, `dropdown-top`, `dropdown-end`, `dropdown-content` |
|
||||
| **Tooltip** | Icon-Tooltips | `tooltip`, `tooltip-right` (via `data-tip`) |
|
||||
| **Select** | Locale-Selector | `select`, `select-sm` |
|
||||
| **Toggle** | Theme-Switch | `toggle`, `theme-controller` |
|
||||
|
||||
### 5.2 Standard Tailwind-Klassen
|
||||
|
||||
**Layout:**
|
||||
- `flex`, `flex-col`, `items-start`, `justify-center`
|
||||
- `gap-2`, `gap-4`, `p-4`, `mt-auto`, `w-full`, `w-64`, `min-h-full`
|
||||
|
||||
**Sizing:**
|
||||
- `size-4`, `size-5`, `w-12`, `w-52`
|
||||
|
||||
**Colors:**
|
||||
- `bg-base-100`, `bg-base-200`, `text-neutral-content`
|
||||
|
||||
**Typography:**
|
||||
- `text-lg`, `text-sm`, `font-bold`
|
||||
|
||||
**Accessibility:**
|
||||
- `sr-only`, `focus:outline-none`, `focus:ring-2`, `focus:ring-primary`
|
||||
|
||||
---
|
||||
|
||||
## 6. Toggle-Button (Navbar)
|
||||
|
||||
### 6.1 Implementierung
|
||||
|
||||
**Datei:** `lib/mv_web/components/layouts/navbar.ex`
|
||||
|
||||
```elixir
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('main-drawer').checked = !document.getElementById('main-drawer').checked"
|
||||
aria-label={gettext("Toggle navigation menu")}
|
||||
aria-expanded="false"
|
||||
aria-controls="main-sidebar"
|
||||
id="sidebar-toggle"
|
||||
class="mr-2 btn btn-square btn-ghost"
|
||||
>
|
||||
<svg><!-- Layout-Panel-Left Icon --></svg>
|
||||
</button>
|
||||
```
|
||||
|
||||
**Funktionalität:**
|
||||
- ✅ Togglet Drawer-Checkbox
|
||||
- ✅ ARIA-Labels vorhanden
|
||||
- ✅ Keyboard-accessible
|
||||
- ⚠️ `aria-expanded` wird durch JavaScript aktualisiert
|
||||
|
||||
**Icon:** Custom SVG (Layout-Panel-Left mit Chevron-Right)
|
||||
|
||||
---
|
||||
|
||||
## 7. Responsive Verhalten
|
||||
|
||||
### 7.1 Aktuelles Konzept (nicht funktional)
|
||||
|
||||
**Versuchte Implementierung:**
|
||||
- **Desktop (collapsed):** Sidebar mit 14px Breite (`is-drawer-close:w-14`)
|
||||
- **Desktop (expanded):** Sidebar mit 64px Breite (`is-drawer-open:w-64`)
|
||||
- **Mobile:** Overlay-Drawer (DaisyUI Standard)
|
||||
|
||||
### 7.2 Problem
|
||||
|
||||
Da die `is-drawer-*` Variants nicht existieren, gibt es **kein responsives Verhalten**:
|
||||
- Die Sidebar hat immer eine feste Breite von `w-64`
|
||||
- Die conditional hiding (`:hidden`, etc.) funktioniert nicht
|
||||
- Tooltips werden nicht conditional angezeigt
|
||||
|
||||
---
|
||||
|
||||
## 8. Accessibility-Features
|
||||
|
||||
### 8.1 Implementierte Features
|
||||
|
||||
| Feature | Status | Implementierung |
|
||||
|---------|--------|-----------------|
|
||||
| **ARIA Labels** | ✅ | Alle interaktiven Elemente haben Labels |
|
||||
| **ARIA Roles** | ✅ | `menubar`, `menuitem`, `menu`, `button` |
|
||||
| **ARIA Expanded** | ✅ | Wird durch JS dynamisch gesetzt |
|
||||
| **ARIA Controls** | ✅ | Toggle → Sidebar verknüpft |
|
||||
| **Keyboard Navigation** | ✅ | Enter, Space, Escape, Tab |
|
||||
| **Focus Management** | ✅ | Logische Focus-Reihenfolge |
|
||||
| **Tabindex Management** | ✅ | Verhindert Focus auf hidden Elements |
|
||||
| **Screen Reader Only** | ✅ | `.sr-only` für visuelle Labels |
|
||||
| **Focus Indicators** | ✅ | `focus:ring-2 focus:ring-primary` |
|
||||
| **Skip Links** | ❌ | Nicht vorhanden |
|
||||
|
||||
### 8.2 Accessibility-Score
|
||||
|
||||
**Geschätzt:** 90/100 (WCAG 2.1 Level AA konform)
|
||||
|
||||
**Verbesserungspotenzial:**
|
||||
- Skip-Link zur Hauptnavigation hinzufügen
|
||||
- High-Contrast-Mode testen
|
||||
|
||||
---
|
||||
|
||||
## 9. Menü-Struktur
|
||||
|
||||
### 9.1 Navigation Items
|
||||
|
||||
```
|
||||
📋 Main Menu
|
||||
├── 👥 Members (/members)
|
||||
├── 👤 Users (/users)
|
||||
├── 💰 Contributions (collapsed submenu)
|
||||
│ ├── Plans (/contribution_types)
|
||||
│ └── Settings (/contribution_settings)
|
||||
└── ⚙️ Settings (/settings)
|
||||
|
||||
🔽 Footer Area (logged in only)
|
||||
├── 🌐 Locale Selector (DE/EN)
|
||||
├── 🌓 Theme Toggle (Light/Dark)
|
||||
└── 👤 User Menu (Dropdown)
|
||||
├── Profile (/users/:id)
|
||||
└── Logout (/sign-out)
|
||||
```
|
||||
|
||||
### 9.2 Conditional Rendering
|
||||
|
||||
**Nicht eingeloggt:**
|
||||
- Sidebar ist leer (nur Struktur)
|
||||
- Keine Menü-Items
|
||||
|
||||
**Eingeloggt:**
|
||||
- Vollständige Navigation
|
||||
- Footer-Bereich mit User-Menu
|
||||
|
||||
### 9.3 Nested Menu (Contributions)
|
||||
|
||||
**Problem:** Das Contributions-Submenu ist **immer versteckt** im collapsed State:
|
||||
|
||||
```elixir
|
||||
<li class="is-drawer-close:hidden" role="none">
|
||||
<h2 class="flex items-center gap-2 menu-title">
|
||||
<.icon name="hero-currency-dollar" />
|
||||
{gettext("Contributions")}
|
||||
</h2>
|
||||
<ul role="menu">
|
||||
<li class="is-drawer-close:hidden">...</li>
|
||||
<li class="is-drawer-close:hidden">...</li>
|
||||
</ul>
|
||||
</li>
|
||||
```
|
||||
|
||||
Da `:hidden` nicht funktioniert, wird das Submenu immer angezeigt.
|
||||
|
||||
---
|
||||
|
||||
## 10. Theme-Funktionalität
|
||||
|
||||
### 10.1 Theme-Toggle
|
||||
|
||||
```elixir
|
||||
<input
|
||||
type="checkbox"
|
||||
value="dark"
|
||||
class="toggle theme-controller"
|
||||
aria-label={gettext("Toggle dark mode")}
|
||||
/>
|
||||
```
|
||||
|
||||
**Funktionalität:**
|
||||
- ✅ DaisyUI `theme-controller` - automatische Theme-Umschaltung
|
||||
- ✅ Persistence durch `localStorage` (siehe root.html.heex Script)
|
||||
- ✅ Icon-Wechsel (Sun ↔ Moon)
|
||||
|
||||
### 10.2 Definierte Themes
|
||||
|
||||
**Datei:** `assets/css/app.css`
|
||||
|
||||
1. **Light Theme** (default)
|
||||
- Base: `oklch(98% 0 0)`
|
||||
- Primary: `oklch(70% 0.213 47.604)` (Orange/Phoenix-inspiriert)
|
||||
|
||||
2. **Dark Theme**
|
||||
- Base: `oklch(30.33% 0.016 252.42)`
|
||||
- Primary: `oklch(58% 0.233 277.117)` (Purple/Elixir-inspiriert)
|
||||
|
||||
---
|
||||
|
||||
## 11. Locale-Funktionalität
|
||||
|
||||
### 11.1 Locale-Selector
|
||||
|
||||
```elixir
|
||||
<form method="post" action="/set_locale">
|
||||
<select
|
||||
id="locale-select-sidebar"
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm w-full is-drawer-close:w-auto"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Funktionalität:**
|
||||
- ✅ POST zu `/set_locale` Endpoint
|
||||
- ✅ CSRF-Token included
|
||||
- ✅ Auto-Submit on change
|
||||
- ✅ Accessible Label (`.sr-only`)
|
||||
|
||||
---
|
||||
|
||||
## 12. Probleme und Defekte
|
||||
|
||||
### 12.1 Kritische Probleme
|
||||
|
||||
| Problem | Schweregrad | Details |
|
||||
|---------|-------------|---------|
|
||||
| **Nicht existierende CSS-Variants** | 🔴 Kritisch | `is-drawer-close:*` und `is-drawer-open:*` sind nicht definiert |
|
||||
| **Keine responsive Funktionalität** | 🔴 Kritisch | Sidebar verhält sich nicht wie geplant |
|
||||
| **Conditional Styles funktionieren nicht** | 🔴 Kritisch | Hidden/Tooltip/Width-Changes werden ignoriert |
|
||||
|
||||
### 12.2 Mittlere Probleme
|
||||
|
||||
| Problem | Schweregrad | Details |
|
||||
|---------|-------------|---------|
|
||||
| **Kein Logo** | 🟡 Mittel | Logo-Element fehlt komplett in der Sidebar |
|
||||
| **Submenu immer sichtbar** | 🟡 Mittel | Contributions-Submenu sollte in collapsed State versteckt sein |
|
||||
| **Toggle-Icon statisch** | 🟡 Mittel | Icon ändert sich nicht zwischen expanded/collapsed |
|
||||
|
||||
### 12.3 Kleinere Probleme
|
||||
|
||||
| Problem | Schweregrad | Details |
|
||||
|---------|-------------|---------|
|
||||
| **Code-Redundanz** | 🟢 Klein | Variants in beiden Tailwind-Configs (3.x und 4.x) |
|
||||
| **Inline-onclick Handler** | 🟢 Klein | Sollten durch JS-Events ersetzt werden |
|
||||
| **Keine Skip-Links** | 🟢 Klein | Accessibility-Verbesserung |
|
||||
|
||||
---
|
||||
|
||||
## 13. Abhängigkeiten
|
||||
|
||||
### 13.1 Externe Abhängigkeiten
|
||||
|
||||
| Dependency | Version | Verwendung |
|
||||
|------------|---------|------------|
|
||||
| **DaisyUI** | Latest (vendor) | Drawer, Menu, Button, etc. |
|
||||
| **Tailwind CSS** | 4.0.9 | Utility-Klassen |
|
||||
| **Heroicons** | v2.2.0 | Icons in Navigation |
|
||||
| **Phoenix LiveView** | ~> 1.1.0 | Backend-Integration |
|
||||
|
||||
### 13.2 Interne Abhängigkeiten
|
||||
|
||||
| Modul | Verwendung |
|
||||
|-------|-----------|
|
||||
| `MvWeb.Gettext` | Internationalisierung |
|
||||
| `Mv.Membership.get_settings()` | Club-Name abrufen |
|
||||
| `MvWeb.CoreComponents` | Icons, Links |
|
||||
|
||||
---
|
||||
|
||||
## 14. Code-Qualität
|
||||
|
||||
### 14.1 Positives
|
||||
|
||||
- ✅ **Sehr gute Accessibility-Implementierung**
|
||||
- ✅ **Saubere Modulstruktur** (Separation of Concerns)
|
||||
- ✅ **Gute Dokumentation** (Moduledocs, Attribute docs)
|
||||
- ✅ **Internationalisierung** vollständig implementiert
|
||||
- ✅ **ARIA-Best-Practices** befolgt
|
||||
- ✅ **Keyboard-Navigation** umfassend
|
||||
|
||||
### 14.2 Verbesserungsbedarf
|
||||
|
||||
- ❌ **Broken CSS-Variants** (Hauptproblem)
|
||||
- ❌ **Fehlende Tests** (keine Component-Tests gefunden)
|
||||
- ⚠️ **Inline-JavaScript** in onclick-Attributen
|
||||
- ⚠️ **Magic-IDs** (`main-drawer`, `sidebar-toggle`) hardcoded
|
||||
- ⚠️ **Komplexe JavaScript-Logik** ohne Dokumentation
|
||||
|
||||
---
|
||||
|
||||
## 15. Empfehlungen für Neuimplementierung
|
||||
|
||||
### 15.1 Sofort-Maßnahmen
|
||||
|
||||
1. **CSS-Variants entfernen**
|
||||
- Alle `is-drawer-close:*` und `is-drawer-open:*` entfernen
|
||||
- Durch Standard-Tailwind oder DaisyUI-Mechanismen ersetzen
|
||||
|
||||
2. **Logo hinzufügen**
|
||||
- Logo-Element als erstes Element in Sidebar
|
||||
- Konsistente Größe (32px / size-8)
|
||||
|
||||
3. **Toggle-Icon implementieren**
|
||||
- Icon-Swap zwischen Chevron-Left und Chevron-Right
|
||||
- Nur auf Desktop sichtbar
|
||||
|
||||
### 15.2 Architektur-Entscheidungen
|
||||
|
||||
1. **Responsive Strategie:**
|
||||
- **Mobile:** Standard DaisyUI Drawer (Overlay)
|
||||
- **Desktop:** Persistent Sidebar mit fester Breite
|
||||
- **Kein collapsing auf Desktop** (einfacher, wartbarer)
|
||||
|
||||
2. **State-Management:**
|
||||
- Drawer-Checkbox für Mobile
|
||||
- Keine zusätzlichen Custom-Variants
|
||||
- Standard DaisyUI-Mechanismen verwenden
|
||||
|
||||
3. **JavaScript-Refactoring:**
|
||||
- Hooks statt inline-onclick
|
||||
- Dokumentierte Funktionen
|
||||
- Unit-Tests für kritische Logik
|
||||
|
||||
### 15.3 Prioritäten
|
||||
|
||||
**High Priority:**
|
||||
1. CSS-Variants-Problem lösen
|
||||
2. Logo implementieren
|
||||
3. Basic responsive Funktionalität
|
||||
|
||||
**Medium Priority:**
|
||||
4. Toggle-Icon implementieren
|
||||
5. Tests schreiben
|
||||
6. JavaScript refactoren
|
||||
|
||||
**Low Priority:**
|
||||
7. Skip-Links hinzufügen
|
||||
8. Code-Optimierung
|
||||
9. Performance-Tuning
|
||||
|
||||
---
|
||||
|
||||
## 16. Checkliste für Neuimplementierung
|
||||
|
||||
### 16.1 Vorbereitung
|
||||
|
||||
- [ ] Alle `is-drawer-*` Klassen aus Code entfernen
|
||||
- [ ] Keine Custom-Variants in CSS/Tailwind definieren
|
||||
- [ ] DaisyUI-Dokumentation für Drawer studieren
|
||||
|
||||
### 16.2 Implementation
|
||||
|
||||
- [ ] Logo-Element hinzufügen (size-8, persistent)
|
||||
- [ ] Toggle-Button mit Icon-Swap (nur Desktop)
|
||||
- [ ] Mobile: Overlay-Drawer (DaisyUI Standard)
|
||||
- [ ] Desktop: Persistent Sidebar (w-64)
|
||||
- [ ] Menü-Items mit korrekten Klassen
|
||||
- [ ] Submenu-Handling (nested `<ul>`)
|
||||
|
||||
### 16.3 Funktionalität
|
||||
|
||||
- [ ] Toggle-Funktionalität auf Mobile
|
||||
- [ ] Accessibility: ARIA, Focus, Keyboard
|
||||
- [ ] Theme-Toggle funktional
|
||||
- [ ] Locale-Selector funktional
|
||||
- [ ] User-Menu-Dropdown funktional
|
||||
|
||||
### 16.4 Testing
|
||||
|
||||
- [ ] Component-Tests schreiben
|
||||
- [ ] Accessibility-Tests (axe-core)
|
||||
- [ ] Keyboard-Navigation testen
|
||||
- [ ] Screen-Reader testen
|
||||
- [ ] Responsive Breakpoints testen
|
||||
|
||||
### 16.5 Dokumentation
|
||||
|
||||
- [ ] Code-Kommentare aktualisieren
|
||||
- [ ] Component-Docs schreiben
|
||||
- [ ] README aktualisieren
|
||||
|
||||
---
|
||||
|
||||
## 17. Technische Details
|
||||
|
||||
### 17.1 CSS-Selektoren
|
||||
|
||||
**Verwendete IDs:**
|
||||
- `#main-drawer` - Drawer-Toggle-Checkbox
|
||||
- `#main-sidebar` - Sidebar-Navigation-Container
|
||||
- `#sidebar-toggle` - Toggle-Button in Navbar
|
||||
- `#locale-select-sidebar` - Locale-Dropdown
|
||||
|
||||
**Verwendete Klassen:**
|
||||
- `.drawer-side` - DaisyUI Sidebar-Container
|
||||
- `.drawer-overlay` - DaisyUI Overlay-Button
|
||||
- `.drawer-content` - DaisyUI Content-Container
|
||||
- `.menu` - DaisyUI Menu-Container
|
||||
- `.is-drawer-close:*` - ❌ NICHT DEFINIERT
|
||||
- `.is-drawer-open:*` - ❌ NICHT DEFINIERT
|
||||
|
||||
### 17.2 Event-Handler
|
||||
|
||||
**JavaScript:**
|
||||
```javascript
|
||||
drawerToggle.addEventListener("change", ...)
|
||||
sidebarToggle.addEventListener("click", ...)
|
||||
sidebarToggle.addEventListener("keydown", ...)
|
||||
document.addEventListener("keydown", ...) // ESC handler
|
||||
```
|
||||
|
||||
**Inline (zu migrieren):**
|
||||
```elixir
|
||||
onclick="document.getElementById('main-drawer').checked = false"
|
||||
onclick="document.getElementById('main-drawer').checked = !..."
|
||||
onchange="this.form.submit()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 18. Metriken
|
||||
|
||||
### 18.1 Code-Metriken
|
||||
|
||||
| Metrik | Wert |
|
||||
|--------|------|
|
||||
| **Zeilen Code (Sidebar)** | 198 |
|
||||
| **Zeilen JavaScript** | 165 (Sidebar-spezifisch) |
|
||||
| **Zeilen CSS** | 0 (nur Tailwind-Klassen) |
|
||||
| **Anzahl Komponenten** | 1 (Sidebar) + 1 (Navbar) |
|
||||
| **Anzahl Menü-Items** | 6 (inkl. Submenu) |
|
||||
| **Anzahl Footer-Controls** | 3 (Locale, Theme, User) |
|
||||
|
||||
### 18.2 Abhängigkeits-Metriken
|
||||
|
||||
| Kategorie | Anzahl |
|
||||
|-----------|--------|
|
||||
| **DaisyUI-Komponenten** | 7 |
|
||||
| **Tailwind-Utility-Klassen** | ~50 |
|
||||
| **Custom-Variants (broken)** | 2 (`is-drawer-close`, `is-drawer-open`) |
|
||||
| **JavaScript-Event-Listener** | 6 |
|
||||
| **ARIA-Attribute** | 12 |
|
||||
|
||||
---
|
||||
|
||||
## 19. Zusammenfassung
|
||||
|
||||
### 19.1 Was funktioniert
|
||||
|
||||
✅ **Sehr gute Grundlage:**
|
||||
- DaisyUI Drawer-Pattern korrekt implementiert
|
||||
- Exzellente Accessibility (ARIA, Keyboard, Focus)
|
||||
- Saubere Modulstruktur
|
||||
- Internationalisierung
|
||||
- Theme-Switching
|
||||
- JavaScript-Logik ist robust
|
||||
|
||||
### 19.2 Was nicht funktioniert
|
||||
|
||||
❌ **Kritische Defekte:**
|
||||
- CSS-Variants existieren nicht → keine responsive Funktionalität
|
||||
- Kein Logo
|
||||
- Kein Toggle-Icon-Swap
|
||||
- Submenu-Handling defekt
|
||||
|
||||
### 19.3 Nächste Schritte
|
||||
|
||||
1. **CSS-Variants entfernen** (alle `is-drawer-*` Klassen)
|
||||
2. **Standard DaisyUI-Pattern verwenden** (ohne Custom-Variants)
|
||||
3. **Logo hinzufügen** (persistent, size-8)
|
||||
4. **Simplify:** Mobile = Overlay, Desktop = Persistent (keine collapsed State)
|
||||
5. **Tests schreiben** (Component + Accessibility)
|
||||
|
||||
---
|
||||
|
||||
## 20. Anhang
|
||||
|
||||
### 20.1 Verwendete CSS-Klassen (alphabetisch)
|
||||
|
||||
```
|
||||
avatar, avatar-placeholder, bg-base-100, bg-base-200, bg-neutral,
|
||||
btn, btn-circle, btn-ghost, btn-square, cursor-pointer, drawer,
|
||||
drawer-content, drawer-overlay, drawer-side, drawer-toggle, dropdown,
|
||||
dropdown-content, dropdown-end, dropdown-top, flex, flex-col,
|
||||
focus:outline-none, focus:ring-2, focus:ring-primary,
|
||||
focus-within:outline-none, focus-within:ring-2, gap-2, gap-4,
|
||||
is-drawer-close:*, is-drawer-open:*, items-center, items-start,
|
||||
mb-2, menu, menu-sm, menu-title, min-h-full, mr-2, mt-3, mt-auto,
|
||||
p-2, p-4, rounded-box, rounded-full, select, select-sm, shadow,
|
||||
shadow-sm, size-4, size-5, sr-only, text-lg, text-neutral-content,
|
||||
text-sm, theme-controller, toggle, tooltip, tooltip-right, w-12,
|
||||
w-52, w-64, w-full, z-1
|
||||
```
|
||||
|
||||
### 20.2 Verwendete ARIA-Attribute
|
||||
|
||||
```
|
||||
aria-busy, aria-controls, aria-describedby, aria-expanded,
|
||||
aria-haspopup, aria-hidden, aria-label, aria-labelledby,
|
||||
aria-live, role="alert", role="button", role="menu",
|
||||
role="menubar", role="menuitem", role="none", role="status"
|
||||
```
|
||||
|
||||
### 20.3 Relevante Links
|
||||
|
||||
- [DaisyUI Drawer Docs](https://daisyui.com/components/drawer/)
|
||||
- [Tailwind CSS Custom Variants](https://tailwindcss.com/docs/adding-custom-styles#adding-custom-variants)
|
||||
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
|
||||
- [Phoenix LiveView Docs](https://hexdocs.pm/phoenix_live_view/)
|
||||
|
||||
---
|
||||
|
||||
**Ende des Berichts**
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,233 +0,0 @@
|
|||
# Analyse der fehlschlagenden Tests
|
||||
|
||||
## Übersicht
|
||||
|
||||
**Gesamtanzahl fehlschlagender Tests:** 5
|
||||
- **show_test.exs:** 1 Fehler
|
||||
- **sidebar_test.exs:** 4 Fehler
|
||||
|
||||
---
|
||||
|
||||
## Kategorisierung
|
||||
|
||||
### Kategorie 1: Test-Assertions passen nicht zur Implementierung (4 Tests)
|
||||
|
||||
Diese Tests erwarten bestimmte Werte/Attribute, die in der aktuellen Implementierung anders sind oder fehlen.
|
||||
|
||||
### Kategorie 2: Datenbank-Isolation Problem (1 Test)
|
||||
|
||||
Ein Test schlägt fehl, weil die Datenbank nicht korrekt isoliert ist.
|
||||
|
||||
---
|
||||
|
||||
## Detaillierte Analyse
|
||||
|
||||
### 1. `show_test.exs` - Custom Fields Sichtbarkeit
|
||||
|
||||
**Test:** `does not display Custom Fields section when no custom fields exist` (Zeile 112)
|
||||
|
||||
**Problem:**
|
||||
- Der Test erwartet, dass die "Custom Fields" Sektion NICHT angezeigt wird, wenn keine Custom Fields existieren
|
||||
- Die Sektion wird aber angezeigt, weil in der Datenbank noch Custom Fields von anderen Tests vorhanden sind
|
||||
|
||||
**Ursache:**
|
||||
- Die LiveView lädt alle Custom Fields aus der Datenbank (Zeile 238-242 in `show.ex`)
|
||||
- Die Test-Datenbank wird nicht zwischen Tests geleert
|
||||
- Da `async: false` verwendet wird, sollten die Tests sequenziell laufen, aber Custom Fields bleiben in der Datenbank
|
||||
|
||||
**Kategorie:** Datenbank-Isolation Problem
|
||||
|
||||
---
|
||||
|
||||
### 2. `sidebar_test.exs` - Settings Link
|
||||
|
||||
**Test:** `T3.1: renders flat menu items with icons and labels` (Zeile 174)
|
||||
|
||||
**Problem:**
|
||||
- Test erwartet `href="#"` für Settings
|
||||
- Tatsächlicher Wert: `href="/settings"`
|
||||
|
||||
**Ursache:**
|
||||
- Die Implementierung verwendet einen echten Link `~p"/settings"` (Zeile 100 in `sidebar.ex`)
|
||||
- Der Test erwartet einen Placeholder-Link `href="#"`
|
||||
|
||||
**Kategorie:** Test-Assertion passt nicht zur Implementierung
|
||||
|
||||
---
|
||||
|
||||
### 3. `sidebar_test.exs` - Drawer Overlay CSS-Klasse
|
||||
|
||||
**Test:** `drawer overlay is present` (Zeile 747)
|
||||
|
||||
**Problem:**
|
||||
- Test sucht nach exakt `class="drawer-overlay"`
|
||||
- Tatsächlicher Wert: `class="drawer-overlay lg:hidden focus:outline-none focus:ring-2 focus:ring-primary"`
|
||||
|
||||
**Ursache:**
|
||||
- Der Test verwendet eine exakte String-Suche (`~s(class="drawer-overlay")`)
|
||||
- Die Implementierung hat mehrere CSS-Klassen
|
||||
|
||||
**Kategorie:** Test-Assertion passt nicht zur Implementierung
|
||||
|
||||
---
|
||||
|
||||
### 4. `sidebar_test.exs` - Toggle Button ARIA-Attribut
|
||||
|
||||
**Test:** `T5.2: toggle button has correct ARIA attributes` (Zeile 324)
|
||||
|
||||
**Problem:**
|
||||
- Test erwartet `aria-controls="main-sidebar"` am Toggle-Button
|
||||
- Das Attribut fehlt in der Implementierung (Zeile 45-65 in `sidebar.ex`)
|
||||
|
||||
**Ursache:**
|
||||
- Das `aria-controls` Attribut wurde nicht in der Implementierung hinzugefügt
|
||||
- Der Test erwartet es für bessere Accessibility
|
||||
|
||||
**Kategorie:** Test-Assertion passt nicht zur Implementierung (Accessibility-Feature fehlt)
|
||||
|
||||
---
|
||||
|
||||
### 5. `sidebar_test.exs` - Contribution Settings Link
|
||||
|
||||
**Test:** `sidebar structure is complete with all sections` (Zeile 501)
|
||||
|
||||
**Problem:**
|
||||
- Test erwartet Link `/contribution_settings`
|
||||
- Tatsächlicher Link: `/membership_fee_settings`
|
||||
|
||||
**Ursache:**
|
||||
- Der Test hat eine veraltete/inkorrekte Erwartung
|
||||
- Die Implementierung verwendet `/membership_fee_settings` (Zeile 96 in `sidebar.ex`)
|
||||
|
||||
**Kategorie:** Test-Assertion passt nicht zur Implementierung (veralteter Test)
|
||||
|
||||
---
|
||||
|
||||
## Lösungsvorschläge
|
||||
|
||||
### Lösung 1: `show_test.exs` - Custom Fields Sichtbarkeit
|
||||
|
||||
**Option A: Test-Datenbank bereinigen (Empfohlen)**
|
||||
- Im `setup` Block alle Custom Fields löschen, bevor der Test läuft
|
||||
- Oder: Explizit prüfen, dass keine Custom Fields existieren
|
||||
|
||||
**Option B: Test anpassen**
|
||||
- Den Test so anpassen, dass er explizit alle Custom Fields löscht
|
||||
- Oder: Die LiveView-Logik ändern, um nur Custom Fields zu laden, die tatsächlich existieren
|
||||
|
||||
**Empfehlung:** Option A - Im Test-Setup alle Custom Fields löschen
|
||||
|
||||
```elixir
|
||||
setup do
|
||||
# Clean up any existing custom fields
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.read!()
|
||||
|> Enum.each(&Ash.destroy!/1)
|
||||
|
||||
# Create test member
|
||||
{:ok, member} = ...
|
||||
%{member: member}
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Lösung 2: `sidebar_test.exs` - Settings Link
|
||||
|
||||
**Option A: Test anpassen (Empfohlen)**
|
||||
- Test ändern, um `href="/settings"` zu erwarten statt `href="#"`
|
||||
|
||||
**Option B: Implementierung ändern**
|
||||
- Settings-Link zu `href="#"` ändern (nicht empfohlen, da es ein echter Link sein sollte)
|
||||
|
||||
**Empfehlung:** Option A - Test anpassen
|
||||
|
||||
```elixir
|
||||
# Zeile 190 ändern von:
|
||||
assert html =~ ~s(href="#")
|
||||
# zu:
|
||||
assert html =~ ~s(href="/settings")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Lösung 3: `sidebar_test.exs` - Drawer Overlay CSS-Klasse
|
||||
|
||||
**Option A: Test anpassen (Empfohlen)**
|
||||
- Test ändern, um nach der Klasse in der Klasse-Liste zu suchen (mit `has_class?` Helper)
|
||||
|
||||
**Option B: Regex verwenden**
|
||||
- Regex verwenden, um die Klasse zu finden
|
||||
|
||||
**Empfehlung:** Option A - Test anpassen
|
||||
|
||||
```elixir
|
||||
# Zeile 752 ändern von:
|
||||
assert html =~ ~s(class="drawer-overlay")
|
||||
# zu:
|
||||
assert has_class?(html, "drawer-overlay")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Lösung 4: `sidebar_test.exs` - Toggle Button ARIA-Attribut
|
||||
|
||||
**Option A: Implementierung anpassen (Empfohlen)**
|
||||
- `aria-controls="main-sidebar"` zum Toggle-Button hinzufügen
|
||||
|
||||
**Option B: Test anpassen**
|
||||
- Test entfernen oder als optional markieren (nicht empfohlen für Accessibility)
|
||||
|
||||
**Empfehlung:** Option A - Implementierung anpassen
|
||||
|
||||
```elixir
|
||||
# In sidebar.ex Zeile 45-52, aria-controls hinzufügen:
|
||||
<button
|
||||
type="button"
|
||||
id="sidebar-toggle"
|
||||
class="hidden lg:flex ml-auto btn btn-ghost btn-sm btn-square"
|
||||
aria-label={gettext("Toggle sidebar")}
|
||||
aria-controls="main-sidebar"
|
||||
aria-expanded="true"
|
||||
onclick="toggleSidebar()"
|
||||
>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Lösung 5: `sidebar_test.exs` - Contribution Settings Link
|
||||
|
||||
**Option A: Test anpassen (Empfohlen)**
|
||||
- Test ändern, um `/membership_fee_settings` statt `/contribution_settings` zu erwarten
|
||||
|
||||
**Option B: Link hinzufügen**
|
||||
- Einen neuen Link `/contribution_settings` hinzufügen (nicht empfohlen, da redundant)
|
||||
|
||||
**Empfehlung:** Option A - Test anpassen
|
||||
|
||||
```elixir
|
||||
# Zeile 519 ändern von:
|
||||
"/contribution_settings",
|
||||
# zu:
|
||||
# Entfernen oder durch "/membership_fee_settings" ersetzen
|
||||
# (da "/membership_fee_settings" bereits in Zeile 518 vorhanden ist)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung der empfohlenen Änderungen
|
||||
|
||||
1. **show_test.exs:** Custom Fields im Setup löschen
|
||||
2. **sidebar_test.exs (T3.1):** Settings-Link Assertion anpassen
|
||||
3. **sidebar_test.exs (drawer overlay):** CSS-Klasse-Suche mit Helper-Funktion
|
||||
4. **sidebar_test.exs (T5.2):** `aria-controls` Attribut zur Implementierung hinzufügen
|
||||
5. **sidebar_test.exs (edge cases):** Falschen Link aus erwarteter Liste entfernen
|
||||
|
||||
---
|
||||
|
||||
## Priorisierung
|
||||
|
||||
1. **Hoch:** Lösung 1 (show_test.exs) - Datenbank-Isolation ist wichtig
|
||||
2. **Mittel:** Lösung 4 (ARIA-Attribut) - Accessibility-Verbesserung
|
||||
3. **Niedrig:** Lösungen 2, 3, 5 - Einfache Test-Anpassungen
|
||||
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
# Test Status: Membership Fee UI Components
|
||||
|
||||
**Date:** 2025-01-XX
|
||||
**Status:** Tests Written - Implementation Complete
|
||||
|
||||
## Übersicht
|
||||
|
||||
Alle Tests für die Membership Fee UI-Komponenten wurden geschrieben. Die Tests sind TDD-konform geschrieben und sollten erfolgreich laufen, da die Implementation bereits vorhanden ist.
|
||||
|
||||
## Test-Dateien
|
||||
|
||||
### Helper Module Tests
|
||||
|
||||
**Datei:** `test/mv_web/helpers/membership_fee_helpers_test.exs`
|
||||
- ✅ format_currency/1 formats correctly
|
||||
- ✅ format_interval/1 formats all interval types
|
||||
- ✅ format_cycle_range/2 formats date ranges correctly
|
||||
- ✅ get_last_completed_cycle/2 returns correct cycle
|
||||
- ✅ get_current_cycle/2 returns correct cycle
|
||||
- ✅ status_color/1 returns correct color classes
|
||||
- ✅ status_icon/1 returns correct icon names
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
**Datei:** `test/mv_web/member_live/index/membership_fee_status_test.exs`
|
||||
- ✅ load_cycles_for_members/2 efficiently loads cycles
|
||||
- ✅ get_cycle_status_for_member/2 returns correct status
|
||||
- ✅ format_cycle_status_badge/1 returns correct badge
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
### Member List View Tests
|
||||
|
||||
**Datei:** `test/mv_web/member_live/index_membership_fee_status_test.exs`
|
||||
- ✅ Status column displays correctly
|
||||
- ✅ Shows last completed cycle status by default
|
||||
- ✅ Toggle switches to current cycle view
|
||||
- ✅ Color coding for paid/unpaid/suspended
|
||||
- ✅ Filter "Unpaid in last cycle" works
|
||||
- ✅ Filter "Unpaid in current cycle" works
|
||||
- ✅ Handles members without cycles gracefully
|
||||
- ✅ Loads cycles efficiently without N+1 queries
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
### Member Detail View Tests
|
||||
|
||||
**Datei:** `test/mv_web/member_live/show_membership_fees_test.exs`
|
||||
- ✅ Cycles table displays all cycles
|
||||
- ✅ Table columns show correct data
|
||||
- ✅ Membership fee type dropdown shows only same-interval types
|
||||
- ✅ Warning displayed if different interval selected
|
||||
- ✅ Status change actions work (mark as paid/suspended/unpaid)
|
||||
- ✅ Cycle regeneration works
|
||||
- ✅ Handles members without membership fee type gracefully
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
### Membership Fee Types Admin Tests
|
||||
|
||||
**Datei:** `test/mv_web/live/membership_fee_type_live/index_test.exs`
|
||||
- ✅ List displays all types with correct data
|
||||
- ✅ Member count column shows correct count
|
||||
- ✅ Create button navigates to form
|
||||
- ✅ Edit button per row navigates to edit form
|
||||
- ✅ Delete button disabled if type is in use
|
||||
- ✅ Delete button works if type is not in use
|
||||
- ✅ Only admin can access
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
**Datei:** `test/mv_web/live/membership_fee_type_live/form_test.exs`
|
||||
- ✅ Create form works
|
||||
- ✅ Edit form loads existing type data
|
||||
- ✅ Interval field editable on create
|
||||
- ✅ Interval field grayed out on edit
|
||||
- ✅ Amount change warning displays on edit
|
||||
- ✅ Amount change warning shows correct affected member count
|
||||
- ✅ Amount change can be confirmed
|
||||
- ✅ Amount change can be cancelled
|
||||
- ✅ Validation errors display correctly
|
||||
- ✅ Only admin can access
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
### Member Form Tests
|
||||
|
||||
**Datei:** `test/mv_web/member_live/form_membership_fee_type_test.exs`
|
||||
- ✅ Membership fee type dropdown displays in form
|
||||
- ✅ Shows available types
|
||||
- ✅ Filters to same interval types if member has type
|
||||
- ✅ Warning displayed if different interval selected
|
||||
- ✅ Warning cleared if same interval selected
|
||||
- ✅ Form saves with selected membership fee type
|
||||
- ✅ New members get default membership fee type
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**Datei:** `test/mv_web/member_live/membership_fee_integration_test.exs`
|
||||
- ✅ End-to-end: Create type → Assign to member → View cycles → Change status
|
||||
- ✅ End-to-end: Change member type → Cycles regenerate
|
||||
- ✅ End-to-end: Update settings → New members get default type
|
||||
- ✅ End-to-end: Delete cycle → Confirmation → Cycle deleted
|
||||
- ✅ End-to-end: Edit cycle amount → Modal → Amount updated
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
## Test-Ausführung
|
||||
|
||||
Alle Tests können mit folgenden Befehlen ausgeführt werden:
|
||||
|
||||
```bash
|
||||
# Alle Tests
|
||||
mix test
|
||||
|
||||
# Nur Membership Fee Tests
|
||||
mix test test/mv_web/helpers/membership_fee_helpers_test.exs
|
||||
mix test test/mv_web/member_live/
|
||||
mix test test/mv_web/live/membership_fee_type_live/
|
||||
|
||||
# Mit Coverage
|
||||
mix test --cover
|
||||
```
|
||||
|
||||
## Bekannte Probleme
|
||||
|
||||
Keine bekannten Probleme. Alle Tests sollten erfolgreich laufen, da die Implementation bereits vorhanden ist.
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. ✅ Tests geschrieben
|
||||
2. ⏳ Tests ausführen und verifizieren
|
||||
3. ⏳ Eventuelle Anpassungen basierend auf Test-Ergebnissen
|
||||
4. ⏳ Code-Review durchführen
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +0,0 @@
|
|||
defmodule Mv.Accounts do
|
||||
@moduledoc """
|
||||
AshAuthentication specific domain to handle Authentication for users.
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
||||
admin do
|
||||
show? true
|
||||
end
|
||||
|
||||
resources do
|
||||
resource Mv.Accounts.User do
|
||||
define :create_user, action: :create_user
|
||||
define :list_users, action: :read
|
||||
define :update_user, action: :update_user
|
||||
define :destroy_user, action: :destroy
|
||||
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
|
||||
end
|
||||
end
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
defmodule Mv.Accounts.Token do
|
||||
@moduledoc """
|
||||
AshAuthentication specific ressource
|
||||
"""
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication.TokenResource],
|
||||
domain: Mv.Accounts
|
||||
|
||||
postgres do
|
||||
table "tokens"
|
||||
repo Mv.Repo
|
||||
end
|
||||
end
|
||||
|
|
@ -1,389 +0,0 @@
|
|||
defmodule Mv.Accounts.User do
|
||||
@moduledoc """
|
||||
The ressource for keeping user-specific data related to the login process. It is used by AshAuthentication to handle the Authentication strategies like SSO.
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Accounts,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication]
|
||||
|
||||
# authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "users"
|
||||
repo Mv.Repo
|
||||
|
||||
references do
|
||||
# When a member is deleted, set the user's member_id to NULL
|
||||
# This allows users to continue existing even if their linked member is removed
|
||||
reference :member, on_delete: :nilify
|
||||
|
||||
# When a role is deleted, prevent deletion if users are assigned to it
|
||||
# This protects critical roles from accidental deletion
|
||||
reference :role, on_delete: :restrict
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
AshAuthentication specific: Defines the strategies we want to use for authentication.
|
||||
Currently password and SSO with Rauthy as OIDC provider
|
||||
"""
|
||||
authentication do
|
||||
session_identifier Application.compile_env!(:mv, :session_identifier)
|
||||
|
||||
tokens do
|
||||
enabled? true
|
||||
token_resource Mv.Accounts.Token
|
||||
|
||||
require_token_presence_for_authentication? Application.compile_env!(
|
||||
:mv,
|
||||
:require_token_presence_for_authentication
|
||||
)
|
||||
|
||||
store_all_tokens? true
|
||||
|
||||
# signing_algorithm "EdDSA" -> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87
|
||||
|
||||
signing_secret fn _, _ ->
|
||||
{:ok, Application.get_env(:mv, :token_signing_secret)}
|
||||
end
|
||||
end
|
||||
|
||||
strategies do
|
||||
oidc :rauthy do
|
||||
client_id Mv.Secrets
|
||||
base_url Mv.Secrets
|
||||
redirect_uri Mv.Secrets
|
||||
client_secret Mv.Secrets
|
||||
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
|
||||
|
||||
password :password do
|
||||
identity_field :email
|
||||
hash_provider AshAuthentication.BcryptProvider
|
||||
confirmation_required? false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
actions do
|
||||
# Default actions for framework/tooling integration:
|
||||
# - :read -> Standard read used across the app and by admin tooling.
|
||||
# - :destroy-> Standard delete used by admin tooling and maintenance tasks.
|
||||
#
|
||||
# NOTE: :create is INTENTIONALLY excluded from defaults!
|
||||
# Using a default :create would bypass email-synchronization logic.
|
||||
# Always use one of these explicit create actions instead:
|
||||
# - :create_user (for manual user creation with optional member link)
|
||||
# - :register_with_password (for password-based registration)
|
||||
# - :register_with_rauthy (for OIDC-based registration)
|
||||
defaults [:read, :destroy]
|
||||
|
||||
# Primary generic update action:
|
||||
# - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
|
||||
# helpers that assume a default update action.
|
||||
# - Intended for simple attribute updates (e.g., :email) and scenarios
|
||||
# that do NOT need to manage the :member relationship.
|
||||
# - For linking/unlinking a member (and the related validations), prefer
|
||||
# the specialized :update_user action below.
|
||||
update :update do
|
||||
primary? true
|
||||
|
||||
# Required because custom validation functions (email validation, member relationship validation)
|
||||
# cannot be executed atomically. These validations need to query the database and perform
|
||||
# complex checks that are not supported in atomic operations.
|
||||
require_atomic? false
|
||||
|
||||
# Sync email changes to linked member (User → Member)
|
||||
# Only runs when email is being changed
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
end
|
||||
|
||||
create :create_user do
|
||||
description "Creates a new user with optional member relationship. The member relationship is managed through the :member argument."
|
||||
# Only accept email directly - member_id is NOT in accept list
|
||||
# This prevents direct foreign key manipulation, forcing use of manage_relationship
|
||||
accept [:email]
|
||||
# Allow member to be passed as argument for relationship management
|
||||
argument :member, :map, allow_nil?: true
|
||||
upsert? true
|
||||
|
||||
# Manage the member relationship during user creation
|
||||
change manage_relationship(:member, :member,
|
||||
# Look up existing member and relate to it
|
||||
on_lookup: :relate,
|
||||
# Error if member doesn't exist in database
|
||||
on_no_match: :error,
|
||||
# If member already linked to this user, ignore (shouldn't happen in create)
|
||||
on_match: :ignore,
|
||||
# If no member provided, that's fine (optional relationship)
|
||||
on_missing: :ignore
|
||||
)
|
||||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
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."
|
||||
# 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
|
||||
|
||||
# Required because custom validation functions (email validation, member relationship validation)
|
||||
# cannot be executed atomically. These validations need to query the database and perform
|
||||
# complex checks that are not supported in atomic operations.
|
||||
require_atomic? false
|
||||
|
||||
# Manage the member relationship during user update
|
||||
change manage_relationship(:member, :member,
|
||||
# Look up existing member and relate to it
|
||||
on_lookup: :relate,
|
||||
# Error if member doesn't exist in database
|
||||
on_no_match: :error,
|
||||
# If same member provided, that's fine (allows updates with same member)
|
||||
on_match: :ignore,
|
||||
# If no member provided, remove existing relationship (allows member removal)
|
||||
on_missing: :unrelate
|
||||
)
|
||||
|
||||
# Sync email changes and handle linking (User → Member)
|
||||
# Runs when email OR member relationship changes
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where any([changing(:email), changing(:member)])
|
||||
end
|
||||
end
|
||||
|
||||
# Admin action for direct password changes in admin panel
|
||||
# Uses the official Ash Authentication HashPasswordChange with correct context
|
||||
update :admin_set_password do
|
||||
accept [:email]
|
||||
argument :password, :string, allow_nil?: false, sensitive?: true
|
||||
require_atomic? false
|
||||
|
||||
# Set the strategy context that HashPasswordChange expects
|
||||
change set_context(%{strategy_name: :password})
|
||||
|
||||
# Use the official Ash Authentication password change
|
||||
change AshAuthentication.Strategy.Password.HashPasswordChange
|
||||
end
|
||||
|
||||
# Action to link an OIDC account to an existing password-only user
|
||||
# This is called after the user has verified their password
|
||||
update :link_oidc_id do
|
||||
description "Links an OIDC ID to an existing user after password verification"
|
||||
accept []
|
||||
argument :oidc_id, :string, allow_nil?: false
|
||||
argument :oidc_user_info, :map, allow_nil?: false
|
||||
require_atomic? false
|
||||
|
||||
change fn changeset, _ctx ->
|
||||
oidc_id = Ash.Changeset.get_argument(changeset, :oidc_id)
|
||||
oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info)
|
||||
|
||||
# Get the new email from OIDC user_info
|
||||
# Support both "email" (standard OIDC) and "preferred_username" (Rauthy)
|
||||
new_email =
|
||||
Map.get(oidc_user_info, "email") || Map.get(oidc_user_info, "preferred_username")
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.change_attribute(:oidc_id, oidc_id)
|
||||
# Update email if it differs from OIDC provider
|
||||
# change_attribute/3 already checks if value matches existing value
|
||||
|> then(fn cs ->
|
||||
if new_email do
|
||||
Ash.Changeset.change_attribute(cs, :email, new_email)
|
||||
else
|
||||
cs
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Sync email changes to member if email was updated
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
end
|
||||
|
||||
read :get_by_subject do
|
||||
description "Get a user by the subject claim in a JWT"
|
||||
argument :subject, :string, allow_nil?: false
|
||||
get? true
|
||||
prepare AshAuthentication.Preparations.FilterBySubject
|
||||
end
|
||||
|
||||
read :sign_in_with_rauthy do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
||||
|
||||
# SECURITY: Filter by oidc_id, NOT by email!
|
||||
# This ensures that OIDC sign-in only works for users who have already
|
||||
# linked their account via OIDC. Password-only users (oidc_id = nil)
|
||||
# cannot be accessed via OIDC login without password verification.
|
||||
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
validate &__MODULE__.validate_oidc_id_present/2
|
||||
|
||||
change AshAuthentication.GenerateTokenChange
|
||||
|
||||
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(:oidc_id, user_info["sub"] || user_info["id"])
|
||||
end
|
||||
|
||||
# Check for email collisions with existing accounts
|
||||
# This validation must run AFTER email and oidc_id are set above
|
||||
# - Raises PasswordVerificationRequired for password-protected OR passwordless users
|
||||
# - The LinkOidcAccountLive will auto-link passwordless users without password prompt
|
||||
validate Mv.Accounts.User.Validations.OidcEmailCollision
|
||||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
end
|
||||
end
|
||||
|
||||
# Global validations - applied to all relevant actions
|
||||
validations do
|
||||
# Password strength policy: minimum 8 characters for all password-related actions
|
||||
validate string_length(:password, min: 8),
|
||||
where: [action_is([:register_with_password, :admin_set_password])],
|
||||
message: "must have length of at least 8"
|
||||
|
||||
# Email uniqueness check for all actions that change the email attribute
|
||||
# Validates that user email is not already used by another (unlinked) member
|
||||
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
|
||||
|
||||
# Email validation with EctoCommons.EmailValidator (same as Member)
|
||||
# This ensures consistency between User and Member email validation
|
||||
validate fn changeset, _ ->
|
||||
# Get email from attribute (Ash.CiString) and convert to string
|
||||
email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
email_string = if email, do: to_string(email), else: nil
|
||||
|
||||
# Only validate if email is present
|
||||
if email_string do
|
||||
changeset2 =
|
||||
{%{}, %{email: :string}}
|
||||
|> Ecto.Changeset.cast(%{email: email_string}, [:email])
|
||||
|> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow])
|
||||
|
||||
if changeset2.valid? do
|
||||
:ok
|
||||
else
|
||||
{:error, field: :email, message: "is not a valid email"}
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# Prevent overwriting existing member relationship
|
||||
# This validation ensures race condition safety by requiring explicit two-step process:
|
||||
# 1. Remove existing member (set member to nil)
|
||||
# 2. Add new member
|
||||
# This prevents accidental overwrites when multiple admins work simultaneously
|
||||
validate fn changeset, _context ->
|
||||
member_arg = Ash.Changeset.get_argument(changeset, :member)
|
||||
current_member_id = changeset.data.member_id
|
||||
|
||||
# Only trigger if:
|
||||
# - member argument is provided AND has an ID
|
||||
# - user currently has a member
|
||||
# - the new member ID is different from current member ID
|
||||
if member_arg && member_arg[:id] && current_member_id &&
|
||||
member_arg[:id] != current_member_id do
|
||||
{:error,
|
||||
field: :member, message: "User already has a member. Remove existing member first."}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_oidc_id_present(changeset, _context) do
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info) || %{}
|
||||
|
||||
if is_binary(user_info["sub"]) or is_binary(user_info["id"]) do
|
||||
:ok
|
||||
else
|
||||
{:error, [user_info: "OIDC user_info must contain a non-empty 'sub' or 'id' field"]}
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
# IMPORTANT: Email Synchronization
|
||||
# When user and member are linked, emails are automatically synced bidirectionally.
|
||||
# User.email is the source of truth - when a link is established, member.email
|
||||
# is overridden to match user.email. Subsequent changes to either email will
|
||||
# sync to the other resource.
|
||||
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
# Mv.EmailSync.Changes.SyncMemberEmailToUser
|
||||
attribute :email, :ci_string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
# Same constraints as Member email for consistency
|
||||
constraints min_length: 5, max_length: 254
|
||||
end
|
||||
|
||||
attribute :hashed_password, :string, sensitive?: true, allow_nil?: true
|
||||
attribute :oidc_id, :string, allow_nil?: true
|
||||
end
|
||||
|
||||
relationships do
|
||||
# 1:1 relationship - User can optionally belong to one Member
|
||||
# This automatically creates a `member_id` attribute in the User table
|
||||
# The relationship is optional (allow_nil? true by default)
|
||||
belongs_to :member, Mv.Membership.Member
|
||||
|
||||
# 1:1 relationship - User belongs to a Role
|
||||
# This automatically creates a `role_id` attribute in the User table
|
||||
# The relationship is optional (allow_nil? true by default)
|
||||
# Foreign key constraint: on_delete: :restrict (prevents deleting roles assigned to users)
|
||||
belongs_to :role, Mv.Authorization.Role
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_email, [:email]
|
||||
identity :unique_oidc_id, [:oidc_id]
|
||||
identity :unique_member, [:member_id]
|
||||
end
|
||||
|
||||
# You can customize this if you wish, but this is a safe default that
|
||||
# only allows user data to be interacted with via AshAuthentication.
|
||||
# policies do
|
||||
# bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||
# authorize_if(always())
|
||||
# end
|
||||
|
||||
# policy always() do
|
||||
# forbid_if(always())
|
||||
# end
|
||||
# end
|
||||
end
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
defmodule Mv.Accounts.User.Errors.PasswordVerificationRequired do
|
||||
@moduledoc """
|
||||
Custom error raised when an OIDC login attempts to use an email that already exists
|
||||
in the system with a password-only account (no oidc_id set).
|
||||
|
||||
This error indicates that the user must verify their password before the OIDC account
|
||||
can be linked to the existing password account.
|
||||
"""
|
||||
use Splode.Error,
|
||||
fields: [:user_id, :oidc_user_info],
|
||||
class: :invalid
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
user_id: String.t(),
|
||||
oidc_user_info: map()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns a human-readable error message.
|
||||
|
||||
## Parameters
|
||||
- error: The error struct containing user_id and oidc_user_info
|
||||
"""
|
||||
def message(%{user_id: user_id, oidc_user_info: user_info}) do
|
||||
email = Map.get(user_info, "preferred_username", "unknown")
|
||||
oidc_id = Map.get(user_info, "sub") || Map.get(user_info, "id", "unknown")
|
||||
|
||||
"""
|
||||
Password verification required: An account with email '#{email}' already exists (user_id: #{user_id}).
|
||||
To link your OIDC account (oidc_id: #{oidc_id}) to this existing account, please verify your password.
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
||||
@moduledoc """
|
||||
Validation that checks for email collisions during OIDC registration.
|
||||
|
||||
This validation prevents unauthorized account takeovers and enforces proper
|
||||
account linking flows based on user state.
|
||||
|
||||
## Scenarios:
|
||||
|
||||
1. **User exists with matching oidc_id**:
|
||||
- Allow (upsert will update the existing user)
|
||||
|
||||
2. **User exists with different oidc_id**:
|
||||
- Hard error: Cannot link multiple OIDC providers to same account
|
||||
- No linking possible - user must use original OIDC provider
|
||||
|
||||
3. **User exists without oidc_id** (password-protected OR passwordless):
|
||||
- Raise PasswordVerificationRequired error
|
||||
- User is redirected to LinkOidcAccountLive which will:
|
||||
- Show password form if user has password
|
||||
- Auto-link immediately if user is passwordless
|
||||
|
||||
4. **No user exists with this email**:
|
||||
- Allow (new user will be created)
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
require Logger
|
||||
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
|
||||
@impl true
|
||||
def init(opts), do: {:ok, opts}
|
||||
|
||||
@impl true
|
||||
def validate(changeset, _opts, _context) do
|
||||
# Get the email and oidc_id from the changeset
|
||||
email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
oidc_id = Ash.Changeset.get_attribute(changeset, :oidc_id)
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
|
||||
# Only validate if we have both email and oidc_id (from OIDC registration)
|
||||
if email && oidc_id && user_info do
|
||||
# Check if a user with this oidc_id already exists
|
||||
# If yes, this will be an upsert (email update), not a new registration
|
||||
existing_oidc_user =
|
||||
case Mv.Accounts.User
|
||||
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|
||||
|> Ash.read_one() do
|
||||
{:ok, user} -> user
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
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) do
|
||||
# Find existing user with this email
|
||||
case Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^to_string(email))
|
||||
|> Ash.read_one() do
|
||||
{:ok, nil} ->
|
||||
# No user exists with this email - OK to create new user
|
||||
:ok
|
||||
|
||||
{:ok, user_with_email} ->
|
||||
# User exists with this email - check if it's an upsert or registration
|
||||
is_upsert = not is_nil(existing_oidc_user)
|
||||
|
||||
if is_upsert do
|
||||
handle_upsert_scenario(user_with_email, user_info, existing_oidc_user)
|
||||
else
|
||||
handle_create_scenario(user_with_email, new_oidc_id, user_info)
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
# Database error - log for debugging but don't expose internals to user
|
||||
Logger.error("Email uniqueness check failed during OIDC registration: #{inspect(error)}")
|
||||
{:error, field: :email, message: "Could not verify email uniqueness. Please try again."}
|
||||
end
|
||||
end
|
||||
|
||||
# Handle email update for existing OIDC user
|
||||
defp handle_upsert_scenario(user_with_email, user_info, existing_oidc_user) do
|
||||
cond do
|
||||
# Same user updating their own record
|
||||
not is_nil(existing_oidc_user) and user_with_email.id == existing_oidc_user.id ->
|
||||
:ok
|
||||
|
||||
# Different user exists with target email
|
||||
not is_nil(existing_oidc_user) and user_with_email.id != existing_oidc_user.id ->
|
||||
handle_email_conflict(user_with_email, user_info)
|
||||
|
||||
# Should not reach here
|
||||
true ->
|
||||
{:error, field: :email, message: "Unexpected error during email update"}
|
||||
end
|
||||
end
|
||||
|
||||
# Handle email conflict during upsert
|
||||
defp handle_email_conflict(user_with_email, user_info) do
|
||||
email = Map.get(user_info, "preferred_username", "unknown")
|
||||
email_user_oidc_id = user_with_email.oidc_id
|
||||
|
||||
# Check if target email belongs to another OIDC user
|
||||
if not is_nil(email_user_oidc_id) and email_user_oidc_id != "" do
|
||||
different_oidc_error(email)
|
||||
else
|
||||
email_taken_error(email)
|
||||
end
|
||||
end
|
||||
|
||||
# Handle new OIDC user registration scenarios
|
||||
defp handle_create_scenario(user_with_email, new_oidc_id, user_info) do
|
||||
email_user_oidc_id = user_with_email.oidc_id
|
||||
|
||||
cond do
|
||||
# Same oidc_id (should not happen in practice, but allow for safety)
|
||||
email_user_oidc_id == new_oidc_id ->
|
||||
:ok
|
||||
|
||||
# Different oidc_id exists (hard error)
|
||||
not is_nil(email_user_oidc_id) and email_user_oidc_id != "" and
|
||||
email_user_oidc_id != new_oidc_id ->
|
||||
email = Map.get(user_info, "preferred_username", "unknown")
|
||||
different_oidc_error(email)
|
||||
|
||||
# No oidc_id (require account linking)
|
||||
is_nil(email_user_oidc_id) or email_user_oidc_id == "" ->
|
||||
{:error,
|
||||
PasswordVerificationRequired.exception(
|
||||
user_id: user_with_email.id,
|
||||
oidc_user_info: user_info
|
||||
)}
|
||||
|
||||
# Should not reach here
|
||||
true ->
|
||||
{:error, field: :email, message: "Unexpected error during OIDC registration"}
|
||||
end
|
||||
end
|
||||
|
||||
# Generate error for different OIDC account conflict
|
||||
defp different_oidc_error(email) do
|
||||
{:error,
|
||||
field: :email,
|
||||
message:
|
||||
"Email '#{email}' is already linked to a different OIDC account. " <>
|
||||
"Cannot link multiple OIDC providers to the same account."}
|
||||
end
|
||||
|
||||
# Generate error for email already taken
|
||||
defp email_taken_error(email) do
|
||||
{:error,
|
||||
field: :email,
|
||||
message:
|
||||
"Cannot update email to '#{email}': This email is already registered to another account. " <>
|
||||
"Please change your email in the identity provider."}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def atomic?(), do: false
|
||||
|
||||
@impl true
|
||||
def describe(_opts) do
|
||||
[
|
||||
message: "OIDC email collision detected",
|
||||
vars: []
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
defmodule Mv.Membership.CustomField do
|
||||
@moduledoc """
|
||||
Ash resource defining the schema for custom member fields.
|
||||
|
||||
## Overview
|
||||
CustomFields define the "schema" for custom fields in the membership system.
|
||||
Each CustomField specifies the name, data type, and behavior of a custom field
|
||||
that can be attached to members via CustomFieldValue resources.
|
||||
|
||||
## Attributes
|
||||
- `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday")
|
||||
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
|
||||
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
|
||||
- `description` - Optional human-readable description
|
||||
- `required` - If true, all members must have this custom field (future feature)
|
||||
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
|
||||
|
||||
## Supported Value Types
|
||||
- `:string` - Text data (max 10,000 characters)
|
||||
- `:integer` - Numeric data (64-bit integers)
|
||||
- `:boolean` - True/false flags
|
||||
- `:date` - Date values (no time component)
|
||||
- `:email` - Validated email addresses (max 254 characters)
|
||||
|
||||
## Relationships
|
||||
- `has_many :custom_field_values` - All custom field values of this type
|
||||
|
||||
## Constraints
|
||||
- Name must be unique across all custom fields
|
||||
- Name maximum length: 100 characters
|
||||
- Deleting a custom field will cascade delete all associated custom field values
|
||||
|
||||
## Calculations
|
||||
- `assigned_members_count` - Returns the number of distinct members with values for this custom field
|
||||
|
||||
## Examples
|
||||
# Create a new custom field
|
||||
CustomField.create!(%{
|
||||
name: "phone_mobile",
|
||||
value_type: :string,
|
||||
description: "Mobile phone number"
|
||||
})
|
||||
|
||||
# Create a required custom field
|
||||
CustomField.create!(%{
|
||||
name: "emergency_contact",
|
||||
value_type: :string,
|
||||
required: true
|
||||
})
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "custom_fields"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :update]
|
||||
default_accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||
|
||||
create :create do
|
||||
accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||
change Mv.Membership.CustomField.Changes.GenerateSlug
|
||||
validate string_length(:slug, min: 1)
|
||||
end
|
||||
|
||||
destroy :destroy_with_values do
|
||||
primary? true
|
||||
end
|
||||
|
||||
read :prepare_deletion do
|
||||
argument :id, :uuid, allow_nil?: false
|
||||
|
||||
filter expr(id == ^arg(:id))
|
||||
prepare build(load: [:assigned_members_count])
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :name, :string,
|
||||
allow_nil?: false,
|
||||
public?: true,
|
||||
constraints: [
|
||||
max_length: 100,
|
||||
trim?: true
|
||||
]
|
||||
|
||||
attribute :slug, :string,
|
||||
allow_nil?: false,
|
||||
public?: true,
|
||||
writable?: false,
|
||||
constraints: [
|
||||
max_length: 100,
|
||||
trim?: true
|
||||
]
|
||||
|
||||
attribute :value_type, :atom,
|
||||
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
|
||||
allow_nil?: false,
|
||||
description: "Defines the datatype `CustomFieldValue.value` is interpreted as"
|
||||
|
||||
attribute :description, :string,
|
||||
allow_nil?: true,
|
||||
public?: true,
|
||||
constraints: [
|
||||
max_length: 500,
|
||||
trim?: true
|
||||
]
|
||||
|
||||
attribute :required, :boolean,
|
||||
default: false,
|
||||
allow_nil?: false
|
||||
|
||||
attribute :show_in_overview, :boolean,
|
||||
default: true,
|
||||
allow_nil?: false,
|
||||
public?: true,
|
||||
description: "If true, this custom field will be displayed in the member overview table"
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :custom_field_values, Mv.Membership.CustomFieldValue
|
||||
end
|
||||
|
||||
calculations do
|
||||
calculate :assigned_members_count,
|
||||
:integer,
|
||||
expr(
|
||||
fragment(
|
||||
"(SELECT COUNT(DISTINCT member_id) FROM custom_field_values WHERE custom_field_id = ?)",
|
||||
id
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_name, [:name]
|
||||
identity :unique_slug, [:slug]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
|
||||
@moduledoc """
|
||||
Ash Change that automatically generates a URL-friendly slug from the `name` attribute.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **On Create**: Generates a slug from the name attribute using slugify
|
||||
- **On Update**: Slug remains unchanged (immutable after creation)
|
||||
- **Slug Generation**: Uses the `slugify` library to convert name to slug
|
||||
- Converts to lowercase
|
||||
- Replaces spaces with hyphens
|
||||
- Removes special characters
|
||||
- Handles UTF-8 characters (e.g., ä → a, ß → ss)
|
||||
- Trims leading/trailing hyphens
|
||||
- Truncates to max 100 characters
|
||||
|
||||
## Examples
|
||||
|
||||
# Create with automatic slug generation
|
||||
CustomField.create!(%{name: "Mobile Phone"})
|
||||
# => %CustomField{name: "Mobile Phone", slug: "mobile-phone"}
|
||||
|
||||
# German umlauts are converted
|
||||
CustomField.create!(%{name: "Café Müller"})
|
||||
# => %CustomField{name: "Café Müller", slug: "cafe-muller"}
|
||||
|
||||
# Slug is immutable on update
|
||||
custom_field = CustomField.create!(%{name: "Original"})
|
||||
CustomField.update!(custom_field, %{name: "New Name"})
|
||||
# => %CustomField{name: "New Name", slug: "original"} # slug unchanged!
|
||||
|
||||
## Implementation Note
|
||||
|
||||
This change only runs on `:create` actions. The slug is immutable by design,
|
||||
as changing slugs would break external references (e.g., CSV imports/exports).
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
@doc """
|
||||
Generates a slug from the changeset's `name` attribute.
|
||||
|
||||
Only runs on create actions. Returns the changeset unchanged if:
|
||||
- The action is not :create
|
||||
- The name is not being changed
|
||||
- The name is nil or empty
|
||||
|
||||
## Parameters
|
||||
|
||||
- `changeset` - The Ash changeset
|
||||
|
||||
## Returns
|
||||
|
||||
The changeset with the `:slug` attribute set to the generated slug.
|
||||
"""
|
||||
def change(changeset, _opts, _context) do
|
||||
# Only generate slug on create, not on update (immutability)
|
||||
if changeset.action_type == :create do
|
||||
case Ash.Changeset.get_attribute(changeset, :name) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
name when is_binary(name) ->
|
||||
slug = generate_slug(name)
|
||||
Ash.Changeset.force_change_attribute(changeset, :slug, slug)
|
||||
end
|
||||
else
|
||||
# On update, don't touch the slug (immutable)
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a URL-friendly slug from a given string.
|
||||
|
||||
Uses the `slugify` library to create a clean, lowercase slug with:
|
||||
- Spaces replaced by hyphens
|
||||
- Special characters removed
|
||||
- UTF-8 characters transliterated (ä → a, ß → ss, etc.)
|
||||
- Multiple consecutive hyphens reduced to single hyphen
|
||||
- Leading/trailing hyphens removed
|
||||
- Maximum length of 100 characters
|
||||
|
||||
## Examples
|
||||
|
||||
iex> generate_slug("Mobile Phone")
|
||||
"mobile-phone"
|
||||
|
||||
iex> generate_slug("Café Müller")
|
||||
"cafe-muller"
|
||||
|
||||
iex> generate_slug("TEST NAME")
|
||||
"test-name"
|
||||
|
||||
iex> generate_slug("E-Mail & Address!")
|
||||
"e-mail-address"
|
||||
|
||||
iex> generate_slug("Multiple Spaces")
|
||||
"multiple-spaces"
|
||||
|
||||
iex> generate_slug("-Test-")
|
||||
"test"
|
||||
|
||||
iex> generate_slug("Straße")
|
||||
"strasse"
|
||||
|
||||
"""
|
||||
def generate_slug(name) when is_binary(name) do
|
||||
slug = Slug.slugify(name)
|
||||
|
||||
case slug do
|
||||
nil -> ""
|
||||
"" -> ""
|
||||
slug when is_binary(slug) -> String.slice(slug, 0, 100)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_slug(_), do: ""
|
||||
end
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
defmodule Mv.Membership.CustomFieldValue do
|
||||
@moduledoc """
|
||||
Ash resource representing a custom field value for a member.
|
||||
|
||||
## Overview
|
||||
CustomFieldValues implement the Entity-Attribute-Value (EAV) pattern, allowing
|
||||
dynamic custom fields to be attached to members. Each custom field value links a
|
||||
member to a custom field and stores the actual value.
|
||||
|
||||
## Value Storage
|
||||
Values are stored using Ash's union type with JSONB storage format:
|
||||
```json
|
||||
{
|
||||
"type": "string",
|
||||
"value": "example"
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Types
|
||||
- `:string` - Text data
|
||||
- `:integer` - Numeric data
|
||||
- `:boolean` - True/false flags
|
||||
- `:date` - Date values
|
||||
- `:email` - Validated email addresses (custom type)
|
||||
|
||||
## Relationships
|
||||
- `belongs_to :member` - The member this custom field value belongs to (CASCADE delete)
|
||||
- `belongs_to :custom_field` - The custom field definition (CASCADE delete)
|
||||
|
||||
## Constraints
|
||||
- Each member can have only one custom field value per custom field (unique composite index)
|
||||
- Custom field values are deleted when the associated member is deleted (CASCADE)
|
||||
- Custom field values are deleted when the associated custom field is deleted (CASCADE)
|
||||
- String values maximum length: 10,000 characters
|
||||
- Email values maximum length: 254 characters (RFC 5321)
|
||||
|
||||
## Future Features
|
||||
- Type-matching validation (value type must match custom field's value_type) - to be implemented
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "custom_field_values"
|
||||
repo Mv.Repo
|
||||
|
||||
references do
|
||||
reference :member, on_delete: :delete
|
||||
reference :custom_field, on_delete: :delete
|
||||
end
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
default_accept [:value, :member_id, :custom_field_id]
|
||||
|
||||
read :by_custom_field_id do
|
||||
argument :custom_field_id, :uuid, allow_nil?: false
|
||||
|
||||
filter expr(custom_field_id == ^arg(:custom_field_id))
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :value, :union,
|
||||
constraints: [
|
||||
storage: :type_and_value,
|
||||
types: [
|
||||
boolean: [
|
||||
type: :boolean
|
||||
],
|
||||
date: [
|
||||
type: :date
|
||||
],
|
||||
integer: [
|
||||
type: :integer
|
||||
],
|
||||
string: [
|
||||
type: :string,
|
||||
constraints: [
|
||||
max_length: 10_000,
|
||||
trim?: true
|
||||
]
|
||||
],
|
||||
email: [
|
||||
type: Mv.Membership.Email
|
||||
]
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :member, Mv.Membership.Member
|
||||
|
||||
belongs_to :custom_field, Mv.Membership.CustomField
|
||||
end
|
||||
|
||||
calculations do
|
||||
calculate :value_to_string, :string, expr(value[:value] <> "")
|
||||
end
|
||||
|
||||
# Ensure a member can only have one custom field value per custom field
|
||||
# For example: A member can have only one "phone" custom field value, one "email" custom field value, etc.
|
||||
identities do
|
||||
identity :unique_custom_field_per_member, [:member_id, :custom_field_id]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,38 +1,4 @@
|
|||
defmodule Mv.Membership.Email do
|
||||
@moduledoc """
|
||||
Custom Ash type for validated email addresses.
|
||||
|
||||
## Overview
|
||||
This type extends `:string` with email-specific validation constraints.
|
||||
It ensures that email values stored in CustomFieldValue resources are valid email
|
||||
addresses according to a standard regex pattern.
|
||||
|
||||
## Validation Rules
|
||||
- **Optional**: `nil` and empty strings are allowed (custom fields are optional)
|
||||
- Minimum length: 5 characters (for non-empty values)
|
||||
- Maximum length: 254 characters (RFC 5321 maximum)
|
||||
- Pattern: Standard email format (username@domain.tld)
|
||||
- Automatic trimming of leading/trailing whitespace (empty strings become `nil`)
|
||||
|
||||
## Usage
|
||||
This type is used in the CustomFieldValue union type for custom fields with
|
||||
`value_type: :email` in CustomField definitions.
|
||||
|
||||
## Example
|
||||
# In a custom field definition
|
||||
CustomField.create!(%{
|
||||
name: "work_email",
|
||||
value_type: :email
|
||||
})
|
||||
|
||||
# Valid values
|
||||
"user@example.com"
|
||||
"first.last@company.co.uk"
|
||||
|
||||
# Invalid values
|
||||
"not-an-email" # Missing @ and domain
|
||||
"a@b" # Too short
|
||||
"""
|
||||
@match_pattern ~S/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
|
||||
@match_regex Regex.compile!(@match_pattern)
|
||||
@min_length 5
|
||||
|
|
@ -48,17 +14,13 @@ defmodule Mv.Membership.Email do
|
|||
]
|
||||
|
||||
@impl true
|
||||
def cast_input(nil, _), do: {:ok, nil}
|
||||
def cast_input("", _), do: {:ok, nil}
|
||||
|
||||
@impl true
|
||||
def cast_input(value, _) when is_binary(value) do
|
||||
value = String.trim(value)
|
||||
|
||||
cond do
|
||||
# Empty string after trim becomes nil (optional field)
|
||||
value == "" ->
|
||||
{:ok, nil}
|
||||
|
||||
String.length(value) < @min_length ->
|
||||
:error
|
||||
|
||||
|
|
|
|||
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,32 +1,6 @@
|
|||
defmodule Mv.Membership do
|
||||
@moduledoc """
|
||||
Ash Domain for membership management.
|
||||
|
||||
## Resources
|
||||
- `Member` - Club members with personal information and custom field values
|
||||
- `CustomFieldValue` - Dynamic custom field values attached to members
|
||||
- `CustomField` - Schema definitions for custom fields
|
||||
- `Setting` - Global application settings (singleton)
|
||||
|
||||
## 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`, etc.
|
||||
- Settings management: `get_settings/0`, `update_settings/2`
|
||||
|
||||
## Admin Interface
|
||||
The domain is configured with AshAdmin for management UI.
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
admin do
|
||||
show? true
|
||||
end
|
||||
extensions: [AshPhoenix]
|
||||
|
||||
resources do
|
||||
resource Mv.Membership.Member do
|
||||
|
|
@ -36,195 +10,18 @@ defmodule Mv.Membership do
|
|||
define :destroy_member, action: :destroy
|
||||
end
|
||||
|
||||
resource Mv.Membership.CustomFieldValue do
|
||||
define :create_custom_field_value, action: :create
|
||||
define :list_custom_field_values, action: :read
|
||||
define :update_custom_field_value, action: :update
|
||||
define :destroy_custom_field_value, action: :destroy
|
||||
resource Mv.Membership.Property do
|
||||
define :create_property, action: :create_property
|
||||
define :list_property, action: :read
|
||||
define :update_property, action: :update_property
|
||||
define :destroy_property, action: :destroy
|
||||
end
|
||||
|
||||
resource Mv.Membership.CustomField do
|
||||
define :create_custom_field, action: :create
|
||||
define :list_custom_fields, action: :read
|
||||
define :update_custom_field, action: :update
|
||||
define :destroy_custom_field, action: :destroy_with_values
|
||||
define :prepare_custom_field_deletion, action: :prepare_deletion, args: [:id]
|
||||
resource Mv.Membership.PropertyType do
|
||||
define :create_property_type, action: :create
|
||||
define :list_property_types, action: :read
|
||||
define :update_property_type, action: :update
|
||||
define :destroy_property_type, action: :destroy
|
||||
end
|
||||
|
||||
resource Mv.Membership.Setting do
|
||||
# Note: create action exists but is not exposed via code interface
|
||||
# It's only used internally as fallback in get_settings/0
|
||||
# Settings should be created via seed script
|
||||
define :update_settings, action: :update
|
||||
define :update_member_field_visibility, action: :update_member_field_visibility
|
||||
|
||||
define :update_single_member_field_visibility,
|
||||
action: :update_single_member_field_visibility
|
||||
end
|
||||
end
|
||||
|
||||
# Singleton pattern: Get the single settings record
|
||||
@doc """
|
||||
Gets the global settings.
|
||||
|
||||
Settings should normally be created via the seed script (`priv/repo/seeds.exs`).
|
||||
If no settings exist, this function will create them as a fallback using the
|
||||
`ASSOCIATION_NAME` environment variable or "Club Name" as default.
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, settings}` - The settings record
|
||||
- `{:ok, nil}` - No settings exist (should not happen if seeds were run)
|
||||
- `{:error, error}` - Error reading settings
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> settings.club_name
|
||||
"My Club"
|
||||
|
||||
"""
|
||||
def get_settings do
|
||||
# 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.create!(domain: __MODULE__)
|
||||
|> then(fn settings -> {:ok, settings} end)
|
||||
|
||||
{:ok, settings} ->
|
||||
{:ok, settings}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the global settings.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `settings` - The settings record to update
|
||||
- `attrs` - A map of attributes to update (e.g., `%{club_name: "New Name"}`)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, updated_settings}` - Successfully updated settings
|
||||
- `{:error, error}` - Validation or update error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> {:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Club"})
|
||||
iex> updated.club_name
|
||||
"New Club"
|
||||
|
||||
"""
|
||||
def update_settings(settings, attrs) do
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update, attrs)
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
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.
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, required_custom_fields}` - List of required custom fields
|
||||
- `{:error, error}` - Error reading custom fields
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields()
|
||||
iex> Enum.all?(required_fields, & &1.required)
|
||||
true
|
||||
"""
|
||||
def list_required_custom_fields do
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.filter(expr(required == true))
|
||||
|> Ash.read(domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the member field visibility configuration.
|
||||
|
||||
This is a specialized action for updating only the member field visibility settings.
|
||||
It validates that all keys are valid member fields and all values are booleans.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `settings` - The settings record to update
|
||||
- `visibility_config` - A map of member field names (strings) to boolean visibility values
|
||||
(e.g., `%{"street" => false, "house_number" => false}`)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, updated_settings}` - Successfully updated settings
|
||||
- `{:error, error}` - Validation or update error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||
iex> updated.member_field_visibility
|
||||
%{"street" => false, "house_number" => false}
|
||||
|
||||
"""
|
||||
def update_member_field_visibility(settings, visibility_config) do
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
||||
member_field_visibility: visibility_config
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
|
||||
@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
|
||||
settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
28
lib/membership/phone_number.ex
Normal file
28
lib/membership/phone_number.ex
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
defmodule Mv.Membership.PhoneNumber do
|
||||
@match_pattern ~S/^\+?\d{5,16}$/
|
||||
@match_regex Regex.compile!(@match_pattern)
|
||||
|
||||
use Ash.Type.NewType,
|
||||
subtype_of: :string,
|
||||
constraints: [
|
||||
match: @match_pattern,
|
||||
trim?: true
|
||||
]
|
||||
|
||||
@impl true
|
||||
def cast_input("", _), do: {:ok, nil}
|
||||
|
||||
@impl true
|
||||
def cast_input(value, _) when is_binary(value) do
|
||||
value = String.trim(value)
|
||||
|
||||
if Regex.match?(@match_regex, value) do
|
||||
{:ok, value}
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def cast_input(_, _), do: :error
|
||||
end
|
||||
67
lib/membership/property.ex
Normal file
67
lib/membership/property.ex
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
defmodule Mv.Membership.Property do
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "properties"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
default_accept [:value, :member_id, :property_type_id]
|
||||
|
||||
create :create_property do
|
||||
primary? true
|
||||
load [:property_type]
|
||||
end
|
||||
|
||||
update :update_property do
|
||||
primary? true
|
||||
require_atomic? false
|
||||
load [:property_type]
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :value, :union,
|
||||
allow_nil?: true,
|
||||
constraints: [
|
||||
storage: :type_and_value,
|
||||
types: [
|
||||
boolean: [type: :boolean],
|
||||
date: [type: :date],
|
||||
integer: [type: :integer],
|
||||
string: [type: :string],
|
||||
email: [type: Mv.Membership.Email],
|
||||
phone: [type: Mv.Membership.PhoneNumber]
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :member, Mv.Membership.Member
|
||||
|
||||
belongs_to :property_type, Mv.Membership.PropertyType
|
||||
end
|
||||
|
||||
calculations do
|
||||
calculate :value_to_string, :string, expr(value[:value] <> "")
|
||||
end
|
||||
|
||||
aggregates do
|
||||
first :property_type_required,
|
||||
:property_type,
|
||||
:required
|
||||
end
|
||||
|
||||
validations do
|
||||
validate {Mv.Membership.Validations.ValidateProperty, attribute: :value}
|
||||
end
|
||||
|
||||
end
|
||||
44
lib/membership/property_type.ex
Normal file
44
lib/membership/property_type.ex
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
defmodule Mv.Membership.PropertyType do
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "property_types"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
default_accept [:name, :value_type, :description, :immutable, :required]
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :name, :string, allow_nil?: false, public?: true
|
||||
|
||||
attribute :value_type, :atom,
|
||||
constraints: [one_of: [:string, :integer, :boolean, :date, :email, :phone]],
|
||||
allow_nil?: false,
|
||||
description: "Definies the datatype `Property.value` is interpreted as"
|
||||
|
||||
attribute :description, :string, allow_nil?: true, public?: true
|
||||
|
||||
attribute :immutable, :boolean,
|
||||
default: false,
|
||||
allow_nil?: false
|
||||
|
||||
attribute :required, :boolean,
|
||||
default: false,
|
||||
allow_nil?: false
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :properties, Mv.Membership.Property
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_name, [:name]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
defmodule Mv.Membership.Setting do
|
||||
@moduledoc """
|
||||
Ash resource representing global application settings.
|
||||
|
||||
## Overview
|
||||
Settings is a singleton resource that stores global configuration for the association,
|
||||
such as the club name, branding information, and membership fee settings. There should
|
||||
only ever be one settings record in the database.
|
||||
|
||||
## Attributes
|
||||
- `club_name` - The name of the association/club (required, cannot be empty)
|
||||
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
|
||||
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
|
||||
- `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)
|
||||
|
||||
## Singleton Pattern
|
||||
This resource uses a singleton pattern - there should only be one settings record.
|
||||
The resource is designed to be read and updated, but not created or destroyed
|
||||
through normal CRUD operations. Initial settings should be seeded.
|
||||
|
||||
## Environment Variable Support
|
||||
The `club_name` can be set via the `ASSOCIATION_NAME` environment variable.
|
||||
If set, the environment variable value is used as a fallback when no database
|
||||
value exists. Database values always take precedence over environment variables.
|
||||
|
||||
## Membership Fee Settings
|
||||
- `include_joining_cycle`: When true, members pay from their joining cycle. When false,
|
||||
they pay from the next full cycle after joining.
|
||||
- `default_membership_fee_type_id`: The membership fee type automatically assigned to
|
||||
new members. Can be nil if no default is set.
|
||||
|
||||
## Examples
|
||||
|
||||
# Get current settings
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
settings.club_name # => "My Club"
|
||||
|
||||
# Update club name
|
||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"})
|
||||
|
||||
# Update member field visibility
|
||||
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||
|
||||
# Update membership fee settings
|
||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "settings"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
resource do
|
||||
description "Global application settings (singleton resource)"
|
||||
end
|
||||
|
||||
actions do
|
||||
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,
|
||||
:include_joining_cycle,
|
||||
:default_membership_fee_type_id
|
||||
]
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
require_atomic? false
|
||||
|
||||
accept [
|
||||
:club_name,
|
||||
:member_field_visibility,
|
||||
:include_joining_cycle,
|
||||
:default_membership_fee_type_id
|
||||
]
|
||||
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_membership_fee_settings do
|
||||
description "Updates the membership fee configuration"
|
||||
require_atomic? false
|
||||
accept [:include_joining_cycle, :default_membership_fee_type_id]
|
||||
|
||||
change Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate present(:club_name), on: [:create, :update]
|
||||
validate string_length(:club_name, min: 1), on: [:create, :update]
|
||||
|
||||
# Validate member_field_visibility map structure and content
|
||||
validate fn changeset, _context ->
|
||||
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
|
||||
|
||||
if visibility && is_map(visibility) do
|
||||
# Validate all values are booleans
|
||||
invalid_values =
|
||||
Enum.filter(visibility, fn {_key, value} ->
|
||||
not is_boolean(value)
|
||||
end)
|
||||
|
||||
# Validate all keys are valid member fields
|
||||
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
invalid_keys =
|
||||
Enum.filter(visibility, fn {key, _value} ->
|
||||
key not in valid_field_strings
|
||||
end)
|
||||
|> Enum.map(fn {key, _value} -> key end)
|
||||
|
||||
cond do
|
||||
not Enum.empty?(invalid_values) ->
|
||||
{:error,
|
||||
field: :member_field_visibility,
|
||||
message: "All values in member_field_visibility must be booleans"}
|
||||
|
||||
not Enum.empty?(invalid_keys) ->
|
||||
{:error,
|
||||
field: :member_field_visibility,
|
||||
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:create, :update]
|
||||
|
||||
# Validate 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
|
||||
case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
|
||||
{:error,
|
||||
field: :default_membership_fee_type_id,
|
||||
message: "Membership fee type not found"}
|
||||
|
||||
{:error, err} ->
|
||||
# Log unexpected errors (DB timeout, connection errors, etc.)
|
||||
require Logger
|
||||
|
||||
Logger.warning(
|
||||
"Unexpected error when validating default_membership_fee_type_id: #{inspect(err)}"
|
||||
)
|
||||
|
||||
# Return generic error to user
|
||||
{:error,
|
||||
field: :default_membership_fee_type_id,
|
||||
message: "Could not validate membership fee type"}
|
||||
end
|
||||
else
|
||||
# Optional, can be nil
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:create, :update]
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :club_name, :string,
|
||||
allow_nil?: false,
|
||||
public?: true,
|
||||
description: "The name of the association/club",
|
||||
constraints: [
|
||||
trim?: true,
|
||||
min_length: 1
|
||||
]
|
||||
|
||||
attribute :member_field_visibility, :map,
|
||||
allow_nil?: true,
|
||||
public?: true,
|
||||
description:
|
||||
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
|
||||
|
||||
# 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
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
relationships do
|
||||
# Optional relationship to the default membership fee type
|
||||
# Note: We use manual FK (default_membership_fee_type_id attribute) instead of belongs_to
|
||||
# to avoid circular dependency between Membership and MembershipFees domains
|
||||
end
|
||||
end
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
defmodule Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId do
|
||||
@moduledoc """
|
||||
Ash change that normalizes empty strings to nil for default_membership_fee_type_id.
|
||||
|
||||
HTML forms submit empty select values as empty strings (""), but the database
|
||||
expects nil for optional UUID fields. This change converts "" to nil.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
default_fee_type_id = Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
|
||||
|
||||
if default_fee_type_id == "" do
|
||||
Ash.Changeset.force_change_attribute(changeset, :default_membership_fee_type_id, nil)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
|
||||
@moduledoc """
|
||||
Ash change that atomically updates a single field in the member_field_visibility JSONB map.
|
||||
|
||||
This change uses PostgreSQL's jsonb_set function to atomically update a single key
|
||||
in the JSONB map, preventing lost updates in concurrent scenarios.
|
||||
|
||||
## Arguments
|
||||
- `field` - The member field name as a string (e.g., "street", "house_number")
|
||||
- `show_in_overview` - Boolean value indicating visibility
|
||||
|
||||
## Example
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility,
|
||||
%{},
|
||||
arguments: %{field: "street", show_in_overview: false}
|
||||
)
|
||||
|> Ash.update(domain: Mv.Membership)
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Ash.Error.Invalid
|
||||
alias Ecto.Adapters.SQL
|
||||
require Logger
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
with {:ok, field} <- get_and_validate_field(changeset),
|
||||
{:ok, show_in_overview} <- get_and_validate_boolean(changeset, :show_in_overview) do
|
||||
add_after_action(changeset, field, show_in_overview)
|
||||
else
|
||||
{:error, updated_changeset} -> updated_changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp get_and_validate_field(changeset) do
|
||||
case Ash.Changeset.get_argument(changeset, :field) do
|
||||
nil ->
|
||||
{:error,
|
||||
add_error(changeset,
|
||||
field: :member_field_visibility,
|
||||
message: "field argument is required"
|
||||
)}
|
||||
|
||||
field ->
|
||||
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
if field in valid_fields do
|
||||
{:ok, field}
|
||||
else
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: :member_field_visibility,
|
||||
message: "Invalid member field: #{field}"
|
||||
)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_and_validate_boolean(changeset, arg_name) do
|
||||
case Ash.Changeset.get_argument(changeset, arg_name) do
|
||||
nil ->
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: :member_field_visibility,
|
||||
message: "#{arg_name} argument is required"
|
||||
)}
|
||||
|
||||
value when is_boolean(value) ->
|
||||
{:ok, value}
|
||||
|
||||
_ ->
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: :member_field_visibility,
|
||||
message: "#{arg_name} must be a boolean"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp add_error(changeset, opts) do
|
||||
Ash.Changeset.add_error(changeset, opts)
|
||||
end
|
||||
|
||||
defp add_after_action(changeset, field, show_in_overview) do
|
||||
# Use after_action to execute atomic SQL update
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
|
||||
# Use PostgreSQL jsonb_set for atomic update
|
||||
# jsonb_set(target, path, new_value, create_missing?)
|
||||
# path is an array: ['field_name']
|
||||
# new_value must be JSON: to_jsonb(boolean)
|
||||
sql = """
|
||||
UPDATE settings
|
||||
SET member_field_visibility = jsonb_set(
|
||||
COALESCE(member_field_visibility, '{}'::jsonb),
|
||||
ARRAY[$1::text],
|
||||
to_jsonb($2::boolean),
|
||||
true
|
||||
)
|
||||
WHERE id = $3
|
||||
RETURNING member_field_visibility
|
||||
"""
|
||||
|
||||
# Convert UUID string to binary for PostgreSQL
|
||||
uuid_binary = Ecto.UUID.dump!(settings.id)
|
||||
|
||||
case SQL.query(Mv.Repo, sql, [field, show_in_overview, uuid_binary]) do
|
||||
{:ok, %{rows: [[updated_jsonb] | _]}} ->
|
||||
updated_visibility = normalize_jsonb_result(updated_jsonb)
|
||||
|
||||
# Update the settings struct with the new visibility
|
||||
updated_settings = %{settings | member_field_visibility: updated_visibility}
|
||||
{:ok, updated_settings}
|
||||
|
||||
{:ok, %{rows: []}} ->
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: :member_field_visibility,
|
||||
message: "Settings not found"
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error("Failed to atomically update member_field_visibility: #{inspect(error)}")
|
||||
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: :member_field_visibility,
|
||||
message: "Failed to update visibility"
|
||||
)}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_jsonb_result(updated_jsonb) do
|
||||
case updated_jsonb do
|
||||
map when is_map(map) ->
|
||||
# Convert atom keys to strings if needed
|
||||
Enum.reduce(map, %{}, fn
|
||||
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
|
||||
{k, v}, acc -> Map.put(acc, k, v)
|
||||
end)
|
||||
|
||||
binary when is_binary(binary) ->
|
||||
case Jason.decode(binary) do
|
||||
{:ok, decoded} when is_map(decoded) ->
|
||||
decoded
|
||||
|
||||
# Not a map after decode
|
||||
{:ok, _} ->
|
||||
%{}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
|
||||
%{}
|
||||
end
|
||||
|
||||
_ ->
|
||||
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
|
||||
%{}
|
||||
end
|
||||
end
|
||||
end
|
||||
27
lib/membership/validate_property.ex
Normal file
27
lib/membership/validate_property.ex
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
defmodule Mv.Membership.Validations.ValidateProperty do
|
||||
use Ash.Resource.Validation
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
if is_atom(opts[:value]) do
|
||||
{:ok, opts}
|
||||
else
|
||||
{:error, "attribute must be an atom!"}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def validate(changeset, _opts, _context) do
|
||||
changeset = Ash.Changeset.load(changeset, [:property_type])
|
||||
property_type = changeset.data.property_type
|
||||
IO.inspect(property_type)
|
||||
required? = property_type.required
|
||||
union_value = Ash.Changeset.get_attribute(changeset, :value)
|
||||
|
||||
if required? and union_value in [nil, ""] do
|
||||
{:error, field: :value, message: "is required"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
||||
@moduledoc """
|
||||
Ash change module that automatically calculates and sets the membership_fee_start_date.
|
||||
|
||||
## Logic
|
||||
|
||||
1. Only executes if `membership_fee_start_date` is not manually set
|
||||
2. Requires both `join_date` and `membership_fee_type_id` to be present
|
||||
3. Reads `include_joining_cycle` setting from global Settings
|
||||
4. Reads `interval` from the assigned `membership_fee_type`
|
||||
5. Calculates the start date:
|
||||
- If `include_joining_cycle = true`: First day of the joining cycle
|
||||
- If `include_joining_cycle = false`: First day of the next cycle after joining
|
||||
|
||||
## Usage
|
||||
|
||||
In a Member action:
|
||||
|
||||
create :create_member do
|
||||
# ...
|
||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
||||
end
|
||||
|
||||
The change module handles all prerequisite checks internally (join_date, membership_fee_type_id).
|
||||
If any required data is missing, the changeset is returned unchanged with a warning logged.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, _context) do
|
||||
# Only calculate if membership_fee_start_date is not already set
|
||||
if has_start_date?(changeset) do
|
||||
changeset
|
||||
else
|
||||
calculate_and_set_start_date(changeset)
|
||||
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) do
|
||||
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),
|
||||
{:ok, include_joining_cycle} <- get_include_joining_cycle() do
|
||||
start_date = calculate_start_date(join_date, interval, include_joining_cycle)
|
||||
Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date)
|
||||
else
|
||||
{:error, :join_date_not_set} ->
|
||||
# Missing join_date is expected for partial creates
|
||||
changeset
|
||||
|
||||
{:error, :membership_fee_type_not_set} ->
|
||||
# Missing membership_fee_type_id is expected for partial creates
|
||||
changeset
|
||||
|
||||
{:error, :membership_fee_type_not_found} ->
|
||||
# This is a data integrity error - membership_fee_type_id references non-existent type
|
||||
# Return changeset error to fail the action
|
||||
Ash.Changeset.add_error(
|
||||
changeset,
|
||||
field: :membership_fee_type_id,
|
||||
message: "not found"
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
# Log warning for other unexpected errors
|
||||
Logger.warning("Could not auto-set membership_fee_start_date: #{inspect(reason)}")
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp get_join_date(changeset) do
|
||||
# First check the changeset for changes
|
||||
case Ash.Changeset.fetch_change(changeset, :join_date) do
|
||||
{:ok, date} when not is_nil(date) ->
|
||||
{:ok, date}
|
||||
|
||||
_ ->
|
||||
# Then check existing data
|
||||
case changeset.data do
|
||||
%{join_date: date} when not is_nil(date) -> {:ok, date}
|
||||
_ -> {:error, :join_date_not_set}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_membership_fee_type_id(changeset) do
|
||||
# First check the changeset for changes
|
||||
case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do
|
||||
{:ok, id} when not is_nil(id) ->
|
||||
{:ok, id}
|
||||
|
||||
_ ->
|
||||
# Then check existing data
|
||||
case changeset.data do
|
||||
%{membership_fee_type_id: id} when not is_nil(id) -> {:ok, id}
|
||||
_ -> {:error, :membership_fee_type_not_set}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_interval(membership_fee_type_id) do
|
||||
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id) 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,148 +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)
|
||||
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) do
|
||||
current_type_id = get_current_type_id(changeset)
|
||||
new_type_id = get_new_type_id(changeset)
|
||||
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
# Validates that intervals match when both types exist
|
||||
defp validate_intervals_match(changeset, current_type_id, new_type_id) do
|
||||
case get_intervals(current_type_id, new_type_id) 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
|
||||
defp get_intervals(current_type_id, new_type_id) do
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
case {Ash.get(MembershipFeeType, current_type_id), Ash.get(MembershipFeeType, new_type_id)} 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,132 +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
|
||||
|
||||
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
|
||||
|
||||
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,190 +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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
member_count =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|
||||
|> Ash.count!()
|
||||
|
||||
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
|
||||
|
||||
cycle_count =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|
||||
|> Ash.count!()
|
||||
|
||||
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
|
||||
|
||||
setting_count =
|
||||
Mv.Membership.Setting
|
||||
|> Ash.Query.filter(default_membership_fee_type_id == ^changeset.data.id)
|
||||
|> Ash.count!()
|
||||
|
||||
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,42 +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
|
||||
|
||||
## 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,46 +0,0 @@
|
|||
defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||
@moduledoc """
|
||||
Sends an email for a new user to confirm their email address.
|
||||
"""
|
||||
|
||||
use AshAuthentication.Sender
|
||||
use MvWeb, :verified_routes
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias Mv.Mailer
|
||||
|
||||
@doc """
|
||||
Sends a confirmation email to a new user.
|
||||
|
||||
This function is called automatically by AshAuthentication when a new
|
||||
user registers and needs to confirm their email address.
|
||||
|
||||
## Parameters
|
||||
- `user` - The user record who needs to confirm their email
|
||||
- `token` - The confirmation token to include in the email link
|
||||
- `_opts` - Additional options (unused)
|
||||
|
||||
## Returns
|
||||
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
|
||||
"""
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
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
|
||||
|
||||
defp body(params) do
|
||||
url = url(~p"/confirm_new_user/#{params[:token]}")
|
||||
|
||||
"""
|
||||
<p>Click this link to confirm your email:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
||||
@moduledoc """
|
||||
Sends a password reset email
|
||||
"""
|
||||
|
||||
use AshAuthentication.Sender
|
||||
use MvWeb, :verified_routes
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias Mv.Mailer
|
||||
|
||||
@doc """
|
||||
Sends a password reset email to a user.
|
||||
|
||||
This function is called automatically by AshAuthentication when a user
|
||||
requests a password reset.
|
||||
|
||||
## Parameters
|
||||
- `user` - The user record requesting the password reset
|
||||
- `token` - The password reset token to include in the email link
|
||||
- `_opts` - Additional options (unused)
|
||||
|
||||
## Returns
|
||||
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
|
||||
"""
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
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
|
||||
|
||||
defp body(params) do
|
||||
url = url(~p"/password-reset/#{params[:token]}")
|
||||
|
||||
"""
|
||||
<p>Click this link to reset your password:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
|
||||
@moduledoc """
|
||||
Validates that the user's email is not already used by another member.
|
||||
Only validates when:
|
||||
- User is already linked to a member (member_id != nil) AND email is changing
|
||||
- User is being linked to a member (member relationship is changing)
|
||||
|
||||
This allows creating users with the same email as unlinked members.
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
|
||||
@doc """
|
||||
Validates email uniqueness across linked User-Member pairs.
|
||||
|
||||
This validation ensures that when a user is linked to a member, their email
|
||||
does not conflict with another member's email. It only runs when necessary
|
||||
to avoid blocking valid operations (see `@moduledoc` for trigger conditions).
|
||||
|
||||
## Parameters
|
||||
- `changeset` - The Ash changeset being validated
|
||||
- `_opts` - Options passed to the validation (unused)
|
||||
- `_context` - Ash context map (unused)
|
||||
|
||||
## Returns
|
||||
- `:ok` if validation passes or should be skipped
|
||||
- `{:error, field: :email, message: ..., value: ...}` if validation fails
|
||||
"""
|
||||
@impl true
|
||||
def validate(changeset, _opts, _context) do
|
||||
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
||||
member_changing? = Ash.Changeset.changing_relationship?(changeset, :member)
|
||||
|
||||
member_id = Ash.Changeset.get_attribute(changeset, :member_id)
|
||||
is_linked? = not is_nil(member_id)
|
||||
|
||||
# Only validate if:
|
||||
# 1. User is linked AND email is changing
|
||||
# 2. User is being linked/unlinked (member relationship changing)
|
||||
should_validate? = (is_linked? and email_changing?) or member_changing?
|
||||
|
||||
if should_validate? do
|
||||
case Ash.Changeset.fetch_change(changeset, :email) do
|
||||
{:ok, new_email} ->
|
||||
# Extract member_id from relationship changes for new links
|
||||
member_id_to_exclude = get_member_id_from_changeset(changeset)
|
||||
check_email_uniqueness(new_email, member_id_to_exclude)
|
||||
|
||||
:error ->
|
||||
# No email change, get current email
|
||||
current_email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
# Extract member_id from relationship changes for new links
|
||||
member_id_to_exclude = get_member_id_from_changeset(changeset)
|
||||
check_email_uniqueness(current_email, member_id_to_exclude)
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# Extract member_id from changeset, checking relationship changes first
|
||||
# This is crucial for new links where member_id is in manage_relationship changes
|
||||
defp get_member_id_from_changeset(changeset) do
|
||||
# Try to get from relationships (for new links via manage_relationship)
|
||||
case Map.get(changeset.relationships, :member) do
|
||||
[{[%{id: id}], _opts}] when not is_nil(id) ->
|
||||
# Found in relationships - this is a new link
|
||||
id
|
||||
|
||||
_ ->
|
||||
# Fall back to attribute (for existing links)
|
||||
Ash.Changeset.get_attribute(changeset, :member_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp check_email_uniqueness(email, exclude_member_id) do
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.filter(email == ^to_string(email))
|
||||
|> maybe_exclude_id(exclude_member_id)
|
||||
|
||||
case Ash.read(query) do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, _} ->
|
||||
{:error, field: :email, message: "is already used by another member", value: email}
|
||||
|
||||
{: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
|
||||
|
|
@ -10,10 +10,10 @@ defmodule Mv.Application do
|
|||
children = [
|
||||
MvWeb.Telemetry,
|
||||
Mv.Repo,
|
||||
{Task.Supervisor, name: Mv.TaskSupervisor},
|
||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Mv.PubSub},
|
||||
{AshAuthentication.Supervisor, otp_app: :my},
|
||||
# Start the Finch HTTP client for sending emails
|
||||
{Finch, name: Mv.Finch},
|
||||
# Start a worker by calling: Mv.Worker.start_link(arg)
|
||||
# {Mv.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
defmodule Mv.Authorization do
|
||||
@moduledoc """
|
||||
Ash Domain for authorization and role management.
|
||||
|
||||
## Resources
|
||||
- `Role` - User roles that reference permission sets
|
||||
|
||||
## Public API
|
||||
The domain exposes these main actions:
|
||||
- Role CRUD: `create_role/1`, `list_roles/0`, `update_role/2`, `destroy_role/1`
|
||||
|
||||
## Admin Interface
|
||||
The domain is configured with AshAdmin for management UI.
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
||||
admin do
|
||||
show? true
|
||||
end
|
||||
|
||||
resources do
|
||||
resource Mv.Authorization.Role do
|
||||
define :create_role, action: :create_role
|
||||
define :list_roles, action: :read
|
||||
define :get_role, action: :read, get_by: [:id]
|
||||
define :update_role, action: :update_role
|
||||
define :destroy_role, action: :destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
defmodule Mv.Authorization.Checks.HasPermission do
|
||||
@moduledoc """
|
||||
Custom Ash Policy Check that evaluates permissions from the PermissionSets module.
|
||||
|
||||
This check:
|
||||
1. Reads the actor's role and permission_set_name
|
||||
2. Looks up permissions from PermissionSets.get_permissions/1
|
||||
3. Finds matching permission for current resource + action
|
||||
4. Applies scope filter (:own, :linked, :all)
|
||||
|
||||
## Usage in Ash Resource
|
||||
|
||||
policies do
|
||||
policy action_type(:read) do
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
## Scope Behavior
|
||||
|
||||
- **:all** - Authorizes without filtering (returns all records)
|
||||
- **:own** - Filters to records where record.id == actor.id
|
||||
- **:linked** - Filters based on resource type:
|
||||
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
||||
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
|
||||
|
||||
## Error Handling
|
||||
|
||||
Returns `false` for:
|
||||
- Missing actor
|
||||
- Actor without role
|
||||
- Invalid permission_set_name
|
||||
- No matching permission found
|
||||
|
||||
All errors result in Forbidden (policy fails).
|
||||
|
||||
## Examples
|
||||
|
||||
# In a resource policy
|
||||
policies do
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
"""
|
||||
|
||||
use Ash.Policy.Check
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
alias Mv.Authorization.PermissionSets
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def describe(_opts) do
|
||||
"checks if actor has permission via their role's permission set"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def strict_check(actor, authorizer, _opts) do
|
||||
resource = authorizer.resource
|
||||
action = get_action_from_authorizer(authorizer)
|
||||
record = get_record_from_authorizer(authorizer)
|
||||
|
||||
cond do
|
||||
is_nil(actor) ->
|
||||
log_auth_failure(actor, resource, action, "no actor")
|
||||
{:ok, false}
|
||||
|
||||
is_nil(action) ->
|
||||
log_auth_failure(
|
||||
actor,
|
||||
resource,
|
||||
action,
|
||||
"authorizer subject shape unsupported (no action)"
|
||||
)
|
||||
|
||||
{:ok, false}
|
||||
|
||||
true ->
|
||||
strict_check_with_permissions(actor, resource, action, record)
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to reduce nesting depth
|
||||
defp strict_check_with_permissions(actor, resource, action, record) do
|
||||
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
|
||||
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
||||
permissions <- PermissionSets.get_permissions(ps_atom),
|
||||
resource_name <- get_resource_name(resource) do
|
||||
case check_permission(
|
||||
permissions.resources,
|
||||
resource_name,
|
||||
action,
|
||||
actor,
|
||||
resource_name
|
||||
) do
|
||||
:authorized ->
|
||||
{:ok, true}
|
||||
|
||||
{:filter, filter_expr} ->
|
||||
# For strict_check on single records, evaluate the filter against the record
|
||||
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
|
||||
|
||||
false ->
|
||||
{:ok, false}
|
||||
end
|
||||
else
|
||||
%{role: nil} ->
|
||||
log_auth_failure(actor, resource, action, "no role assigned")
|
||||
{:ok, false}
|
||||
|
||||
%{role: %{permission_set_name: nil}} ->
|
||||
log_auth_failure(actor, resource, action, "role has no permission_set_name")
|
||||
{:ok, false}
|
||||
|
||||
{:error, :invalid_permission_set} ->
|
||||
log_auth_failure(actor, resource, action, "invalid permission_set_name")
|
||||
{:ok, false}
|
||||
|
||||
_ ->
|
||||
log_auth_failure(actor, resource, action, "missing data")
|
||||
{:ok, false}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def auto_filter(actor, authorizer, _opts) do
|
||||
resource = authorizer.resource
|
||||
action = get_action_from_authorizer(authorizer)
|
||||
|
||||
cond do
|
||||
is_nil(actor) ->
|
||||
# No actor - deny access (fail-closed)
|
||||
# Return filter that never matches (expr(false) = match none)
|
||||
deny_filter()
|
||||
|
||||
is_nil(action) ->
|
||||
# Cannot determine action - deny access (fail-closed)
|
||||
deny_filter()
|
||||
|
||||
true ->
|
||||
auto_filter_with_permissions(actor, resource, action)
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to reduce nesting depth
|
||||
defp auto_filter_with_permissions(actor, resource, action) do
|
||||
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
|
||||
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
||||
permissions <- PermissionSets.get_permissions(ps_atom),
|
||||
resource_name <- get_resource_name(resource) do
|
||||
case check_permission(
|
||||
permissions.resources,
|
||||
resource_name,
|
||||
action,
|
||||
actor,
|
||||
resource_name
|
||||
) do
|
||||
:authorized ->
|
||||
# :all scope - allow all records (no filter)
|
||||
# Return empty keyword list (no filtering)
|
||||
[]
|
||||
|
||||
{:filter, filter_expr} ->
|
||||
# :linked or :own scope - apply filter
|
||||
# filter_expr is a keyword list from expr(...), return it directly
|
||||
filter_expr
|
||||
|
||||
false ->
|
||||
# No permission - deny access (fail-closed)
|
||||
deny_filter()
|
||||
end
|
||||
else
|
||||
_ ->
|
||||
# Error case (no role, invalid permission set, etc.) - deny access (fail-closed)
|
||||
deny_filter()
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to return a filter that never matches (deny all records)
|
||||
# Used when authorization should be denied (fail-closed)
|
||||
#
|
||||
# Using `expr(false)` avoids depending on the primary key being named `:id`.
|
||||
# This is more robust than [id: {:in, []}] which assumes the primary key is `:id`.
|
||||
defp deny_filter do
|
||||
expr(false)
|
||||
end
|
||||
|
||||
# Helper to extract action type from authorizer
|
||||
# CRITICAL: Must use action_type, not action.name!
|
||||
# Action types: :create, :read, :update, :destroy
|
||||
# Action names: :create_member, :update_member, etc.
|
||||
# PermissionSets uses action types, not action names
|
||||
#
|
||||
# Prefer authorizer.action.type (stable API) over authorizer.subject (varies by context)
|
||||
defp get_action_from_authorizer(authorizer) do
|
||||
# Primary: Use authorizer.action.type (stable API)
|
||||
case Map.get(authorizer, :action) do
|
||||
%{type: action_type} when action_type in [:create, :read, :update, :destroy] ->
|
||||
action_type
|
||||
|
||||
_ ->
|
||||
# Fallback: Try authorizer.subject (for compatibility with different Ash versions/contexts)
|
||||
case Map.get(authorizer, :subject) do
|
||||
%{action_type: action_type} when action_type in [:create, :read, :update, :destroy] ->
|
||||
action_type
|
||||
|
||||
%{action: %{type: action_type}}
|
||||
when action_type in [:create, :read, :update, :destroy] ->
|
||||
action_type
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to extract record from authorizer for strict_check
|
||||
defp get_record_from_authorizer(authorizer) do
|
||||
case authorizer.subject do
|
||||
%{data: data} when not is_nil(data) -> data
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Evaluate filter expression for strict_check on single records
|
||||
# For :linked scope with Member resource: id == actor.member_id
|
||||
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
|
||||
case {resource_name, record} do
|
||||
{"Member", %{id: member_id}} when not is_nil(member_id) ->
|
||||
# Check if this member's ID matches the actor's member_id
|
||||
if member_id == actor.member_id do
|
||||
{:ok, true}
|
||||
else
|
||||
{:ok, false}
|
||||
end
|
||||
|
||||
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
|
||||
# Check if this CFV's member_id matches the actor's member_id
|
||||
if cfv_member_id == actor.member_id do
|
||||
{:ok, true}
|
||||
else
|
||||
{:ok, false}
|
||||
end
|
||||
|
||||
_ ->
|
||||
# For other cases or when record is not available, return :unknown
|
||||
# This will cause Ash to use auto_filter instead
|
||||
{:ok, :unknown}
|
||||
end
|
||||
end
|
||||
|
||||
# Extract resource name from module (e.g., Mv.Membership.Member -> "Member")
|
||||
defp get_resource_name(resource) when is_atom(resource) do
|
||||
resource |> Module.split() |> List.last()
|
||||
end
|
||||
|
||||
# Find matching permission and apply scope
|
||||
defp check_permission(resource_perms, resource_name, action, actor, resource_name_for_logging) do
|
||||
case Enum.find(resource_perms, fn perm ->
|
||||
perm.resource == resource_name and perm.action == action and perm.granted
|
||||
end) do
|
||||
nil ->
|
||||
log_auth_failure(actor, resource_name_for_logging, action, "no matching permission found")
|
||||
false
|
||||
|
||||
perm ->
|
||||
apply_scope(perm.scope, actor, resource_name)
|
||||
end
|
||||
end
|
||||
|
||||
# Scope: all - No filtering, access to all records
|
||||
defp apply_scope(:all, _actor, _resource) do
|
||||
:authorized
|
||||
end
|
||||
|
||||
# Scope: own - Filter to records where record.id == actor.id
|
||||
# Used for User resource (users can access their own user record)
|
||||
defp apply_scope(:own, actor, _resource) do
|
||||
{:filter, expr(id == ^actor.id)}
|
||||
end
|
||||
|
||||
# Scope: linked - Filter based on user relationship (resource-specific!)
|
||||
# IMPORTANT: Understand the relationship direction!
|
||||
# - User belongs_to :member (User.member_id → Member.id)
|
||||
# - Member has_one :user (inverse, no FK on Member)
|
||||
defp apply_scope(:linked, actor, resource_name) do
|
||||
case resource_name do
|
||||
"Member" ->
|
||||
# User.member_id → Member.id (inverse relationship)
|
||||
# Filter: member.id == actor.member_id
|
||||
{:filter, expr(id == ^actor.member_id)}
|
||||
|
||||
"CustomFieldValue" ->
|
||||
# CustomFieldValue.member_id → Member.id → User.member_id
|
||||
# Filter: custom_field_value.member_id == actor.member_id
|
||||
{:filter, expr(member_id == ^actor.member_id)}
|
||||
|
||||
_ ->
|
||||
# Fallback for other resources
|
||||
{:filter, expr(user_id == ^actor.id)}
|
||||
end
|
||||
end
|
||||
|
||||
# Log authorization failures for debugging (lazy evaluation)
|
||||
defp log_auth_failure(actor, resource, action, reason) do
|
||||
Logger.debug(fn ->
|
||||
actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil"
|
||||
resource_name = get_resource_name_for_logging(resource)
|
||||
|
||||
"""
|
||||
Authorization failed:
|
||||
Actor: #{actor_id}
|
||||
Resource: #{resource_name}
|
||||
Action: #{inspect(action)}
|
||||
Reason: #{reason}
|
||||
"""
|
||||
end)
|
||||
end
|
||||
|
||||
# Helper to extract resource name for logging (handles both atoms and strings)
|
||||
defp get_resource_name_for_logging(resource) when is_atom(resource) do
|
||||
resource |> Module.split() |> List.last()
|
||||
end
|
||||
|
||||
defp get_resource_name_for_logging(resource) when is_binary(resource) do
|
||||
resource
|
||||
end
|
||||
|
||||
defp get_resource_name_for_logging(_resource) do
|
||||
"unknown"
|
||||
end
|
||||
end
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
defmodule Mv.Authorization.Checks.NoActor do
|
||||
@moduledoc """
|
||||
Custom Ash Policy Check that allows actions when no actor is present.
|
||||
|
||||
**IMPORTANT:** This check ONLY works in test environment for security reasons.
|
||||
In production/dev, ALL operations without an actor are denied.
|
||||
|
||||
## Security Note
|
||||
|
||||
This check uses compile-time environment detection to prevent accidental
|
||||
security issues in production. In production, ALL operations (including :create
|
||||
and :read) will be denied if no actor is present.
|
||||
|
||||
For seeds and system operations in production, use an admin actor instead:
|
||||
|
||||
admin_user = get_admin_user()
|
||||
Ash.create!(resource, attrs, actor: admin_user)
|
||||
|
||||
## Usage in Policies
|
||||
|
||||
policies do
|
||||
# Allow system operations without actor (TEST ENVIRONMENT ONLY)
|
||||
# In test: All operations allowed
|
||||
# In production: ALL operations denied (fail-closed)
|
||||
bypass action_type([:create, :read, :update, :destroy]) do
|
||||
authorize_if NoActor
|
||||
end
|
||||
|
||||
# Check permissions when actor is present
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
authorize_if HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
## Behavior
|
||||
|
||||
- In test environment: Returns `true` when actor is nil (allows all operations)
|
||||
- In production/dev: Returns `false` when actor is nil (denies all operations - fail-closed)
|
||||
- Returns `false` when actor is present (delegates to other policies)
|
||||
"""
|
||||
|
||||
use Ash.Policy.SimpleCheck
|
||||
|
||||
# Compile-time check: Only allow no-actor bypass in test environment
|
||||
@allow_no_actor_bypass Mix.env() == :test
|
||||
# Alternative (if you want to control via config):
|
||||
# @allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
|
||||
|
||||
@impl true
|
||||
def describe(_opts) do
|
||||
if @allow_no_actor_bypass do
|
||||
"allows actions when no actor is present (test environment only)"
|
||||
else
|
||||
"denies all actions when no actor is present (production/dev - fail-closed)"
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def match?(nil, _context, _opts) do
|
||||
# Actor is nil
|
||||
if @allow_no_actor_bypass do
|
||||
# Test environment: Allow all operations
|
||||
true
|
||||
else
|
||||
# Production/dev: Deny all operations (fail-closed for security)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def match?(_actor, _context, _opts) do
|
||||
# Actor is present - don't match (let other policies decide)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
|
@ -1,294 +0,0 @@
|
|||
defmodule Mv.Authorization.PermissionSets do
|
||||
@moduledoc """
|
||||
Defines the four hardcoded permission sets for the application.
|
||||
|
||||
Each permission set specifies:
|
||||
- Resource permissions (what CRUD operations on which resources)
|
||||
- Page permissions (which LiveView pages can be accessed)
|
||||
- Scopes (own, linked, all)
|
||||
|
||||
## Permission Sets
|
||||
|
||||
1. **own_data** - Default for "Mitglied" role
|
||||
- Can only access own user data and linked member/custom field values
|
||||
- Cannot create new members or manage system
|
||||
|
||||
2. **read_only** - For "Vorstand" and "Buchhaltung" roles
|
||||
- Can read all member data
|
||||
- Cannot create, update, or delete
|
||||
|
||||
3. **normal_user** - For "Kassenwart" role
|
||||
- Create/Read/Update members (no delete for safety), full CRUD on custom field values
|
||||
- Cannot manage custom fields or users
|
||||
|
||||
4. **admin** - For "Admin" role
|
||||
- Unrestricted access to all resources
|
||||
- Can manage users, roles, custom fields
|
||||
|
||||
## Usage
|
||||
|
||||
# Get permissions for a role's permission set
|
||||
permissions = PermissionSets.get_permissions(:admin)
|
||||
|
||||
# Check if a permission set name is valid
|
||||
PermissionSets.valid_permission_set?("read_only") # => true
|
||||
|
||||
# Convert string to atom safely
|
||||
{:ok, atom} = PermissionSets.permission_set_name_to_atom("own_data")
|
||||
|
||||
## Performance
|
||||
|
||||
All functions are pure and intended to be constant-time. Permission lookups
|
||||
are very fast (typically < 1 microsecond in practice) as they are simple
|
||||
pattern matches and map lookups with no database queries or external calls.
|
||||
"""
|
||||
|
||||
@type scope :: :own | :linked | :all
|
||||
@type action :: :read | :create | :update | :destroy
|
||||
|
||||
@type resource_permission :: %{
|
||||
resource: String.t(),
|
||||
action: action(),
|
||||
scope: scope(),
|
||||
granted: boolean()
|
||||
}
|
||||
|
||||
@type permission_set :: %{
|
||||
resources: [resource_permission()],
|
||||
pages: [String.t()]
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns the list of all valid permission set names.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> PermissionSets.all_permission_sets()
|
||||
[:own_data, :read_only, :normal_user, :admin]
|
||||
"""
|
||||
@spec all_permission_sets() :: [atom()]
|
||||
def all_permission_sets do
|
||||
[:own_data, :read_only, :normal_user, :admin]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns permissions for the given permission set.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> permissions = PermissionSets.get_permissions(:admin)
|
||||
iex> Enum.any?(permissions.resources, fn p ->
|
||||
...> p.resource == "User" and p.action == :destroy
|
||||
...> end)
|
||||
true
|
||||
|
||||
iex> PermissionSets.get_permissions(:invalid)
|
||||
** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin]
|
||||
"""
|
||||
@spec get_permissions(atom()) :: permission_set()
|
||||
|
||||
def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do
|
||||
raise ArgumentError,
|
||||
"invalid permission set: #{inspect(set)}. Must be one of: #{inspect(all_permission_sets())}"
|
||||
end
|
||||
|
||||
def get_permissions(:own_data) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Can always read/update own credentials
|
||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||
|
||||
# Member: Can read/update linked member
|
||||
%{resource: "Member", action: :read, scope: :linked, granted: true},
|
||||
%{resource: "Member", action: :update, scope: :linked, granted: true},
|
||||
|
||||
# CustomFieldValue: Can read/update custom field values of linked member
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
|
||||
|
||||
# CustomField: Can read all (needed for forms)
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
||||
],
|
||||
pages: [
|
||||
# Home page
|
||||
"/",
|
||||
# Own profile
|
||||
"/profile",
|
||||
# Linked member detail (filtered by policy)
|
||||
"/members/:id"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def get_permissions(:read_only) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Can read/update own credentials only
|
||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||
|
||||
# Member: Can read all members, no modifications
|
||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
||||
|
||||
# CustomFieldValue: Can read all custom field values
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||
|
||||
# CustomField: Can read all
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
||||
],
|
||||
pages: [
|
||||
"/",
|
||||
# Own profile
|
||||
"/profile",
|
||||
# Member list
|
||||
"/members",
|
||||
# Member detail
|
||||
"/members/:id",
|
||||
# Custom field values overview
|
||||
"/custom_field_values",
|
||||
# Custom field value detail
|
||||
"/custom_field_values/:id"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def get_permissions(:normal_user) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Can read/update own credentials only
|
||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||
|
||||
# Member: Full CRUD except destroy (safety)
|
||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :update, scope: :all, granted: true},
|
||||
# Note: destroy intentionally omitted for safety
|
||||
|
||||
# CustomFieldValue: Full CRUD
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# CustomField: Read only (admin manages definitions)
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
||||
],
|
||||
pages: [
|
||||
"/",
|
||||
# Own profile
|
||||
"/profile",
|
||||
"/members",
|
||||
# Create member
|
||||
"/members/new",
|
||||
"/members/:id",
|
||||
# Edit member
|
||||
"/members/:id/edit",
|
||||
"/custom_field_values",
|
||||
# Custom field value detail
|
||||
"/custom_field_values/:id",
|
||||
"/custom_field_values/new",
|
||||
"/custom_field_values/:id/edit"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def get_permissions(:admin) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Full management including other users
|
||||
%{resource: "User", action: :read, scope: :all, granted: true},
|
||||
%{resource: "User", action: :create, scope: :all, granted: true},
|
||||
%{resource: "User", action: :update, scope: :all, granted: true},
|
||||
%{resource: "User", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# Member: Full CRUD
|
||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :update, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# CustomFieldValue: Full CRUD
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# CustomField: Full CRUD (admin manages custom field definitions)
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
||||
%{resource: "CustomField", action: :create, scope: :all, granted: true},
|
||||
%{resource: "CustomField", action: :update, scope: :all, granted: true},
|
||||
%{resource: "CustomField", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# Role: Full CRUD (admin manages roles)
|
||||
%{resource: "Role", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Role", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Role", action: :update, scope: :all, granted: true},
|
||||
%{resource: "Role", action: :destroy, scope: :all, granted: true}
|
||||
],
|
||||
pages: [
|
||||
# Wildcard: Admin can access all pages
|
||||
"*"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def get_permissions(invalid) do
|
||||
raise ArgumentError,
|
||||
"invalid permission set: #{inspect(invalid)}. Must be one of: #{inspect(all_permission_sets())}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a permission set name (string or atom) is valid.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> PermissionSets.valid_permission_set?("admin")
|
||||
true
|
||||
|
||||
iex> PermissionSets.valid_permission_set?(:read_only)
|
||||
true
|
||||
|
||||
iex> PermissionSets.valid_permission_set?("invalid")
|
||||
false
|
||||
"""
|
||||
@spec valid_permission_set?(any()) :: boolean()
|
||||
def valid_permission_set?(name) when is_binary(name) do
|
||||
case permission_set_name_to_atom(name) do
|
||||
{:ok, _atom} -> true
|
||||
{:error, _} -> false
|
||||
end
|
||||
end
|
||||
|
||||
def valid_permission_set?(name) when is_atom(name) do
|
||||
name in all_permission_sets()
|
||||
end
|
||||
|
||||
def valid_permission_set?(_), do: false
|
||||
|
||||
@doc """
|
||||
Converts a permission set name string to atom safely.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> PermissionSets.permission_set_name_to_atom("admin")
|
||||
{:ok, :admin}
|
||||
|
||||
iex> PermissionSets.permission_set_name_to_atom("invalid")
|
||||
{:error, :invalid_permission_set}
|
||||
"""
|
||||
@spec permission_set_name_to_atom(String.t()) ::
|
||||
{:ok, atom()} | {:error, :invalid_permission_set}
|
||||
def permission_set_name_to_atom(name) when is_binary(name) do
|
||||
atom = String.to_existing_atom(name)
|
||||
|
||||
if valid_permission_set?(atom) do
|
||||
{:ok, atom}
|
||||
else
|
||||
{:error, :invalid_permission_set}
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> {:error, :invalid_permission_set}
|
||||
end
|
||||
end
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
defmodule Mv.Authorization.Role do
|
||||
@moduledoc """
|
||||
Represents a user role that references a permission set.
|
||||
|
||||
Roles are stored in the database and link users to permission sets.
|
||||
Each role has a `permission_set_name` that references one of the four
|
||||
hardcoded permission sets defined in `Mv.Authorization.PermissionSets`.
|
||||
|
||||
## Fields
|
||||
|
||||
- `name` - Unique role name (e.g., "Vorstand", "Admin")
|
||||
- `description` - Human-readable description of the role
|
||||
- `permission_set_name` - Must be one of: "own_data", "read_only", "normal_user", "admin"
|
||||
- `is_system_role` - If true, role cannot be deleted (protects critical roles like "Mitglied")
|
||||
|
||||
## Relationships
|
||||
|
||||
- `has_many :users` - Users assigned to this role
|
||||
|
||||
## Validations
|
||||
|
||||
- `permission_set_name` must be a valid permission set (checked against PermissionSets.all_permission_sets/0)
|
||||
- `name` must be unique
|
||||
- System roles cannot be deleted (enforced via validation)
|
||||
|
||||
## Examples
|
||||
|
||||
# Create a new role
|
||||
{:ok, role} = Mv.Authorization.create_role(%{
|
||||
name: "Vorstand",
|
||||
description: "Board member with read access",
|
||||
permission_set_name: "read_only"
|
||||
})
|
||||
|
||||
# List all roles
|
||||
{:ok, roles} = Mv.Authorization.list_roles()
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Authorization,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "roles"
|
||||
repo Mv.Repo
|
||||
|
||||
references do
|
||||
# Prevent deletion of roles that are assigned to users
|
||||
reference :users, on_delete: :restrict
|
||||
end
|
||||
end
|
||||
|
||||
code_interface do
|
||||
define :create_role
|
||||
define :list_roles, action: :read
|
||||
define :update_role
|
||||
define :destroy_role, action: :destroy
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read]
|
||||
|
||||
create :create_role do
|
||||
primary? true
|
||||
# is_system_role is intentionally excluded - should only be set via seeds/internal actions
|
||||
accept [:name, :description, :permission_set_name]
|
||||
# Note: In Ash 3.0, require_atomic? is not available for create actions
|
||||
# Custom validations will still work
|
||||
end
|
||||
|
||||
update :update_role do
|
||||
primary? true
|
||||
# is_system_role is intentionally excluded - should only be set via seeds/internal actions
|
||||
accept [:name, :description, :permission_set_name]
|
||||
# Required because custom validation functions cannot be executed atomically
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
destroy :destroy do
|
||||
# Required because custom validation functions cannot be executed atomically
|
||||
require_atomic? false
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate one_of(
|
||||
:permission_set_name,
|
||||
Mv.Authorization.PermissionSets.all_permission_sets()
|
||||
|> Enum.map(&Atom.to_string/1)
|
||||
),
|
||||
message:
|
||||
"must be one of: #{Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
|
||||
|
||||
validate fn changeset, _context ->
|
||||
if changeset.data.is_system_role do
|
||||
{:error,
|
||||
field: :is_system_role,
|
||||
message:
|
||||
"Cannot delete system role. System roles are required for the application to function."}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:destroy]
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_v7_primary_key :id
|
||||
|
||||
attribute :name, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :description, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :permission_set_name, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :is_system_role, :boolean do
|
||||
allow_nil? false
|
||||
default false
|
||||
public? true
|
||||
end
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :users, Mv.Accounts.User do
|
||||
destination_attribute :role_id
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_name, [:name]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
defmodule Mv.Config do
|
||||
@moduledoc """
|
||||
Configuration helper functions for the application.
|
||||
|
||||
Provides centralized access to configuration values to avoid
|
||||
magic strings/atoms scattered throughout the codebase.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Returns whether SQL sandbox mode is enabled.
|
||||
|
||||
SQL sandbox mode is typically enabled in test environments
|
||||
to allow concurrent database access in tests.
|
||||
|
||||
## Returns
|
||||
|
||||
- `true` if SQL sandbox is enabled
|
||||
- `false` otherwise
|
||||
"""
|
||||
@spec sql_sandbox?() :: boolean()
|
||||
def sql_sandbox? do
|
||||
Application.get_env(:mv, :sql_sandbox, false)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
defmodule Mv.Constants do
|
||||
@moduledoc """
|
||||
Module for defining constants and atoms.
|
||||
"""
|
||||
|
||||
@member_fields [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code,
|
||||
:membership_fee_start_date
|
||||
]
|
||||
|
||||
@custom_field_prefix "custom_field_"
|
||||
|
||||
def member_fields, do: @member_fields
|
||||
|
||||
@doc """
|
||||
Returns the prefix used for custom field keys in field visibility maps.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Constants.custom_field_prefix()
|
||||
"custom_field_"
|
||||
"""
|
||||
def custom_field_prefix, do: @custom_field_prefix
|
||||
end
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
|
||||
@moduledoc """
|
||||
Synchronizes Member.email → User.email
|
||||
|
||||
Trigger conditions are configured in resources via `where` clauses:
|
||||
- Member resource: Use `where: [changing(:email)]`
|
||||
|
||||
Used by Member resource for bidirectional email sync.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
alias Mv.EmailSync.{Helpers, Loader}
|
||||
|
||||
@doc """
|
||||
Implements the email synchronization from Member to User.
|
||||
|
||||
This function is called automatically by Ash when the configured trigger
|
||||
conditions are met (see `@moduledoc` for trigger details).
|
||||
|
||||
## Parameters
|
||||
- `changeset` - The Ash changeset being processed
|
||||
- `_opts` - Options passed to the change (unused)
|
||||
- `context` - Ash context map containing metadata (e.g., `:syncing_email` flag)
|
||||
|
||||
## Returns
|
||||
Modified changeset with email synchronization applied, or original changeset
|
||||
if recursion detected.
|
||||
"""
|
||||
@impl true
|
||||
def change(changeset, _opts, context) do
|
||||
# Only recursion protection needed - trigger logic is in `where` clauses
|
||||
if Map.get(context, :syncing_email, false) do
|
||||
changeset
|
||||
else
|
||||
sync_email(changeset)
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_email(changeset) do
|
||||
new_email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
|
||||
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
|
||||
result = callback.(cs)
|
||||
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
|
||||
with {:ok, member} <- Helpers.extract_record(result),
|
||||
linked_user <- Loader.get_linked_user(member, actor) do
|
||||
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
|
||||
else
|
||||
_ -> result
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
@moduledoc """
|
||||
Synchronizes User.email → Member.email
|
||||
User.email is always the source of truth.
|
||||
|
||||
Trigger conditions are configured in resources via `where` clauses:
|
||||
- User resource: Use `where: [changing(:email)]` or `where: any([changing(:email), changing(:member)])`
|
||||
- Member resource: Use `where: [changing(:user)]`
|
||||
|
||||
Can be used by both User and Member resources.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
alias Mv.EmailSync.{Helpers, Loader}
|
||||
|
||||
@doc """
|
||||
Implements the email synchronization from User to Member.
|
||||
|
||||
This function is called automatically by Ash when the configured trigger
|
||||
conditions are met (see `@moduledoc` for trigger details).
|
||||
|
||||
## Parameters
|
||||
- `changeset` - The Ash changeset being processed
|
||||
- `_opts` - Options passed to the change (unused)
|
||||
- `context` - Ash context map containing metadata (e.g., `:syncing_email` flag)
|
||||
|
||||
## Returns
|
||||
Modified changeset with email synchronization applied, or original changeset
|
||||
if recursion detected.
|
||||
"""
|
||||
@impl true
|
||||
def change(changeset, _opts, context) do
|
||||
# Only recursion protection needed - trigger logic is in `where` clauses
|
||||
if Map.get(context, :syncing_email, false) do
|
||||
changeset
|
||||
else
|
||||
# Ensure actor is in changeset context - get it from context if available
|
||||
actor = Map.get(changeset.context, :actor) || Map.get(context, :actor)
|
||||
|
||||
changeset_with_actor =
|
||||
if actor && !Map.has_key?(changeset.context, :actor) do
|
||||
Ash.Changeset.put_context(changeset, :actor, actor)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
|
||||
sync_email(changeset_with_actor)
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_email(changeset) do
|
||||
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
|
||||
result = callback.(cs)
|
||||
|
||||
with {:ok, record} <- Helpers.extract_record(result),
|
||||
{:ok, user, member} <- get_user_and_member(record, cs) do
|
||||
# When called from Member-side, we need to update the member in the result
|
||||
# When called from User-side, we update the linked member in DB only
|
||||
case record do
|
||||
%Mv.Membership.Member{} ->
|
||||
# Member-side: Override member email in result with user email
|
||||
Helpers.override_with_linked_email(result, user.email)
|
||||
|
||||
%Mv.Accounts.User{} ->
|
||||
# User-side: Sync user email to linked member in DB
|
||||
Helpers.sync_email_to_linked_record(result, member, user.email)
|
||||
end
|
||||
else
|
||||
_ -> result
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Retrieves user and member - works for both resource types
|
||||
defp get_user_and_member(%Mv.Accounts.User{} = user, changeset) do
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
|
||||
case Loader.get_linked_member(user, actor) do
|
||||
nil -> {:error, :no_member}
|
||||
member -> {:ok, user, member}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_user_and_member(%Mv.Membership.Member{} = member, changeset) do
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
|
||||
case Loader.load_linked_user!(member, actor) do
|
||||
{:ok, user} -> {:ok, user, member}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
defmodule Mv.EmailSync.Helpers do
|
||||
@moduledoc """
|
||||
Shared helper functions for email synchronization between User and Member.
|
||||
|
||||
Handles the complexity of `around_transaction` callback results and
|
||||
provides clean abstractions for email updates within transactions.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
import Ecto.Changeset
|
||||
|
||||
@doc """
|
||||
Extracts the record from an Ash action result.
|
||||
|
||||
Handles both 2-tuple `{:ok, record}` and 4-tuple
|
||||
`{:ok, record, changeset, notifications}` patterns.
|
||||
"""
|
||||
def extract_record({:ok, record, _changeset, _notifications}), do: {:ok, record}
|
||||
def extract_record({:ok, record}), do: {:ok, record}
|
||||
def extract_record({:error, _} = error), do: error
|
||||
|
||||
@doc """
|
||||
Updates the result with a new record while preserving the original structure.
|
||||
|
||||
If the original result was a 4-tuple, returns a 4-tuple with the updated record.
|
||||
If it was a 2-tuple, returns a 2-tuple with the updated record.
|
||||
"""
|
||||
def update_result_record({:ok, _old_record, changeset, notifications}, new_record) do
|
||||
{:ok, new_record, changeset, notifications}
|
||||
end
|
||||
|
||||
def update_result_record({:ok, _old_record}, new_record) do
|
||||
{:ok, new_record}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates an email field directly via Ecto within the current transaction.
|
||||
|
||||
This bypasses Ash's action system to ensure the update happens in the
|
||||
same database transaction as the parent action.
|
||||
"""
|
||||
def update_email_via_ecto(record, new_email) do
|
||||
record
|
||||
|> cast(%{email: to_string(new_email)}, [:email])
|
||||
|> Mv.Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Synchronizes email to a linked record if it exists.
|
||||
|
||||
Returns the original result unchanged, or an error if sync fails.
|
||||
"""
|
||||
def sync_email_to_linked_record(result, linked_record, new_email) do
|
||||
with {:ok, _source} <- extract_record(result),
|
||||
record when not is_nil(record) <- linked_record,
|
||||
{:ok, _updated} <- update_email_via_ecto(record, new_email) do
|
||||
# Successfully synced - return original result unchanged
|
||||
result
|
||||
else
|
||||
nil ->
|
||||
# No linked record - return original result
|
||||
result
|
||||
|
||||
{:error, error} ->
|
||||
# Sync failed - log and propagate error to rollback transaction
|
||||
Logger.error("Email sync failed: #{inspect(error)}")
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Overrides the record's email with the linked email if emails differ.
|
||||
|
||||
Returns updated result with new record, or original result if no update needed.
|
||||
"""
|
||||
def override_with_linked_email(result, linked_email) do
|
||||
with {:ok, record} <- extract_record(result),
|
||||
true <- record.email != to_string(linked_email),
|
||||
{:ok, updated_record} <- update_email_via_ecto(record, linked_email) do
|
||||
# Email was different - return result with updated record
|
||||
update_result_record(result, updated_record)
|
||||
else
|
||||
false ->
|
||||
# Emails already match - no update needed
|
||||
result
|
||||
|
||||
{:error, error} ->
|
||||
# Override failed - log and propagate error
|
||||
Logger.error("Email override failed: #{inspect(error)}")
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
defmodule Mv.EmailSync.Loader do
|
||||
@moduledoc """
|
||||
Helper functions for loading linked records in email synchronization.
|
||||
Centralizes the logic for retrieving related User/Member entities.
|
||||
|
||||
## Authorization
|
||||
|
||||
This module runs systemically and accepts optional actor parameters.
|
||||
When called from hooks/changes, actor is extracted from changeset context.
|
||||
When called directly, actor should be provided for proper authorization.
|
||||
|
||||
All functions accept an optional `actor` parameter that is passed to Ash operations
|
||||
to ensure proper authorization checks are performed.
|
||||
"""
|
||||
alias Mv.Helpers
|
||||
|
||||
@doc """
|
||||
Loads the member linked to a user, returns nil if not linked or on error.
|
||||
|
||||
Accepts optional actor for authorization.
|
||||
"""
|
||||
def get_linked_member(user, actor \\ nil)
|
||||
def get_linked_member(%{member_id: nil}, _actor), do: nil
|
||||
|
||||
def get_linked_member(%{member_id: id}, actor) do
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.get(Mv.Membership.Member, id, opts) do
|
||||
{:ok, member} -> member
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads the user linked to a member, returns nil if not linked or on error.
|
||||
|
||||
Accepts optional actor for authorization.
|
||||
"""
|
||||
def get_linked_user(member, actor \\ nil) do
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.load(member, :user, opts) do
|
||||
{:ok, %{user: user}} -> user
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads the user linked to a member, returning an error tuple if not linked.
|
||||
Useful when a link is required for the operation.
|
||||
|
||||
Accepts optional actor for authorization.
|
||||
"""
|
||||
def load_linked_user!(member, actor \\ nil) do
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.load(member, :user, opts) do
|
||||
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
|
||||
{:ok, _} -> {:error, :no_linked_user}
|
||||
{:error, _} = error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
defmodule Mv.Helpers do
|
||||
@moduledoc """
|
||||
Shared helper functions used across the Mv application.
|
||||
|
||||
Provides utilities that are not specific to a single domain or layer.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Converts an actor to Ash options list for authorization.
|
||||
Returns empty list if actor is nil.
|
||||
|
||||
This helper ensures consistent actor handling across all Ash operations
|
||||
in the application, whether called from LiveViews, changes, validations,
|
||||
or other contexts.
|
||||
|
||||
## Examples
|
||||
|
||||
opts = ash_actor_opts(actor)
|
||||
Ash.read(query, opts)
|
||||
|
||||
opts = ash_actor_opts(nil)
|
||||
# => []
|
||||
"""
|
||||
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
|
||||
def ash_actor_opts(nil), do: []
|
||||
def ash_actor_opts(actor) when not is_nil(actor), do: [actor: actor]
|
||||
end
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
defmodule Mv.Helpers.TypeParsers do
|
||||
@moduledoc """
|
||||
Helper functions for parsing various input types to common Elixir types.
|
||||
|
||||
Provides safe parsing functions for common type conversions, especially useful
|
||||
when dealing with form data or external APIs.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Parses various input types to boolean.
|
||||
|
||||
Handles: booleans, strings ("true"/"false"), integers (1/0), and other values (defaults to false).
|
||||
|
||||
## Parameters
|
||||
|
||||
- `value` - The value to parse (boolean, string, integer, or other)
|
||||
|
||||
## Returns
|
||||
|
||||
A boolean value
|
||||
|
||||
## Examples
|
||||
|
||||
iex> parse_boolean(true)
|
||||
true
|
||||
|
||||
iex> parse_boolean("true")
|
||||
true
|
||||
|
||||
iex> parse_boolean("false")
|
||||
false
|
||||
|
||||
iex> parse_boolean(1)
|
||||
true
|
||||
|
||||
iex> parse_boolean(0)
|
||||
false
|
||||
|
||||
iex> parse_boolean(nil)
|
||||
false
|
||||
"""
|
||||
@spec parse_boolean(any()) :: boolean()
|
||||
def parse_boolean(value) when is_boolean(value), do: value
|
||||
def parse_boolean("true"), do: true
|
||||
def parse_boolean("false"), do: false
|
||||
def parse_boolean(1), do: true
|
||||
def parse_boolean(0), do: false
|
||||
def parse_boolean(_), do: false
|
||||
end
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
defmodule Mv.Membership.Helpers.VisibilityConfig do
|
||||
@moduledoc """
|
||||
Helper functions for normalizing member field visibility configuration.
|
||||
|
||||
Handles conversion between string keys (from JSONB) and atom keys (Elixir convention).
|
||||
JSONB in PostgreSQL converts atom keys to string keys when storing.
|
||||
This module provides functions to normalize these back to atoms for Elixir usage.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Normalizes visibility config map keys from strings to atoms.
|
||||
|
||||
JSONB in PostgreSQL converts atom keys to string keys when storing.
|
||||
This function converts them back to atoms for Elixir usage.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `config` - A map with either string or atom keys
|
||||
|
||||
## Returns
|
||||
|
||||
A map with atom keys (where possible)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> normalize(%{"first_name" => true, "email" => false})
|
||||
%{first_name: true, email: false}
|
||||
|
||||
iex> normalize(%{first_name: true, email: false})
|
||||
%{first_name: true, email: false}
|
||||
|
||||
iex> normalize(%{"invalid_field" => true})
|
||||
%{}
|
||||
"""
|
||||
@spec normalize(map()) :: map()
|
||||
def normalize(config) when is_map(config) do
|
||||
Enum.reduce(config, %{}, fn
|
||||
{key, value}, acc when is_atom(key) ->
|
||||
Map.put(acc, key, value)
|
||||
|
||||
{key, value}, acc when is_binary(key) ->
|
||||
try do
|
||||
atom_key = String.to_existing_atom(key)
|
||||
Map.put(acc, atom_key, value)
|
||||
rescue
|
||||
ArgumentError -> acc
|
||||
end
|
||||
|
||||
_, acc ->
|
||||
acc
|
||||
end)
|
||||
end
|
||||
|
||||
def normalize(_), do: %{}
|
||||
end
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
NimbleCSV.define(Mv.Membership.Import.CsvParserSemicolon, separator: ";", escape: "\"")
|
||||
NimbleCSV.define(Mv.Membership.Import.CsvParserComma, separator: ",", escape: "\"")
|
||||
|
||||
defmodule Mv.Membership.Import.CsvParser do
|
||||
@moduledoc """
|
||||
CSV parser with BOM handling, delimiter auto-detection, and physical line numbering.
|
||||
|
||||
Guarantees:
|
||||
- UTF-8 BOM is stripped (Excel)
|
||||
- Delimiter auto-detected (semicolon/comma) using NimbleCSV parsing (quote-aware)
|
||||
- Returns rows tagged with their *physical start line number* in the CSV file (1-based)
|
||||
- Skips completely empty rows (but preserves numbering by using physical line numbers)
|
||||
- Handles `\\r\\n`, `\\n`, `\\r`
|
||||
- Correct even when fields contain newlines inside quotes: the row gets the start line number
|
||||
"""
|
||||
|
||||
@utf8_bom <<0xEF, 0xBB, 0xBF>>
|
||||
@quote ?"
|
||||
@max_error_snippet_length 50
|
||||
|
||||
@type line_number :: pos_integer()
|
||||
@type row :: [String.t()]
|
||||
@type numbered_row :: {line_number(), row()}
|
||||
|
||||
@spec parse(binary()) :: {:ok, row(), [numbered_row()]} | {:error, String.t()}
|
||||
def parse(file_content) when is_binary(file_content) do
|
||||
with :ok <- validate_utf8(file_content),
|
||||
content <- file_content |> strip_bom() |> normalize_line_endings(),
|
||||
:ok <- validate_content_not_empty(content),
|
||||
{:ok, header_record, data_records} <- extract_header_and_data(content),
|
||||
:ok <- validate_header_not_empty(header_record) do
|
||||
parse_csv_records(header_record, data_records)
|
||||
end
|
||||
end
|
||||
|
||||
def parse(_), do: {:error, "Invalid CSV content"}
|
||||
|
||||
@spec validate_utf8(binary()) :: :ok | {:error, String.t()}
|
||||
defp validate_utf8(content) do
|
||||
if String.valid?(content) do
|
||||
:ok
|
||||
else
|
||||
{:error, "CSV must be valid UTF-8"}
|
||||
end
|
||||
end
|
||||
|
||||
@spec validate_content_not_empty(binary()) :: :ok | {:error, String.t()}
|
||||
defp validate_content_not_empty(content) do
|
||||
if String.trim(content) == "" do
|
||||
{:error, "CSV file is empty"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@spec extract_header_and_data(binary()) ::
|
||||
{:ok, binary(), [{line_number(), binary()}]} | {:error, String.t()}
|
||||
defp extract_header_and_data(content) do
|
||||
records = split_records_with_line_numbers(content)
|
||||
|
||||
case records do
|
||||
[] ->
|
||||
{:error, "CSV file is empty"}
|
||||
|
||||
[{_line1, header_record} | data_records] ->
|
||||
{:ok, header_record, data_records}
|
||||
end
|
||||
end
|
||||
|
||||
@spec validate_header_not_empty(binary()) :: :ok | {:error, String.t()}
|
||||
defp validate_header_not_empty(header_record) do
|
||||
if String.trim(header_record) == "" do
|
||||
{:error, "CSV file has no header row"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@spec parse_csv_records(binary(), [{line_number(), binary()}]) ::
|
||||
{:ok, row(), [numbered_row()]} | {:error, String.t()}
|
||||
defp parse_csv_records(header_record, data_records) do
|
||||
delimiter = detect_delimiter_by_parsing(header_record)
|
||||
parser = get_parser(delimiter)
|
||||
|
||||
with {:ok, headers} <-
|
||||
parse_single_record(parser, header_record, "CSV file has no header row"),
|
||||
{:ok, rows} <- parse_data_records(parser, data_records) do
|
||||
{:ok, headers, rows}
|
||||
end
|
||||
end
|
||||
|
||||
@spec strip_bom(binary()) :: binary()
|
||||
defp strip_bom(<<@utf8_bom, rest::binary>>), do: rest
|
||||
defp strip_bom(content), do: content
|
||||
|
||||
@spec normalize_line_endings(binary()) :: binary()
|
||||
defp normalize_line_endings(content) do
|
||||
content
|
||||
|> String.replace("\r\n", "\n")
|
||||
|> String.replace("\r", "\n")
|
||||
end
|
||||
|
||||
@spec get_parser(String.t()) :: module()
|
||||
defp get_parser(";"), do: Mv.Membership.Import.CsvParserSemicolon
|
||||
defp get_parser(","), do: Mv.Membership.Import.CsvParserComma
|
||||
defp get_parser(_), do: Mv.Membership.Import.CsvParserSemicolon
|
||||
|
||||
# --- Delimiter detection (quote-aware by actually parsing the header) ---
|
||||
|
||||
@spec detect_delimiter_by_parsing(binary()) :: String.t()
|
||||
defp detect_delimiter_by_parsing(header_record) do
|
||||
semicolon_score = header_field_count(Mv.Membership.Import.CsvParserSemicolon, header_record)
|
||||
comma_score = header_field_count(Mv.Membership.Import.CsvParserComma, header_record)
|
||||
|
||||
# prefer ";" on tie
|
||||
if semicolon_score >= comma_score, do: ";", else: ","
|
||||
end
|
||||
|
||||
@spec header_field_count(module(), binary()) :: non_neg_integer()
|
||||
defp header_field_count(parser, header_record) do
|
||||
case parse_single_record(parser, header_record, nil) do
|
||||
{:ok, fields} -> Enum.count(fields, &(String.trim(&1) != ""))
|
||||
{:error, _} -> 0
|
||||
end
|
||||
end
|
||||
|
||||
# Parses exactly one record (string without trailing newline is fine).
|
||||
# Returns `{:ok, row}` or `{:error, reason}`.
|
||||
@spec parse_single_record(module(), binary(), String.t() | nil) ::
|
||||
{:ok, row()} | {:error, String.t()}
|
||||
defp parse_single_record(parser, record, error_reason_if_empty) do
|
||||
# NimbleCSV is happiest if there's a newline at the end.
|
||||
rows = parser.parse_string(ensure_trailing_newline(record), skip_headers: false)
|
||||
|
||||
case rows do
|
||||
[row] when is_list(row) and row != [] ->
|
||||
{:ok, row}
|
||||
|
||||
_ ->
|
||||
if is_binary(error_reason_if_empty),
|
||||
do: {:error, error_reason_if_empty},
|
||||
else: {:error, "Failed to parse CSV header"}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
{:error, "Failed to parse CSV: #{Exception.message(e)}"}
|
||||
end
|
||||
|
||||
@spec ensure_trailing_newline(binary()) :: binary()
|
||||
defp ensure_trailing_newline(str) do
|
||||
if String.ends_with?(str, "\n"), do: str, else: str <> "\n"
|
||||
end
|
||||
|
||||
# --- Data parsing preserving *physical* line numbers ---
|
||||
#
|
||||
# Parses data records while preserving physical line numbers.
|
||||
# Skips empty rows but maintains correct line numbering for error reporting.
|
||||
#
|
||||
@spec parse_data_records(module(), [{line_number(), binary()}]) ::
|
||||
{:ok, [numbered_row()]} | {:error, String.t()}
|
||||
defp parse_data_records(parser, data_records) do
|
||||
rows =
|
||||
data_records
|
||||
|> Enum.reduce_while([], fn {line_no, record}, acc ->
|
||||
process_data_record(parser, line_no, record, acc)
|
||||
end)
|
||||
|
||||
case rows do
|
||||
{:error, reason} -> {:error, reason}
|
||||
rows -> {:ok, Enum.reverse(rows)}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
{:error, "Failed to parse CSV: #{Exception.message(e)}"}
|
||||
end
|
||||
|
||||
@spec process_data_record(module(), line_number(), binary(), [numbered_row()]) ::
|
||||
{:cont, [numbered_row()]} | {:halt, {:error, String.t()}}
|
||||
defp process_data_record(parser, line_no, record, acc) do
|
||||
trimmed = String.trim(record)
|
||||
|
||||
if trimmed == "" do
|
||||
{:cont, acc}
|
||||
else
|
||||
process_non_empty_record(parser, line_no, record, acc)
|
||||
end
|
||||
end
|
||||
|
||||
@spec process_non_empty_record(module(), line_number(), binary(), [numbered_row()]) ::
|
||||
{:cont, [numbered_row()]} | {:halt, {:error, String.t()}}
|
||||
defp process_non_empty_record(parser, line_no, record, acc) do
|
||||
parsed = parser.parse_string(ensure_trailing_newline(record), skip_headers: false)
|
||||
|
||||
case parsed do
|
||||
[row] when is_list(row) ->
|
||||
if empty_row?(row) do
|
||||
{:cont, acc}
|
||||
else
|
||||
{:cont, [{line_no, row} | acc]}
|
||||
end
|
||||
|
||||
# unparsable row -> return error with line number
|
||||
_ ->
|
||||
snippet =
|
||||
String.slice(record, 0, min(@max_error_snippet_length, String.length(record)))
|
||||
|
||||
{:halt, {:error, "Failed to parse CSV data at line #{line_no}: #{inspect(snippet)}"}}
|
||||
end
|
||||
end
|
||||
|
||||
@spec empty_row?(row()) :: boolean()
|
||||
defp empty_row?(row) when is_list(row) do
|
||||
Enum.all?(row, fn field -> String.trim(field) == "" end)
|
||||
end
|
||||
|
||||
# --- Record splitting with correct line numbers (quote-aware) ---
|
||||
#
|
||||
# Splits the CSV into records separated by newline *outside quotes*.
|
||||
# Returns `[{start_line_number, record_string_without_newline}, ...]`.
|
||||
#
|
||||
# Line numbers are 1-based and represent the physical line in the CSV file.
|
||||
# Empty lines are included in the numbering (they're just skipped later).
|
||||
#
|
||||
@spec split_records_with_line_numbers(binary()) :: [{line_number(), binary()}]
|
||||
defp split_records_with_line_numbers(content) do
|
||||
{acc, buf, _in_quotes, _line, start_line} =
|
||||
do_split(content, [], [], false, 1, 1)
|
||||
|
||||
# finalize last record only if there is buffered content
|
||||
acc =
|
||||
case buf do
|
||||
[] ->
|
||||
acc
|
||||
|
||||
_ ->
|
||||
record = buf |> Enum.reverse() |> :erlang.list_to_binary()
|
||||
[{start_line, record} | acc]
|
||||
end
|
||||
|
||||
Enum.reverse(acc)
|
||||
end
|
||||
|
||||
# Recursively splits CSV content into records with correct line numbering.
|
||||
#
|
||||
# Handles quote-aware parsing:
|
||||
# - Escaped quotes (`""`) inside quoted fields are preserved
|
||||
# - Newlines inside quotes are part of the record but advance line counter
|
||||
# - Newlines outside quotes end a record
|
||||
#
|
||||
# Parameters:
|
||||
# - `content` - Remaining binary content to parse
|
||||
# - `acc` - Accumulated records `[{line_number, record}, ...]`
|
||||
# - `buf` - Current record buffer (reversed byte list)
|
||||
# - `in_quotes` - Whether we're currently inside a quoted field
|
||||
# - `line` - Current physical line number
|
||||
# - `start_line` - Line number where current record started
|
||||
#
|
||||
@spec do_split(
|
||||
binary(),
|
||||
[{line_number(), binary()}],
|
||||
[byte()],
|
||||
boolean(),
|
||||
line_number(),
|
||||
line_number()
|
||||
) ::
|
||||
{[{line_number(), binary()}], [byte()], boolean(), line_number(), line_number()}
|
||||
defp do_split(<<>>, acc, buf, in_quotes, line, start_line),
|
||||
do: {acc, buf, in_quotes, line, start_line}
|
||||
|
||||
# Escaped quote inside quoted field: "" -> keep both quotes, do NOT toggle in_quotes
|
||||
defp do_split(<<@quote, @quote, rest::binary>>, acc, buf, true = in_quotes, line, start_line) do
|
||||
do_split(rest, acc, [@quote, @quote | buf], in_quotes, line, start_line)
|
||||
end
|
||||
|
||||
# Quote toggles quote state (when not escaped "")
|
||||
defp do_split(<<@quote, rest::binary>>, acc, buf, in_quotes, line, start_line) do
|
||||
do_split(rest, acc, [@quote | buf], not in_quotes, line, start_line)
|
||||
end
|
||||
|
||||
# Newline outside quotes ends a record (even if empty)
|
||||
defp do_split(<<"\n", rest::binary>>, acc, buf, false, line, start_line) do
|
||||
record = buf |> Enum.reverse() |> :erlang.list_to_binary()
|
||||
do_split(rest, [{start_line, record} | acc], [], false, line + 1, line + 1)
|
||||
end
|
||||
|
||||
# Newline inside quotes is part of the record, but advances physical line counter
|
||||
defp do_split(<<"\n", rest::binary>>, acc, buf, true = in_quotes, line, start_line) do
|
||||
do_split(rest, acc, [?\n | buf], in_quotes, line + 1, start_line)
|
||||
end
|
||||
|
||||
# Any other byte
|
||||
defp do_split(<<ch, rest::binary>>, acc, buf, in_quotes, line, start_line) do
|
||||
do_split(rest, acc, [ch | buf], in_quotes, line, start_line)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,396 +0,0 @@
|
|||
defmodule Mv.Membership.Import.HeaderMapper do
|
||||
@moduledoc """
|
||||
Maps CSV headers to canonical member fields and custom fields.
|
||||
|
||||
Provides header normalization and mapping functionality for CSV imports.
|
||||
Handles bilingual header variants (English/German) and custom field detection.
|
||||
|
||||
## Header Normalization
|
||||
|
||||
Headers are normalized using the following rules:
|
||||
- Trim whitespace
|
||||
- Convert to lowercase
|
||||
- Unicode normalization (ß → ss, ä → ae, ö → oe, ü → ue)
|
||||
- Remove all whitespace (ensures "first name" == "firstname")
|
||||
- Unify hyphen variants (en dash, minus sign → standard hyphen)
|
||||
- Remove or unify punctuation (parentheses, slashes → spaces)
|
||||
|
||||
## Member Field Mapping
|
||||
|
||||
Maps CSV headers to canonical member fields:
|
||||
- `email` (required)
|
||||
- `first_name` (optional)
|
||||
- `last_name` (optional)
|
||||
- `street` (optional)
|
||||
- `postal_code` (optional)
|
||||
- `city` (optional)
|
||||
|
||||
Supports both English and German variants (e.g., "Email" / "E-Mail", "First Name" / "Vorname").
|
||||
|
||||
## Custom Field Detection
|
||||
|
||||
Custom fields are detected by matching normalized header names to custom field names.
|
||||
Member fields have priority over custom fields (member field wins in case of collision).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> HeaderMapper.normalize_header(" E-Mail ")
|
||||
"e-mail"
|
||||
|
||||
iex> HeaderMapper.build_maps(["Email", "First Name"], [])
|
||||
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}}
|
||||
|
||||
iex> HeaderMapper.build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
|
||||
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}}
|
||||
"""
|
||||
|
||||
@type column_map :: %{atom() => non_neg_integer()}
|
||||
@type custom_field_map :: %{String.t() => non_neg_integer()}
|
||||
@type unknown_headers :: [String.t()]
|
||||
|
||||
# Required member fields
|
||||
@required_member_fields [:email]
|
||||
|
||||
# Canonical member fields with their raw variants
|
||||
# These will be normalized at runtime when building the lookup map
|
||||
@member_field_variants_raw %{
|
||||
email: [
|
||||
"email",
|
||||
"e-mail",
|
||||
"e_mail",
|
||||
"e mail",
|
||||
"e-mail adresse",
|
||||
"e-mail-adresse",
|
||||
"mail"
|
||||
],
|
||||
first_name: [
|
||||
"first name",
|
||||
"firstname",
|
||||
"vorname"
|
||||
],
|
||||
last_name: [
|
||||
"last name",
|
||||
"lastname",
|
||||
"surname",
|
||||
"nachname",
|
||||
"familienname"
|
||||
],
|
||||
street: [
|
||||
"street",
|
||||
"address",
|
||||
"strasse"
|
||||
],
|
||||
postal_code: [
|
||||
"postal code",
|
||||
"postal_code",
|
||||
"zip",
|
||||
"postcode",
|
||||
"plz",
|
||||
"postleitzahl"
|
||||
],
|
||||
city: [
|
||||
"city",
|
||||
"town",
|
||||
"stadt",
|
||||
"ort"
|
||||
]
|
||||
}
|
||||
|
||||
# Build reverse map: normalized_variant -> canonical_field
|
||||
# Cached on first access for performance
|
||||
defp normalized_to_canonical do
|
||||
cached = Process.get({__MODULE__, :normalized_to_canonical})
|
||||
|
||||
if cached do
|
||||
cached
|
||||
else
|
||||
map = build_normalized_to_canonical_map()
|
||||
Process.put({__MODULE__, :normalized_to_canonical}, map)
|
||||
map
|
||||
end
|
||||
end
|
||||
|
||||
# Builds the normalized variant -> canonical field map
|
||||
defp build_normalized_to_canonical_map do
|
||||
@member_field_variants_raw
|
||||
|> Enum.flat_map(&map_variants_to_normalized/1)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
# Maps a canonical field and its variants to normalized tuples
|
||||
defp map_variants_to_normalized({canonical, variants}) do
|
||||
Enum.map(variants, fn variant ->
|
||||
{normalize_header(variant), canonical}
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Normalizes a CSV header string for comparison.
|
||||
|
||||
Applies the following transformations:
|
||||
- Trim whitespace
|
||||
- Convert to lowercase
|
||||
- Unicode transliteration (ß → ss, ä → ae, ö → oe, ü → ue)
|
||||
- Unify hyphen variants (en dash U+2013, minus sign U+2212 → standard hyphen)
|
||||
- Remove or unify punctuation (parentheses, slashes → spaces)
|
||||
- Remove all whitespace (ensures "first name" == "firstname")
|
||||
- Final trim
|
||||
|
||||
## Examples
|
||||
|
||||
iex> normalize_header(" E-Mail ")
|
||||
"e-mail"
|
||||
|
||||
iex> normalize_header("Straße")
|
||||
"strasse"
|
||||
|
||||
iex> normalize_header("E-Mail (privat)")
|
||||
"e-mailprivat"
|
||||
|
||||
iex> normalize_header("First Name")
|
||||
"firstname"
|
||||
|
||||
"""
|
||||
@spec normalize_header(String.t()) :: String.t()
|
||||
def normalize_header(header) when is_binary(header) do
|
||||
header
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
|> transliterate_unicode()
|
||||
|> unify_hyphens()
|
||||
|> normalize_punctuation()
|
||||
|> compress_whitespace()
|
||||
|> String.trim()
|
||||
end
|
||||
|
||||
def normalize_header(_), do: ""
|
||||
|
||||
@doc """
|
||||
Builds column maps for member fields and custom fields from CSV headers.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `headers` - List of CSV header strings (in column order, 0-based indices)
|
||||
- `custom_fields` - List of custom field maps/structs with at least `:id` and `:name` keys
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers}}` on success
|
||||
- `{:error, reason}` on error (missing required field, duplicate headers)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> build_maps(["Email", "First Name"], [])
|
||||
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}}
|
||||
|
||||
iex> build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
|
||||
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}}
|
||||
|
||||
"""
|
||||
@spec build_maps([String.t()], [map()]) ::
|
||||
{:ok, %{member: column_map(), custom: custom_field_map(), unknown: unknown_headers()}}
|
||||
| {:error, String.t()}
|
||||
def build_maps(headers, custom_fields) when is_list(headers) and is_list(custom_fields) do
|
||||
with {:ok, member_map, unknown_after_member} <- build_member_map(headers),
|
||||
{:ok, custom_map, unknown_after_custom} <-
|
||||
build_custom_field_map(headers, unknown_after_member, custom_fields, member_map) do
|
||||
unknown = Enum.map(unknown_after_custom, &Enum.at(headers, &1))
|
||||
{:ok, %{member: member_map, custom: custom_map, unknown: unknown}}
|
||||
end
|
||||
end
|
||||
|
||||
# --- Private Functions ---
|
||||
|
||||
# Transliterates German umlauts and special characters
|
||||
defp transliterate_unicode(str) do
|
||||
str
|
||||
|> String.replace("ß", "ss")
|
||||
|> String.replace("ä", "ae")
|
||||
|> String.replace("ö", "oe")
|
||||
|> String.replace("ü", "ue")
|
||||
|> String.replace("Ä", "ae")
|
||||
|> String.replace("Ö", "oe")
|
||||
|> String.replace("Ü", "ue")
|
||||
end
|
||||
|
||||
# Unifies different hyphen variants to standard hyphen
|
||||
defp unify_hyphens(str) do
|
||||
str
|
||||
# en dash
|
||||
|> String.replace(<<0x2013::utf8>>, "-")
|
||||
# em dash
|
||||
|> String.replace(<<0x2014::utf8>>, "-")
|
||||
# minus sign
|
||||
|> String.replace(<<0x2212::utf8>>, "-")
|
||||
end
|
||||
|
||||
# Normalizes punctuation: parentheses, slashes, underscores become spaces
|
||||
defp normalize_punctuation(str) do
|
||||
str
|
||||
|> String.replace("_", " ")
|
||||
|> String.replace(~r/[()\[\]{}]/, " ")
|
||||
|> String.replace(~r/[\/\\]/, " ")
|
||||
end
|
||||
|
||||
# Compresses multiple whitespace characters to single space, then removes all spaces
|
||||
# This ensures "first name" and "firstname" normalize to the same value
|
||||
defp compress_whitespace(str) do
|
||||
str
|
||||
|> String.replace(~r/\s+/, " ")
|
||||
|> String.replace(" ", "")
|
||||
end
|
||||
|
||||
# Builds member field column map
|
||||
defp build_member_map(headers) do
|
||||
result =
|
||||
headers
|
||||
|> Enum.with_index()
|
||||
|> Enum.reduce_while({%{}, []}, fn {header, index}, {acc_map, acc_unknown} ->
|
||||
normalized = normalize_header(header)
|
||||
|
||||
case process_member_header(header, index, normalized, acc_map, %{}) do
|
||||
{:error, reason} ->
|
||||
{:halt, {:error, reason}}
|
||||
|
||||
{:ok, new_map, _} ->
|
||||
{:cont, {new_map, acc_unknown}}
|
||||
|
||||
{:unknown} ->
|
||||
{:cont, {acc_map, [index | acc_unknown]}}
|
||||
end
|
||||
end)
|
||||
|
||||
case result do
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
||||
{member_map, unknown_indices} ->
|
||||
validate_required_fields(member_map, unknown_indices)
|
||||
end
|
||||
end
|
||||
|
||||
# Processes a single header for member field mapping
|
||||
defp process_member_header(_header, _index, normalized, acc_map, acc_seen)
|
||||
when normalized == "" do
|
||||
{:ok, acc_map, acc_seen}
|
||||
end
|
||||
|
||||
defp process_member_header(_header, index, normalized, acc_map, _acc_seen) do
|
||||
case Map.get(normalized_to_canonical(), normalized) do
|
||||
nil ->
|
||||
{:unknown}
|
||||
|
||||
canonical ->
|
||||
if Map.has_key?(acc_map, canonical) do
|
||||
{:error, "duplicate header for #{canonical} (normalized: #{normalized})"}
|
||||
else
|
||||
{:ok, Map.put(acc_map, canonical, index), %{}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Validates that all required member fields are present
|
||||
defp validate_required_fields(member_map, unknown_indices) do
|
||||
missing_required =
|
||||
@required_member_fields
|
||||
|> Enum.filter(&(not Map.has_key?(member_map, &1)))
|
||||
|
||||
if Enum.empty?(missing_required) do
|
||||
{:ok, member_map, Enum.reverse(unknown_indices)}
|
||||
else
|
||||
missing_field = List.first(missing_required)
|
||||
variants = Map.get(@member_field_variants_raw, missing_field, [])
|
||||
accepted = Enum.join(variants, ", ")
|
||||
|
||||
{:error, "Missing required header: #{missing_field} (accepted: #{accepted})"}
|
||||
end
|
||||
end
|
||||
|
||||
# Builds custom field column map from unmatched headers
|
||||
defp build_custom_field_map(headers, unknown_indices, custom_fields, _member_map) do
|
||||
custom_field_lookup = build_custom_field_lookup(custom_fields)
|
||||
|
||||
result =
|
||||
unknown_indices
|
||||
|> Enum.reduce_while({%{}, []}, fn index, {acc_map, acc_unknown} ->
|
||||
header = Enum.at(headers, index)
|
||||
normalized = normalize_header(header)
|
||||
|
||||
case process_custom_field_header(
|
||||
header,
|
||||
index,
|
||||
normalized,
|
||||
custom_field_lookup,
|
||||
acc_map,
|
||||
%{}
|
||||
) do
|
||||
{:error, reason} ->
|
||||
{:halt, {:error, reason}}
|
||||
|
||||
{:ok, new_map, _} ->
|
||||
{:cont, {new_map, acc_unknown}}
|
||||
|
||||
{:unknown} ->
|
||||
{:cont, {acc_map, [index | acc_unknown]}}
|
||||
end
|
||||
end)
|
||||
|
||||
case result do
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
||||
{custom_map, remaining_unknown} ->
|
||||
{:ok, custom_map, Enum.reverse(remaining_unknown)}
|
||||
end
|
||||
end
|
||||
|
||||
# Builds normalized custom field name -> id lookup map
|
||||
defp build_custom_field_lookup(custom_fields) do
|
||||
custom_fields
|
||||
|> Enum.reduce(%{}, fn cf, acc ->
|
||||
name = Map.get(cf, :name) || Map.get(cf, "name")
|
||||
id = Map.get(cf, :id) || Map.get(cf, "id")
|
||||
|
||||
if name && id do
|
||||
normalized_name = normalize_header(name)
|
||||
Map.put(acc, normalized_name, id)
|
||||
else
|
||||
acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Processes a single header for custom field mapping
|
||||
defp process_custom_field_header(
|
||||
_header,
|
||||
_index,
|
||||
normalized,
|
||||
_custom_field_lookup,
|
||||
acc_map,
|
||||
_acc_seen
|
||||
)
|
||||
when normalized == "" do
|
||||
{:ok, acc_map, %{}}
|
||||
end
|
||||
|
||||
defp process_custom_field_header(
|
||||
_header,
|
||||
index,
|
||||
normalized,
|
||||
custom_field_lookup,
|
||||
acc_map,
|
||||
_acc_seen
|
||||
) do
|
||||
if Map.has_key?(custom_field_lookup, normalized) do
|
||||
custom_field_id = custom_field_lookup[normalized]
|
||||
|
||||
if Map.has_key?(acc_map, custom_field_id) do
|
||||
{:error, "duplicate custom field header (normalized: #{normalized})"}
|
||||
else
|
||||
{:ok, Map.put(acc_map, custom_field_id, index), %{}}
|
||||
end
|
||||
else
|
||||
{:unknown}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,476 +0,0 @@
|
|||
defmodule Mv.Membership.Import.MemberCSV do
|
||||
@moduledoc """
|
||||
Service module for importing members from CSV files.
|
||||
|
||||
require Ash.Query
|
||||
|
||||
This module provides the core API for CSV member import functionality:
|
||||
- `prepare/2` - Parses and validates CSV content, returns import state
|
||||
- `process_chunk/3` - Processes a chunk of rows and creates members
|
||||
|
||||
## Error Handling
|
||||
|
||||
Errors are returned as `%MemberCSV.Error{}` structs containing:
|
||||
- `csv_line_number` - The physical line number in the CSV file (or `nil` for general errors)
|
||||
- `field` - The field name (atom) or `nil` if not field-specific
|
||||
- `message` - Human-readable error message (or `nil` for general errors)
|
||||
|
||||
## Import State
|
||||
|
||||
The `import_state` returned by `prepare/2` contains:
|
||||
- `chunks` - List of row chunks ready for processing
|
||||
- `column_map` - Map of canonical field names to column indices
|
||||
- `custom_field_map` - Map of custom field names to column indices
|
||||
- `warnings` - List of warning messages (e.g., unknown custom field columns)
|
||||
|
||||
## Chunk Results
|
||||
|
||||
The `chunk_result` returned by `process_chunk/3` contains:
|
||||
- `inserted` - Number of successfully created members
|
||||
- `failed` - Number of failed member creations
|
||||
- `errors` - List of `%MemberCSV.Error{}` structs (capped at 50 per import)
|
||||
|
||||
## Examples
|
||||
|
||||
# Prepare CSV for import
|
||||
{:ok, import_state} = MemberCSV.prepare(csv_content)
|
||||
|
||||
# Process first chunk
|
||||
chunk = Enum.at(import_state.chunks, 0)
|
||||
{:ok, result} = MemberCSV.process_chunk(chunk, import_state.column_map)
|
||||
"""
|
||||
|
||||
defmodule Error do
|
||||
@moduledoc """
|
||||
Error struct for CSV import errors.
|
||||
|
||||
## Fields
|
||||
|
||||
- `csv_line_number` - The physical line number in the CSV file (1-based, header is line 1)
|
||||
- `field` - The field name as an atom (e.g., `:email`) or `nil` if not field-specific
|
||||
- `message` - Human-readable error message
|
||||
"""
|
||||
defstruct csv_line_number: nil, field: nil, message: nil
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
csv_line_number: pos_integer() | nil,
|
||||
field: atom() | nil,
|
||||
message: String.t() | nil
|
||||
}
|
||||
end
|
||||
|
||||
@type import_state :: %{
|
||||
chunks: list(list({pos_integer(), map()})),
|
||||
column_map: %{atom() => non_neg_integer()},
|
||||
custom_field_map: %{String.t() => non_neg_integer()},
|
||||
custom_field_lookup: %{String.t() => %{id: String.t(), value_type: atom()}},
|
||||
warnings: list(String.t())
|
||||
}
|
||||
|
||||
@type chunk_result :: %{
|
||||
inserted: non_neg_integer(),
|
||||
failed: non_neg_integer(),
|
||||
errors: list(Error.t())
|
||||
}
|
||||
|
||||
alias Mv.Membership.Import.CsvParser
|
||||
alias Mv.Membership.Import.HeaderMapper
|
||||
|
||||
@doc """
|
||||
Prepares CSV content for import by parsing, mapping headers, and validating limits.
|
||||
|
||||
This function:
|
||||
1. Strips UTF-8 BOM if present
|
||||
2. Detects CSV delimiter (semicolon or comma)
|
||||
3. Parses headers and data rows
|
||||
4. Maps headers to canonical member fields
|
||||
5. Maps custom field columns by name
|
||||
6. Validates row count limits
|
||||
7. Chunks rows for processing
|
||||
|
||||
## Parameters
|
||||
|
||||
- `file_content` - The raw CSV file content as a string
|
||||
- `opts` - Optional keyword list:
|
||||
- `:max_rows` - Maximum number of data rows allowed (default: 1000)
|
||||
- `:chunk_size` - Number of rows per chunk (default: 200)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, import_state}` - Successfully prepared import state
|
||||
- `{:error, reason}` - Error reason (string or error struct)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MemberCSV.prepare("email\\njohn@example.com")
|
||||
{:ok, %{chunks: [...], column_map: %{email: 0}, ...}}
|
||||
|
||||
iex> MemberCSV.prepare("")
|
||||
{:error, "CSV file is empty"}
|
||||
"""
|
||||
@spec prepare(String.t(), keyword()) :: {:ok, import_state()} | {:error, String.t()}
|
||||
def prepare(file_content, opts \\ []) do
|
||||
max_rows = Keyword.get(opts, :max_rows, 1000)
|
||||
chunk_size = Keyword.get(opts, :chunk_size, 200)
|
||||
|
||||
with {:ok, headers, rows} <- CsvParser.parse(file_content),
|
||||
{:ok, custom_fields} <- load_custom_fields(),
|
||||
{:ok, maps, warnings} <- build_header_maps(headers, custom_fields),
|
||||
:ok <- validate_row_count(rows, max_rows) do
|
||||
chunks = chunk_rows(rows, maps, chunk_size)
|
||||
|
||||
# Build custom field lookup for efficient value processing
|
||||
custom_field_lookup = build_custom_field_lookup(custom_fields)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
chunks: chunks,
|
||||
column_map: maps.member,
|
||||
custom_field_map: maps.custom,
|
||||
custom_field_lookup: custom_field_lookup,
|
||||
warnings: warnings
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
# Loads all custom fields from the database
|
||||
defp load_custom_fields do
|
||||
custom_fields =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.read!()
|
||||
|
||||
{:ok, custom_fields}
|
||||
rescue
|
||||
e ->
|
||||
{:error, "Failed to load custom fields: #{Exception.message(e)}"}
|
||||
end
|
||||
|
||||
# Builds custom field lookup map for efficient value processing
|
||||
defp build_custom_field_lookup(custom_fields) do
|
||||
custom_fields
|
||||
|> Enum.reduce(%{}, fn cf, acc ->
|
||||
id_str = to_string(cf.id)
|
||||
Map.put(acc, id_str, %{id: cf.id, value_type: cf.value_type})
|
||||
end)
|
||||
end
|
||||
|
||||
# Builds header maps using HeaderMapper and collects warnings for unknown custom fields
|
||||
defp build_header_maps(headers, custom_fields) do
|
||||
# Convert custom fields to maps with id and name
|
||||
custom_field_maps =
|
||||
Enum.map(custom_fields, fn cf ->
|
||||
%{id: to_string(cf.id), name: cf.name}
|
||||
end)
|
||||
|
||||
case HeaderMapper.build_maps(headers, custom_field_maps) do
|
||||
{:ok, %{member: member_map, custom: custom_map, unknown: unknown}} ->
|
||||
# Build warnings for unknown custom field columns
|
||||
warnings =
|
||||
unknown
|
||||
|> Enum.filter(fn header ->
|
||||
# Check if it could be a custom field (not a known member field)
|
||||
normalized = HeaderMapper.normalize_header(header)
|
||||
# If it's not empty and not a member field, it might be a custom field
|
||||
normalized != "" && not member_field?(normalized)
|
||||
end)
|
||||
|> Enum.map(fn header ->
|
||||
"Unknown column '#{header}' will be ignored. " <>
|
||||
"If this is a custom field, create it in Mila before importing."
|
||||
end)
|
||||
|
||||
{:ok, %{member: member_map, custom: custom_map}, warnings}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if a normalized header matches a member field
|
||||
# Uses HeaderMapper's internal logic to check if header would map to a member field
|
||||
defp member_field?(normalized) do
|
||||
# Try to build maps with just this header - if it maps to a member field, it's a member field
|
||||
case HeaderMapper.build_maps([normalized], []) do
|
||||
{:ok, %{member: member_map}} ->
|
||||
# If member_map is not empty, it's a member field
|
||||
map_size(member_map) > 0
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Validates that row count doesn't exceed limit
|
||||
defp validate_row_count(rows, max_rows) do
|
||||
if length(rows) > max_rows do
|
||||
{:error, "CSV file exceeds maximum row limit of #{max_rows} rows"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# Chunks rows and converts them to row maps using column maps
|
||||
defp chunk_rows(rows, maps, chunk_size) do
|
||||
rows
|
||||
|> Enum.chunk_every(chunk_size)
|
||||
|> Enum.map(fn chunk ->
|
||||
Enum.map(chunk, fn {line_number, row_values} ->
|
||||
row_map = build_row_map(row_values, maps)
|
||||
{line_number, row_map}
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
# Builds a row map from raw row values using column maps
|
||||
defp build_row_map(row_values, maps) do
|
||||
member_map =
|
||||
maps.member
|
||||
|> Enum.reduce(%{}, fn {field, index}, acc ->
|
||||
value = Enum.at(row_values, index, "")
|
||||
Map.put(acc, field, value)
|
||||
end)
|
||||
|
||||
custom_map =
|
||||
maps.custom
|
||||
|> Enum.reduce(%{}, fn {custom_field_id, index}, acc ->
|
||||
value = Enum.at(row_values, index, "")
|
||||
Map.put(acc, custom_field_id, value)
|
||||
end)
|
||||
|
||||
%{member: member_map, custom: custom_map}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Processes a chunk of CSV rows and creates members.
|
||||
|
||||
This function:
|
||||
1. Validates each row
|
||||
2. Creates members via Ash resource
|
||||
3. Creates custom field values for each member
|
||||
4. Collects errors with correct CSV line numbers
|
||||
5. Returns chunk processing results
|
||||
|
||||
## Parameters
|
||||
|
||||
- `chunk_rows_with_lines` - List of tuples `{csv_line_number, row_map}` where:
|
||||
- `csv_line_number` - Physical line number in CSV (1-based)
|
||||
- `row_map` - Map with `:member` and `:custom` keys containing field values
|
||||
- `column_map` - Map of canonical field names (atoms) to column indices (for reference)
|
||||
- `custom_field_map` - Map of custom field IDs (strings) to column indices (for reference)
|
||||
- `opts` - Optional keyword list for processing options
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, chunk_result}` - Chunk processing results
|
||||
- `{:error, reason}` - Error reason (string)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> chunk = [{2, %{member: %{email: "john@example.com"}, custom: %{}}}]
|
||||
iex> column_map = %{email: 0}
|
||||
iex> custom_field_map = %{}
|
||||
iex> MemberCSV.process_chunk(chunk, column_map, custom_field_map)
|
||||
{:ok, %{inserted: 1, failed: 0, errors: []}}
|
||||
"""
|
||||
@spec process_chunk(
|
||||
list({pos_integer(), map()}),
|
||||
%{atom() => non_neg_integer()},
|
||||
%{String.t() => non_neg_integer()},
|
||||
keyword()
|
||||
) :: {:ok, chunk_result()} | {:error, String.t()}
|
||||
def process_chunk(chunk_rows_with_lines, _column_map, _custom_field_map, opts \\ []) do
|
||||
custom_field_lookup = Keyword.get(opts, :custom_field_lookup, %{})
|
||||
|
||||
{inserted, failed, errors} =
|
||||
Enum.reduce(chunk_rows_with_lines, {0, 0, []}, fn {line_number, row_map},
|
||||
{acc_inserted, acc_failed, acc_errors} ->
|
||||
case process_row(row_map, line_number, custom_field_lookup) do
|
||||
{:ok, _member} ->
|
||||
{acc_inserted + 1, acc_failed, acc_errors}
|
||||
|
||||
{:error, error} ->
|
||||
{acc_inserted, acc_failed + 1, [error | acc_errors]}
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, %{inserted: inserted, failed: failed, errors: Enum.reverse(errors)}}
|
||||
end
|
||||
|
||||
# Processes a single row and creates member with custom field values
|
||||
defp process_row(
|
||||
%{member: member_attrs, custom: custom_attrs},
|
||||
line_number,
|
||||
custom_field_lookup
|
||||
) do
|
||||
# Prepare custom field values for Ash
|
||||
custom_field_values = prepare_custom_field_values(custom_attrs, custom_field_lookup)
|
||||
|
||||
# Create member with custom field values
|
||||
member_attrs_with_cf =
|
||||
member_attrs
|
||||
|> Map.put(:custom_field_values, custom_field_values)
|
||||
|> trim_string_values()
|
||||
|
||||
# Only include custom_field_values if not empty
|
||||
final_attrs =
|
||||
if Enum.empty?(custom_field_values) do
|
||||
Map.delete(member_attrs_with_cf, :custom_field_values)
|
||||
else
|
||||
member_attrs_with_cf
|
||||
end
|
||||
|
||||
case Mv.Membership.create_member(final_attrs) do
|
||||
{:ok, member} ->
|
||||
{:ok, member}
|
||||
|
||||
{:error, %Ash.Error.Invalid{} = error} ->
|
||||
{:error, format_ash_error(error, line_number)}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}}
|
||||
end
|
||||
|
||||
# Prepares custom field values from row map for Ash
|
||||
defp prepare_custom_field_values(custom_attrs, custom_field_lookup) when is_map(custom_attrs) do
|
||||
custom_attrs
|
||||
|> Enum.filter(fn {_id, value} -> value != nil && value != "" end)
|
||||
|> Enum.map(fn {custom_field_id_str, value} ->
|
||||
case Map.get(custom_field_lookup, custom_field_id_str) do
|
||||
nil ->
|
||||
# Custom field not found, skip
|
||||
nil
|
||||
|
||||
%{id: custom_field_id, value_type: value_type} ->
|
||||
%{
|
||||
"custom_field_id" => to_string(custom_field_id),
|
||||
"value" => format_custom_field_value(value, value_type)
|
||||
}
|
||||
end
|
||||
end)
|
||||
|> Enum.filter(&(&1 != nil))
|
||||
end
|
||||
|
||||
defp prepare_custom_field_values(_, _), do: []
|
||||
|
||||
# Formats a custom field value according to its type
|
||||
# Uses _union_type and _union_value format as expected by Ash
|
||||
defp format_custom_field_value(value, :string) when is_binary(value) do
|
||||
%{"_union_type" => "string", "_union_value" => String.trim(value)}
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :integer) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int_value, _} -> %{"_union_type" => "integer", "_union_value" => int_value}
|
||||
:error -> %{"_union_type" => "string", "_union_value" => String.trim(value)}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :boolean) when is_binary(value) do
|
||||
bool_value =
|
||||
value
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
|> case do
|
||||
"true" -> true
|
||||
"1" -> true
|
||||
"yes" -> true
|
||||
"ja" -> true
|
||||
_ -> false
|
||||
end
|
||||
|
||||
%{"_union_type" => "boolean", "_union_value" => bool_value}
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :date) when is_binary(value) do
|
||||
case Date.from_iso8601(String.trim(value)) do
|
||||
{:ok, date} -> %{"_union_type" => "date", "_union_value" => date}
|
||||
{:error, _} -> %{"_union_type" => "string", "_union_value" => String.trim(value)}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :email) when is_binary(value) do
|
||||
%{"_union_type" => "email", "_union_value" => String.trim(value)}
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, _type) when is_binary(value) do
|
||||
# Default to string if type is unknown
|
||||
%{"_union_type" => "string", "_union_value" => String.trim(value)}
|
||||
end
|
||||
|
||||
# Trims all string values in member attributes
|
||||
defp trim_string_values(attrs) do
|
||||
Enum.reduce(attrs, %{}, fn {key, value}, acc ->
|
||||
trimmed_value =
|
||||
if is_binary(value) do
|
||||
String.trim(value)
|
||||
else
|
||||
value
|
||||
end
|
||||
|
||||
Map.put(acc, key, trimmed_value)
|
||||
end)
|
||||
end
|
||||
|
||||
# Formats Ash errors into MemberCSV.Error structs
|
||||
defp format_ash_error(%Ash.Error.Invalid{errors: errors}, line_number) do
|
||||
# Try to find email-related errors first (for better error messages)
|
||||
email_error =
|
||||
Enum.find(errors, fn error ->
|
||||
case error do
|
||||
%{field: :email} -> true
|
||||
_ -> false
|
||||
end
|
||||
end)
|
||||
|
||||
case email_error || List.first(errors) do
|
||||
%{field: field, message: message} when is_atom(field) ->
|
||||
%Error{
|
||||
csv_line_number: line_number,
|
||||
field: field,
|
||||
message: format_error_message(message, field)
|
||||
}
|
||||
|
||||
%{message: message} ->
|
||||
%Error{
|
||||
csv_line_number: line_number,
|
||||
field: nil,
|
||||
message: format_error_message(message, nil)
|
||||
}
|
||||
|
||||
_ ->
|
||||
%Error{
|
||||
csv_line_number: line_number,
|
||||
field: nil,
|
||||
message: "Validation failed"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Formats error messages, handling common cases like email uniqueness
|
||||
defp format_error_message(message, field) when is_binary(message) do
|
||||
if email_uniqueness_error?(message, field) do
|
||||
"email has already been taken"
|
||||
else
|
||||
message
|
||||
end
|
||||
end
|
||||
|
||||
defp format_error_message(message, _field), do: to_string(message)
|
||||
|
||||
# Checks if error message indicates email uniqueness constraint violation
|
||||
defp email_uniqueness_error?(message, :email) do
|
||||
message_lower = String.downcase(message)
|
||||
|
||||
String.contains?(message_lower, "unique") or
|
||||
String.contains?(message_lower, "constraint") or
|
||||
String.contains?(message_lower, "duplicate") or
|
||||
String.contains?(message_lower, "already been taken") or
|
||||
String.contains?(message_lower, "already exists") or
|
||||
String.contains?(message_lower, "violates unique constraint")
|
||||
end
|
||||
|
||||
defp email_uniqueness_error?(_message, _field), do: false
|
||||
end
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||
@moduledoc """
|
||||
Validates that the member's email is not already used by another user.
|
||||
Only validates when:
|
||||
- Member is already linked to a user (user != nil) AND email is changing
|
||||
- Member is being linked to a user (user relationship is changing)
|
||||
|
||||
This allows creating members with the same email as unlinked users.
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
alias Mv.Helpers
|
||||
|
||||
@doc """
|
||||
Validates email uniqueness across linked Member-User pairs.
|
||||
|
||||
This validation ensures that when a member is linked to a user, their email
|
||||
does not conflict with another user's email. It only runs when necessary
|
||||
to avoid blocking valid operations (see `@moduledoc` for trigger conditions).
|
||||
|
||||
## Parameters
|
||||
- `changeset` - The Ash changeset being validated
|
||||
- `_opts` - Options passed to the validation (unused)
|
||||
- `_context` - Ash context map (unused)
|
||||
|
||||
## Returns
|
||||
- `:ok` if validation passes or should be skipped
|
||||
- `{:error, field: :email, message: ..., value: ...}` if validation fails
|
||||
"""
|
||||
@impl true
|
||||
def validate(changeset, _opts, _context) do
|
||||
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
||||
|
||||
actor = Map.get(changeset.context || %{}, :actor)
|
||||
linked_user_id = get_linked_user_id(changeset.data, actor)
|
||||
is_linked? = not is_nil(linked_user_id)
|
||||
|
||||
# Only validate if member is already linked AND email is changing
|
||||
# Do NOT validate when member is being linked (email will be overridden from user)
|
||||
should_validate? = is_linked? and email_changing?
|
||||
|
||||
if should_validate? do
|
||||
new_email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
check_email_uniqueness(new_email, linked_user_id, actor)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp check_email_uniqueness(email, exclude_user_id, actor) do
|
||||
query =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^email)
|
||||
|> maybe_exclude_id(exclude_user_id)
|
||||
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.read(query, opts) do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, _} ->
|
||||
{:error, field: :email, message: "is already used by another user", value: email}
|
||||
|
||||
{: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)
|
||||
|
||||
defp get_linked_user_id(member_data, actor) do
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.load(member_data, :user, opts) do
|
||||
{:ok, %{user: %{id: id}}} -> id
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,337 +0,0 @@
|
|||
defmodule Mv.MembershipFees.CalendarCycles do
|
||||
@moduledoc """
|
||||
Calendar-based cycle calculation functions for membership fees.
|
||||
|
||||
This module provides functions for calculating cycle boundaries
|
||||
based on interval types (monthly, quarterly, half-yearly, yearly).
|
||||
|
||||
The calculation functions (`calculate_cycle_start/3`, `calculate_cycle_end/2`,
|
||||
`next_cycle_start/2`) are pure functions with no side effects.
|
||||
|
||||
The time-dependent functions (`current_cycle?/3`, `last_completed_cycle?/3`)
|
||||
depend on a date parameter for testability. Their 2-argument variants
|
||||
(`current_cycle?/2`, `last_completed_cycle?/2`) use `Date.utc_today()` and
|
||||
are not referentially transparent.
|
||||
|
||||
## Interval Types
|
||||
|
||||
- `:monthly` - Cycles from 1st to last day of each month
|
||||
- `:quarterly` - Cycles from 1st of Jan/Apr/Jul/Oct to last day of quarter
|
||||
- `:half_yearly` - Cycles from 1st of Jan/Jul to last day of half-year
|
||||
- `:yearly` - Cycles from Jan 1st to Dec 31st
|
||||
|
||||
## Examples
|
||||
|
||||
iex> date = ~D[2024-03-15]
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(date, :monthly)
|
||||
~D[2024-03-01]
|
||||
|
||||
iex> cycle_start = ~D[2024-01-01]
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle_start, :yearly)
|
||||
~D[2024-12-31]
|
||||
|
||||
iex> cycle_start = ~D[2024-01-01]
|
||||
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(cycle_start, :yearly)
|
||||
~D[2025-01-01]
|
||||
"""
|
||||
|
||||
@typedoc """
|
||||
Interval type for membership fee cycles.
|
||||
|
||||
- `:monthly` - Monthly cycles (1st to last day of month)
|
||||
- `:quarterly` - Quarterly cycles (1st of quarter to last day of quarter)
|
||||
- `:half_yearly` - Half-yearly cycles (1st of half-year to last day of half-year)
|
||||
- `:yearly` - Yearly cycles (Jan 1st to Dec 31st)
|
||||
"""
|
||||
@type interval :: :monthly | :quarterly | :half_yearly | :yearly
|
||||
|
||||
@doc """
|
||||
Calculates the start date of the cycle that contains the reference date.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `date` - Ignored in this 3-argument version (kept for API consistency)
|
||||
- `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`)
|
||||
- `reference_date` - The date used to determine which cycle to calculate
|
||||
|
||||
## Returns
|
||||
|
||||
The start date of the cycle containing the reference date.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly, ~D[2024-05-20])
|
||||
~D[2024-05-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :quarterly, ~D[2024-05-20])
|
||||
~D[2024-04-01]
|
||||
"""
|
||||
@spec calculate_cycle_start(Date.t(), interval(), Date.t()) :: Date.t()
|
||||
def calculate_cycle_start(_date, interval, reference_date) do
|
||||
case interval do
|
||||
:monthly -> monthly_cycle_start(reference_date)
|
||||
:quarterly -> quarterly_cycle_start(reference_date)
|
||||
:half_yearly -> half_yearly_cycle_start(reference_date)
|
||||
:yearly -> yearly_cycle_start(reference_date)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the start date of the cycle that contains the given date.
|
||||
|
||||
This is a convenience function that calls `calculate_cycle_start/3` with `date` as both
|
||||
the input and reference date.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `date` - The date used to determine which cycle to calculate
|
||||
- `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`)
|
||||
|
||||
## Returns
|
||||
|
||||
The start date of the cycle containing the given date.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly)
|
||||
~D[2024-03-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly)
|
||||
~D[2024-04-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly)
|
||||
~D[2024-07-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-12-15], :yearly)
|
||||
~D[2024-01-01]
|
||||
"""
|
||||
@spec calculate_cycle_start(Date.t(), interval()) :: Date.t()
|
||||
def calculate_cycle_start(date, interval) do
|
||||
calculate_cycle_start(date, interval, date)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the end date of a cycle based on its start date and interval.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `cycle_start` - The start date of the cycle
|
||||
- `interval` - The interval type
|
||||
|
||||
## Returns
|
||||
|
||||
The end date of the cycle.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly)
|
||||
~D[2024-03-31]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly)
|
||||
~D[2024-02-29]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly)
|
||||
~D[2024-03-31]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly)
|
||||
~D[2024-06-30]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly)
|
||||
~D[2024-12-31]
|
||||
"""
|
||||
@spec calculate_cycle_end(Date.t(), interval()) :: Date.t()
|
||||
def calculate_cycle_end(cycle_start, interval) do
|
||||
case interval do
|
||||
:monthly -> monthly_cycle_end(cycle_start)
|
||||
:quarterly -> quarterly_cycle_end(cycle_start)
|
||||
:half_yearly -> half_yearly_cycle_end(cycle_start)
|
||||
:yearly -> yearly_cycle_end(cycle_start)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the start date of the next cycle.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `cycle_start` - The start date of the current cycle
|
||||
- `interval` - The interval type
|
||||
|
||||
## Returns
|
||||
|
||||
The start date of the next cycle.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :monthly)
|
||||
~D[2024-02-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :quarterly)
|
||||
~D[2024-04-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :half_yearly)
|
||||
~D[2024-07-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly)
|
||||
~D[2025-01-01]
|
||||
"""
|
||||
@spec next_cycle_start(Date.t(), interval()) :: Date.t()
|
||||
def next_cycle_start(cycle_start, interval) do
|
||||
cycle_end = calculate_cycle_end(cycle_start, interval)
|
||||
next_date = Date.add(cycle_end, 1)
|
||||
calculate_cycle_start(next_date, interval)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the cycle contains the given date.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `cycle_start` - The start date of the cycle
|
||||
- `interval` - The interval type
|
||||
- `today` - The date to check (defaults to today's date)
|
||||
|
||||
## Returns
|
||||
|
||||
`true` if the given date is within the cycle, `false` otherwise.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15])
|
||||
true
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-02-01], :monthly, ~D[2024-03-15])
|
||||
false
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-01])
|
||||
true
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-31])
|
||||
true
|
||||
"""
|
||||
@spec current_cycle?(Date.t(), interval(), Date.t()) :: boolean()
|
||||
def current_cycle?(cycle_start, interval, today) do
|
||||
cycle_end = calculate_cycle_end(cycle_start, interval)
|
||||
|
||||
Date.compare(cycle_start, today) in [:lt, :eq] and
|
||||
Date.compare(today, cycle_end) in [:lt, :eq]
|
||||
end
|
||||
|
||||
@spec current_cycle?(Date.t(), interval()) :: boolean()
|
||||
def current_cycle?(cycle_start, interval) do
|
||||
current_cycle?(cycle_start, interval, Date.utc_today())
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the cycle is the last completed cycle.
|
||||
|
||||
A cycle is considered the last completed cycle if:
|
||||
- The cycle has ended (cycle_end < today)
|
||||
- The next cycle has not ended yet (today <= next_end)
|
||||
|
||||
In other words: `cycle_end < today <= next_end`
|
||||
|
||||
## Parameters
|
||||
|
||||
- `cycle_start` - The start date of the cycle
|
||||
- `interval` - The interval type
|
||||
- `today` - The date to check against (defaults to today's date)
|
||||
|
||||
## Returns
|
||||
|
||||
`true` if the cycle is the last completed cycle, `false` otherwise.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-04-01])
|
||||
true
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15])
|
||||
false
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-02-01], :monthly, ~D[2024-04-15])
|
||||
false
|
||||
"""
|
||||
@spec last_completed_cycle?(Date.t(), interval(), Date.t()) :: boolean()
|
||||
def last_completed_cycle?(cycle_start, interval, today) do
|
||||
cycle_end = calculate_cycle_end(cycle_start, interval)
|
||||
|
||||
# Cycle must have ended (cycle_end < today)
|
||||
case Date.compare(today, cycle_end) do
|
||||
:gt ->
|
||||
# Check if this is the most recent completed cycle
|
||||
# by verifying that the next cycle hasn't ended yet (today <= next_end)
|
||||
next_start = next_cycle_start(cycle_start, interval)
|
||||
next_end = calculate_cycle_end(next_start, interval)
|
||||
|
||||
Date.compare(today, next_end) in [:lt, :eq]
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@spec last_completed_cycle?(Date.t(), interval()) :: boolean()
|
||||
def last_completed_cycle?(cycle_start, interval) do
|
||||
last_completed_cycle?(cycle_start, interval, Date.utc_today())
|
||||
end
|
||||
|
||||
# Private helper functions
|
||||
|
||||
defp monthly_cycle_start(date) do
|
||||
Date.new!(date.year, date.month, 1)
|
||||
end
|
||||
|
||||
defp monthly_cycle_end(cycle_start) do
|
||||
Date.end_of_month(cycle_start)
|
||||
end
|
||||
|
||||
defp quarterly_cycle_start(date) do
|
||||
quarter_start_month =
|
||||
case date.month do
|
||||
m when m in [1, 2, 3] -> 1
|
||||
m when m in [4, 5, 6] -> 4
|
||||
m when m in [7, 8, 9] -> 7
|
||||
m when m in [10, 11, 12] -> 10
|
||||
end
|
||||
|
||||
Date.new!(date.year, quarter_start_month, 1)
|
||||
end
|
||||
|
||||
defp quarterly_cycle_end(cycle_start) do
|
||||
# Ensure cycle_start is aligned to quarter boundary
|
||||
# This handles cases where cycle_start might not be at the correct quarter start (e.g., month 12)
|
||||
aligned_start = quarterly_cycle_start(cycle_start)
|
||||
|
||||
case aligned_start.month do
|
||||
1 -> Date.new!(aligned_start.year, 3, 31)
|
||||
4 -> Date.new!(aligned_start.year, 6, 30)
|
||||
7 -> Date.new!(aligned_start.year, 9, 30)
|
||||
10 -> Date.new!(aligned_start.year, 12, 31)
|
||||
end
|
||||
end
|
||||
|
||||
defp half_yearly_cycle_start(date) do
|
||||
half_start_month = if date.month in 1..6, do: 1, else: 7
|
||||
Date.new!(date.year, half_start_month, 1)
|
||||
end
|
||||
|
||||
defp half_yearly_cycle_end(cycle_start) do
|
||||
# Ensure cycle_start is aligned to half-year boundary
|
||||
# This handles cases where cycle_start might not be at the correct half-year start (e.g., month 10)
|
||||
aligned_start = half_yearly_cycle_start(cycle_start)
|
||||
|
||||
case aligned_start.month do
|
||||
1 -> Date.new!(aligned_start.year, 6, 30)
|
||||
7 -> Date.new!(aligned_start.year, 12, 31)
|
||||
end
|
||||
end
|
||||
|
||||
defp yearly_cycle_start(date) do
|
||||
Date.new!(date.year, 1, 1)
|
||||
end
|
||||
|
||||
defp yearly_cycle_end(cycle_start) do
|
||||
Date.new!(cycle_start.year, 12, 31)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
defmodule Mv.MembershipFees.CycleGenerationJob do
|
||||
@moduledoc """
|
||||
Scheduled job for generating membership fee cycles.
|
||||
|
||||
This module provides a skeleton for scheduled cycle generation.
|
||||
In the future, this can be integrated with Oban or similar job processing libraries.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
Currently provides manual execution functions that can be called:
|
||||
- From IEx console for administrative tasks
|
||||
- From a cron job via a Mix task
|
||||
- From the admin UI (future)
|
||||
|
||||
## Future Oban Integration
|
||||
|
||||
When Oban is added to the project, this module can be converted to an Oban worker:
|
||||
|
||||
defmodule Mv.MembershipFees.CycleGenerationJob do
|
||||
use Oban.Worker,
|
||||
queue: :membership_fees,
|
||||
max_attempts: 3
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
Mv.MembershipFees.CycleGenerator.generate_cycles_for_all_members()
|
||||
end
|
||||
end
|
||||
|
||||
## Usage
|
||||
|
||||
# Manual execution from IEx
|
||||
Mv.MembershipFees.CycleGenerationJob.run()
|
||||
|
||||
# Check if cycles need to be generated
|
||||
Mv.MembershipFees.CycleGenerationJob.pending_members_count()
|
||||
|
||||
"""
|
||||
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Runs the cycle generation job for all active members.
|
||||
|
||||
This is the main entry point for scheduled execution.
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, results}` - Map with success/failed counts
|
||||
- `{:error, reason}` - Error with reason
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CycleGenerationJob.run()
|
||||
{:ok, %{success: 45, failed: 0, total: 45}}
|
||||
|
||||
"""
|
||||
@spec run() :: {:ok, map()} | {:error, term()}
|
||||
def run do
|
||||
Logger.info("Starting membership fee cycle generation job")
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
||||
result = CycleGenerator.generate_cycles_for_all_members()
|
||||
|
||||
elapsed = System.monotonic_time(:millisecond) - start_time
|
||||
|
||||
case result do
|
||||
{:ok, stats} ->
|
||||
Logger.info(
|
||||
"Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total"
|
||||
)
|
||||
|
||||
result
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Cycle generation failed: #{inspect(reason)}")
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Runs cycle generation with custom options.
|
||||
|
||||
## Options
|
||||
|
||||
- `:today` - Override today's date (useful for testing or catch-up)
|
||||
- `:batch_size` - Number of members to process in parallel
|
||||
|
||||
## Examples
|
||||
|
||||
# Generate cycles as if today was a specific date
|
||||
Mv.MembershipFees.CycleGenerationJob.run(today: ~D[2024-12-31])
|
||||
|
||||
# Process with smaller batch size
|
||||
Mv.MembershipFees.CycleGenerationJob.run(batch_size: 5)
|
||||
|
||||
"""
|
||||
@spec run(keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def run(opts) when is_list(opts) do
|
||||
Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}")
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
||||
result = CycleGenerator.generate_cycles_for_all_members(opts)
|
||||
|
||||
elapsed = System.monotonic_time(:millisecond) - start_time
|
||||
|
||||
case result do
|
||||
{:ok, stats} ->
|
||||
Logger.info(
|
||||
"Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total"
|
||||
)
|
||||
|
||||
result
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Cycle generation failed: #{inspect(reason)}")
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of members that need cycle generation.
|
||||
|
||||
A member needs cycle generation if:
|
||||
- Has a membership_fee_type assigned
|
||||
- Has a join_date set
|
||||
- Is active (no exit_date or exit_date >= today)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, count}` - Number of members needing generation
|
||||
- `{:error, reason}` - Error with reason
|
||||
|
||||
"""
|
||||
@spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, term()}
|
||||
def pending_members_count do
|
||||
today = Date.utc_today()
|
||||
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|
||||
|> Ash.Query.filter(not is_nil(join_date))
|
||||
|> Ash.Query.filter(is_nil(exit_date) or exit_date >= ^today)
|
||||
|
||||
case Ash.count(query) do
|
||||
{:ok, count} -> {:ok, count}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates cycles for a specific member by ID.
|
||||
|
||||
Useful for administrative tasks or manual corrections.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `member_id` - The UUID of the member
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, cycles}` - List of newly created cycles
|
||||
- `{:error, reason}` - Error with reason
|
||||
|
||||
"""
|
||||
@spec run_for_member(String.t()) :: {:ok, [map()]} | {:error, term()}
|
||||
def run_for_member(member_id) when is_binary(member_id) do
|
||||
Logger.info("Generating cycles for member #{member_id}")
|
||||
CycleGenerator.generate_cycles_for_member(member_id)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,487 +0,0 @@
|
|||
defmodule Mv.MembershipFees.CycleGenerator do
|
||||
@moduledoc """
|
||||
Module for generating membership fee cycles for members.
|
||||
|
||||
This module provides functions to automatically generate membership fee cycles
|
||||
based on a member's fee type, start date, and exit date.
|
||||
|
||||
## Algorithm
|
||||
|
||||
1. Load member with relationships (membership_fee_type, membership_fee_cycles)
|
||||
2. Determine the generation start point:
|
||||
- If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
|
||||
- If cycles exist: Start from the cycle AFTER the last existing one
|
||||
3. Generate all cycle starts from the determined start point to today (or `exit_date`)
|
||||
4. Create new cycles with the current amount from `membership_fee_type`
|
||||
|
||||
## Important: Gap Handling
|
||||
|
||||
**Gaps are NOT filled.** If a cycle was explicitly deleted (e.g., 2023 was deleted
|
||||
but 2022 and 2024 exist), the generator will NOT recreate the deleted cycle.
|
||||
It always continues from the LAST existing cycle, regardless of any gaps.
|
||||
|
||||
This behavior ensures that manually deleted cycles remain deleted and prevents
|
||||
unwanted automatic recreation of intentionally removed cycles.
|
||||
|
||||
## Concurrency
|
||||
|
||||
Uses PostgreSQL advisory locks to prevent race conditions when generating
|
||||
cycles for the same member concurrently.
|
||||
|
||||
## Authorization
|
||||
|
||||
This module runs systemically and accepts optional actor parameters.
|
||||
When called from hooks/changes, actor is extracted from changeset context.
|
||||
When called directly, actor should be provided for proper authorization.
|
||||
|
||||
All functions accept an optional `actor` parameter in the `opts` keyword list
|
||||
that is passed to Ash operations to ensure proper authorization checks are performed.
|
||||
|
||||
## Examples
|
||||
|
||||
# Generate cycles for a single member
|
||||
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member)
|
||||
|
||||
# Generate cycles for all active members
|
||||
{:ok, results} = CycleGenerator.generate_cycles_for_all_members()
|
||||
|
||||
"""
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.Repo
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@type generate_result ::
|
||||
{:ok, [MembershipFeeCycle.t()], [Ash.Notifier.Notification.t()]} | {:error, term()}
|
||||
|
||||
@doc """
|
||||
Generates membership fee cycles for a single member.
|
||||
|
||||
Uses an advisory lock to prevent concurrent generation for the same member.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `member` - The member struct or member ID
|
||||
- `opts` - Options:
|
||||
- `:today` - Override today's date (useful for testing)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, cycles, notifications}` - List of newly created cycles and notifications
|
||||
- `{:error, reason}` - Error with reason
|
||||
|
||||
## Examples
|
||||
|
||||
{:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member)
|
||||
{:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member_id)
|
||||
{:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member, today: ~D[2024-12-31])
|
||||
|
||||
"""
|
||||
@spec generate_cycles_for_member(Member.t() | String.t(), keyword()) :: generate_result()
|
||||
def generate_cycles_for_member(member_or_id, opts \\ [])
|
||||
|
||||
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
case load_member(member_id, actor: actor) do
|
||||
{:ok, member} -> generate_cycles_for_member(member, opts)
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def generate_cycles_for_member(%Member{} = member, opts) do
|
||||
today = Keyword.get(opts, :today, Date.utc_today())
|
||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||
|
||||
do_generate_cycles_with_lock(member, today, skip_lock?, opts)
|
||||
end
|
||||
|
||||
# Generate cycles with lock handling
|
||||
# Returns {:ok, cycles, notifications} - notifications are never sent here,
|
||||
# they should be returned to the caller (e.g., via after_action hook)
|
||||
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
|
||||
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
|
||||
# Just generate cycles without additional locking
|
||||
actor = Keyword.get(opts, :actor)
|
||||
do_generate_cycles(member, today, actor: actor)
|
||||
end
|
||||
|
||||
defp do_generate_cycles_with_lock(member, today, false, opts) do
|
||||
lock_key = :erlang.phash2(member.id)
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
|
||||
case do_generate_cycles(member, today, actor: actor) do
|
||||
{:ok, cycles, notifications} ->
|
||||
# Return cycles and notifications - do NOT send notifications here
|
||||
# They will be sent by the caller (e.g., via after_action hook)
|
||||
{cycles, notifications}
|
||||
|
||||
{:error, reason} ->
|
||||
Repo.rollback(reason)
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:ok, {cycles, notifications}} -> {:ok, cycles, notifications}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates membership fee cycles for all members with a fee type assigned.
|
||||
|
||||
This includes both active and inactive (left) members. Inactive members
|
||||
will have cycles generated up to their exit_date if they don't have cycles
|
||||
for that period yet. This allows for catch-up generation of missing cycles.
|
||||
|
||||
Members processed are those who:
|
||||
- Have a membership_fee_type assigned
|
||||
- Have a join_date set
|
||||
|
||||
The exit_date boundary is respected during generation (not in the query),
|
||||
so inactive members will get cycles up to their exit date.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `opts` - Options:
|
||||
- `:today` - Override today's date (useful for testing)
|
||||
- `:batch_size` - Number of members to process in parallel (default: 10)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, results}` - Map with :success and :failed counts
|
||||
- `{:error, reason}` - Error with reason
|
||||
|
||||
"""
|
||||
@spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def generate_cycles_for_all_members(opts \\ []) do
|
||||
today = Keyword.get(opts, :today, Date.utc_today())
|
||||
batch_size = Keyword.get(opts, :batch_size, 10)
|
||||
|
||||
# Query ALL members with fee type assigned (including inactive/left members)
|
||||
# The exit_date boundary is applied during cycle generation, not here.
|
||||
# This allows catch-up generation for members who left but are missing cycles.
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|
||||
|> Ash.Query.filter(not is_nil(join_date))
|
||||
|
||||
case Ash.read(query) do
|
||||
{:ok, members} ->
|
||||
results = process_members_in_batches(members, batch_size, today)
|
||||
{:ok, build_results_summary(results)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp process_members_in_batches(members, batch_size, today) do
|
||||
members
|
||||
|> Enum.chunk_every(batch_size)
|
||||
|> Enum.flat_map(&process_batch(&1, today))
|
||||
end
|
||||
|
||||
defp process_batch(batch, today) do
|
||||
batch
|
||||
|> Task.async_stream(fn member ->
|
||||
process_member_cycle_generation(member, today)
|
||||
end)
|
||||
|> Enum.map(fn
|
||||
{:ok, result} ->
|
||||
result
|
||||
|
||||
{:exit, reason} ->
|
||||
# Task crashed - log and return error tuple
|
||||
Logger.error("Task crashed during cycle generation: #{inspect(reason)}")
|
||||
{nil, {:error, {:task_exit, reason}}}
|
||||
end)
|
||||
end
|
||||
|
||||
# Process cycle generation for a single member in batch job
|
||||
# Returns {member_id, result} tuple where result is {:ok, cycles, notifications} or {:error, reason}
|
||||
defp process_member_cycle_generation(member, today) do
|
||||
case generate_cycles_for_member(member, today: today) do
|
||||
{:ok, _cycles, notifications} = ok ->
|
||||
send_notifications_for_batch_job(notifications)
|
||||
{member.id, ok}
|
||||
|
||||
{:error, _reason} = err ->
|
||||
{member.id, err}
|
||||
end
|
||||
end
|
||||
|
||||
# Send notifications for batch job
|
||||
# This is a top-level job, so we need to send notifications explicitly
|
||||
defp send_notifications_for_batch_job(notifications) do
|
||||
if Enum.any?(notifications) do
|
||||
Ash.Notifier.notify(notifications)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_results_summary(results) do
|
||||
success_count = Enum.count(results, fn {_id, result} -> match?({:ok, _, _}, result) end)
|
||||
failed_count = Enum.count(results, fn {_id, result} -> match?({:error, _}, result) end)
|
||||
|
||||
%{success: success_count, failed: failed_count, total: length(results)}
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp load_member(member_id, opts) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.filter(id == ^member_id)
|
||||
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
|
||||
|
||||
result =
|
||||
if actor do
|
||||
Ash.read_one(query, actor: actor)
|
||||
else
|
||||
Ash.read_one(query)
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, nil} -> {:error, :member_not_found}
|
||||
{:ok, member} -> {:ok, member}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_generate_cycles(member, today, opts) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
# Reload member with relationships to ensure fresh data
|
||||
case load_member(member.id, actor: actor) do
|
||||
{:ok, member} ->
|
||||
cond do
|
||||
is_nil(member.membership_fee_type_id) ->
|
||||
{:error, :no_membership_fee_type}
|
||||
|
||||
is_nil(member.join_date) ->
|
||||
{:error, :no_join_date}
|
||||
|
||||
true ->
|
||||
generate_missing_cycles(member, today, actor: actor)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_missing_cycles(member, today, opts) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
fee_type = member.membership_fee_type
|
||||
interval = fee_type.interval
|
||||
amount = fee_type.amount
|
||||
existing_cycles = member.membership_fee_cycles || []
|
||||
|
||||
# Determine start point based on existing cycles
|
||||
# Note: We do NOT fill gaps - only generate from the last existing cycle onwards
|
||||
start_date = determine_generation_start(member, existing_cycles, interval)
|
||||
|
||||
# Determine end date (today or exit_date, whichever is earlier)
|
||||
end_date = determine_end_date(member, today)
|
||||
|
||||
# Only generate if start_date <= end_date
|
||||
if start_date && Date.compare(start_date, end_date) != :gt do
|
||||
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
|
||||
create_cycles(cycle_starts, member.id, fee_type.id, amount, actor: actor)
|
||||
else
|
||||
{:ok, [], []}
|
||||
end
|
||||
end
|
||||
|
||||
# No existing cycles: start from membership_fee_start_date
|
||||
defp determine_generation_start(member, [], interval) do
|
||||
determine_start_date(member, interval)
|
||||
end
|
||||
|
||||
# Has existing cycles: start from the cycle AFTER the last one
|
||||
# This ensures gaps (deleted cycles) are NOT filled
|
||||
defp determine_generation_start(_member, existing_cycles, interval) do
|
||||
last_cycle_start =
|
||||
existing_cycles
|
||||
|> Enum.map(& &1.cycle_start)
|
||||
|> Enum.max(Date)
|
||||
|
||||
CalendarCycles.next_cycle_start(last_cycle_start, interval)
|
||||
end
|
||||
|
||||
defp determine_start_date(member, interval) do
|
||||
if member.membership_fee_start_date do
|
||||
member.membership_fee_start_date
|
||||
else
|
||||
# Calculate from join_date using global settings
|
||||
include_joining_cycle = get_include_joining_cycle()
|
||||
|
||||
SetMembershipFeeStartDate.calculate_start_date(
|
||||
member.join_date,
|
||||
interval,
|
||||
include_joining_cycle
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp determine_end_date(member, today) do
|
||||
if member.exit_date && Date.compare(member.exit_date, today) == :lt do
|
||||
# Member has left - use the exit date as boundary
|
||||
# Note: If exit_date == cycle_start, the cycle IS still generated.
|
||||
# This means the member is considered a member on the first day of that cycle.
|
||||
# Example: exit_date = 2025-01-01, yearly interval
|
||||
# -> The 2025 cycle (starting 2025-01-01) WILL be generated
|
||||
member.exit_date
|
||||
else
|
||||
today
|
||||
end
|
||||
end
|
||||
|
||||
defp get_include_joining_cycle do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, %{include_joining_cycle: include}} -> include
|
||||
{:error, _} -> true
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates all cycle start dates from a start date to an end date.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `start_date` - The first cycle start date
|
||||
- `end_date` - The date up to which cycles should be generated
|
||||
- `interval` - The billing interval
|
||||
|
||||
## Returns
|
||||
|
||||
List of cycle start dates.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> generate_cycle_starts(~D[2024-01-01], ~D[2024-12-31], :quarterly)
|
||||
[~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01], ~D[2024-10-01]]
|
||||
|
||||
"""
|
||||
@spec generate_cycle_starts(Date.t(), Date.t(), atom()) :: [Date.t()]
|
||||
def generate_cycle_starts(start_date, end_date, interval) do
|
||||
# Ensure start_date is aligned to cycle boundary
|
||||
aligned_start = CalendarCycles.calculate_cycle_start(start_date, interval)
|
||||
|
||||
generate_cycle_starts_acc(aligned_start, end_date, interval, [])
|
||||
end
|
||||
|
||||
defp generate_cycle_starts_acc(current_start, end_date, interval, acc) do
|
||||
if Date.compare(current_start, end_date) == :gt do
|
||||
# Current cycle start is after end date - stop
|
||||
Enum.reverse(acc)
|
||||
else
|
||||
# Include this cycle and continue to next
|
||||
next_start = CalendarCycles.next_cycle_start(current_start, interval)
|
||||
generate_cycle_starts_acc(next_start, end_date, interval, [current_start | acc])
|
||||
end
|
||||
end
|
||||
|
||||
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
# Always use return_notifications?: true to collect notifications
|
||||
# Notifications will be returned to the caller, who is responsible for
|
||||
# sending them (e.g., via after_action hook returning {:ok, result, notifications})
|
||||
results =
|
||||
Enum.map(cycle_starts, fn cycle_start ->
|
||||
attrs = %{
|
||||
cycle_start: cycle_start,
|
||||
member_id: member_id,
|
||||
membership_fee_type_id: fee_type_id,
|
||||
amount: amount,
|
||||
status: :unpaid
|
||||
}
|
||||
|
||||
handle_cycle_creation_result(
|
||||
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true, actor: actor),
|
||||
cycle_start
|
||||
)
|
||||
end)
|
||||
|
||||
{successes, skips, errors} =
|
||||
Enum.reduce(results, {[], [], []}, fn
|
||||
{:ok, cycle, notifications}, {successes, skips, errors} ->
|
||||
{[{:ok, cycle, notifications} | successes], skips, errors}
|
||||
|
||||
{:skip, cycle_start}, {successes, skips, errors} ->
|
||||
{successes, [cycle_start | skips], errors}
|
||||
|
||||
{:error, error}, {successes, skips, errors} ->
|
||||
{successes, skips, [error | errors]}
|
||||
end)
|
||||
|
||||
all_notifications =
|
||||
Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end)
|
||||
|
||||
if Enum.empty?(errors) do
|
||||
successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end)
|
||||
|
||||
if Enum.any?(skips) do
|
||||
Logger.debug("Skipped #{length(skips)} cycles that already exist for member #{member_id}")
|
||||
end
|
||||
|
||||
{:ok, successful_cycles, all_notifications}
|
||||
else
|
||||
Logger.warning("Some cycles failed to create: #{inspect(errors)}")
|
||||
# Return partial failure with errors
|
||||
# Note: When this error occurs, the transaction will be rolled back,
|
||||
# so no cycles were actually persisted in the database
|
||||
{:error, {:partial_failure, errors}}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_cycle_creation_result({:ok, cycle, notifications}, _cycle_start)
|
||||
when is_list(notifications) do
|
||||
{:ok, cycle, notifications}
|
||||
end
|
||||
|
||||
defp handle_cycle_creation_result({:ok, cycle}, _cycle_start) do
|
||||
{:ok, cycle, []}
|
||||
end
|
||||
|
||||
defp handle_cycle_creation_result(
|
||||
{:error,
|
||||
%Ash.Error.Invalid{
|
||||
errors: [
|
||||
%Ash.Error.Changes.InvalidAttribute{
|
||||
private_vars: %{constraint: constraint, constraint_type: :unique}
|
||||
}
|
||||
]
|
||||
}} = error,
|
||||
cycle_start
|
||||
) do
|
||||
# Cycle already exists (unique constraint violation) - skip it silently
|
||||
# This makes the function idempotent and prevents errors on server restart
|
||||
handle_unique_constraint_violation(constraint, cycle_start, error)
|
||||
end
|
||||
|
||||
defp handle_cycle_creation_result({:error, reason}, cycle_start) do
|
||||
{:error, {cycle_start, reason}}
|
||||
end
|
||||
|
||||
defp handle_unique_constraint_violation(
|
||||
"membership_fee_cycles_unique_cycle_per_member_index",
|
||||
cycle_start,
|
||||
_error
|
||||
) do
|
||||
{:skip, cycle_start}
|
||||
end
|
||||
|
||||
defp handle_unique_constraint_violation(_constraint, cycle_start, error) do
|
||||
{:error, {cycle_start, error}}
|
||||
end
|
||||
end
|
||||
|
|
@ -5,7 +5,7 @@ defmodule Mv.Repo do
|
|||
@impl true
|
||||
def installed_extensions do
|
||||
# Add extensions here, and the migration generator will install them.
|
||||
["ash-functions", "citext", "pg_trgm"]
|
||||
["ash-functions"]
|
||||
end
|
||||
|
||||
# Don't open unnecessary transactions
|
||||
|
|
|
|||
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