Compare commits

..

29 commits

Author SHA1 Message Date
Renovate Bot
8429fb2b9c chore(deps): update mix dependencies to v1
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-03 00:06:45 +00:00
d51dcb1ac3 chore(ci): make test workflow faster with test --stale
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-02 23:35:39 +02:00
1ef6ea502e Merge pull request 'Add dialyzer and resolve all findings closes #503 #504 #514' (#516) from issue/mitgliederverwaltung-514 into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #516
2026-06-02 13:15:00 +02:00
9a14cedc14 fix(repo): define all_tenants/0 as empty for non-multitenant schema
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
2026-06-02 12:26:35 +02:00
b5756d8e00 refactor(vereinfacht): gate retry skipping on runtime sandbox flag
The compile-time Mix.env() comparison folded to an always-false literal under analysis. sql_sandbox?/0 reads runtime config (true only in test) and works in releases where Mix is unavailable, preserving the fast-fail-no-retry behavior in tests.
2026-06-02 12:23:04 +02:00
a7ad608051 fix(auth): redirect a live-view socket in the user-required guard
LiveSession.assign_new_resources/2 is typed to return a Phoenix.Socket, which made the on_mount redirect type-incompatible. The authenticated-routes live_session already assigns current_user, so the guard reads it from socket.assigns directly. Also assign the locale into the socket actually used by the no-user redirect instead of discarding it.
2026-06-02 12:19:21 +02:00
6a4a99f638 refactor(types): drop guards and clauses that can never succeed 2026-06-02 12:11:59 +02:00
ec6422d450 fix(membership-fees): show error for unparseable cycle date instead of crashing
Date.from_iso8601/1 returns {:error, reason}, so the with else clause matching a bare :error never fired and an invalid date raised a WithClauseError. Match the real date/calendar error reasons so the user sees the validation message.
2026-06-02 12:08:19 +02:00
2db467d5d1 fix(pdf-export): match DateTime.from_iso8601 three-tuple when formatting cells
DateTime.from_iso8601/1 returns {:ok, datetime, offset}, so the two-tuple clauses never matched and datetime cells fell through to the naive-parse fallback. Matching the real shape routes them through the intended DateTime path; UTC values render identically.
2026-06-02 12:04:37 +02:00
c41d24113f fix(import): return readable string for unreadable upload errors
File.read/1 only yields posix atoms, so the File.Error and bare-reason branches were unreachable, and :file.format_error/1 returns a charlist rather than a String. Normalize the error to a binary so it interpolates correctly in flash messages.
2026-06-02 12:00:38 +02:00
05f66ccf74 refactor(types): remove dead catch-all clauses unreachable per success typing 2026-06-02 11:56:44 +02:00
d9a5a081df refactor(import): drop unreachable CSV error-formatting path
consume_and_read_csv/2 and MemberCSV.prepare/2 only ever return {:error, binary()}, so the non-binary error branch and the format_error_message/* helpers it called were unreachable. Removed them and bound the remaining discarded locale/dispatch results.
2026-06-02 11:50:43 +02:00
c0395f16e8 fix(types): resolve unknown type references in member and authorization specs 2026-06-02 11:46:54 +02:00
848f0cd013 refactor(types): bind intentionally discarded side-effecting results 2026-06-02 11:42:57 +02:00
04ab05f556 fix(member-export): forbid request without actor instead of falling through
The nil-actor guard used a one-armed if and continued into the export path regardless. The CheckPagePermission plug already halts unauthenticated requests before this controller runs, so the corrected early return preserves observable behavior while removing the dead fall-through. The export action is split into per-payload clauses so the guard reads as a flat early return.
2026-06-02 11:39:04 +02:00
5352a635c6 refactor(release): bind discarded results of side-effecting release tasks 2026-06-02 11:33:14 +02:00
fd8e6ac178 refactor(types): reconcile @specs with their success typings 2026-06-02 11:25:03 +02:00
263857ee26 feat(dialyzer): add typecheck stage to full CI pipelines
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is failing
2026-06-01 23:45:07 +02:00
ce57d046b9 ci(drone): run full test suite on main, tags and promote
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2026-06-01 22:59:05 +02:00
35b884e6e1 Merge pull request 'Fix OIDC Loop and seed rauthy dev setup closes #510' (#513) from rauthy_setup into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #513
2026-06-01 20:39:07 +02:00
a27425b5fb fix(auth): replace sign-out page with accessible custom LiveView
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-06-01 20:06:32 +02:00
ba66bc15db fix(auth): trigger RP-initiated logout at OIDC provider 2026-06-01 19:59:52 +02:00
22955bdd9e feat(rauthy): auto-seed mv OIDC client via bootstrap dir 2026-06-01 19:06:14 +02:00
c6578662d8 Merge pull request 'Add filter for date fields closes #340' (#497) from issue/mitgliederverwaltung-340 into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #497
2026-06-01 14:42:13 +02:00
d36703450a
chore(ci): suppress cowlib advisory
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-06-01 14:05:46 +02:00
d6671daf1a feat(member-filter): add date filter sections with active-count badge and reset support
Some checks failed
continuous-integration/drone/push Build is failing
2026-05-20 16:32:29 +02:00
e3295ab4b5 feat(member-live): wire date filters into LiveView lifecycle 2026-05-20 16:28:17 +02:00
ddd4a9a878 feat(date-filter): introduce DateFilter module with URL codec and Ash query expressions 2026-05-20 16:24:08 +02:00
143c0c5c24 chore(deps): suppress cowlib advisory and bump bandit, cowboy, plug 2026-05-20 16:16:27 +02:00
86 changed files with 3948 additions and 753 deletions

9
.deps_audit_ignore Normal file
View file

@ -0,0 +1,9 @@
# Temporarily ignored security advisories
#
# Format: one GHSA ID per line.
# Remove an entry once a patched version is available and the dependency is updated.
# cowlib >= 2.9.0 <= 2.16.1 — Cookie Request Header Injection via cow_cookie:cookie/1
# Severity: low. No patched version available as of 2026-05-20.
# Tracked upstream: https://github.com/advisories/GHSA-g2wm-735q-3f56
GHSA-g2wm-735q-3f56

11
.dialyzer_ignore.exs Normal file
View file

@ -0,0 +1,11 @@
# Dialyzer ignore list.
#
# This file is for PROVEN false positives only. Each entry must carry a
# `# why:` comment explaining why Dialyzer is wrong about the call site.
# Real findings get fixed by adjusting @spec, return types, or pattern
# matches — never silenced here.
#
# Format: each entry is either a path string, a {path, warning} tuple,
# or a {path, warning, line} tuple. See:
# https://hexdocs.pm/dialyxir/readme.html#elixir-format
[]

184
.drone.jsonnet Normal file
View file

@ -0,0 +1,184 @@
local elixir = 'docker.io/library/elixir:1.18.3-otp-27';
local postgres_image = 'docker.io/library/postgres:18.3';
local pg_service = {
name: 'postgres',
image: postgres_image,
environment: {
POSTGRES_USER: 'postgres',
POSTGRES_PASSWORD: 'postgres',
},
};
local cache_volume = { name: 'cache', host: { path: '/tmp/drone_cache' } };
local cache_mount = [{ name: 'cache', path: '/cache' }];
local step_compute_cache = {
name: 'compute cache key',
image: elixir,
commands: [
"mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)",
'echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key',
// Print cache key for debugging
'cat .cache_key',
],
};
local step_restore_cache = {
name: 'restore-cache',
image: 'drillster/drone-volume-cache',
settings: { restore: true, mount: ['./deps', './_build', './priv/plts'], ttl: 30 },
volumes: cache_mount,
};
local step_lint = {
name: 'lint',
image: elixir,
commands: [
'mix local.hex --force', // Install hex package manager
'mix deps.get', // Fetch dependencies
'mix compile --warnings-as-errors', // Check for compilation errors & warnings
'mix format --check-formatted', // Check formatting
'mix sobelow --config', // Security checks
'mix deps.audit --ignore-file .deps_audit_ignore', // Known vulnerabilities
'mix hex.audit', // Unmaintained dependencies
'mix credo --strict', // Code quality hints
'mix gettext.extract --check-up-to-date', // Translations up to date
],
};
local step_typecheck = {
name: 'typecheck',
image: elixir,
commands: [
'mix local.hex --force',
'mix deps.get',
'mkdir -p priv/plts',
// Build/refresh PLT no-op on cache hit, full build (5-15 min) on cache miss.
'mix dialyzer --plt',
// Actual typecheck. --format short keeps log noise down on red builds.
'mix dialyzer --format short',
],
};
local step_wait_postgres = {
name: 'wait_for_postgres',
image: postgres_image,
commands: [
|||
for i in {1..20}; do
if pg_isready -h postgres -U postgres; then
exit 0
else
true
fi
sleep 2
done
echo "Postgres did not become available, aborting."
exit 1
|||,
],
};
local step_rebuild_cache = {
name: 'rebuild-cache',
image: 'drillster/drone-volume-cache',
settings: { rebuild: true, mount: ['./deps', './_build', './priv/plts'] },
volumes: cache_mount,
};
// test_cmd is the only thing that differs between the fast and full suites.
local test_step(name, test_cmd) = {
name: name,
image: elixir,
environment: {
MIX_ENV: 'test',
TEST_POSTGRES_HOST: 'postgres',
TEST_POSTGRES_PORT: '5432',
},
commands: ['mix local.hex --force', 'mix deps.get', test_cmd],
};
local test_fast = test_step('test-fast', 'mix test --exclude slow --exclude ui --max-cases 2');
local test_all = test_step('test-all', 'mix test');
// A full check pipeline: identical steps, only name + trigger + test step vary.
local check_pipeline(name, trigger, test) = {
kind: 'pipeline',
type: 'docker',
name: name,
services: [pg_service],
trigger: trigger,
steps: [
step_compute_cache,
step_restore_cache,
step_lint,
] + (if test.name == 'test-all' then [step_typecheck] else []) + [
step_wait_postgres,
test,
step_rebuild_cache,
],
volumes: [cache_volume],
};
local docker_publish(name, extra_settings, trigger_event, deps) = {
kind: 'pipeline',
type: 'docker',
name: name,
trigger: trigger_event,
steps: [{
name: 'build-and-publish-container' + (if name == 'build-and-publish' then '-branch' else ''),
image: 'plugins/docker',
settings: {
registry: 'git.local-it.org',
repo: 'git.local-it.org/local-it/mitgliederverwaltung',
username: { from_secret: 'DRONE_REGISTRY_USERNAME' },
password: { from_secret: 'DRONE_REGISTRY_TOKEN' },
} + extra_settings,
when: trigger_event,
}],
depends_on: deps,
};
[
check_pipeline('check-fast', { branch: { exclude: ['main'] }, event: ['push'] }, test_fast),
check_pipeline('check-full', { branch: ['main'], event: ['push'] }, test_all),
check_pipeline('check-full-promote', { event: ['promote'], target: ['production'] }, test_all),
check_pipeline('check-full-tag', { event: ['tag'] }, test_all),
docker_publish(
'build-and-publish',
{ tags: ['latest', '${DRONE_COMMIT_SHA:0:8}'] },
{ branch: ['main'], event: ['push'] },
['check-full'],
),
docker_publish(
'build-and-release',
{ auto_tag: true },
{ event: ['tag'] },
['check-full-tag'],
),
{
kind: 'pipeline',
type: 'docker',
name: 'renovate',
trigger: { event: ['cron', 'custom'], branch: ['main'] },
environment: { LOG_LEVEL: 'debug' },
steps: [{
name: 'renovate',
image: 'renovate/renovate:43.165',
environment: {
RENOVATE_CONFIG_FILE: 'renovate_backend_config.js',
RENOVATE_TOKEN: { from_secret: 'RENOVATE_TOKEN' },
GITHUB_COM_TOKEN: { from_secret: 'GITHUB_COM_TOKEN' },
},
commands: [
// https://github.com/renovatebot/renovate/discussions/15049
'unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL',
'renovate-config-validator',
'renovate',
],
}],
},
]

View file

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

View file

@ -24,7 +24,7 @@ ASSOCIATION_NAME="Sportsclub XYZ"
# OIDC_CLIENT_ID=mv # OIDC_CLIENT_ID=mv
# OIDC_BASE_URL=http://localhost:8080/auth/v1 # OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/oidc/callback # OIDC_REDIRECT_URI=http://localhost:4001/auth/user/oidc/callback
# OIDC_CLIENT_SECRET=your-oidc-client-secret # OIDC_CLIENT_SECRET=mv-dev-shared-secret-not-for-production-do-not-use-anywhere-else
# Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope) # Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope)
# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in. # If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in.

4
.gitignore vendored
View file

@ -49,3 +49,7 @@ notes.md
# Do NOT commit these — they are local to the dev machine # Do NOT commit these — they are local to the dev machine
.pipeline/ .pipeline/
.claude/ .claude/
# Dialyzer PLT files — built locally and in CI cache, never tracked.
/priv/plts/*.plt
/priv/plts/*.plt.hash

View file

@ -29,7 +29,27 @@ seed-database:
start-database: start-database:
docker compose up -d docker compose up -d
ci-dev: lint audit test-fast # Full check suite: lint + audit + the fast tests (slow/ui excluded). No Dialyzer.
ci-dev: install-dependencies lint audit test-fast
# Fast pre-commit check: lint + sobelow + only the affected tests (mix test --stale)
# with reduced property runs. Run the full `ci-dev` before pushing.
check: install-dependencies lint sobelow test-stale
# Build the Dialyzer PLT. Idempotent — no-op once the PLT is up to date.
# First build takes 515 min; subsequent runs are seconds. PLT files live in
# priv/plts/ and are gitignored.
plt: install-dependencies
@mkdir -p priv/plts
mix dialyzer --plt
# Typecheck via Dialyzer. Slow stage, NOT part of ci-dev.
typecheck: plt
mix dialyzer --format short
# Full CI: inner loop plus typecheck. Use locally before pushing; Drone CI
# runs equivalent steps with PLT caching.
ci: ci-dev typecheck
gettext: gettext:
mix gettext.extract mix gettext.extract
@ -43,19 +63,28 @@ lint:
@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' @bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
mix gettext.extract --check-up-to-date mix gettext.extract --check-up-to-date
audit: # Static security scan (Sobelow).
sobelow:
mix sobelow --config mix sobelow --config
mix deps.audit
# Full security audit: Sobelow + dependency advisory scans.
audit: sobelow
mix deps.audit --ignore-file .deps_audit_ignore
mix hex.audit mix hex.audit
# Run all tests # Run all tests. No install-dependencies prerequisite so single-file runs stay
test *args: install-dependencies # fast; run `just install-dependencies` once on a fresh checkout.
test *args:
mix test {{args}} mix test {{args}}
# Run only fast tests (excludes slow/performance and UI tests) # Fast tests only (excludes slow/performance and UI tests).
test-fast *args: install-dependencies test-fast *args:
mix test --exclude slow --exclude ui {{args}} mix test --exclude slow --exclude ui {{args}}
# Affected fast tests only (mix test --stale) with reduced property runs.
test-stale *args:
PROPERTY_RUNS=25 mix test --stale --exclude slow --exclude ui {{args}}
# Run only UI tests # Run only UI tests
ui *args: install-dependencies ui *args: install-dependencies
mix test --only ui {{args}} mix test --only ui {{args}}

View file

@ -124,8 +124,8 @@ mix archive.install hex phx_new
1. Copy env file: 1. Copy env file:
```bash ```bash
cp .env.example .env cp .env.example .env
# Set OIDC_CLIENT_SECRET inside .env
``` ```
The dev `OIDC_CLIENT_SECRET` is already preset — no manual GUI step needed.
2. Start everything (database, Mailcrab, Rauthy, app): 2. Start everything (database, Mailcrab, Rauthy, app):
```bash ```bash
@ -139,21 +139,9 @@ mix archive.install hex phx_new
## 🔐 Testing SSO locally ## 🔐 Testing SSO locally
Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance is provided. A local **Rauthy** instance is provided in dev. The `mv` client is auto-seeded from `rauthy-bootstrap/clients.json` on first start (and after `docker compose down -v`), so the secret in `.env.example` always matches.
1. `just run` Rauthy admin UI: <http://localhost:8080> — login `admin@localhost`, password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in `docker-compose.yml`.
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/oidc/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.) ### OIDC with other providers (Authentik, Keycloak, etc.)

View file

@ -62,3 +62,7 @@ config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2
# Ash: silence "after_transaction hooks in surrounding transaction" warning when using # Ash: silence "after_transaction hooks in surrounding transaction" warning when using
# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected). # Ecto sandbox (tests run in a transaction; create_member after_transaction is expected).
config :ash, warn_on_transaction_hooks?: false config :ash, warn_on_transaction_hooks?: false
# StreamData property tests: generated cases per property, overridable via PROPERTY_RUNS
# (the `just check` recipe sets it low for speed; default 100 otherwise).
config :stream_data, max_runs: String.to_integer(System.get_env("PROPERTY_RUNS") || "100")

View file

@ -36,6 +36,9 @@ services:
- BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345 - BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345
# Disable strict IP validation to allow access from multiple Docker networks # Disable strict IP validation to allow access from multiple Docker networks
- SESSION_VALIDATE_IP=false - SESSION_VALIDATE_IP=false
# Auto-seed the `mv` OIDC client (id + plain secret) on first DB init.
# Re-runs after `docker compose down -v` because the DB is empty again.
- BOOTSTRAP_DIR=/app/bootstrap
ports: ports:
- "8080:8080" - "8080:8080"
depends_on: depends_on:
@ -46,6 +49,7 @@ services:
- local - local
volumes: volumes:
- rauthy-data:/app/data - rauthy-data:/app/data
- ./rauthy-bootstrap:/app/bootstrap:ro
volumes: volumes:
postgres-data: postgres-data:

View file

@ -17,16 +17,10 @@ defmodule Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist do
form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{} form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{}
allowlist_ids = allowlist_ids =
case Membership.get_join_form_allowlist() do Membership.get_join_form_allowlist()
list when is_list(list) -> |> Enum.map(fn item -> item.id end)
list |> MapSet.new()
|> Enum.map(fn item -> item.id end) |> MapSet.difference(MapSet.new(@typed_fields))
|> MapSet.new()
|> MapSet.difference(MapSet.new(@typed_fields))
_ ->
MapSet.new()
end
filtered = filtered =
form_data form_data

View file

@ -51,6 +51,9 @@ defmodule Mv.Membership.Member do
require Logger require Logger
@typedoc "An `Mv.Membership.Member` resource record."
@type t :: %__MODULE__{}
# Module constants # Module constants
@member_search_limit 10 @member_search_limit 10
@ -791,7 +794,7 @@ defmodule Mv.Membership.Member do
# nil/[] when membership_fee_type is missing. # nil/[] when membership_fee_type is missing.
@doc false @doc false
@spec get_current_cycle(Member.t()) :: MembershipFeeCycle.t() | nil @spec get_current_cycle(t()) :: MembershipFeeCycle.t() | nil
def get_current_cycle(member) do def get_current_cycle(member) do
today = Date.utc_today() today = Date.utc_today()
@ -821,7 +824,7 @@ defmodule Mv.Membership.Member do
end end
@doc false @doc false
@spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil @spec get_last_completed_cycle(t()) :: MembershipFeeCycle.t() | nil
def get_last_completed_cycle(member) do def get_last_completed_cycle(member) do
today = Date.utc_today() today = Date.utc_today()
@ -867,7 +870,7 @@ defmodule Mv.Membership.Member do
end end
@doc false @doc false
@spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()] @spec get_overdue_cycles(t()) :: [MembershipFeeCycle.t()]
def get_overdue_cycles(member) do def get_overdue_cycles(member) do
today = Date.utc_today() today = Date.utc_today()
@ -939,7 +942,7 @@ defmodule Mv.Membership.Member do
# Already in transaction: use advisory lock directly # Already in transaction: use advisory lock directly
# Returns {:ok, notifications} - notifications should be returned to after_action hook # Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles_in_transaction(member, today, lock_key) do defp regenerate_cycles_in_transaction(member, today, lock_key) do
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) _ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
end end
@ -947,7 +950,7 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action) # Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
defp regenerate_cycles_new_transaction(member, today, lock_key) do defp regenerate_cycles_new_transaction(member, today, lock_key) do
Repo.transaction(fn -> Repo.transaction(fn ->
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) _ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
{:ok, notifications} -> {:ok, notifications} ->
@ -1093,7 +1096,7 @@ defmodule Mv.Membership.Member do
initiator: initiator initiator: initiator
) do ) do
{:ok, cycles, notifications} -> {:ok, cycles, notifications} ->
send_notifications_if_any(notifications) _ = send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications, log_cycle_generation_success(member, cycles, notifications,
sync: true, sync: true,
@ -1112,7 +1115,7 @@ defmodule Mv.Membership.Member do
initiator: initiator initiator: initiator
) do ) do
{:ok, cycles, notifications} -> {:ok, cycles, notifications} ->
send_notifications_if_any(notifications) _ = send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications, log_cycle_generation_success(member, cycles, notifications,
sync: false, sync: false,
@ -1231,8 +1234,6 @@ defmodule Mv.Membership.Member do
|> String.replace("_", "\\_") |> String.replace("_", "\\_")
end end
defp sanitize_search_query(_), do: ""
# ============================================================================ # ============================================================================
# Search Filter Builders # Search Filter Builders
# ============================================================================ # ============================================================================

View file

@ -37,9 +37,10 @@ defmodule Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil do
{:ok, %{user: user}} when not is_nil(user) -> {:ok, %{user: user}} when not is_nil(user) ->
# User's :update action only accepts [:email]; use :update_user so # User's :update action only accepts [:email]; use :update_user so
# manage_relationship(:member, ..., on_missing: :unrelate) runs and clears member_id. # manage_relationship(:member, ..., on_missing: :unrelate) runs and clears member_id.
user _ =
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts) user
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false) |> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
changeset changeset

View file

@ -836,7 +836,10 @@ defmodule Mv.Membership do
- `{:ok, rejected_request}` - Rejected JoinRequest - `{:ok, rejected_request}` - Rejected JoinRequest
- `{:error, error}` - Status error or authorization error - `{:error, error}` - Status error or authorization error
""" """
@spec reject_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()} @spec reject_join_request(String.t(), keyword()) ::
{:ok, JoinRequest.t()}
| {:ok, JoinRequest.t(), [Ash.Notifier.Notification.t()]}
| {:error, term()}
def reject_join_request(id, opts \\ []) do def reject_join_request(id, opts \\ []) do
actor = Keyword.get(opts, :actor) actor = Keyword.get(opts, :actor)

View file

@ -26,8 +26,6 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
""" """
use Ash.Resource.Change use Ash.Resource.Change
require Logger
alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.CalendarCycles
@impl true @impl true
@ -83,11 +81,6 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
field: :membership_fee_type_id, field: :membership_fee_type_id,
message: "not found" 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
end end

View file

@ -43,6 +43,7 @@ defmodule Mv.Authorization.PermissionSets do
pattern matches and map lookups with no database queries or external calls. pattern matches and map lookups with no database queries or external calls.
""" """
@type permission_set_name :: :own_data | :read_only | :normal_user | :admin
@type scope :: :own | :linked | :all @type scope :: :own | :linked | :all
@type action :: :read | :create | :update | :destroy @type action :: :read | :create | :update | :destroy
@ -88,7 +89,7 @@ defmodule Mv.Authorization.PermissionSets do
iex> PermissionSets.all_permission_sets() iex> PermissionSets.all_permission_sets()
[:own_data, :read_only, :normal_user, :admin] [:own_data, :read_only, :normal_user, :admin]
""" """
@spec all_permission_sets() :: [atom()] @spec all_permission_sets() :: [permission_set_name(), ...]
def all_permission_sets do def all_permission_sets do
[:own_data, :read_only, :normal_user, :admin] [:own_data, :read_only, :normal_user, :admin]
end end
@ -107,7 +108,7 @@ defmodule Mv.Authorization.PermissionSets do
iex> PermissionSets.get_permissions(:invalid) iex> PermissionSets.get_permissions(:invalid)
** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin] ** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin]
""" """
@spec get_permissions(atom()) :: permission_set() @spec get_permissions(permission_set_name()) :: permission_set()
def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do
raise ArgumentError, raise ArgumentError,

View file

@ -207,8 +207,6 @@ defmodule Mv.Config do
end end
end end
defp derive_app_url_from_api_url(_), do: nil
@doc """ @doc """
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set). Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
""" """
@ -251,7 +249,6 @@ defmodule Mv.Config do
case System.get_env(key) do case System.get_env(key) do
nil -> false nil -> false
v when is_binary(v) -> String.trim(v) != "" v when is_binary(v) -> String.trim(v) != ""
_ -> false
end end
end end
@ -270,9 +267,6 @@ defmodule Mv.Config do
value when is_binary(value) -> value when is_binary(value) ->
v = String.trim(value) |> String.downcase() v = String.trim(value) |> String.downcase()
v in ["true", "1", "yes"] v in ["true", "1", "yes"]
_ ->
false
end end
end end
@ -328,7 +322,6 @@ defmodule Mv.Config do
defp present?(nil), do: false defp present?(nil), do: false
defp present?(s) when is_binary(s), do: String.trim(s) != "" defp present?(s) when is_binary(s), do: String.trim(s) != ""
defp present?(_), do: false
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# OIDC authentication # OIDC authentication
@ -409,7 +402,7 @@ defmodule Mv.Config do
@doc """ @doc """
Returns the OIDC groups claim name (default "groups"). ENV first, then Settings. Returns the OIDC groups claim name (default "groups"). ENV first, then Settings.
""" """
@spec oidc_groups_claim() :: String.t() | nil @spec oidc_groups_claim() :: String.t()
def oidc_groups_claim do def oidc_groups_claim do
case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do
nil -> "groups" nil -> "groups"
@ -492,7 +485,7 @@ defmodule Mv.Config do
- ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_PORT` - ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_PORT`
- Settings mode: read from Settings only - Settings mode: read from Settings only
""" """
@spec smtp_port() :: non_neg_integer() | nil @spec smtp_port() :: pos_integer() | nil
def smtp_port do def smtp_port do
if smtp_env_mode?() do if smtp_env_mode?() do
parse_smtp_port_env(System.get_env("SMTP_PORT")) parse_smtp_port_env(System.get_env("SMTP_PORT"))
@ -638,9 +631,15 @@ defmodule Mv.Config do
""" """
@spec mail_from_name() :: String.t() @spec mail_from_name() :: String.t()
def mail_from_name do def mail_from_name do
case System.get_env("MAIL_FROM_NAME") do name =
nil -> get_from_settings(:smtp_from_name) || "Mila" case System.get_env("MAIL_FROM_NAME") do
value -> trim_nil(value) || "Mila" nil -> get_from_settings(:smtp_from_name)
value -> trim_nil(value)
end
case name do
nil -> "Mila"
name -> name
end end
end end

View file

@ -26,6 +26,18 @@ defmodule Mv.Constants do
@fee_type_filter_prefix "fee_type_" @fee_type_filter_prefix "fee_type_"
@join_date_from_param "jd_from"
@join_date_to_param "jd_to"
@exit_date_mode_param "ed_mode"
@exit_date_from_param "ed_from"
@exit_date_to_param "ed_to"
@custom_date_filter_prefix "cdf_"
@max_boolean_filters 50 @max_boolean_filters 50
@max_uuid_length 36 @max_uuid_length 36
@ -84,6 +96,70 @@ defmodule Mv.Constants do
""" """
def fee_type_filter_prefix, do: @fee_type_filter_prefix def fee_type_filter_prefix, do: @fee_type_filter_prefix
@doc """
Returns the URL parameter name for the join_date lower bound filter.
## Examples
iex> Mv.Constants.join_date_from_param()
"jd_from"
"""
def join_date_from_param, do: @join_date_from_param
@doc """
Returns the URL parameter name for the join_date upper bound filter.
## Examples
iex> Mv.Constants.join_date_to_param()
"jd_to"
"""
def join_date_to_param, do: @join_date_to_param
@doc """
Returns the URL parameter name for the exit_date filter mode
(`active_only` | `inactive_only` | `all` | `custom`).
## Examples
iex> Mv.Constants.exit_date_mode_param()
"ed_mode"
"""
def exit_date_mode_param, do: @exit_date_mode_param
@doc """
Returns the URL parameter name for the exit_date lower bound filter
(only relevant when ed_mode=custom).
## Examples
iex> Mv.Constants.exit_date_from_param()
"ed_from"
"""
def exit_date_from_param, do: @exit_date_from_param
@doc """
Returns the URL parameter name for the exit_date upper bound filter
(only relevant when ed_mode=custom).
## Examples
iex> Mv.Constants.exit_date_to_param()
"ed_to"
"""
def exit_date_to_param, do: @exit_date_to_param
@doc """
Returns the prefix for custom date field filter URL parameters
(e.g. cdf_<uuid>_from / cdf_<uuid>_to).
## Examples
iex> Mv.Constants.custom_date_filter_prefix()
"cdf_"
"""
def custom_date_filter_prefix, do: @custom_date_filter_prefix
@doc """ @doc """
Returns the maximum number of boolean custom field filters allowed per request. Returns the maximum number of boolean custom field filters allowed per request.

View file

@ -225,7 +225,10 @@ defmodule Mv.Helpers.SystemActor do
# This allows configuration via SYSTEM_ACTOR_EMAIL env var # This allows configuration via SYSTEM_ACTOR_EMAIL env var
@spec system_user_email_config() :: String.t() @spec system_user_email_config() :: String.t()
defp system_user_email_config do defp system_user_email_config do
System.get_env("SYSTEM_ACTOR_EMAIL") || "system@mila.local" case System.get_env("SYSTEM_ACTOR_EMAIL") do
nil -> "system@mila.local"
email -> email
end
end end
# Loads the system actor from the database # Loads the system actor from the database
@ -257,7 +260,7 @@ defmodule Mv.Helpers.SystemActor do
end end
# Handles database error when loading system user # Handles database error when loading system user
@spec handle_system_user_error(term()) :: Mv.Accounts.User.t() | no_return() @spec handle_system_user_error({:error, Ash.Error.t()}) :: Mv.Accounts.User.t() | no_return()
defp handle_system_user_error(error) do defp handle_system_user_error(error) do
case load_admin_user_fallback() do case load_admin_user_fallback() do
{:ok, admin_user} -> {:ok, admin_user} ->
@ -393,15 +396,18 @@ defmodule Mv.Helpers.SystemActor do
# 1. Only creates system user with known email # 1. Only creates system user with known email
# 2. Only called during system actor initialization (bootstrap) # 2. Only called during system actor initialization (bootstrap)
# 3. Once created, all subsequent operations use proper authorization # 3. Once created, all subsequent operations use proper authorization
Accounts.create_user!(%{email: system_user_email_config()}, user =
upsert?: true, Accounts.create_user!(%{email: system_user_email_config()},
upsert_identity: :unique_email, upsert?: true,
authorize?: false upsert_identity: :unique_email,
) authorize?: false
|> Ash.Changeset.for_update(:update_internal, %{}) )
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.Changeset.for_update(:update_internal, %{})
|> Ash.update!(authorize?: false) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false) |> Ash.update!(authorize?: false)
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
%Accounts.User{} = user
end end
# Finds a user by email address # Finds a user by email address

View file

@ -190,6 +190,4 @@ defmodule Mv.Mailer do
defp valid_email?(email) when is_binary(email) do defp valid_email?(email) when is_binary(email) do
Regex.match?(@email_regex, String.trim(email)) Regex.match?(@email_regex, String.trim(email))
end end
defp valid_email?(_), do: false
end end

View file

@ -100,7 +100,8 @@ defmodule Mv.Membership.Import.CsvParser do
|> String.replace("\r", "\n") |> String.replace("\r", "\n")
end end
@spec get_parser(String.t()) :: module() @spec get_parser(String.t()) ::
Mv.Membership.Import.CsvParserSemicolon | Mv.Membership.Import.CsvParserComma
defp get_parser(";"), do: Mv.Membership.Import.CsvParserSemicolon defp get_parser(";"), do: Mv.Membership.Import.CsvParserSemicolon
defp get_parser(","), do: Mv.Membership.Import.CsvParserComma defp get_parser(","), do: Mv.Membership.Import.CsvParserComma
defp get_parser(_), do: Mv.Membership.Import.CsvParserSemicolon defp get_parser(_), do: Mv.Membership.Import.CsvParserSemicolon
@ -116,7 +117,10 @@ defmodule Mv.Membership.Import.CsvParser do
if semicolon_score >= comma_score, do: ";", else: "," if semicolon_score >= comma_score, do: ";", else: ","
end end
@spec header_field_count(module(), binary()) :: non_neg_integer() @spec header_field_count(
Mv.Membership.Import.CsvParserSemicolon | Mv.Membership.Import.CsvParserComma,
binary()
) :: non_neg_integer()
defp header_field_count(parser, header_record) do defp header_field_count(parser, header_record) do
case parse_single_record(parser, header_record, nil) do case parse_single_record(parser, header_record, nil) do
{:ok, fields} -> Enum.count(fields, &(String.trim(&1) != "")) {:ok, fields} -> Enum.count(fields, &(String.trim(&1) != ""))

View file

@ -26,14 +26,8 @@ defmodule Mv.Membership.Import.ImportRunner do
{:ok, content} -> {:ok, content} ->
{:ok, content} {:ok, content}
{:error, reason} when is_atom(reason) ->
{:error, :file.format_error(reason)}
{:error, %File.Error{reason: reason}} ->
{:error, :file.format_error(reason)}
{:error, reason} -> {:error, reason} ->
{:error, Exception.message(reason)} {:error, to_string(:file.format_error(reason))}
end end
end end

View file

@ -210,8 +210,6 @@ defmodule Mv.Membership.Import.MemberCSV do
MapSet.member?(HeaderMapper.known_member_fields(), normalized) MapSet.member?(HeaderMapper.known_member_fields(), normalized)
end end
defp member_field?(_), do: false
# Validates that row count doesn't exceed limit # Validates that row count doesn't exceed limit
defp validate_row_count(rows, max_rows) do defp validate_row_count(rows, max_rows) do
if length(rows) > max_rows do if length(rows) > max_rows do

View file

@ -59,7 +59,7 @@ defmodule Mv.Membership.Member.Validations.EmailChangePermission do
# Ash stores actor in changeset.context.private.actor; validation context has .actor; some callsites use context.actor # Ash stores actor in changeset.context.private.actor; validation context has .actor; some callsites use context.actor
defp resolve_actor(changeset, context) do defp resolve_actor(changeset, context) do
ctx = changeset.context || %{} ctx = changeset.context
get_in(ctx, [:private, :actor]) || get_in(ctx, [:private, :actor]) ||
Map.get(ctx, :actor) || Map.get(ctx, :actor) ||

View file

@ -16,6 +16,21 @@ defmodule Mv.Membership.MemberExport do
alias MvWeb.MemberLive.Index alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
@typedoc "Validated export parameters produced by `parse_params/1`."
@type parsed_params :: %{
selected_ids: [String.t()],
member_fields: [String.t()],
selectable_member_fields: [String.t()],
computed_fields: [String.t()],
custom_field_ids: [String.t()],
query: String.t() | nil,
sort_field: String.t() | nil,
sort_order: String.t() | nil,
show_current_cycle: boolean(),
cycle_status_filter: :paid | :unpaid | nil,
boolean_filters: %{optional(String.t()) => boolean()}
}
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["membership_fee_type", "membership_fee_status", "groups"] ["membership_fee_type", "membership_fee_status", "groups"]
@computed_export_fields ["membership_fee_status"] @computed_export_fields ["membership_fee_status"]
@ -305,7 +320,7 @@ defmodule Mv.Membership.MemberExport do
:computed_fields, :custom_field_ids, :query, :sort_field, :sort_order, :computed_fields, :custom_field_ids, :query, :sort_field, :sort_order,
:show_current_cycle, :cycle_status_filter, :boolean_filters. :show_current_cycle, :cycle_status_filter, :boolean_filters.
""" """
@spec parse_params(map()) :: map() @spec parse_params(map()) :: parsed_params()
def parse_params(params) do def parse_params(params) do
# DB fields come from "member_fields" # DB fields come from "member_fields"
raw_member_fields = extract_list(params, "member_fields") raw_member_fields = extract_list(params, "member_fields")
@ -458,9 +473,6 @@ defmodule Mv.Membership.MemberExport do
computed_fields, computed_fields,
member_fields member_fields
) do ) do
computed_fields = computed_fields || []
member_fields = member_fields || []
db_with_insert = db_with_insert =
Enum.flat_map(db_fields_ordered, fn f -> Enum.flat_map(db_fields_ordered, fn f ->
expand_field_with_computed(f, member_fields, computed_fields) expand_field_with_computed(f, member_fields, computed_fields)
@ -507,6 +519,4 @@ defmodule Mv.Membership.MemberExport do
other -> other other -> other
end) end)
end end
defp normalize_computed_fields(_), do: []
end end

View file

@ -21,7 +21,7 @@ defmodule Mv.Membership.MembersCSV do
Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body. Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body.
RFC 4180 escaping and formula-injection safe_cell are applied. RFC 4180 escaping and formula-injection safe_cell are applied.
""" """
@spec export([struct() | map()], [map()]) :: iodata() @spec export([struct() | map()], [map()]) :: [iodata()] | Enumerable.t()
def export(members, columns) when is_list(members) do def export(members, columns) when is_list(members) do
header = build_header(columns) header = build_header(columns)
rows = Enum.map(members, fn member -> build_row(member, columns) end) rows = Enum.map(members, fn member -> build_row(member, columns) end)

View file

@ -143,7 +143,7 @@ defmodule Mv.Membership.MembersPDF do
defp convert_to_template_format(export_data, locale, club_name) do defp convert_to_template_format(export_data, locale, club_name) do
# Set locale for translations # Set locale for translations
Gettext.put_locale(MvWeb.Gettext, locale) _ = Gettext.put_locale(MvWeb.Gettext, locale)
headers = Enum.map(export_data.columns, & &1.label) headers = Enum.map(export_data.columns, & &1.label)
column_count = length(export_data.columns) column_count = length(export_data.columns)
@ -211,9 +211,6 @@ defmodule Mv.Membership.MembersPDF do
{:ok, datetime, _offset} -> {:ok, datetime, _offset} ->
format_datetime(datetime, locale) format_datetime(datetime, locale)
{:ok, datetime} ->
format_datetime(datetime, locale)
{:error, _} -> {:error, _} ->
# Try NaiveDateTime if DateTime parsing fails # Try NaiveDateTime if DateTime parsing fails
case NaiveDateTime.from_iso8601(iso8601_string) do case NaiveDateTime.from_iso8601(iso8601_string) do
@ -257,8 +254,6 @@ defmodule Mv.Membership.MembersPDF do
end end
end end
defp format_date(_, _), do: ""
defp format_dates_in_rows(rows, columns, locale) do defp format_dates_in_rows(rows, columns, locale) do
date_indices = find_date_column_indices(columns) date_indices = find_date_column_indices(columns)
@ -321,7 +316,7 @@ defmodule Mv.Membership.MembersPDF do
defp format_cell_date_datetime(cell_value, locale) do defp format_cell_date_datetime(cell_value, locale) do
case DateTime.from_iso8601(cell_value) do case DateTime.from_iso8601(cell_value) do
{:ok, datetime} -> format_datetime(datetime, locale) {:ok, datetime, _offset} -> format_datetime(datetime, locale)
_ -> format_cell_date_naive(cell_value, locale) _ -> format_cell_date_naive(cell_value, locale)
end end
end end

View file

@ -58,7 +58,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
{:ok, %{success: 45, failed: 0, total: 45}} {:ok, %{success: 45, failed: 0, total: 45}}
""" """
@spec run() :: {:ok, map()} | {:error, term()} @spec run() :: {:ok, CycleGenerator.results_summary()} | {:error, Ash.Error.t()}
def run do def run do
Logger.info("Starting membership fee cycle generation job") Logger.info("Starting membership fee cycle generation job")
start_time = System.monotonic_time(:millisecond) start_time = System.monotonic_time(:millisecond)
@ -98,7 +98,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
Mv.MembershipFees.CycleGenerationJob.run(batch_size: 5) Mv.MembershipFees.CycleGenerationJob.run(batch_size: 5)
""" """
@spec run(keyword()) :: {:ok, map()} | {:error, term()} @spec run(keyword()) :: {:ok, CycleGenerator.results_summary()} | {:error, Ash.Error.t()}
def run(opts) when is_list(opts) do def run(opts) when is_list(opts) do
Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}") Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}")
start_time = System.monotonic_time(:millisecond) start_time = System.monotonic_time(:millisecond)
@ -135,7 +135,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
- `{:error, reason}` - Error with reason - `{:error, reason}` - Error with reason
""" """
@spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, term()} @spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, Ash.Error.t()}
def pending_members_count do def pending_members_count do
today = Date.utc_today() today = Date.utc_today()
@ -166,7 +166,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
- `{:error, reason}` - Error with reason - `{:error, reason}` - Error with reason
""" """
@spec run_for_member(String.t()) :: {:ok, [map()]} | {:error, term()} @spec run_for_member(String.t()) :: CycleGenerator.generate_result()
def run_for_member(member_id) when is_binary(member_id) do def run_for_member(member_id) when is_binary(member_id) do
Logger.info("Generating cycles for member #{member_id}") Logger.info("Generating cycles for member #{member_id}")
CycleGenerator.generate_cycles_for_member(member_id) CycleGenerator.generate_cycles_for_member(member_id)

View file

@ -1,4 +1,11 @@
defmodule Mv.MembershipFees.CycleGenerator do defmodule Mv.MembershipFees.CycleGenerator do
@typedoc "Aggregate counts returned by a batch cycle-generation run."
@type results_summary :: %{
success: non_neg_integer(),
failed: non_neg_integer(),
total: non_neg_integer()
}
@moduledoc """ @moduledoc """
Module for generating membership fee cycles for members. Module for generating membership fee cycles for members.
@ -115,7 +122,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
lock_key = Member.advisory_lock_key_for_member_id(member.id) lock_key = Member.advisory_lock_key_for_member_id(member.id)
Repo.transaction(fn -> Repo.transaction(fn ->
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) _ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today, opts) do case do_generate_cycles(member, today, opts) do
{:ok, cycles, notifications} -> {:ok, cycles, notifications} ->
@ -159,7 +166,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
- `{:error, reason}` - Error with reason - `{:error, reason}` - Error with reason
""" """
@spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()} @spec generate_cycles_for_all_members(keyword()) ::
{:ok, results_summary()} | {:error, Ash.Error.t()}
def generate_cycles_for_all_members(opts \\ []) do def generate_cycles_for_all_members(opts \\ []) do
today = Keyword.get(opts, :today, Date.utc_today()) today = Keyword.get(opts, :today, Date.utc_today())
batch_size = Keyword.get(opts, :batch_size, 10) batch_size = Keyword.get(opts, :batch_size, 10)
@ -212,7 +220,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
defp process_member_cycle_generation(member, today) do defp process_member_cycle_generation(member, today) do
case generate_cycles_for_member(member, today: today) do case generate_cycles_for_member(member, today: today) do
{:ok, _cycles, notifications} = ok -> {:ok, _cycles, notifications} = ok ->
send_notifications_for_batch_job(notifications) _ = send_notifications_for_batch_job(notifications)
{member.id, ok} {member.id, ok}
{:error, _reason} = err -> {:error, _reason} = err ->

88
lib/mv/oidc/discovery.ex Normal file
View file

@ -0,0 +1,88 @@
defmodule Mv.Oidc.Discovery do
@moduledoc """
Fetches and caches the OIDC provider's discovery document
(`/.well-known/openid-configuration`).
Currently only `end_session_endpoint` is exposed used by the logout flow to
trigger RP-initiated logout at the IdP so the user's SSO session is cleared
and they don't get auto-re-logged-in.
Cache lives in `:persistent_term`, keyed by base URL, for the lifetime of the
BEAM. Re-fetch on next call after `clear_cache/0`.
"""
require Logger
@persistent_term_key {__MODULE__, :discovery}
@request_timeout 5_000
@doc """
Returns the IdP's `end_session_endpoint` URL.
- `{:ok, url}` if discovery succeeds (and is cached for future calls)
- `{:error, reason}` if the IdP is unreachable, the document is malformed,
or the field is missing
"""
@spec end_session_endpoint(String.t()) :: {:ok, String.t()} | {:error, term()}
def end_session_endpoint(base_url) when is_binary(base_url) do
case fetch_cached(base_url) do
{:ok, %{"end_session_endpoint" => url}} when is_binary(url) -> {:ok, url}
{:ok, _config} -> {:error, :no_end_session_endpoint}
{:error, _} = err -> err
end
end
@doc """
Clears the cached discovery documents. Intended for tests.
"""
@spec clear_cache() :: :ok
def clear_cache do
:persistent_term.erase(@persistent_term_key)
:ok
end
@doc """
Seeds the cache with a fixed result for a base URL. Intended for tests so the
HTTP fetch is skipped.
"""
@spec put_cache(String.t(), {:ok, map()} | {:error, term()}) :: :ok
def put_cache(base_url, result) when is_binary(base_url) do
cache = :persistent_term.get(@persistent_term_key, %{})
:persistent_term.put(@persistent_term_key, Map.put(cache, base_url, result))
:ok
end
defp fetch_cached(base_url) do
cache = :persistent_term.get(@persistent_term_key, %{})
case Map.fetch(cache, base_url) do
{:ok, result} ->
result
:error ->
result = fetch(base_url)
:persistent_term.put(@persistent_term_key, Map.put(cache, base_url, result))
result
end
end
defp fetch(base_url) do
url = String.trim_trailing(base_url, "/") <> "/.well-known/openid-configuration"
case Req.get(url,
receive_timeout: @request_timeout,
connect_options: [timeout: @request_timeout]
) do
{:ok, %Req.Response{status: 200, body: body}} when is_map(body) ->
{:ok, body}
{:ok, %Req.Response{status: status}} ->
Logger.warning("OIDC discovery returned HTTP #{status} for #{url}")
{:error, {:http_status, status}}
{:error, reason} ->
Logger.warning("OIDC discovery request failed for #{url}: #{inspect(reason)}")
{:error, reason}
end
end
end

View file

@ -87,8 +87,6 @@ defmodule Mv.OidcRoleSync do
ArgumentError -> nil ArgumentError -> nil
end end
defp safe_get_atom(_map, _key), do: nil
defp peek_jwt_claims(token) do defp peek_jwt_claims(token) do
parts = String.split(token, ".") parts = String.split(token, ".")

View file

@ -15,6 +15,6 @@ defmodule Mv.OidcRoleSyncConfig do
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"." @doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
def oidc_groups_claim do def oidc_groups_claim do
Mv.Config.oidc_groups_claim() || "groups" Mv.Config.oidc_groups_claim()
end end
end end

View file

@ -22,7 +22,7 @@ defmodule Mv.Release do
require Logger require Logger
def migrate do def migrate do
load_app() _ = load_app()
for repo <- repos() do for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
@ -75,14 +75,14 @@ defmodule Mv.Release do
dev_path = Path.join(priv, "repo/seeds_dev.exs") dev_path = Path.join(priv, "repo/seeds_dev.exs")
prev = Code.compiler_options() prev = Code.compiler_options()
Code.compiler_options(ignore_module_conflict: true) _ = Code.compiler_options(ignore_module_conflict: true)
try do try do
Code.eval_file(bootstrap_path) _ = Code.eval_file(bootstrap_path)
IO.puts("✅ Bootstrap seeds completed.") IO.puts("✅ Bootstrap seeds completed.")
if System.get_env("RUN_DEV_SEEDS") == "true" do if System.get_env("RUN_DEV_SEEDS") == "true" do
Code.eval_file(dev_path) _ = Code.eval_file(dev_path)
IO.puts("✅ Dev seeds completed.") IO.puts("✅ Dev seeds completed.")
end end
after after
@ -92,7 +92,7 @@ defmodule Mv.Release do
end end
def rollback(repo, version) do def rollback(repo, version) do
load_app() _ = load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end end
@ -139,10 +139,11 @@ defmodule Mv.Release do
{:ok, %Role{} = admin_role} -> {:ok, %Role{} = admin_role} ->
case get_user_by_email(email) do case get_user_by_email(email) do
{:ok, %User{} = user} -> {:ok, %User{} = user} ->
user _ =
|> Ash.Changeset.for_update(:update, %{}) user
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.Changeset.for_update(:update, %{})
|> Ash.update!(authorize?: false) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
:ok :ok
@ -189,15 +190,16 @@ defmodule Mv.Release do
defp create_admin_user(email, password, admin_role) do defp create_admin_user(email, password, admin_role) do
case Accounts.create_user(%{email: email}, authorize?: false) do case Accounts.create_user(%{email: email}, authorize?: false) do
{:ok, user} -> {:ok, user} ->
user _ =
|> Ash.Changeset.for_update(:admin_set_password, %{password: password}) user
|> Ash.update!(authorize?: false) |> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|> then(fn u ->
u
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false) |> Ash.update!(authorize?: false)
end) |> then(fn u ->
u
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
end)
:ok :ok
@ -207,15 +209,16 @@ defmodule Mv.Release do
end end
defp update_admin_user(user, password, admin_role) do defp update_admin_user(user, password, admin_role) do
user _ =
|> Ash.Changeset.for_update(:admin_set_password, %{password: password}) user
|> Ash.update!(authorize?: false) |> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|> then(fn u ->
u
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false) |> Ash.update!(authorize?: false)
end) |> then(fn u ->
u
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
end)
:ok :ok
end end

View file

@ -19,4 +19,12 @@ defmodule Mv.Repo do
def min_pg_version do def min_pg_version do
%Version{major: 17, minor: 2, patch: 0} %Version{major: 17, minor: 2, patch: 0}
end end
# This app does not use schema-based multitenancy, so there are no tenant
# schemas to migrate. Returning [] keeps the AshPostgres callback total
# rather than raising the default "not defined" error.
@impl true
def all_tenants do
[]
end
end end

View file

@ -8,6 +8,12 @@ defmodule Mv.Vereinfacht.Client do
""" """
require Logger require Logger
@typedoc "Error reasons returned by Vereinfacht API calls."
@type error_reason ::
:not_configured
| {:request_failed, map()}
| {:http, non_neg_integer(), :html_response | binary()}
@content_type "application/vnd.api+json" @content_type "application/vnd.api+json"
@doc """ @doc """
@ -31,7 +37,7 @@ defmodule Mv.Vereinfacht.Client do
{:error, :not_configured} {:error, :not_configured}
""" """
@spec test_connection(String.t() | nil, String.t() | nil, String.t() | nil) :: @spec test_connection(String.t() | nil, String.t() | nil, String.t() | nil) ::
{:ok, :connected} | {:error, term()} {:ok, :connected} | {:error, error_reason()}
def test_connection(api_url, api_key, club_id) do def test_connection(api_url, api_key, club_id) do
if blank?(api_url) or blank?(api_key) or blank?(club_id) do if blank?(api_url) or blank?(api_key) or blank?(club_id) do
{:error, :not_configured} {:error, :not_configured}
@ -92,13 +98,12 @@ defmodule Mv.Vereinfacht.Client do
@sync_timeout_ms 5_000 @sync_timeout_ms 5_000
# Resolved at compile time so Mix is never called at runtime (Mix is not available in releases).
@env Mix.env()
# In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits). # In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits).
# `sql_sandbox?/0` reads runtime config (true only in test) and avoids calling Mix at runtime,
# which is unavailable in releases.
defp req_http_options do defp req_http_options do
opts = [receive_timeout: @sync_timeout_ms] opts = [receive_timeout: @sync_timeout_ms]
if @env == :test, do: [retry: false] ++ opts, else: opts if Mv.Config.sql_sandbox?(), do: [retry: false] ++ opts, else: opts
end end
defp post_and_parse_contact(url, body, api_key) do defp post_and_parse_contact(url, body, api_key) do
@ -230,7 +235,7 @@ defmodule Mv.Vereinfacht.Client do
Returns the full response body (decoded JSON) for debugging/display. Returns the full response body (decoded JSON) for debugging/display.
""" """
@spec get_contact(String.t()) :: {:ok, map()} | {:error, term()} @spec get_contact(String.t()) :: {:ok, map()} | {:error, error_reason()}
def get_contact(contact_id) when is_binary(contact_id) do def get_contact(contact_id) when is_binary(contact_id) do
fetch_contact(contact_id, []) fetch_contact(contact_id, [])
end end

View file

@ -37,9 +37,10 @@ defmodule Mv.Vereinfacht.SyncFlash do
def create_table! do def create_table! do
# :public so any process can write (SyncContact runs in LiveView/Ash transaction process, # :public so any process can write (SyncContact runs in LiveView/Ash transaction process,
# not the process that created the table). :protected would restrict writes to the creating process. # not the process that created the table). :protected would restrict writes to the creating process.
if :ets.whereis(@table) == :undefined do _ =
:ets.new(@table, [:set, :public, :named_table]) if :ets.whereis(@table) == :undefined do
end :ets.new(@table, [:set, :public, :named_table])
end
:ok :ok
end end

View file

@ -26,7 +26,7 @@ defmodule Mv.Vereinfacht do
- `{:error, {:http, status, message}}` API returned an error (e.g. 401, 403) - `{:error, {:http, status, message}}` API returned an error (e.g. 401, 403)
- `{:error, {:request_failed, reason}}` network/transport error - `{:error, {:request_failed, reason}}` network/transport error
""" """
@spec test_connection() :: {:ok, :connected} | {:error, term()} @spec test_connection() :: {:ok, :connected} | {:error, Mv.Vereinfacht.Client.error_reason()}
def test_connection do def test_connection do
Client.test_connection( Client.test_connection(
Mv.Config.vereinfacht_api_url(), Mv.Config.vereinfacht_api_url(),

View file

@ -113,8 +113,7 @@ defmodule MvWeb.Authorization do
iex> can_access_page?(mitglied, "/members") iex> can_access_page?(mitglied, "/members")
false false
""" """
@spec can_access_page?(map() | nil, String.t() | Phoenix.VerifiedRoutes.unverified_path()) :: @spec can_access_page?(map() | nil, String.t()) :: boolean()
boolean()
def can_access_page?(nil, _page_path), do: false def can_access_page?(nil, _page_path), do: false
def can_access_page?(user, page_path) do def can_access_page?(user, page_path) do

View file

@ -16,6 +16,7 @@ defmodule MvWeb.AuthController do
alias Mv.Accounts.User.Errors.PasswordVerificationRequired alias Mv.Accounts.User.Errors.PasswordVerificationRequired
alias Mv.Config alias Mv.Config
alias Mv.Oidc.Discovery
def success(conn, {:password, :sign_in} = _activity, user, token) do def success(conn, {:password, :sign_in} = _activity, user, token) do
if Config.oidc_only?() do if Config.oidc_only?() do
@ -334,14 +335,29 @@ defmodule MvWeb.AuthController do
end end
end end
defp redact_url(_), do: "[redacted]"
def sign_out(conn, _params) do def sign_out(conn, _params) do
return_to = get_session(conn, :return_to) || ~p"/" conn = clear_session(conn, :mv) |> put_flash(:success, gettext("You are now signed out"))
conn case oidc_end_session_url() do
|> clear_session(:mv) {:ok, url} ->
|> put_flash(:success, gettext("You are now signed out")) redirect(conn, external: url)
|> redirect(to: return_to)
:no_oidc ->
redirect(conn, to: get_session(conn, :return_to) || ~p"/")
{:error, _reason} ->
# IdP discovery failed — fall back to local logout. The user's IdP session
# is still active, so OIDC_ONLY setups may auto-re-login. Better than
# blocking logout entirely.
redirect(conn, to: ~p"/sign-in?oidc_failed=1")
end
end
defp oidc_end_session_url do
if Config.oidc_configured?() do
Discovery.end_session_endpoint(Config.oidc_base_url())
else
:no_oidc
end
end end
end end

View file

@ -25,31 +25,33 @@ defmodule MvWeb.MemberExportController do
@custom_field_prefix Mv.Constants.custom_field_prefix() @custom_field_prefix Mv.Constants.custom_field_prefix()
def export(conn, params) do def export(conn, params) do
actor = current_actor(conn) case current_actor(conn) do
if is_nil(actor), do: return_forbidden(conn) nil -> return_forbidden(conn)
actor -> export_with_actor(conn, actor, params["payload"])
case params["payload"] do
nil ->
conn
|> put_status(400)
|> put_resp_content_type("application/json")
|> json(%{error: "payload required"})
payload when is_binary(payload) ->
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) ->
parsed = parse_and_validate(decoded)
run_export(conn, actor, parsed)
_ ->
conn
|> put_status(400)
|> put_resp_content_type("application/json")
|> json(%{error: "invalid JSON"})
end
end end
end end
defp export_with_actor(conn, actor, payload) when is_binary(payload) do
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) ->
run_export(conn, actor, parse_and_validate(decoded))
_ ->
json_error(conn, "invalid JSON")
end
end
defp export_with_actor(conn, _actor, _payload) do
json_error(conn, "payload required")
end
defp json_error(conn, message) do
conn
|> put_status(400)
|> put_resp_content_type("application/json")
|> json(%{error: message})
end
defp current_actor(conn) do defp current_actor(conn) do
conn.assigns[:current_user] conn.assigns[:current_user]
|> Actor.ensure_loaded() |> Actor.ensure_loaded()

View file

@ -30,8 +30,8 @@ defmodule MvWeb.SignInLive do
# Set both backend-specific and global locale so Gettext.get_locale/0 and # Set both backend-specific and global locale so Gettext.get_locale/0 and
# Gettext.get_locale/1 both return the correct value (important for the # Gettext.get_locale/1 both return the correct value (important for the
# language-selector `selected` attribute in Layouts.public_page). # language-selector `selected` attribute in Layouts.public_page).
Gettext.put_locale(MvWeb.Gettext, locale) _ = Gettext.put_locale(MvWeb.Gettext, locale)
Gettext.put_locale(locale) _ = Gettext.put_locale(locale)
# Prepend DE-specific overrides when locale is German so that components # Prepend DE-specific overrides when locale is German so that components
# without _gettext support (e.g. HorizontalRule) still render in German. # without _gettext support (e.g. HorizontalRule) still render in German.

View file

@ -0,0 +1,62 @@
defmodule MvWeb.SignOutLive do
@moduledoc """
Custom sign-out confirmation page.
Replaces AshAuthentication.Phoenix.SignOutLive so the page meets accessibility
requirements (main landmark via Layouts.public_page, level-one heading) and
uses the project's DaisyUI button styles. Submits DELETE /sign-out for CSRF
protection, same contract as the library default.
"""
use Phoenix.LiveView
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias MvWeb.Layouts
@impl true
def mount(_params, session, socket) do
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
_ = Gettext.put_locale(MvWeb.Gettext, locale)
_ = Gettext.put_locale(locale)
club_name =
case Membership.get_settings() do
{:ok, settings} when is_binary(settings.club_name) -> settings.club_name
_ -> nil
end
socket =
socket
|> assign(:sign_out_path, session["sign_out_path"] || "/sign-out")
|> assign(:locale, locale)
|> assign(:club_name, club_name)
|> Layouts.assign_page_title(dgettext("auth", "Sign out"))
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<Layouts.public_page flash={@flash} club_name={@club_name}>
<div class="hero min-h-[40vh] bg-base-200 rounded-lg">
<div class="hero-content flex-col items-start text-left">
<div class="w-full max-w-md">
<h1 class="text-xl font-semibold leading-8 mb-4">
{dgettext("auth", "Sign out")}
</h1>
<p class="text-base-content/70 mb-4">
{dgettext("auth", "Are you sure you want to sign out?")}
</p>
<.form for={%{}} action={@sign_out_path} method="delete">
<button type="submit" class="btn btn-primary w-full">
{dgettext("auth", "Sign out")}
</button>
</.form>
</div>
</div>
</div>
</Layouts.public_page>
"""
end
end

View file

@ -23,6 +23,11 @@ defmodule MvWeb.Components.MemberFilterComponent do
- `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All). - `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All).
- `:boolean_custom_fields` - List of boolean custom fields to display - `:boolean_custom_fields` - List of boolean custom fields to display
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}` - `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
- `:date_custom_fields` - List of date-typed custom fields rendered in the
"Custom date fields" section (each with `:id`, `:name`, `:value_type`).
- `:date_filters` - Date filter state map (see `MvWeb.MemberLive.Index.DateFilter`):
built-in `:join_date` / `:exit_date` bounds and mode, plus optional
UUID-keyed custom date field bound entries.
- `:id` - Component ID (required) - `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0) - `:member_count` - Number of filtered members to display in badge (optional, default: 0)
@ -31,13 +36,18 @@ defmodule MvWeb.Components.MemberFilterComponent do
- Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in) - Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in)
- Sends `{:fee_type_filter_changed, fee_type_id_str, value}` to parent when a fee type filter changes (value: nil | :in | :not_in) - Sends `{:fee_type_filter_changed, fee_type_id_str, value}` to parent when a fee type filter changes (value: nil | :in | :not_in)
- Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
- Sends `{:date_filters_changed, new_filters}` to parent when any date
filter input changes (built-in date bounds, exit_date mode, or custom
date field bounds).
""" """
use MvWeb, :live_component use MvWeb, :live_component
alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.FilterParams
@group_filter_prefix Mv.Constants.group_filter_prefix() @group_filter_prefix Mv.Constants.group_filter_prefix()
@fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix() @fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix()
@custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix()
@impl true @impl true
def mount(socket) do def mount(socket) do
@ -50,19 +60,42 @@ defmodule MvWeb.Components.MemberFilterComponent do
socket socket
|> assign(:id, assigns.id) |> assign(:id, assigns.id)
|> assign(:cycle_status_filter, assigns[:cycle_status_filter]) |> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:groups, assigns[:groups] || []) |> assign_group_assigns(assigns)
|> assign(:group_filters, assigns[:group_filters] || %{}) |> assign_fee_type_assigns(assigns)
|> assign(:group_filter_prefix, @group_filter_prefix) |> assign_boolean_assigns(assigns)
|> assign(:fee_types, assigns[:fee_types] || []) |> assign_date_assigns(assigns)
|> assign(:fee_type_filters, assigns[:fee_type_filters] || %{})
|> assign(:fee_type_filter_prefix, @fee_type_filter_prefix)
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|> assign(:member_count, assigns[:member_count] || 0) |> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket} {:ok, socket}
end end
defp assign_group_assigns(socket, assigns) do
socket
|> assign(:groups, assigns[:groups] || [])
|> assign(:group_filters, assigns[:group_filters] || %{})
|> assign(:group_filter_prefix, @group_filter_prefix)
end
defp assign_fee_type_assigns(socket, assigns) do
socket
|> assign(:fee_types, assigns[:fee_types] || [])
|> assign(:fee_type_filters, assigns[:fee_type_filters] || %{})
|> assign(:fee_type_filter_prefix, @fee_type_filter_prefix)
end
defp assign_boolean_assigns(socket, assigns) do
socket
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
end
defp assign_date_assigns(socket, assigns) do
socket
|> assign(:date_custom_fields, assigns[:date_custom_fields] || [])
|> assign(:date_filters, assigns[:date_filters] || DateFilter.default())
|> assign(:custom_date_filter_prefix, @custom_date_filter_prefix)
end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -81,7 +114,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
"gap-2", "gap-2",
(@cycle_status_filter || map_size(@group_filters) > 0 || (@cycle_status_filter || map_size(@group_filters) > 0 ||
map_size(@fee_type_filters) > 0 || map_size(@fee_type_filters) > 0 ||
active_boolean_filters_count(@boolean_filters) > 0) && active_boolean_filters_count(@boolean_filters) > 0 ||
date_filters_active?(@date_filters)) &&
"btn-active" "btn-active"
]} ]}
phx-click="toggle_dropdown" phx-click="toggle_dropdown"
@ -99,7 +133,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
@fee_types, @fee_types,
@fee_type_filters, @fee_type_filters,
@boolean_custom_fields, @boolean_custom_fields,
@boolean_filters @boolean_filters,
@date_filters
)} )}
</span> </span>
<.badge <.badge
@ -111,7 +146,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
</.badge> </.badge>
<.badge <.badge
:if={ :if={
(@cycle_status_filter || map_size(@group_filters) > 0 || map_size(@fee_type_filters) > 0) && (@cycle_status_filter || map_size(@group_filters) > 0 ||
map_size(@fee_type_filters) > 0 ||
date_filters_active?(@date_filters)) &&
active_boolean_filters_count(@boolean_filters) == 0 active_boolean_filters_count(@boolean_filters) == 0
} }
variant="primary" variant="primary"
@ -329,6 +366,163 @@ defmodule MvWeb.Components.MemberFilterComponent do
</div> </div>
</div> </div>
<!-- Dates Group (built-in fields: exit_date with mode selector, join_date range) -->
<div class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Dates")}
</div>
<fieldset class="border-0 p-0 m-0 min-w-0 mb-3">
<legend class="text-sm font-medium mb-1">
{gettext("Exit date")}
</legend>
<div class="join w-full">
<label
class={"#{exit_mode_label_class(@date_filters, :active_only)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-active-only"
>
<input
type="radio"
id="ed-mode-active-only"
name="ed_mode"
value="active_only"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :active_only}
/>
<span class="text-xs">{gettext("Active only")}</span>
</label>
<label
class={"#{exit_mode_label_class(@date_filters, :all)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-all"
>
<input
type="radio"
id="ed-mode-all"
name="ed_mode"
value="all"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :all}
/>
<span class="text-xs">{gettext("All")}</span>
</label>
<label
class={"#{exit_mode_label_class(@date_filters, :inactive_only)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-inactive-only"
>
<input
type="radio"
id="ed-mode-inactive-only"
name="ed_mode"
value="inactive_only"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :inactive_only}
/>
<span class="text-xs">{gettext("Inactive only")}</span>
</label>
<label
class={"#{exit_mode_label_class(@date_filters, :custom)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-custom"
>
<input
type="radio"
id="ed-mode-custom"
name="ed_mode"
value="custom"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :custom}
/>
<span class="text-xs">{gettext("Range")}</span>
</label>
</div>
<div
:if={exit_mode(@date_filters) == :custom}
class="mt-2 flex gap-3 items-end flex-wrap"
>
<.input
type="date"
id="ed-from"
name="ed_from"
label={gettext("From")}
class="input input-sm input-bordered"
aria-label={gettext("Exit date from")}
value={date_value_for_input(@date_filters, :exit_date, :from)}
/>
<.input
type="date"
id="ed-to"
name="ed_to"
label={gettext("To")}
class="input input-sm input-bordered"
aria-label={gettext("Exit date to")}
value={date_value_for_input(@date_filters, :exit_date, :to)}
/>
</div>
</fieldset>
<fieldset class="border-0 p-0 m-0 min-w-0">
<legend class="text-sm font-medium mb-1">
{gettext("Join date")}
</legend>
<div class="flex gap-3 items-end flex-wrap">
<.input
type="date"
id="jd-from"
name="jd_from"
label={gettext("From")}
class="input input-sm input-bordered"
aria-label={gettext("Join date from")}
value={date_value_for_input(@date_filters, :join_date, :from)}
/>
<.input
type="date"
id="jd-to"
name="jd_to"
label={gettext("To")}
class="input input-sm input-bordered"
aria-label={gettext("Join date to")}
value={date_value_for_input(@date_filters, :join_date, :to)}
/>
</div>
</fieldset>
</div>
<!-- Custom Date Fields Group (in-memory filter; one row per :date custom field) -->
<div :if={length(@date_custom_fields) > 0} class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Custom date fields")}
</div>
<div class={
if length(@date_custom_fields) > 5, do: "max-h-60 overflow-y-auto pr-2", else: ""
}>
<fieldset
:for={field <- @date_custom_fields}
class="border-0 border-b border-base-200 last:border-b-0 p-0 m-0 min-w-0 py-2"
>
<legend class="text-sm font-medium mb-1">
{field.name}
</legend>
<div class="flex gap-3 items-end flex-wrap">
<.input
type="date"
id={"cdf-#{field.id}-from"}
name={"#{@custom_date_filter_prefix}#{field.id}_from"}
label={gettext("From")}
class="input input-sm input-bordered"
aria-label={gettext("%{field} from", field: field.name)}
value={custom_date_value_for_input(@date_filters, field.id, :from)}
/>
<.input
type="date"
id={"cdf-#{field.id}-to"}
name={"#{@custom_date_filter_prefix}#{field.id}_to"}
label={gettext("To")}
class="input input-sm input-bordered"
aria-label={gettext("%{field} to", field: field.name)}
value={custom_date_value_for_input(@date_filters, field.id, :to)}
/>
</div>
</fieldset>
</div>
</div>
<!-- Custom Fields Group --> <!-- Custom Fields Group -->
<div :if={length(@boolean_custom_fields) > 0} class="mb-2"> <div :if={length(@boolean_custom_fields) > 0} class="mb-2">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider"> <div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
@ -438,17 +632,27 @@ defmodule MvWeb.Components.MemberFilterComponent do
payment_filter = parse_payment_filter(params) payment_filter = parse_payment_filter(params)
group_filters_parsed = group_filters_parsed =
parse_prefix_filters(params, @group_filter_prefix, &FilterParams.parse_in_not_in_value/1) FilterParams.parse_prefix_filters(
params,
@group_filter_prefix,
&FilterParams.parse_in_not_in_value/1
)
fee_type_filters_parsed = fee_type_filters_parsed =
parse_prefix_filters(params, @fee_type_filter_prefix, &FilterParams.parse_in_not_in_value/1) FilterParams.parse_prefix_filters(
params,
@fee_type_filter_prefix,
&FilterParams.parse_in_not_in_value/1
)
custom_boolean_filters_parsed = parse_custom_boolean_filters(params) custom_boolean_filters_parsed = parse_custom_boolean_filters(params)
new_date_filters = DateFilter.from_params(params, socket.assigns.date_custom_fields)
dispatch_payment_filter_change(socket, payment_filter) dispatch_payment_filter_change(socket, payment_filter)
dispatch_group_filter_changes(socket, group_filters_parsed) dispatch_group_filter_changes(socket, group_filters_parsed)
dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed)
dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed)
dispatch_date_filters_change(socket, new_date_filters)
{:noreply, socket} {:noreply, socket}
end end
@ -486,17 +690,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
end end
end end
defp parse_prefix_filters(params, prefix, parse_value_fn) do
prefix_len = String.length(prefix)
params
|> Enum.filter(fn {key, _} -> String.starts_with?(key, prefix) end)
|> Enum.reduce(%{}, fn {key, value_str}, acc ->
id_str = String.slice(key, prefix_len, String.length(key) - prefix_len)
Map.put(acc, id_str, parse_value_fn.(value_str))
end)
end
defp parse_custom_boolean_filters(params) do defp parse_custom_boolean_filters(params) do
params params
|> Map.get("custom_boolean", %{}) |> Map.get("custom_boolean", %{})
@ -543,6 +736,12 @@ defmodule MvWeb.Components.MemberFilterComponent do
end) end)
end end
defp dispatch_date_filters_change(socket, new_date_filters) do
if new_date_filters != socket.assigns.date_filters do
send(self(), {:date_filters_changed, new_date_filters})
end
end
# Get display label for button # Get display label for button
defp button_label( defp button_label(
cycle_status_filter, cycle_status_filter,
@ -551,14 +750,16 @@ defmodule MvWeb.Components.MemberFilterComponent do
fee_types, fee_types,
fee_type_filters, fee_type_filters,
boolean_custom_fields, boolean_custom_fields,
boolean_filters boolean_filters,
date_filters
) do ) do
active_count = active_count =
count_active_filter_categories( count_active_filter_categories(
cycle_status_filter, cycle_status_filter,
group_filters, group_filters,
fee_type_filters, fee_type_filters,
boolean_filters boolean_filters,
date_filters
) )
if active_count >= 2 do if active_count >= 2 do
@ -579,6 +780,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
map_size(boolean_filters) > 0 -> map_size(boolean_filters) > 0 ->
boolean_filter_label(boolean_custom_fields, boolean_filters) boolean_filter_label(boolean_custom_fields, boolean_filters)
date_filters_active?(date_filters) ->
gettext("Dates")
true -> true ->
gettext("Apply filters") gettext("Apply filters")
end end
@ -589,17 +793,27 @@ defmodule MvWeb.Components.MemberFilterComponent do
cycle_status_filter, cycle_status_filter,
group_filters, group_filters,
fee_type_filters, fee_type_filters,
boolean_filters boolean_filters,
date_filters
) do ) do
[ [
cycle_status_filter, cycle_status_filter,
map_size(group_filters) > 0, map_size(group_filters) > 0,
map_size(fee_type_filters) > 0, map_size(fee_type_filters) > 0,
map_size(boolean_filters) > 0 map_size(boolean_filters) > 0,
date_filters_active?(date_filters)
] ]
|> Enum.count(& &1) |> Enum.count(& &1)
end end
# Date filter is "active" when its state differs from the default — i.e. the
# user selected something other than active-only with no custom date bounds.
defp date_filters_active?(date_filters) when is_map(date_filters) do
date_filters != DateFilter.default()
end
defp date_filters_active?(_), do: false
defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0, defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0,
do: gettext("All") do: gettext("All")
@ -721,7 +935,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
{nil, true} -> "#{base_classes} btn-active" {nil, true} -> "#{base_classes} btn-active"
{:in, true} -> "#{base_classes} btn-success btn-active" {:in, true} -> "#{base_classes} btn-success btn-active"
{:not_in, true} -> "#{base_classes} btn-error btn-active" {:not_in, true} -> "#{base_classes} btn-error btn-active"
_ -> "#{base_classes} btn-outline"
end end
end end
@ -768,4 +981,35 @@ defmodule MvWeb.Components.MemberFilterComponent do
"#{base_classes} btn-outline" "#{base_classes} btn-outline"
end end
end end
# --- Date filter helpers ----------------------------------------------
defp exit_mode(%{exit_date: %{mode: mode}}), do: mode
defp exit_mode(_), do: :active_only
defp exit_mode_label_class(date_filters, expected) do
base_classes = "join-item btn btn-sm"
if exit_mode(date_filters) == expected do
"#{base_classes} btn-active"
else
"#{base_classes} btn"
end
end
defp date_value_for_input(date_filters, field, bound) do
case date_filters do
%{^field => %{^bound => %Date{} = d}} -> Date.to_iso8601(d)
_ -> ""
end
end
defp custom_date_value_for_input(date_filters, field_id, bound) do
key = to_string(field_id)
case Map.get(date_filters, key) do
%{^bound => %Date{} = d} -> Date.to_iso8601(d)
_ -> ""
end
end
end end

View file

@ -52,7 +52,7 @@ defmodule MvWeb.GlobalSettingsLive do
# Get locale from session; same fallback as router/LiveUserAuth (respects config :default_locale in test) # Get locale from session; same fallback as router/LiveUserAuth (respects config :default_locale in test)
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de") locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
Gettext.put_locale(MvWeb.Gettext, locale) _ = Gettext.put_locale(MvWeb.Gettext, locale)
actor = MvWeb.LiveHelpers.current_actor(socket) actor = MvWeb.LiveHelpers.current_actor(socket)
custom_fields = load_custom_fields(actor) custom_fields = load_custom_fields(actor)

View file

@ -836,12 +836,6 @@ defmodule MvWeb.GroupLive.Show do
end end
end end
defp perform_add_members(socket, _group, _member_ids, _actor) do
{:noreply,
socket
|> put_flash(:error, gettext("No members selected."))}
end
defp handle_successful_add_members(socket, group, actor) do defp handle_successful_add_members(socket, group, actor) do
socket = reload_group(socket, group.slug, actor) socket = reload_group(socket, group.slug, actor)

View file

@ -47,14 +47,11 @@ defmodule MvWeb.ImportLive do
# after this limit is reached. # after this limit is reached.
@max_errors 50 @max_errors 50
# Maximum length for error messages before truncation
@max_error_message_length 200
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
# Get locale from session for translations # Get locale from session for translations
locale = session["locale"] || "de" locale = session["locale"] || "de"
Gettext.put_locale(MvWeb.Gettext, locale) _ = Gettext.put_locale(MvWeb.Gettext, locale)
# Get club name from settings # Get club name from settings
club_name = club_name =
@ -193,16 +190,6 @@ defmodule MvWeb.ImportLive do
:error, :error,
gettext("Failed to prepare CSV import: %{reason}", reason: reason) gettext("Failed to prepare CSV import: %{reason}", reason: reason)
)} )}
{:error, error} ->
error_message = format_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to prepare CSV import: %{reason}", reason: error_message)
)}
end end
end end
@ -223,64 +210,6 @@ defmodule MvWeb.ImportLive do
{:noreply, socket} {:noreply, socket}
end end
# Formats error messages for user-friendly display.
#
# Handles various error types including Ash errors, maps with message fields,
# lists of errors, and fallback formatting for unknown types.
@spec format_error_message(any()) :: String.t()
defp format_error_message(error) do
case error do
%Ash.Error.Invalid{} = ash_error ->
format_ash_error(ash_error)
%{message: msg} when is_binary(msg) ->
msg
%{errors: errors} when is_list(errors) ->
format_error_list(errors)
reason when is_binary(reason) ->
reason
other ->
format_unknown_error(other)
end
end
# Formats Ash validation errors for display
defp format_ash_error(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
Enum.map_join(errors, ", ", &format_single_error/1)
end
defp format_ash_error(error) do
format_unknown_error(error)
end
# Formats a list of errors into a readable string
defp format_error_list(errors) do
Enum.map_join(errors, ", ", &format_single_error/1)
end
# Formats a single error item
defp format_single_error(error) when is_map(error) do
Map.get(error, :message) || Map.get(error, :field) || inspect(error, limit: :infinity)
end
defp format_single_error(error) do
to_string(error)
end
# Formats unknown error types with truncation for very long messages
defp format_unknown_error(other) do
error_str = inspect(other, limit: :infinity, pretty: true)
if String.length(error_str) > @max_error_message_length do
String.slice(error_str, 0, @max_error_message_length - 3) <> "..."
else
error_str
end
end
@impl true @impl true
def handle_info({:process_chunk, idx}, socket) do def handle_info({:process_chunk, idx}, socket) do
case socket.assigns do case socket.assigns do
@ -337,32 +266,33 @@ defmodule MvWeb.ImportLive do
actor: actor actor: actor
] ]
if Config.sql_sandbox?() do _ =
run_chunk_with_locale( if Config.sql_sandbox?() do
locale, run_chunk_with_locale(
chunk, locale,
import_state.column_map, chunk,
import_state.custom_field_map, import_state.column_map,
opts, import_state.custom_field_map,
live_view_pid, opts,
idx live_view_pid,
) idx
else )
Task.Supervisor.start_child( else
Mv.TaskSupervisor, Task.Supervisor.start_child(
fn -> Mv.TaskSupervisor,
run_chunk_with_locale( fn ->
locale, run_chunk_with_locale(
chunk, locale,
import_state.column_map, chunk,
import_state.custom_field_map, import_state.column_map,
opts, import_state.custom_field_map,
live_view_pid, opts,
idx live_view_pid,
) idx
end )
) end
end )
end
{:noreply, socket} {:noreply, socket}
end end
@ -378,7 +308,7 @@ defmodule MvWeb.ImportLive do
live_view_pid, live_view_pid,
idx idx
) do ) do
Gettext.put_locale(MvWeb.Gettext, locale) _ = Gettext.put_locale(MvWeb.Gettext, locale)
ImportRunner.process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx) ImportRunner.process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx)
end end

View file

@ -287,8 +287,6 @@ defmodule MvWeb.JoinLive do
end end
end end
defp member_field_input_type(_), do: "text"
defp member_field_atom(field_id) when is_binary(field_id) do defp member_field_atom(field_id) when is_binary(field_id) do
Mv.Constants.member_fields() Mv.Constants.member_fields()
|> Enum.find(&(Atom.to_string(&1) == field_id)) |> Enum.find(&(Atom.to_string(&1) == field_id))

View file

@ -36,6 +36,8 @@ defmodule MvWeb.MemberLive.Index do
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.DateFormatter alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility alias MvWeb.MemberLive.Index.FieldVisibility
alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.FilterParams
@ -87,6 +89,13 @@ defmodule MvWeb.MemberLive.Index do
|> Enum.filter(&(&1.value_type == :boolean)) |> Enum.filter(&(&1.value_type == :boolean))
|> Enum.sort_by(& &1.name, :asc) |> Enum.sort_by(& &1.name, :asc)
# Date-typed custom fields surface in the new "Custom date fields" filter
# section and are needed by DateFilter.from_params/2 to validate UUIDs.
date_custom_fields =
all_custom_fields
|> Enum.filter(&(&1.value_type == :date))
|> Enum.sort_by(& &1.name, :asc)
# Load groups for filter dropdown (sorted by name) # Load groups for filter dropdown (sorted by name)
groups = groups =
Mv.Membership.Group Mv.Membership.Group
@ -143,6 +152,8 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:custom_fields_visible, custom_fields_visible) |> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields) |> assign(:all_custom_fields, all_custom_fields)
|> assign(:boolean_custom_fields, boolean_custom_fields) |> assign(:boolean_custom_fields, boolean_custom_fields)
|> assign(:date_custom_fields, date_custom_fields)
|> assign(:date_filters, DateFilter.default())
|> assign(:all_available_fields, all_available_fields) |> assign(:all_available_fields, all_available_fields)
|> assign(:user_field_selection, initial_selection) |> assign(:user_field_selection, initial_selection)
|> assign(:fields_in_url?, false) |> assign(:fields_in_url?, false)
@ -448,6 +459,25 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: new_path, replace: true)} {:noreply, push_patch(socket, to: new_path, replace: true)}
end end
@impl true
def handle_info({:date_filters_changed, new_date_filters}, socket) do
socket =
socket
|> assign(:date_filters, new_date_filters)
|> load_members()
|> update_selection_assigns()
query_params =
build_query_params(opts_for_query_params(socket, %{date_filters: new_date_filters}))
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
# Backward compatibility: tuple form delegates to map form # Backward compatibility: tuple form delegates to map form
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
handle_info( handle_info(
@ -502,6 +532,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:group_filters, Map.get(opts, :group_filters, %{})) |> assign(:group_filters, Map.get(opts, :group_filters, %{}))
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{})) |> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|> load_members() |> load_members()
|> update_selection_assigns() |> update_selection_assigns()
@ -632,6 +663,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_group_filters(params) |> maybe_update_group_filters(params)
|> maybe_update_fee_type_filters(params) |> maybe_update_fee_type_filters(params)
|> maybe_update_boolean_filters(params) |> maybe_update_boolean_filters(params)
|> maybe_update_date_filters(params)
|> maybe_update_show_current_cycle(params) |> maybe_update_show_current_cycle(params)
|> assign(:fields_in_url?, fields_in_url?) |> assign(:fields_in_url?, fields_in_url?)
|> assign(:query, params["query"]) |> assign(:query, params["query"])
@ -683,7 +715,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters, socket.assigns.boolean_custom_field_filters,
socket.assigns.user_field_selection, socket.assigns.user_field_selection,
socket.assigns[:visible_custom_field_ids] || [] socket.assigns[:visible_custom_field_ids] || [],
socket.assigns[:date_filters]
} }
end end
@ -783,7 +816,12 @@ defmodule MvWeb.MemberLive.Index do
base_params = add_group_filters(base_params, opts.group_filters || %{}) base_params = add_group_filters(base_params, opts.group_filters || %{})
base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{}) base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{})
base_params = add_show_current_cycle(base_params, opts.show_current_cycle) base_params = add_show_current_cycle(base_params, opts.show_current_cycle)
add_boolean_filters(base_params, opts.boolean_filters || %{}) base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
add_date_filters(base_params, opts.date_filters)
end
defp add_date_filters(params, date_filters) do
Map.merge(params, DateFilter.to_params(date_filters))
end end
defp opts_for_query_params(socket, overrides \\ %{}) do defp opts_for_query_params(socket, overrides \\ %{}) do
@ -795,7 +833,8 @@ defmodule MvWeb.MemberLive.Index do
group_filters: socket.assigns[:group_filters] || %{}, group_filters: socket.assigns[:group_filters] || %{},
show_current_cycle: socket.assigns.show_current_cycle, show_current_cycle: socket.assigns.show_current_cycle,
boolean_filters: socket.assigns.boolean_custom_field_filters || %{}, boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
fee_type_filters: socket.assigns[:fee_type_filters] || %{} fee_type_filters: socket.assigns[:fee_type_filters] || %{},
date_filters: socket.assigns.date_filters
} }
|> Map.merge(overrides) |> Map.merge(overrides)
end end
@ -941,26 +980,7 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.new() |> Ash.Query.new()
|> Ash.Query.select(@overview_fields) |> Ash.Query.select(@overview_fields)
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] query = load_custom_field_values(query, compute_ids_to_load(socket))
boolean_custom_fields_map =
socket.assigns.boolean_custom_fields
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
active_boolean_filter_ids =
socket.assigns.boolean_custom_field_filters
|> Map.keys()
|> Enum.filter(fn id_str ->
String.length(id_str) <= @max_uuid_length &&
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
Map.has_key?(boolean_custom_fields_map, id_str)
end)
ids_to_load =
(visible_custom_field_ids ++ active_boolean_filter_ids)
|> Enum.uniq()
query = load_custom_field_values(query, ids_to_load)
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
@ -984,6 +1004,13 @@ defmodule MvWeb.MemberLive.Index do
query = query =
apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types]) apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types])
# Built-in date filters (join_date, exit_date) are pushed to the DB so
# excluded rows never reach the BEAM. The active_only default is part of
# this — fresh load returns only members without an exit_date or with an
# exit_date strictly in the future.
query =
DateFilter.apply_ash_filter(query, socket.assigns.date_filters)
# Use ALL custom fields for sorting (not just show_in_overview subset) # Use ALL custom fields for sorting (not just show_in_overview subset)
custom_fields_for_sort = socket.assigns.all_custom_fields custom_fields_for_sort = socket.assigns.all_custom_fields
@ -1003,21 +1030,7 @@ defmodule MvWeb.MemberLive.Index do
# Custom field values are already filtered at the database level in load_custom_field_values/2 # Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore # No need for in-memory filtering anymore
# Apply cycle status filter if set members = apply_in_memory_filters(members, socket)
members =
apply_cycle_status_filter(
members,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
# Apply boolean custom field filters if set
members =
apply_boolean_custom_field_filters(
members,
socket.assigns.boolean_custom_field_filters,
socket.assigns.all_custom_fields
)
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status # Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
@ -1037,6 +1050,55 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :members, members) assign(socket, :members, members)
end end
# Collects every custom field UUID whose values must be loaded for a given
# render — visible columns plus any active boolean or date filter. Kept as a
# standalone helper so load_members/1 stays under the credo complexity bar.
defp compute_ids_to_load(socket) do
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
boolean_custom_fields_map =
socket.assigns.boolean_custom_fields
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
active_boolean_filter_ids =
socket.assigns.boolean_custom_field_filters
|> Map.keys()
|> Enum.filter(fn id_str ->
String.length(id_str) <= @max_uuid_length &&
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
Map.has_key?(boolean_custom_fields_map, id_str)
end)
date_custom_fields = socket.assigns[:date_custom_fields] || []
active_date_filter_ids =
DateFilter.active_custom_field_ids(
socket.assigns.date_filters,
date_custom_fields
)
(visible_custom_field_ids ++ active_boolean_filter_ids ++ active_date_filter_ids)
|> Enum.uniq()
end
# Post-DB filtering: cycle status, boolean custom fields, and custom date
# fields. Date custom fields are last so they see the already-narrowed list.
defp apply_in_memory_filters(members, socket) do
members
|> apply_cycle_status_filter(
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
|> apply_boolean_custom_field_filters(
socket.assigns.boolean_custom_field_filters,
socket.assigns.all_custom_fields
)
|> DateFilter.apply_in_memory(
socket.assigns.date_filters,
socket.assigns[:date_custom_fields] || []
)
end
defp load_custom_field_values(query, []), do: query defp load_custom_field_values(query, []), do: query
defp load_custom_field_values(query, custom_field_ids) do defp load_custom_field_values(query, custom_field_ids) do
@ -1156,8 +1218,6 @@ defmodule MvWeb.MemberLive.Index do
end end
end end
defp apply_one_fee_type_filter(query, _, _), do: query
defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current) defp apply_cycle_status_filter(members, status, show_current)
@ -1235,8 +1295,6 @@ defmodule MvWeb.MemberLive.Index do
end end
end end
defp valid_sort_field?(_), do: false
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
non_sortable_fields = [:notes] non_sortable_fields = [:notes]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
@ -1496,8 +1554,6 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :group_filters, Map.take(filters, valid_group_ids)) assign(socket, :group_filters, Map.take(filters, valid_group_ids))
end end
defp maybe_update_group_filters(socket, _), do: socket
defp maybe_update_fee_type_filters(socket, params) when is_map(params) do defp maybe_update_fee_type_filters(socket, params) when is_map(params) do
prefix = @fee_type_filter_prefix prefix = @fee_type_filter_prefix
prefix_len = String.length(prefix) prefix_len = String.length(prefix)
@ -1524,8 +1580,6 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids)) assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids))
end end
defp maybe_update_fee_type_filters(socket, _), do: socket
defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do
key_str = to_string(key) key_str = to_string(key)
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len) raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
@ -1649,24 +1703,20 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_show_current_cycle(socket, _params), do: socket defp maybe_update_show_current_cycle(socket, _params), do: socket
# URL params are the source of truth for filter state on every navigation.
# When no date filter params are present, this falls through to the
# active_only default — exactly the spec behavior for fresh load (§1.1).
defp maybe_update_date_filters(socket, params) when is_map(params) do
date_custom_fields = socket.assigns[:date_custom_fields] || []
assign(socket, :date_filters, DateFilter.from_params(params, date_custom_fields))
end
# ------------------------------------------------------------- # -------------------------------------------------------------
# Custom Field Value Helpers # Custom Field Value Helpers
# ------------------------------------------------------------- # -------------------------------------------------------------
def get_custom_field_value(member, custom_field) do def get_custom_field_value(member, custom_field) do
case member.custom_field_values do CustomFieldValueLookup.find_by_field(member, custom_field)
nil ->
nil
values when is_list(values) ->
Enum.find(values, fn cfv ->
cfv.custom_field_id == custom_field.id or
(match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id)
end)
_ ->
nil
end
end end
def get_boolean_custom_field_value(member, custom_field) do def get_boolean_custom_field_value(member, custom_field) do
@ -1725,29 +1775,12 @@ defmodule MvWeb.MemberLive.Index do
end end
defp matches_filter?(member, custom_field_id_str, filter_value) do defp matches_filter?(member, custom_field_id_str, filter_value) do
case find_custom_field_value_by_id(member, custom_field_id_str) do case CustomFieldValueLookup.find_by_id(member, custom_field_id_str) do
nil -> false nil -> false
cfv -> extract_boolean_value(cfv.value) == filter_value cfv -> extract_boolean_value(cfv.value) == filter_value
end end
end end
defp find_custom_field_value_by_id(member, custom_field_id_str) do
case member.custom_field_values do
nil ->
nil
values when is_list(values) ->
Enum.find(values, fn cfv ->
to_string(cfv.custom_field_id) == custom_field_id_str or
(match?(%{custom_field: %{id: _}}, cfv) &&
to_string(cfv.custom_field.id) == custom_field_id_str)
end)
_ ->
nil
end
end
def format_selected_member_emails(members, selected_members) do def format_selected_member_emails(members, selected_members) do
members members
|> Enum.filter(fn member -> |> Enum.filter(fn member ->

View file

@ -54,6 +54,8 @@
fee_type_filters={@fee_type_filters} fee_type_filters={@fee_type_filters}
boolean_custom_fields={@boolean_custom_fields} boolean_custom_fields={@boolean_custom_fields}
boolean_filters={@boolean_custom_field_filters} boolean_filters={@boolean_custom_field_filters}
date_custom_fields={@date_custom_fields}
date_filters={@date_filters}
member_count={length(@members)} member_count={length(@members)}
/> />
<.tooltip <.tooltip

View file

@ -0,0 +1,61 @@
defmodule MvWeb.MemberLive.Index.CustomFieldValueLookup do
@moduledoc """
Centralized lookup for a member's `custom_field_values` entry that matches
a given custom field.
Two callable shapes:
* `find_by_id/2` match against a stringified UUID (used by the URL-param
driven date and boolean filter pipelines).
* `find_by_field/2` match against a loaded `%CustomField{}` struct
(used by the table rendering / display path that already has the
field record at hand).
Both forms handle the two CFV layouts that appear on a loaded member:
* the direct foreign key `%{custom_field_id: id, value: ...}`
* the nested loaded relation `%{custom_field: %{id: id, ...}, value: ...}`
All non-loaded or empty containers (`nil`, `%Ash.NotLoaded{}`, empty list)
return `nil`.
"""
@doc """
Returns the CFV entry whose custom field id, compared as a string, equals
`custom_field_id_str`. Returns `nil` when no entry matches or the
`custom_field_values` association is not a list.
"""
@spec find_by_id(map(), String.t()) :: map() | nil
def find_by_id(member, custom_field_id_str) when is_binary(custom_field_id_str) do
member
|> Map.get(:custom_field_values)
|> find_in(fn cfv -> cfv_id_string(cfv) == custom_field_id_str end)
end
@doc """
Returns the CFV entry whose custom field id matches the given
`custom_field` struct's `:id`. The comparison is identity-based (not
stringified) because both sides are typically `Ash.UUID` binaries; falls
back to string comparison so atom-id callers still work.
"""
@spec find_by_field(map(), map()) :: map() | nil
def find_by_field(member, %{id: field_id}) do
member
|> Map.get(:custom_field_values)
|> find_in(fn cfv -> cfv_id(cfv) == field_id end)
end
defp find_in(values, predicate) when is_list(values), do: Enum.find(values, predicate)
defp find_in(_other, _predicate), do: nil
defp cfv_id(%{custom_field_id: id}) when not is_nil(id), do: id
defp cfv_id(%{custom_field: %{id: id}}) when not is_nil(id), do: id
defp cfv_id(_), do: nil
defp cfv_id_string(cfv) do
case cfv_id(cfv) do
nil -> nil
id -> to_string(id)
end
end
end

View file

@ -0,0 +1,454 @@
defmodule MvWeb.MemberLive.Index.DateFilter do
@moduledoc """
Encapsulates the complete lifecycle of date-range filters used on the
member overview page.
Owns:
- the default filter state (active members only)
- URL encoding / decoding of filter state
- DB-level Ash expression construction for built-in date fields
(`join_date`, `exit_date`)
- in-memory predicates for custom date-typed custom fields
## Filter state shape
%{
join_date: %{from: nil | %Date{}, to: nil | %Date{}},
exit_date: %{
mode: :active_only | :inactive_only | :all | :custom,
from: nil | %Date{},
to: nil | %Date{}
},
# optional custom date field entries (UUID string keys):
"<uuid>" => %{from: nil | %Date{}, to: nil | %Date{}}
}
The default mode for `exit_date` is `:active_only`, which means
`exit_date IS NULL OR exit_date > today` a member who left today is hidden.
"""
require Ash.Query
import Ash.Expr
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
alias MvWeb.MemberLive.Index.FilterParams
@join_date_from_param Mv.Constants.join_date_from_param()
@join_date_to_param Mv.Constants.join_date_to_param()
@exit_date_mode_param Mv.Constants.exit_date_mode_param()
@exit_date_from_param Mv.Constants.exit_date_from_param()
@exit_date_to_param Mv.Constants.exit_date_to_param()
@custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix()
@max_uuid_length Mv.Constants.max_uuid_length()
# An id stripped from a cdf_-prefixed param still has its `_from` / `_to`
# bound suffix attached when we first see it. The longest legal suffix is
# `_from` (5 chars), so the upper bound on a valid suffixed_id is
# @max_uuid_length + 5. Anything longer cannot map to a known custom date
# field and is rejected before further string work — matching the same
# DoS-protection contract enforced by the boolean / group / fee_type
# filter parsers in `MvWeb.MemberLive.Index`.
@max_suffixed_id_length @max_uuid_length + 5
@doc """
Returns the default date filter state used on fresh page load and after
"Clear filters". `exit_date` is set to `:active_only`; all other bounds are nil.
"""
@spec default() :: %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :active_only, from: nil, to: nil}
}
def default do
%{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :active_only, from: nil, to: nil}
}
end
@doc """
Decodes URL params into a date filter state map.
Recognized keys:
* `"jd_from"` / `"jd_to"` join_date bounds (ISO-8601 dates)
* `"ed_mode"` exit_date mode (`"active_only"` | `"inactive_only"` |
`"all"` | `"custom"`); absent or unknown values fall back to
`:active_only`
* `"ed_from"` / `"ed_to"` exit_date bounds (ISO-8601 dates, used when
`ed_mode=custom`)
* `"cdf_<uuid>_from"` / `"cdf_<uuid>_to"` custom date field bounds;
the UUID must appear (by `to_string/1` on its `:id`) in
`date_custom_fields`, otherwise the entry is dropped
Malformed ISO-8601 strings are silently discarded; the corresponding bound
stays `nil`. No exception is raised for any malformed input.
"""
@spec from_params(map(), list()) :: map()
def from_params(params, date_custom_fields)
when is_map(params) and is_list(date_custom_fields) do
base = %{
join_date: %{
from: parse_date(Map.get(params, @join_date_from_param)),
to: parse_date(Map.get(params, @join_date_to_param))
},
exit_date: %{
mode: parse_exit_date_mode(Map.get(params, @exit_date_mode_param)),
from: parse_date(Map.get(params, @exit_date_from_param)),
to: parse_date(Map.get(params, @exit_date_to_param))
}
}
parse_custom_date_filters(params, date_custom_fields, base)
end
@doc """
Encodes a date filter state map into a URL params map (string keys, string
values).
Encoding rules:
* `join_date` from/to `"jd_from"` / `"jd_to"` (omitted when nil)
* `exit_date` mode
- `:active_only` is the default and is omitted entirely (no `ed_mode`,
no bounds a fresh URL is the canonical representation of the default
state)
- `:all` / `:inactive_only` `"ed_mode"` only; bounds are omitted
- `:custom` `"ed_mode" => "custom"` plus `"ed_from"` / `"ed_to"`
when those bounds are set
* custom date field entries (UUID string keys) `"cdf_<uuid>_from"` /
`"cdf_<uuid>_to"`; each bound is included only when non-nil; an entry
with both bounds nil produces no params
All dates are serialized via `Date.to_iso8601/1`.
"""
@spec to_params(map()) :: %{optional(String.t()) => String.t()}
def to_params(filters) when is_map(filters) do
%{}
|> put_join_date_params(Map.get(filters, :join_date, %{}))
|> put_exit_date_params(Map.get(filters, :exit_date, %{}))
|> put_custom_date_params(filters)
end
@doc """
Applies the DB-level portion of the date filter `join_date` and
`exit_date` constraints to the given Ash query.
Exit_date semantics by mode:
* `:active_only` `is_nil(exit_date) or exit_date > today`
* `:inactive_only` `not is_nil(exit_date) and exit_date <= today`
* `:all` no filter added for exit_date
* `:custom` `not is_nil(exit_date)` plus the active bounds; if both
bounds are nil, no filter is added (the user picked "custom" but
entered nothing)
Join_date is purely a range filter nil join_date is always excluded when
any bound is set:
* `from` set `not is_nil(join_date) and join_date >= from`
* `to` set `not is_nil(join_date) and join_date <= to`
* neither set no filter
Today's date is captured via `Date.utc_today/0`; callers needing a frozen
clock should wrap the call site, not this function.
The caller is expected to pass an `%Ash.Query{}` (typically built with
`Ash.Query.new/1` or via earlier filter chaining), matching the convention
used by the sibling `apply_search_filter/2`, `apply_group_filters/3`, and
`apply_fee_type_filters/3` helpers in `MvWeb.MemberLive.Index`.
"""
@spec apply_ash_filter(Ash.Query.t(), map()) :: Ash.Query.t()
def apply_ash_filter(%Ash.Query{} = query, filters) when is_map(filters) do
exit_bounds = normalize_exit_bounds(Map.get(filters, :exit_date, %{}))
join_bounds = normalize_join_bounds(Map.get(filters, :join_date, %{}))
query
|> apply_exit_date_filter(exit_bounds)
|> apply_join_date_filter(join_bounds)
end
# Defensive shape normalization: callers may supply maps where one bound key
# is absent entirely (not just nil). Pattern-match heads require both keys
# present, so we backfill nil here.
defp normalize_exit_bounds(bounds) when is_map(bounds) do
%{
mode: Map.get(bounds, :mode, :active_only),
from: Map.get(bounds, :from),
to: Map.get(bounds, :to)
}
end
defp normalize_exit_bounds(_), do: %{mode: :active_only, from: nil, to: nil}
defp normalize_join_bounds(bounds) when is_map(bounds) do
%{from: Map.get(bounds, :from), to: Map.get(bounds, :to)}
end
defp normalize_join_bounds(_), do: %{from: nil, to: nil}
defp apply_exit_date_filter(query, %{mode: :all}), do: query
defp apply_exit_date_filter(query, %{mode: :active_only}) do
today = Date.utc_today()
Ash.Query.filter(query, expr(is_nil(exit_date) or exit_date > ^today))
end
defp apply_exit_date_filter(query, %{mode: :inactive_only}) do
today = Date.utc_today()
Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date <= ^today))
end
defp apply_exit_date_filter(query, %{mode: :custom, from: nil, to: nil}), do: query
defp apply_exit_date_filter(query, %{mode: :custom, from: from, to: nil}) do
Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date >= ^from))
end
defp apply_exit_date_filter(query, %{mode: :custom, from: nil, to: to}) do
Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date <= ^to))
end
defp apply_exit_date_filter(query, %{mode: :custom, from: from, to: to}) do
Ash.Query.filter(
query,
expr(not is_nil(exit_date) and exit_date >= ^from and exit_date <= ^to)
)
end
defp apply_exit_date_filter(query, _), do: query
defp apply_join_date_filter(query, %{from: nil, to: nil}), do: query
defp apply_join_date_filter(query, %{from: from, to: nil}) when not is_nil(from) do
Ash.Query.filter(query, expr(not is_nil(join_date) and join_date >= ^from))
end
defp apply_join_date_filter(query, %{from: nil, to: to}) when not is_nil(to) do
Ash.Query.filter(query, expr(not is_nil(join_date) and join_date <= ^to))
end
defp apply_join_date_filter(query, %{from: from, to: to})
when not is_nil(from) and not is_nil(to) do
Ash.Query.filter(
query,
expr(not is_nil(join_date) and join_date >= ^from and join_date <= ^to)
)
end
defp apply_join_date_filter(query, _), do: query
@doc """
Applies the in-memory portion of the date filter custom date fields
whose values live in JSONB-backed `custom_field_values`.
Behavior:
* Only entries whose UUID key matches a `date_custom_fields` entry
(by `to_string(field.id)` and `value_type == :date`) are considered.
* Entries with both bounds nil add no constraint.
* For an active entry, a member is kept iff its custom field value is
present AND the value (unwrapped from `%Ash.Union{type: :date}`)
satisfies `value >= from` (when from set) AND `value <= to`
(when to set).
* Members with `custom_field_values` nil, `%Ash.NotLoaded{}`, an empty
list, or no entry for the active field are excluded.
* Non-date `Ash.Union` types are treated as "no value" and exclude the
member.
Returns the filtered list of members (order preserved).
"""
@spec apply_in_memory([map()], map(), [map()]) :: [map()]
def apply_in_memory(members, filters, date_custom_fields)
when is_list(members) and is_map(filters) and is_list(date_custom_fields) do
active_filters = active_custom_date_filters(filters, date_custom_fields)
if active_filters == [] do
members
else
Enum.filter(members, &matches_all_custom_dates?(&1, active_filters))
end
end
@doc """
Returns the UUID string keys of `filters` that name an active (at-least-one-
bound-set) custom date field. The UUID must appear in `date_custom_fields`
(matched by `to_string(field.id)` and `value_type == :date`); other entries
are dropped.
Use this to compute which custom field values must be loaded so the
in-memory predicate (`apply_in_memory/3`) has the data it needs.
"""
@spec active_custom_field_ids(map(), [map()]) :: [String.t()]
def active_custom_field_ids(filters, date_custom_fields)
when is_map(filters) and is_list(date_custom_fields) do
filters
|> active_custom_date_filters(date_custom_fields)
|> Enum.map(fn {id, _bounds} -> id end)
end
defp matches_all_custom_dates?(member, active_filters) do
Enum.all?(active_filters, fn {id, bounds} ->
member_matches_custom_date?(member, id, bounds)
end)
end
defp active_custom_date_filters(filters, date_custom_fields) do
valid_ids = valid_custom_date_field_ids(date_custom_fields)
filters
|> Enum.filter(fn
{key, %{from: from, to: to}} when is_binary(key) ->
MapSet.member?(valid_ids, key) and (not is_nil(from) or not is_nil(to))
_ ->
false
end)
end
defp member_matches_custom_date?(member, custom_field_id, %{from: from, to: to}) do
case extract_member_date(member, custom_field_id) do
%Date{} = date -> within_bounds?(date, from, to)
_ -> false
end
end
defp extract_member_date(member, custom_field_id) do
member
|> CustomFieldValueLookup.find_by_id(custom_field_id)
|> extract_date_from_cfv()
end
defp extract_date_from_cfv(nil), do: nil
defp extract_date_from_cfv(%{value: value}), do: extract_date_value(value)
defp extract_date_from_cfv(_), do: nil
defp extract_date_value(%Ash.Union{value: %Date{} = date, type: :date}), do: date
defp extract_date_value(_), do: nil
defp within_bounds?(%Date{} = date, from, to) do
from_ok? = is_nil(from) or Date.compare(date, from) != :lt
to_ok? = is_nil(to) or Date.compare(date, to) != :gt
from_ok? and to_ok?
end
defp put_join_date_params(params, %{from: from, to: to}) do
params
|> maybe_put_date(@join_date_from_param, from)
|> maybe_put_date(@join_date_to_param, to)
end
defp put_join_date_params(params, _), do: params
defp put_exit_date_params(params, %{mode: :active_only}), do: params
defp put_exit_date_params(params, %{mode: mode})
when mode in [:all, :inactive_only] do
Map.put(params, @exit_date_mode_param, Atom.to_string(mode))
end
defp put_exit_date_params(params, %{mode: :custom, from: from, to: to}) do
params
|> Map.put(@exit_date_mode_param, "custom")
|> maybe_put_date(@exit_date_from_param, from)
|> maybe_put_date(@exit_date_to_param, to)
end
defp put_exit_date_params(params, _), do: params
defp put_custom_date_params(params, filters) do
prefix = @custom_date_filter_prefix
filters
|> Enum.filter(fn {key, _value} -> is_binary(key) end)
|> Enum.reduce(params, fn {id, %{from: from, to: to}}, acc ->
acc
|> maybe_put_date("#{prefix}#{id}_from", from)
|> maybe_put_date("#{prefix}#{id}_to", to)
end)
end
defp maybe_put_date(params, _key, nil), do: params
defp maybe_put_date(params, key, %Date{} = date),
do: Map.put(params, key, Date.to_iso8601(date))
defp parse_date(nil), do: nil
defp parse_date(value) when is_binary(value) do
case Date.from_iso8601(String.trim(value)) do
{:ok, date} -> date
_ -> nil
end
end
defp parse_date(_), do: nil
defp parse_exit_date_mode("all"), do: :all
defp parse_exit_date_mode("inactive_only"), do: :inactive_only
defp parse_exit_date_mode("custom"), do: :custom
defp parse_exit_date_mode("active_only"), do: :active_only
defp parse_exit_date_mode(_), do: :active_only
defp parse_custom_date_filters(params, date_custom_fields, base) do
valid_ids = valid_custom_date_field_ids(date_custom_fields)
# FilterParams.parse_prefix_filters narrows the params map to the
# cdf_-prefixed subset once; the per-entry work below scales with the
# date filter count, not the full form-param map size.
params
|> FilterParams.parse_prefix_filters(@custom_date_filter_prefix, & &1)
|> Enum.reduce(base, fn {suffixed_id, value}, acc ->
with true <- bounded_id?(suffixed_id),
{id, bound} <- split_suffix(suffixed_id),
true <- MapSet.member?(valid_ids, id),
%Date{} = date <- parse_date(value) do
update_custom_date_entry(acc, id, bound, date)
else
_ -> acc
end
end)
end
# Reject any suffixed_id that could not possibly fit a UUID + bound suffix
# before doing further string work. This is the DoS-protection contract
# used by the boolean / group / fee_type filter parsers in
# `MvWeb.MemberLive.Index` (see `process_boolean_filter_param/5`,
# `add_group_filter_entry/4`, `add_fee_type_filter_entry/4`).
defp bounded_id?(suffixed_id) when is_binary(suffixed_id),
do: String.length(suffixed_id) <= @max_suffixed_id_length
defp bounded_id?(_), do: false
defp date_field?(%{value_type: :date}), do: true
defp date_field?(_), do: false
# Single source of truth for the set of valid custom-date-field UUID strings.
# Used both when parsing URL params (to drop bogus UUIDs) and when computing
# which active filter entries actually correspond to a known date field.
defp valid_custom_date_field_ids(date_custom_fields) do
date_custom_fields
|> Enum.filter(&date_field?/1)
|> MapSet.new(&to_string(&1.id))
end
defp split_suffix(suffixed_id) do
cond do
String.ends_with?(suffixed_id, "_from") ->
{String.replace_suffix(suffixed_id, "_from", ""), :from}
String.ends_with?(suffixed_id, "_to") ->
{String.replace_suffix(suffixed_id, "_to", ""), :to}
true ->
:error
end
end
defp update_custom_date_entry(acc, id, bound, date) do
current = Map.get(acc, id, %{from: nil, to: nil})
Map.put(acc, id, Map.put(current, bound, date))
end
end

View file

@ -103,8 +103,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
end) end)
end end
defp parse_cookie_header(_), do: %{}
@doc """ @doc """
Saves field selection to cookie. Saves field selection to cookie.
@ -218,8 +216,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
end end
end end
defp parse_json(_), do: %{}
# Parses a comma-separated string of field names # Parses a comma-separated string of field names
defp parse_fields_string(fields_string) do defp parse_fields_string(fields_string) do
fields_string fields_string

View file

@ -190,7 +190,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
These fields are not in the database; they must not be used for Ash query These fields are not in the database; they must not be used for Ash query
select/sort. Use this to filter sort options and validate sort_field. select/sort. Use this to filter sort options and validate sort_field.
""" """
@spec computed_member_fields() :: [atom()] @spec computed_member_fields() :: [:membership_fee_status | :membership_fee_type | :groups, ...]
def computed_member_fields, do: @pseudo_member_fields def computed_member_fields, do: @pseudo_member_fields
@doc """ @doc """

View file

@ -1,8 +1,12 @@
defmodule MvWeb.MemberLive.Index.FilterParams do defmodule MvWeb.MemberLive.Index.FilterParams do
@moduledoc """ @moduledoc """
Shared parsing helpers for member list filter URL/params (in/not_in style). Shared parsing helpers for member list filter URL/params.
Used by MemberLive.Index and MemberFilterComponent to avoid duplication and recursion bugs.
Used by `MvWeb.MemberLive.Index`, `MvWeb.Components.MemberFilterComponent`,
and `MvWeb.MemberLive.Index.DateFilter` to avoid duplication and to keep
param-extraction logic in one place.
""" """
@doc """ @doc """
Parses a value for group or fee-type filter params. Parses a value for group or fee-type filter params.
Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion. Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion.
@ -19,4 +23,29 @@ defmodule MvWeb.MemberLive.Index.FilterParams do
end end
def parse_in_not_in_value(_), do: nil def parse_in_not_in_value(_), do: nil
@doc """
Selects every `{key, value}` pair in `params` whose `key` is a binary that
starts with `prefix`, strips the prefix from the key, runs `parse_value_fn`
on the value, and accumulates the results into a map.
Non-binary keys are ignored. Exactly one occurrence of the prefix is
stripped (so a key like `"p_p_abc"` with prefix `"p_"` yields id `"p_abc"`).
The prefix-match filter is applied before the reduce so unrelated params
(e.g. `query`, `sort_field`, other-prefix filters) do not enter the
per-entry work keeping the cost proportional to the matched subset on
every `phx-change` keystroke.
"""
@spec parse_prefix_filters(map(), String.t(), (String.t() -> term())) ::
%{optional(String.t()) => term()}
def parse_prefix_filters(params, prefix, parse_value_fn)
when is_map(params) and is_binary(prefix) and is_function(parse_value_fn, 1) do
params
|> Enum.filter(fn {key, _} -> is_binary(key) and String.starts_with?(key, prefix) end)
|> Enum.reduce(%{}, fn {key, value}, acc ->
id = String.replace_prefix(key, prefix, "")
Map.put(acc, id, parse_value_fn.(value))
end)
end
end end

View file

@ -1027,7 +1027,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:create_cycle_error, format_error(error))} |> assign(:create_cycle_error, format_error(error))}
end end
else else
:error -> {:error, reason} when reason in [:invalid_format, :invalid_date, :incompatible_calendars] ->
{:noreply, {:noreply,
socket socket
|> assign(:create_cycle_error, gettext("Invalid date format"))} |> assign(:create_cycle_error, gettext("Invalid date format"))}

View file

@ -464,7 +464,6 @@ defmodule MvWeb.MembershipFeeSettingsLive do
Enum.map_join(error.errors, ", ", fn e -> e.message end) Enum.map_join(error.errors, ", ", fn e -> e.message end)
end end
defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred") defp format_error(_error), do: gettext("An error occurred")
defp assign_form(%{assigns: %{settings: settings}} = socket) do defp assign_form(%{assigns: %{settings: settings}} = socket) do

View file

@ -326,7 +326,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
case submit_form(socket.assigns.form, params, actor) do case submit_form(socket.assigns.form, params, actor) do
{:ok, membership_fee_type} -> {:ok, membership_fee_type} ->
notify_parent({:saved, membership_fee_type}) _ = notify_parent({:saved, membership_fee_type})
socket = socket =
socket socket
@ -341,7 +341,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
end end
end end
@spec notify_parent(any()) :: any() @spec notify_parent(any()) :: {module(), any()}
defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()

View file

@ -214,7 +214,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
Enum.map_join(error.errors, ", ", fn e -> e.message end) Enum.map_join(error.errors, ", ", fn e -> e.message end)
end end
defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred") defp format_error(_error), do: gettext("An error occurred")
# Info card explaining the membership fee type concept # Info card explaining the membership fee type concept

View file

@ -165,7 +165,7 @@ defmodule MvWeb.RoleLive.Form do
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, role_params, actor) do case MvWeb.LiveHelpers.submit_form(socket.assigns.form, role_params, actor) do
{:ok, role} -> {:ok, role} ->
notify_parent({:saved, role}) _ = notify_parent({:saved, role})
redirect_path = redirect_path =
if socket.assigns.return_to == "show" do if socket.assigns.return_to == "show" do
@ -186,7 +186,7 @@ defmodule MvWeb.RoleLive.Form do
end end
end end
@spec notify_parent(any()) :: any() @spec notify_parent(any()) :: {module(), any()}
defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()

View file

@ -734,7 +734,7 @@ defmodule MvWeb.UserLive.Form do
end end
defp handle_save_success(socket, updated_user) do defp handle_save_success(socket, updated_user) do
notify_parent({:saved, updated_user}) _ = notify_parent({:saved, updated_user})
action = get_action_name(socket.assigns.form.source.type) action = get_action_name(socket.assigns.form.source.type)
@ -775,7 +775,7 @@ defmodule MvWeb.UserLive.Form do
)} )}
end end
@spec notify_parent(any()) :: any() @spec notify_parent(any()) :: {module(), any()}
defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
# Helper to ignore keyboard events when dropdown is closed # Helper to ignore keyboard events when dropdown is closed
@ -913,7 +913,7 @@ defmodule MvWeb.UserLive.Form do
MemberResource.filter_by_email_match(members, user_email_str) MemberResource.filter_by_email_match(members, user_email_str)
end end
@spec load_roles(any()) :: [Mv.Authorization.Role.t()] @spec load_roles(any()) :: [Mv.Authorization.Role.t()] | Ash.Page.page()
defp load_roles(actor) do defp load_roles(actor) do
case Authorization.list_roles(actor: actor) do case Authorization.list_roles(actor: actor) do
{:ok, roles} -> roles {:ok, roles} -> roles
@ -922,7 +922,7 @@ defmodule MvWeb.UserLive.Form do
end end
# Extract user-friendly error message from Ash.Error # Extract user-friendly error message from Ash.Error
@spec extract_error_message(any()) :: String.t() @spec extract_error_message(Ash.Error.t()) :: String.t()
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
# Take first error and extract message # Take first error and extract message
case List.first(errors) do case List.first(errors) do
@ -932,6 +932,5 @@ defmodule MvWeb.UserLive.Form do
end end
end end
defp extract_error_message(error) when is_binary(error), do: error
defp extract_error_message(_), do: gettext("Unknown error") defp extract_error_message(_), do: gettext("Unknown error")
end end

View file

@ -22,7 +22,7 @@ defmodule MvWeb.LiveHelpers do
def on_mount(:default, _params, session, socket) do def on_mount(:default, _params, session, socket) do
locale = session["locale"] || "de" locale = session["locale"] || "de"
Gettext.put_locale(locale) _ = Gettext.put_locale(locale)
# Browser timezone from LiveSocket connect params (set in app.js via Intl API) # Browser timezone from LiveSocket connect params (set in app.js via Intl API)
connect_params = socket.private[:connect_params] || %{} connect_params = socket.private[:connect_params] || %{}
@ -145,7 +145,10 @@ defmodule MvWeb.LiveHelpers do
end end
""" """
@spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) :: @spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) ::
{:ok, Ash.Resource.t()} | {:error, AshPhoenix.Form.t()} {:ok, Ash.Resource.record() | nil | [Ash.Notifier.Notification.t()]}
| {:ok, Ash.Resource.record(), [Ash.Notifier.Notification.t()]}
| :ok
| {:error, AshPhoenix.Form.t()}
def submit_form(form, params, actor) do def submit_form(form, params, actor) do
AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor)) AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor))
end end

View file

@ -31,27 +31,24 @@ defmodule MvWeb.LiveUserAuth do
end end
end end
def on_mount(:live_user_required, _params, session, socket) do def on_mount(:live_user_required, _params, _session, socket) do
socket = LiveSession.assign_new_resources(socket, session)
case socket.assigns do case socket.assigns do
%{current_user: %{} = user} -> %{current_user: %{} = user} ->
{:cont, assign(socket, :current_user, user)} {:cont, assign(socket, :current_user, user)}
_ -> _ ->
socket = LiveView.redirect(socket, to: ~p"/sign-in") {:halt, LiveView.redirect(socket, to: ~p"/sign-in")}
{:halt, socket}
end end
end end
def on_mount(:live_no_user, _params, session, socket) do def on_mount(:live_no_user, _params, session, socket) do
# Set the locale for not logged in user (default from config, "de" in dev/prod). # Set the locale for not logged in user (default from config, "de" in dev/prod).
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de") locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
Gettext.put_locale(MvWeb.Gettext, locale) _ = Gettext.put_locale(MvWeb.Gettext, locale)
{:cont, assign(socket, :locale, locale)} socket = assign(socket, :locale, locale)
if socket.assigns[:current_user] do if socket.assigns[:current_user] do
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")} {:halt, LiveView.redirect(socket, to: ~p"/")}
else else
{:cont, assign(socket, :current_user, nil)} {:cont, assign(socket, :current_user, nil)}
end end

View file

@ -112,7 +112,7 @@ defmodule MvWeb.Router do
# ASHAUTHENTICATION GENERATED AUTH ROUTES # ASHAUTHENTICATION GENERATED AUTH ROUTES
auth_routes AuthController, Mv.Accounts.User, path: "/auth" auth_routes AuthController, Mv.Accounts.User, path: "/auth"
sign_out_route AuthController sign_out_route AuthController, "/sign-out", live_view: MvWeb.SignOutLive
# Remove these if you'd like to use your own authentication views # Remove these if you'd like to use your own authentication views
sign_in_route register_path: "/register", sign_in_route register_path: "/register",
@ -188,7 +188,7 @@ defmodule MvWeb.Router do
get_locale_from_cookie(conn) || get_locale_from_cookie(conn) ||
extract_locale_from_headers(conn.req_headers) extract_locale_from_headers(conn.req_headers)
Gettext.put_locale(MvWeb.Gettext, locale) _ = Gettext.put_locale(MvWeb.Gettext, locale)
conn conn
|> put_session(:locale, locale) |> put_session(:locale, locale)

View file

@ -12,7 +12,9 @@ defmodule MvWeb.Translations.FieldTypes do
""" """
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
@spec label(atom()) :: String.t() @type field_type :: :string | :integer | :boolean | :date | :email
@spec label(field_type()) :: String.t()
def label(:string), do: gettext("Text") def label(:string), do: gettext("Text")
def label(:integer), do: gettext("Number") def label(:integer), do: gettext("Number")
def label(:boolean), do: gettext("Yes/No-Selection") def label(:boolean), do: gettext("Yes/No-Selection")

19
mix.exs
View file

@ -12,6 +12,7 @@ defmodule Mv.MixProject do
compilers: [:phoenix_live_view] ++ Mix.compilers(), compilers: [:phoenix_live_view] ++ Mix.compilers(),
aliases: aliases(), aliases: aliases(),
deps: deps(), deps: deps(),
dialyzer: dialyzer(),
listeners: [Phoenix.CodeReloader], listeners: [Phoenix.CodeReloader],
gettext: [write_reference_line_numbers: false] gettext: [write_reference_line_numbers: false]
] ]
@ -80,6 +81,7 @@ defmodule Mv.MixProject do
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
{:bypass, "~> 2.1", only: [:dev, :test]}, {:bypass, "~> 2.1", only: [:dev, :test]},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:picosat_elixir, "~> 0.1"}, {:picosat_elixir, "~> 0.1"},
{:ecto_commons, "~> 0.3"}, {:ecto_commons, "~> 0.3"},
{:slugify, "~> 1.3"}, {:slugify, "~> 1.3"},
@ -112,4 +114,21 @@ defmodule Mv.MixProject do
"phx.routes": ["phx.routes", "ash_authentication.phoenix.routes"] "phx.routes": ["phx.routes", "ash_authentication.phoenix.routes"]
] ]
end end
defp dialyzer do
[
plt_file: {:no_warn, "priv/plts/dialyzer.plt"},
plt_core_path: "priv/plts/core.plt",
plt_add_apps: [:mix, :ex_unit],
flags: [
:error_handling,
:unmatched_returns,
:extra_return,
:missing_return,
:underspecs
],
ignore_warnings: ".dialyzer_ignore.exs",
list_unused_filters: true
]
end
end end

View file

@ -1,5 +1,5 @@
%{ %{
"ash": {:hex, :ash, "3.25.2", "d23c52a9f823e98895d0cf1dc8bbf5d22943ffa45ba087e583d94bb05d205b2e", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c4e3fb9252719dd3fec84610a5a19e309f298265076da23c0bef21de237e98bb"}, "ash": {:hex, :ash, "3.27.7", "349e47b9fc293c8de56866f900f6e1a3a5deea1e110d205749f94a9833431811", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eb8a1a74090d7f1753a63fe422cd493b7f50736e2d95d280ccfb508956dccc1d"},
"ash_admin": {:hex, :ash_admin, "1.1.0", "df7c8075347ca9229f132648534a33319f29ae5aceed6c1015d138bba1a4811f", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.20 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "f8bd6c08b584a315a9574c7bbe9c1c914bc5c51838045994b0e5369871f9b3d8"}, "ash_admin": {:hex, :ash_admin, "1.1.0", "df7c8075347ca9229f132648534a33319f29ae5aceed6c1015d138bba1a4811f", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.20 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "f8bd6c08b584a315a9574c7bbe9c1c914bc5c51838045994b0e5369871f9b3d8"},
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"}, "ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"}, "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"},
@ -16,25 +16,27 @@
"cinder": {:hex, :cinder, "0.14.0", "ae0866aaa3c166cc882de04f1c9d9906d5be0e5cfb8d7e9f7d62c097d97013e7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e05d9c6c75dc839faaaa2063e46cd69dda3907718af71964231c93d2f602bc49"}, "cinder": {:hex, :cinder, "0.14.0", "ae0866aaa3c166cc882de04f1c9d9906d5be0e5cfb8d7e9f7d62c097d97013e7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e05d9c6c75dc839faaaa2063e46cd69dda3907718af71964231c93d2f602bc49"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"}, "cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"}, "cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
"crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"}, "crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"},
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
"decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"}, "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
"ecto_commons": {:hex, :ecto_commons, "0.3.7", "f33c162a6f63695d5939af02c65a0e76aa6e7278b82c7bfc357ffbfea353bf0f", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.4", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "9c33771ebd38cd83d3f90fab6069826ba9d4f7580f1481b3c0913f8b9795c5fd"}, "ecto_commons": {:hex, :ecto_commons, "0.3.7", "f33c162a6f63695d5939af02c65a0e76aa6e7278b82c7bfc357ffbfea353bf0f", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.4", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "9c33771ebd38cd83d3f90fab6069826ba9d4f7580f1481b3c0913f8b9795c5fd"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"}, "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"},
"ex_phone_number": {:hex, :ex_phone_number, "0.4.10", "11809f6600b2ecb0a2e75d496c2ec2f273d49d1e2f58b2be2667decb0aabfb43", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "eefccf58d8149d64af658721bff0edcb9e9b8943f74000ede151948ef03046c1"}, "ex_phone_number": {:hex, :ex_phone_number, "0.4.10", "11809f6600b2ecb0a2e75d496c2ec2f273d49d1e2f58b2be2667decb0aabfb43", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "eefccf58d8149d64af658721bff0edcb9e9b8943f74000ede151948ef03046c1"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
@ -43,7 +45,7 @@
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"}, "igniter": {:hex, :igniter, "0.8.1", "3c6ea47f3a6031015e29da8b4ba5c685f0a2e409facf63041fd83e982ca3aa89", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "d99472e6daf3bfc3675d699c6c7ace9196f377207aab83e09d7b95e9d90e8ae8"},
"imprintor": {:hex, :imprintor, "0.6.0", "c6dfeb3c47d15cfb7e8491cf0a40548b3b9e37d0fc33940ca6dd283d36c4bada", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9c26b0e665c0ab183860e77c450e0b0a4e390249e8322328dbe1be70d0fbca36"}, "imprintor": {:hex, :imprintor, "0.6.0", "c6dfeb3c47d15cfb7e8491cf0a40548b3b9e37d0fc33940ca6dd283d36c4bada", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9c26b0e665c0ab183860e77c450e0b0a4e390249e8322328dbe1be70d0fbca36"},
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
@ -54,7 +56,7 @@
"live_debugger": {:hex, :live_debugger, "1.0.0", "0bbcbdcb3b40b6862b6dfbb76579e7832e2787fee643031e5958f597def6fb32", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 1.1.7 and < 2.0.0-0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b80f5d9db874d3270eb534738a10982b2b4ce55d58f94544e43b4a36111585fc"}, "live_debugger": {:hex, :live_debugger, "1.0.0", "0bbcbdcb3b40b6862b6dfbb76579e7832e2787fee643031e5958f597def6fb32", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 1.1.7 and < 2.0.0-0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b80f5d9db874d3270eb534738a10982b2b4ce55d58f94544e43b4a36111585fc"},
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"}, "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, "mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
"multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"}, "multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"},
"nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"}, "nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"},
@ -67,7 +69,7 @@
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.30", "a84af1610755dc208da35d4d45564485edbf18c3f3c77373c4a650dc994cdcdb", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a353c51ac1e3190910f01a6100c7d5cc02c5e22e7374fd817bd3aedd21149039"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.31", "c45c85df509dd79c917bc530e26c71299e3920850f65ea52ab6a19ccee66875a", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2f53cc6a9e149f30449341c2775990819d97e3b22338fe719c4d30342e6f9638"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"}, "phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
@ -79,7 +81,7 @@
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"}, "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
"ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"},
"reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"}, "reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"},
"rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
@ -96,13 +98,13 @@
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
"tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"}, "tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"},
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"}, "tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"},
"ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"}, "ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"},
} }

View file

@ -152,3 +152,13 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Register" msgid "Register"
msgstr "" msgstr ""
#: lib/mv_web/live/auth/sign_out_live.ex
#, elixir-autogen, elixir-format
msgid "Are you sure you want to sign out?"
msgstr ""
#: lib/mv_web/live/auth/sign_out_live.ex
#, elixir-autogen, elixir-format
msgid "Sign out"
msgstr ""

View file

@ -148,3 +148,13 @@ msgstr "Sprache auswählen"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Register" msgid "Register"
msgstr "Registrieren" msgstr "Registrieren"
#: lib/mv_web/live/auth/sign_out_live.ex
#, elixir-autogen, elixir-format
msgid "Are you sure you want to sign out?"
msgstr "Möchtest du dich wirklich abmelden?"
#: lib/mv_web/live/auth/sign_out_live.ex
#, elixir-autogen, elixir-format
msgid "Sign out"
msgstr "Abmelden"

View file

@ -2208,11 +2208,6 @@ msgstr "Keine Mitglieder in dieser Gruppe"
msgid "No members selected" msgid "No members selected"
msgstr "Keine Mitglieder ausgewählt" msgstr "Keine Mitglieder ausgewählt"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No members selected."
msgstr "Keine Mitglieder ausgewählt."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned." msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
@ -3897,3 +3892,83 @@ msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt." msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt."
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} from"
msgstr "%{field} von"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} to"
msgstr "%{field} bis"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Active only"
msgstr "Nur aktive"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Custom date fields"
msgstr "Benutzerdefinierte Datumsfelder"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Dates"
msgstr "Daten"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date from"
msgstr "Austrittsdatum von"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date to"
msgstr "Austrittsdatum bis"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "From"
msgstr "Von"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Inactive only"
msgstr "Nur ehemalige"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date"
msgstr "Beitrittsdatum"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date from"
msgstr "Beitrittsdatum von"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date to"
msgstr "Beitrittsdatum bis"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Range"
msgstr "Zeitraum"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "To"
msgstr "Bis"
#~ #: lib/mv_web/live/group_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "No members selected."
#~ msgstr "Keine Mitglieder ausgewählt."

View file

@ -2209,11 +2209,6 @@ msgstr ""
msgid "No members selected" msgid "No members selected"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No members selected."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned." msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
@ -3897,3 +3892,78 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
msgstr "" msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} to"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Active only"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Custom date fields"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Dates"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date to"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "From"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Inactive only"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date to"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Range"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "To"
msgstr ""

View file

@ -145,3 +145,13 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Register" msgid "Register"
msgstr "" msgstr ""
#: lib/mv_web/live/auth/sign_out_live.ex
#, elixir-autogen, elixir-format
msgid "Are you sure you want to sign out?"
msgstr ""
#: lib/mv_web/live/auth/sign_out_live.ex
#, elixir-autogen, elixir-format
msgid "Sign out"
msgstr ""

View file

@ -2209,11 +2209,6 @@ msgstr ""
msgid "No members selected" msgid "No members selected"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "No members selected."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned." msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
@ -3897,3 +3892,83 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
msgstr "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgstr "SMTP is fully managed via environment variables. All SMTP fields are read-only."
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} to"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Active only"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom date fields"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Dates"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Exit date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Exit date from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Exit date to"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "From"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Inactive only"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Join date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Join date from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Join date to"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Range"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "To"
msgstr ""
#~ #: lib/mv_web/live/group_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "No members selected."
#~ msgstr ""

View file

@ -0,0 +1,19 @@
[
{
"id": "mv",
"name": "Mila dev",
"secret": { "Plain": "mv-dev-shared-secret-not-for-production-do-not-use-anywhere-else" },
"redirect_uris": ["http://localhost:4000/auth/user/oidc/callback"],
"allowed_origins": ["http://localhost:4000"],
"enabled": true,
"flows_enabled": ["authorization_code", "refresh_token"],
"access_token_alg": "RS256",
"id_token_alg": "RS256",
"auth_code_lifetime": 60,
"access_token_lifetime": 1800,
"scopes": ["openid", "profile", "email", "groups"],
"default_scopes": ["openid", "profile", "email", "groups"],
"challenges": ["S256"],
"force_mfa": false
}
]

View file

@ -0,0 +1,33 @@
defmodule Mv.ConstantsTest do
@moduledoc """
Tests for Mv.Constants accessor functions. Focus is on the date filter
URL parameter prefixes that drive the bookmarkable filter state.
"""
use ExUnit.Case, async: true
describe "date filter URL param prefixes" do
test "join_date_from_param/0 returns jd_from" do
assert Mv.Constants.join_date_from_param() == "jd_from"
end
test "join_date_to_param/0 returns jd_to" do
assert Mv.Constants.join_date_to_param() == "jd_to"
end
test "exit_date_mode_param/0 returns ed_mode" do
assert Mv.Constants.exit_date_mode_param() == "ed_mode"
end
test "exit_date_from_param/0 returns ed_from" do
assert Mv.Constants.exit_date_from_param() == "ed_from"
end
test "exit_date_to_param/0 returns ed_to" do
assert Mv.Constants.exit_date_to_param() == "ed_to"
end
test "custom_date_filter_prefix/0 returns cdf_" do
assert Mv.Constants.custom_date_filter_prefix() == "cdf_"
end
end
end

View file

@ -0,0 +1,33 @@
defmodule Mv.Membership.Import.ImportRunnerTest do
use ExUnit.Case, async: true
alias Mv.Membership.Import.ImportRunner
describe "read_file_entry/2" do
test "returns {:ok, content} for a readable file" do
path =
Path.join(
System.tmp_dir!(),
"import_runner_read_#{System.unique_integer([:positive])}.csv"
)
File.write!(path, "email;first_name\njohn@example.com;John")
on_exit(fn -> File.rm_rf(path) end)
assert {:ok, "email;first_name\njohn@example.com;John"} =
ImportRunner.read_file_entry(%{path: path}, %{})
end
test "returns {:error, message} with a binary message when the file cannot be read" do
missing_path =
Path.join(
System.tmp_dir!(),
"import_runner_missing_#{System.unique_integer([:positive])}.csv"
)
assert {:error, message} = ImportRunner.read_file_entry(%{path: missing_path}, %{})
assert is_binary(message)
assert message != ""
end
end
end

View file

@ -101,6 +101,29 @@ defmodule Mv.Membership.MembersPDFTest do
assert byte_size(pdf_binary) > 1000 assert byte_size(pdf_binary) > 1000
end end
test "renders date column holding an ISO8601 datetime value" do
# Regression: a date column whose value is a full datetime string must be
# parsed via DateTime.from_iso8601/1 (which returns a 3-tuple) and rendered,
# not silently dropped.
export_data = %{
columns: [
%{key: "first_name", kind: :member_field, label: "Vorname"},
%{key: "join_date", kind: :member_field, label: "Eintritt"}
],
rows: [
["Max", "2024-01-15T14:30:00Z"]
],
meta: %{
generated_at: "2024-01-15T14:30:00Z",
member_count: 1
}
}
assert {:ok, pdf_binary} = MembersPDF.render(export_data)
assert String.starts_with?(pdf_binary, "%PDF")
assert byte_size(pdf_binary) > 1000
end
test "generates valid PDF with custom fields and computed fields" do test "generates valid PDF with custom fields and computed fields" do
export_data = %{ export_data = %{
columns: [ columns: [

View file

@ -209,6 +209,57 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
# Button should still contain some text (truncated version or indicator) # Button should still contain some text (truncated version or indicator)
assert String.length(button_html) > 0 assert String.length(button_html) > 0
end end
test "date-only activation (ed_mode=all) replaces the idle label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?ed_mode=all")
button_html =
view
|> element("#member-filter button[aria-haspopup='true']")
|> render()
# The idle label must not appear; some non-idle label is shown. This is
# the same observable contract as the other filter categories — the
# button visually communicates "a filter is active". The `btn-active`
# CSS class is set by the parent class= attribute but the `<.button>`
# core component currently composes its own class string and drops the
# caller-supplied one — that is a pre-existing component constraint, not
# specific to date filters.
refute button_html =~ gettext("Apply filters")
end
test "date-only activation (jd_from) replaces the idle label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?jd_from=2024-01-15")
button_html =
view
|> element("#member-filter button[aria-haspopup='true']")
|> render()
refute button_html =~ gettext("Apply filters")
end
test "date filter combined with one other filter shows '2 filters active'", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
{:ok, view, _html} =
live(conn, "/members?ed_mode=all&bf_#{boolean_field.id}=true")
button_html =
view
|> element("#member-filter button[aria-haspopup='true']")
|> render()
# With two distinct filter categories active, the label switches to the
# pluralized "N filters active" form. Without counting date filters as
# a category, this would show only "1 filter active" or the boolean
# field name.
assert button_html =~ "2"
assert button_html =~ gettext("filters active")
end
end end
describe "badge" do describe "badge" do
@ -268,6 +319,293 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
refute dropdown_html =~ "String Field" refute dropdown_html =~ "String Field"
end end
test "renders the Dates section with exit_date and join_date controls", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~ gettext("Dates")
assert dropdown_html =~ gettext("Join date")
assert dropdown_html =~ gettext("Exit date")
# Exit-date segmented control modes.
assert dropdown_html =~ gettext("Active only")
assert dropdown_html =~ gettext("Inactive only")
# Built-in date inputs (always present for join_date and the ed_mode selector).
assert dropdown_html =~ ~s(name="jd_from")
assert dropdown_html =~ ~s(name="jd_to")
assert dropdown_html =~ ~s(name="ed_mode")
end
test "exit_date custom mode reveals ed_from and ed_to inputs", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?ed_mode=custom")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~ ~s(name="ed_from")
assert dropdown_html =~ ~s(name="ed_to")
end
test "date inputs render via MvWeb.CoreComponents.input (no raw DaisyUI input markup)",
%{conn: conn} do
# DESIGN_GUIDELINES §1.1 mandates that LiveViews/HEEX use the project's
# `<.input>` wrapper rather than emitting raw `<input>` tags carrying
# DaisyUI component classes (e.g. `input input-sm input-bordered`)
# directly in HEEX. `<.input>` is the project's single source of truth
# for input styling; bypassing it splits styling across many call sites.
#
# The recognizable structural fingerprint of `<.input>` is a wrapping
# `<fieldset class="mb-2 fieldset">` `<label>` chain immediately
# preceding the `<input>`. The raw inline form has no such wrapper —
# the input sits directly inside a sibling `<label>`/`<input>` flex row.
# We assert that fingerprint on each of the date inputs.
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?ed_mode=custom")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
for name <- ["jd_from", "jd_to", "ed_from", "ed_to"] do
# Match `<fieldset class="mb-2 fieldset">` followed (within a short
# window of HTML) by an `<input>` carrying the expected `name`. The
# window prevents the regex from spanning unrelated `mb-2` /
# `fieldset` occurrences scattered across the dropdown. The wrapper
# is the canonical fingerprint of `MvWeb.CoreComponents.input/1`
# (see `lib/mv_web/components/core_components.ex` — every input
# branch starts with `<fieldset class="mb-2 fieldset">`).
assert Regex.match?(
~r/<fieldset[^>]*class="mb-2 fieldset"[^>]*>\s*<label[^>]*>(?:\s*<span[^>]*>.*?<\/span>)?\s*<input[^>]*name="#{name}"/s,
dropdown_html
),
"expected date input #{name} to be wrapped by MvWeb.CoreComponents.input " <>
"(class=\"mb-2 fieldset\" fieldset wrapper), not a raw inline " <>
"<input type=\"date\"> element"
end
end
test "exit_date defaults to :active_only in the rendered radio", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~
~r/name="ed_mode"[^>]*value="active_only"[^>]*checked|checked[^>]*name="ed_mode"[^>]*value="active_only"/
end
test "Custom date fields section is non-scrollable with 5 or fewer fields (§3.4)", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
for i <- 1..5 do
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
value_type: :date
})
|> Ash.create!(actor: system_actor)
end
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
section_html = custom_date_section_html(view)
# With ≤ 5 fields the section must NOT carry the scrollable wrapper.
refute section_html =~ "max-h-60"
refute section_html =~ "overflow-y-auto"
end
test "Custom date fields section becomes scrollable with more than 5 fields (§3.4)", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
for i <- 1..6 do
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
value_type: :date
})
|> Ash.create!(actor: system_actor)
end
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
section_html = custom_date_section_html(view)
# With more than 5 fields the section is wrapped in the scrollable container.
assert section_html =~ "max-h-60"
assert section_html =~ "overflow-y-auto"
end
# Extract the HTML of the rendered "Custom date fields" section. Returns
# "" if the section is not rendered. Used by the threshold tests to avoid
# picking up scrollable classes from sibling sections.
defp custom_date_section_html(view) do
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
label = gettext("Custom date fields")
case String.split(dropdown_html, label, parts: 2) do
[_before, after_label] ->
# Up to the next group header label, or the footer.
after_label
|> String.split(["text-xs font-semibold opacity-70 mb-2 uppercase"], parts: 2)
|> List.first()
_ ->
""
end
end
test "Custom date fields section appears only when date custom fields exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view_no_field, _} = live(conn, "/members")
view_no_field
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view_no_field
|> element("#member-filter div[role='dialog']")
|> render()
refute dropdown_html =~ gettext("Custom date fields")
# Add a date-typed custom field and re-load: the section appears.
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Birthday-#{System.unique_integer([:positive])}",
value_type: :date
})
|> Ash.create(actor: system_actor)
{:ok, view, _} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~ gettext("Custom date fields")
assert dropdown_html =~ field.name
assert dropdown_html =~ "cdf_#{field.id}_from"
assert dropdown_html =~ "cdf_#{field.id}_to"
end
test "update_filters event dispatches a date_filters_changed patch with the new jd_from", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
view
|> form("#member-filter form", %{
"jd_from" => "2024-01-15",
"payment_filter" => "all"
})
|> render_change()
# Parent LiveView receives {:date_filters_changed, ...} and patches the URL.
path = assert_patch(view)
assert path =~ "jd_from=2024-01-15"
end
test "selecting ed_mode=all updates the URL and reveals former members", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
today = Date.utc_today()
unique_name = "Zarquon-#{System.unique_integer([:positive])}"
{:ok, former} =
Mv.Membership.create_member(
%{
first_name: unique_name,
last_name: "Exited",
email: "ex-#{System.unique_integer([:positive])}@example.com",
join_date: Date.add(today, -1000),
exit_date: Date.add(today, -30)
},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Fresh load hides the former member.
refute html =~ former.first_name
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
view
|> form("#member-filter form", %{
"ed_mode" => "all",
"payment_filter" => "all"
})
|> render_change()
path = assert_patch(view)
assert path =~ "ed_mode=all"
# Now Eve appears in the rendered list.
assert render(view) =~ former.first_name
end
test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)

View file

@ -62,6 +62,87 @@ defmodule MvWeb.AuthControllerTest do
assert redirected_to(conn) == ~p"/" assert redirected_to(conn) == ~p"/"
end end
describe "DELETE /sign-out with OIDC configured" do
@base_url "https://idp.example.com"
defp with_oidc_settings(fun) do
{:ok, settings} = Membership.get_settings()
prev = %{
oidc_client_id: settings.oidc_client_id,
oidc_base_url: settings.oidc_base_url,
oidc_redirect_uri: settings.oidc_redirect_uri,
oidc_client_secret: settings.oidc_client_secret
}
{:ok, _} =
Membership.update_settings(settings, %{
oidc_client_id: "test-client",
oidc_base_url: @base_url,
oidc_redirect_uri: "http://localhost:4000/auth/user/oidc/callback",
oidc_client_secret: "test-secret"
})
try do
fun.()
after
Mv.Oidc.Discovery.clear_cache()
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, prev)
end
end
test "redirects to end_session_endpoint when discovery succeeds", %{
conn: authenticated_conn
} do
with_oidc_settings(fn ->
end_session_url = "https://idp.example.com/end-session"
Mv.Oidc.Discovery.put_cache(
@base_url,
{:ok, %{"end_session_endpoint" => end_session_url}}
)
conn =
authenticated_conn
|> conn_with_oidc_user()
|> delete(~p"/sign-out")
assert redirected_to(conn, 302) == end_session_url
end)
end
test "falls back to /sign-in?oidc_failed=1 when discovery fails", %{
conn: authenticated_conn
} do
with_oidc_settings(fn ->
Mv.Oidc.Discovery.put_cache(@base_url, {:error, :test_failure})
conn =
authenticated_conn
|> conn_with_oidc_user()
|> delete(~p"/sign-out")
assert redirected_to(conn) == "/sign-in?oidc_failed=1"
end)
end
test "falls back to /sign-in?oidc_failed=1 when end_session_endpoint is missing", %{
conn: authenticated_conn
} do
with_oidc_settings(fn ->
Mv.Oidc.Discovery.put_cache(@base_url, {:ok, %{"issuer" => @base_url}})
conn =
authenticated_conn
|> conn_with_oidc_user()
|> delete(~p"/sign-out")
assert redirected_to(conn) == "/sign-in?oidc_failed=1"
end)
end
end
defp csrf_token_from_sign_out_form(html) when is_binary(html) do defp csrf_token_from_sign_out_form(html) when is_binary(html) do
case Regex.run(~r/name="_csrf_token"[^>]*value="([^"]+)"/, html) do case Regex.run(~r/name="_csrf_token"[^>]*value="([^"]+)"/, html) do
[_, token] -> [_, token] ->

View file

@ -0,0 +1,319 @@
defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do
@moduledoc """
Unit tests for `DateFilter.apply_in_memory/3` the post-`Ash.read!`
predicate that filters members by custom date field values stored as
JSONB `Ash.Union` types in `custom_field_values`.
Integration coverage against a real database lives in the second module
in this file (DateFilterCustomFieldIntegrationTest).
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.DateFilter
# ---- helpers ---------------------------------------------------------
defp date_custom_field(id, name \\ "Birthday") do
%{id: id, value_type: :date, name: name}
end
defp date_cfv(custom_field_id, %Date{} = date) do
%{
custom_field_id: custom_field_id,
value: %Ash.Union{value: date, type: :date}
}
end
defp member_with_dates(id, custom_field_values) do
%{id: id, custom_field_values: custom_field_values}
end
# ---- no-op cases -----------------------------------------------------
describe "apply_in_memory/3 — no-op cases" do
test "returns members unchanged when filters has no custom date entries" do
filters = DateFilter.default()
members = [member_with_dates("m1", []), member_with_dates("m2", [])]
assert DateFilter.apply_in_memory(members, filters, []) == members
end
test "ignores custom date entries whose UUID is not in the date_custom_fields list" do
id = "11111111-2222-3333-4444-555555555555"
other_id = "99999999-8888-7777-6666-555555555555"
filters =
DateFilter.default()
|> Map.put(other_id, %{from: ~D[2024-01-01], to: nil})
m = member_with_dates("m1", [date_cfv(id, ~D[2023-01-01])])
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m]
end
test "entry with both bounds nil is treated as inactive" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: nil, to: nil})
m = member_with_dates("m1", [date_cfv(id, ~D[2023-01-01])])
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m]
end
end
# ---- inclusive range semantics --------------------------------------
describe "apply_in_memory/3 — inclusive range semantics" do
setup do
id = "11111111-2222-3333-4444-555555555555"
members = [
member_with_dates("before", [date_cfv(id, ~D[2024-05-31])]),
member_with_dates("from_boundary", [date_cfv(id, ~D[2024-06-01])]),
member_with_dates("inside", [date_cfv(id, ~D[2024-06-15])]),
member_with_dates("to_boundary", [date_cfv(id, ~D[2024-06-30])]),
member_with_dates("after", [date_cfv(id, ~D[2024-07-01])])
]
%{id: id, members: members, fields: [date_custom_field(id)]}
end
test "from-only includes member when value >= from (boundary inclusive)", ctx do
filters =
DateFilter.default()
|> Map.put(ctx.id, %{from: ~D[2024-06-01], to: nil})
ids =
ctx.members
|> DateFilter.apply_in_memory(filters, ctx.fields)
|> Enum.map(& &1.id)
assert ids == ["from_boundary", "inside", "to_boundary", "after"]
end
test "from-only excludes member when value < from", ctx do
filters =
DateFilter.default()
|> Map.put(ctx.id, %{from: ~D[2024-06-01], to: nil})
refute Enum.any?(
DateFilter.apply_in_memory(ctx.members, filters, ctx.fields),
&(&1.id == "before")
)
end
test "to-only includes member when value <= to (boundary inclusive)", ctx do
filters =
DateFilter.default()
|> Map.put(ctx.id, %{from: nil, to: ~D[2024-06-30]})
ids =
ctx.members
|> DateFilter.apply_in_memory(filters, ctx.fields)
|> Enum.map(& &1.id)
assert ids == ["before", "from_boundary", "inside", "to_boundary"]
end
test "from+to applies an inclusive range", ctx do
filters =
DateFilter.default()
|> Map.put(ctx.id, %{from: ~D[2024-06-01], to: ~D[2024-06-30]})
ids =
ctx.members
|> DateFilter.apply_in_memory(filters, ctx.fields)
|> Enum.map(& &1.id)
assert ids == ["from_boundary", "inside", "to_boundary"]
end
end
# ---- exclusion of members without a value ---------------------------
describe "apply_in_memory/3 — members without a value" do
test "excludes member with no custom_field_values when bound is active" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-01-01], to: nil})
members = [
member_with_dates("present", [date_cfv(id, ~D[2024-06-01])]),
member_with_dates("nil_list", nil),
member_with_dates("empty_list", []),
# member missing the specific field but having other CFVs:
member_with_dates("other_field", [
date_cfv("other-aaaa-bbbb-cccc-dddddddddddd", ~D[2024-06-01])
])
]
ids =
members
|> DateFilter.apply_in_memory(filters, [date_custom_field(id)])
|> Enum.map(& &1.id)
assert ids == ["present"]
end
test "treats Ash.NotLoaded custom_field_values as no value (excluded)" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-01-01], to: nil})
m = %{id: "m1", custom_field_values: %Ash.NotLoaded{}}
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == []
end
end
# ---- Ash.Union unwrapping -------------------------------------------
describe "apply_in_memory/3 — Ash.Union unwrapping" do
test "unwraps %Ash.Union{value: %Date{}, type: :date}" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-06-01], to: nil})
m =
member_with_dates("m1", [
%{
custom_field_id: id,
value: %Ash.Union{value: ~D[2024-06-15], type: :date}
}
])
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m]
end
test "rejects values whose Ash.Union type is not :date" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-06-01], to: nil})
# A boolean-typed value for what the filter believes is a date field —
# treat as no value, exclude the member.
m =
member_with_dates("m1", [
%{
custom_field_id: id,
value: %Ash.Union{value: true, type: :boolean}
}
])
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == []
end
end
end
defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldIntegrationTest do
@moduledoc """
Integration tests for custom date field filtering on /members (§1.13, §1.14).
Creates a real `:date`-typed CustomField plus members with corresponding
CustomFieldValue rows, then asserts visibility through the LiveView with
`cdf_<uuid>_from` and `cdf_<uuid>_to` URL params.
"""
# async: false because we mutate global custom_fields and custom_field_values tables.
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership.{CustomField, CustomFieldValue}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Birthday-#{System.unique_integer([:positive])}",
value_type: :date,
show_in_overview: true
})
|> Ash.create(actor: system_actor)
{:ok, alice} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, bob} =
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
{:ok, carla} =
Mv.Membership.create_member(
%{first_name: "Carla", last_name: "Carter", email: "carla@example.com"},
actor: system_actor
)
{:ok, dan_no_value} =
Mv.Membership.create_member(
%{first_name: "Dan", last_name: "Dixon", email: "dan@example.com"},
actor: system_actor
)
create_cfv(system_actor, alice.id, field.id, ~D[2020-05-15])
create_cfv(system_actor, bob.id, field.id, ~D[2022-08-01])
create_cfv(system_actor, carla.id, field.id, ~D[2024-02-20])
%{
field: field,
alice: alice,
bob: bob,
carla: carla,
dan_no_value: dan_no_value
}
end
defp create_cfv(actor, member_id, custom_field_id, %Date{} = date) do
{:ok, cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_id,
custom_field_id: custom_field_id,
value: %{"_union_type" => "date", "_union_value" => Date.to_iso8601(date)}
})
|> Ash.create(actor: actor, domain: Mv.Membership)
cfv
end
describe "custom date field URL filter" do
test "from-only includes members with value >= bound (§1.13)",
%{conn: conn, field: field, alice: alice, bob: bob, carla: carla} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?cdf_#{field.id}_from=2022-01-01")
refute html =~ alice.first_name
assert html =~ bob.first_name
assert html =~ carla.first_name
end
test "from+to applies inclusive range (§1.14)",
%{conn: conn, field: field, alice: alice, bob: bob, carla: carla} do
conn = conn_with_oidc_user(conn)
url = "/members?cdf_#{field.id}_from=2022-01-01&cdf_#{field.id}_to=2023-12-31"
{:ok, _view, html} = live(conn, url)
refute html =~ alice.first_name
assert html =~ bob.first_name
refute html =~ carla.first_name
end
test "excludes member with no value for the active custom date field (§1.13)",
%{conn: conn, field: field, dan_no_value: dan} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?cdf_#{field.id}_from=2000-01-01")
refute html =~ dan.first_name
end
end
end

View file

@ -0,0 +1,144 @@
defmodule MvWeb.MemberLive.Index.DateFilterDefaultTest do
@moduledoc """
Unit tests for DateFilter.default/0 the initial filter map used when
no URL params are present (fresh load) and after "Clear filters".
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.DateFilter
describe "default/0" do
test "returns :active_only mode for exit_date with nil bounds" do
assert %{exit_date: %{mode: :active_only, from: nil, to: nil}} = DateFilter.default()
end
test "returns nil bounds for join_date" do
assert %{join_date: %{from: nil, to: nil}} = DateFilter.default()
end
test "contains only :join_date and :exit_date top-level keys" do
defaults = DateFilter.default()
assert Map.keys(defaults) |> Enum.sort() == [:exit_date, :join_date]
end
end
end
defmodule MvWeb.MemberLive.Index.DateFilterDefaultIntegrationTest do
@moduledoc """
Integration tests for the default exit_date filter behavior on the member
overview page (§1.1, §1.2, §1.3, §1.4, §1.6 in the issue specs).
These exercise the full `mount/3` `handle_params/3` `load_members/1`
pipeline against a real database, asserting that the active-only default
is applied to a fresh page load and overridden when the URL says so.
"""
# async: false because we mutate the global member table.
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
today = Date.utc_today()
{:ok, active_no_exit} =
Mv.Membership.create_member(
%{first_name: "Anna", last_name: "Active", email: "anna@example.com"},
actor: system_actor
)
{:ok, future_exit} =
Mv.Membership.create_member(
%{
first_name: "Felix",
last_name: "Future",
email: "felix@example.com",
join_date: Date.add(today, -365),
exit_date: Date.add(today, 30)
},
actor: system_actor
)
{:ok, exit_today} =
Mv.Membership.create_member(
%{
first_name: "Tina",
last_name: "Today",
email: "tina@example.com",
join_date: Date.add(today, -365),
exit_date: today
},
actor: system_actor
)
{:ok, past_exit} =
Mv.Membership.create_member(
%{
first_name: "Paula",
last_name: "Past",
email: "paula@example.com",
join_date: Date.add(today, -365),
exit_date: Date.add(today, -1)
},
actor: system_actor
)
%{
active_no_exit: active_no_exit,
future_exit: future_exit,
exit_today: exit_today,
past_exit: past_exit
}
end
describe "fresh load — no URL params" do
test "hides member with exit_date strictly before today (§1.1)", %{
conn: conn,
past_exit: past
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
refute html =~ past.first_name
end
test "hides member whose exit_date equals today (§1.4)", %{
conn: conn,
exit_today: exit_today
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
refute html =~ exit_today.first_name
end
test "shows member with no exit_date (§1.2)", %{conn: conn, active_no_exit: anna} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ anna.first_name
end
test "shows member with exit_date strictly in the future (§1.3)", %{
conn: conn,
future_exit: felix
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ felix.first_name
end
end
describe "URL with ed_mode=all overrides the default (§1.6)" do
test "shows former members when URL contains ed_mode=all", %{
conn: conn,
past_exit: past,
exit_today: today,
active_no_exit: anna,
future_exit: felix
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?ed_mode=all")
assert html =~ past.first_name
assert html =~ today.first_name
assert html =~ anna.first_name
assert html =~ felix.first_name
end
end
end

View file

@ -0,0 +1,137 @@
defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do
@moduledoc """
Property tests for the pure functions on `DateFilter`:
* `to_params/1` `from_params/2` must be the identity for all valid
built-in date filter states (§2.3).
* `apply_in_memory/3` matches the inclusive range predicate
`(from == nil or value >= from) and (to == nil or value <= to)`
for any custom date field value and bound pair (§2.4).
Custom date field round-trip is not part of the URL codec property because
`from_params/2` needs the caller-supplied `date_custom_fields` list to
validate UUIDs; that interaction is covered by example tests in
`MvWeb.MemberLive.Index.DateFilterTest`.
"""
use ExUnit.Case, async: true
use ExUnitProperties
alias MvWeb.MemberLive.Index.DateFilter
# Generators -----------------------------------------------------------
defp optional_date_gen do
one_of([
constant(nil),
map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1))
])
end
defp exit_date_mode_gen do
one_of([
constant(:active_only),
constant(:all),
constant(:inactive_only),
constant(:custom)
])
end
defp exit_date_state_gen do
gen all(
mode <- exit_date_mode_gen(),
from <- optional_date_gen(),
to <- optional_date_gen()
) do
%{mode: mode, from: from, to: to}
end
end
defp join_date_state_gen do
gen all(
from <- optional_date_gen(),
to <- optional_date_gen()
) do
%{from: from, to: to}
end
end
# Property -------------------------------------------------------------
defp bound_pair_with_at_least_one_set_gen do
gen all(
from <- optional_date_gen(),
to <- optional_date_gen(),
from != nil or to != nil
) do
{from, to}
end
end
defp value_date_gen do
map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1))
end
# Property -------------------------------------------------------------
property "in-memory date filter matches the inclusive range predicate" do
id = "11111111-2222-3333-4444-555555555555"
field = %{id: id, value_type: :date, name: "Property field"}
check all(
value <- value_date_gen(),
{from, to} <- bound_pair_with_at_least_one_set_gen()
) do
filters =
DateFilter.default()
|> Map.put(id, %{from: from, to: to})
member = %{
id: "m1",
custom_field_values: [
%{
custom_field_id: id,
value: %Ash.Union{value: value, type: :date}
}
]
}
result = DateFilter.apply_in_memory([member], filters, [field])
from_ok? = is_nil(from) or Date.compare(value, from) != :lt
to_ok? = is_nil(to) or Date.compare(value, to) != :gt
expected_included? = from_ok? and to_ok?
actually_included? = result == [member]
assert actually_included? == expected_included?
end
end
property "encoding then decoding built-in date filter state is identity" do
check all(
join_date <- join_date_state_gen(),
exit_date <- exit_date_state_gen()
) do
filters = %{join_date: join_date, exit_date: exit_date}
decoded = DateFilter.from_params(DateFilter.to_params(filters), [])
# join_date round-trips verbatim.
assert decoded.join_date == join_date
# exit_date semantics:
# * :active_only is the default and discards bounds — the canonical
# URL omits them, so decoding restores nil bounds.
# * :all and :inactive_only also drop bounds in the URL — same reason.
# * :custom preserves bounds.
expected_exit_date =
case exit_date.mode do
:active_only -> %{mode: :active_only, from: nil, to: nil}
:all -> %{mode: :all, from: nil, to: nil}
:inactive_only -> %{mode: :inactive_only, from: nil, to: nil}
:custom -> exit_date
end
assert decoded.exit_date == expected_exit_date
end
end
end

View file

@ -0,0 +1,628 @@
defmodule MvWeb.MemberLive.Index.DateFilterTest do
@moduledoc """
Unit tests for DateFilter URL codec and pure helpers.
DB-level filtering against real members is covered by the integration
module below in this file.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.DateFilter
# Synthesize the minimal shape of a date-typed custom field expected by
# from_params/2. Only :id and :value_type are inspected by the decoder.
defp date_custom_field(id) do
%{id: id, value_type: :date, name: "Birthday-#{id}"}
end
describe "from_params/2 — built-in date fields" do
test "parses jd_from as a Date" do
params = %{"jd_from" => "2024-05-01"}
filters = DateFilter.from_params(params, [])
assert filters.join_date.from == ~D[2024-05-01]
assert filters.join_date.to == nil
end
test "parses jd_to as a Date" do
params = %{"jd_to" => "2024-08-31"}
filters = DateFilter.from_params(params, [])
assert filters.join_date.to == ~D[2024-08-31]
assert filters.join_date.from == nil
end
test "ignores malformed jd_from string" do
params = %{"jd_from" => "notadate"}
filters = DateFilter.from_params(params, [])
assert filters.join_date.from == nil
assert filters.join_date.to == nil
end
test "ignores malformed jd_to string" do
params = %{"jd_to" => "2024-13-45"}
filters = DateFilter.from_params(params, [])
assert filters.join_date.to == nil
end
test "parses ed_mode=all" do
params = %{"ed_mode" => "all"}
filters = DateFilter.from_params(params, [])
assert filters.exit_date.mode == :all
end
test "parses ed_mode=inactive_only" do
params = %{"ed_mode" => "inactive_only"}
filters = DateFilter.from_params(params, [])
assert filters.exit_date.mode == :inactive_only
end
test "parses ed_mode=custom with bounds" do
params = %{"ed_mode" => "custom", "ed_from" => "2024-01-01", "ed_to" => "2024-12-31"}
filters = DateFilter.from_params(params, [])
assert filters.exit_date.mode == :custom
assert filters.exit_date.from == ~D[2024-01-01]
assert filters.exit_date.to == ~D[2024-12-31]
end
test "returns :active_only mode when ed_mode is absent" do
filters = DateFilter.from_params(%{}, [])
assert filters.exit_date.mode == :active_only
end
test "treats unknown ed_mode value as :active_only" do
params = %{"ed_mode" => "gibberish"}
filters = DateFilter.from_params(params, [])
assert filters.exit_date.mode == :active_only
end
end
describe "to_params/1 — built-in date fields" do
test "omits ed_mode when mode is :active_only (default)" do
params = DateFilter.to_params(DateFilter.default())
refute Map.has_key?(params, "ed_mode")
refute Map.has_key?(params, "ed_from")
refute Map.has_key?(params, "ed_to")
refute Map.has_key?(params, "jd_from")
refute Map.has_key?(params, "jd_to")
end
test "encodes ed_mode=all" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :all, from: nil, to: nil}
}
params = DateFilter.to_params(filters)
assert params["ed_mode"] == "all"
end
test "encodes ed_mode=inactive_only" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :inactive_only, from: nil, to: nil}
}
params = DateFilter.to_params(filters)
assert params["ed_mode"] == "inactive_only"
end
test "encodes ed_mode=custom with bounds" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :custom, from: ~D[2024-01-01], to: ~D[2024-12-31]}
}
params = DateFilter.to_params(filters)
assert params["ed_mode"] == "custom"
assert params["ed_from"] == "2024-01-01"
assert params["ed_to"] == "2024-12-31"
end
test "encodes jd_from as ISO-8601 string" do
filters = %{
join_date: %{from: ~D[2024-05-01], to: nil},
exit_date: %{mode: :active_only, from: nil, to: nil}
}
params = DateFilter.to_params(filters)
assert params["jd_from"] == "2024-05-01"
refute Map.has_key?(params, "jd_to")
end
test "encodes jd_to as ISO-8601 string" do
filters = %{
join_date: %{from: nil, to: ~D[2024-08-31]},
exit_date: %{mode: :active_only, from: nil, to: nil}
}
params = DateFilter.to_params(filters)
assert params["jd_to"] == "2024-08-31"
refute Map.has_key?(params, "jd_from")
end
test "omits exit_date bounds when mode is not :custom" do
# Bounds may linger in state for UX (preserve user's last input) but the
# URL should not advertise them while a non-custom mode is active.
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :all, from: ~D[2024-01-01], to: ~D[2024-12-31]}
}
params = DateFilter.to_params(filters)
assert params["ed_mode"] == "all"
refute Map.has_key?(params, "ed_from")
refute Map.has_key?(params, "ed_to")
end
end
describe "to_params/1 — custom date field entries" do
test "encodes from/to bounds with cdf_<uuid>_ prefix" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-06-01], to: ~D[2024-06-30]})
params = DateFilter.to_params(filters)
assert params["cdf_#{id}_from"] == "2024-06-01"
assert params["cdf_#{id}_to"] == "2024-06-30"
end
test "omits nil bounds for custom date field entries" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-06-01], to: nil})
params = DateFilter.to_params(filters)
assert params["cdf_#{id}_from"] == "2024-06-01"
refute Map.has_key?(params, "cdf_#{id}_to")
end
test "omits custom date field entry entirely when both bounds are nil" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: nil, to: nil})
params = DateFilter.to_params(filters)
refute Map.has_key?(params, "cdf_#{id}_from")
refute Map.has_key?(params, "cdf_#{id}_to")
end
end
describe "apply_ash_filter/2 — shape contract" do
# The behavioral correctness of apply_ash_filter/2 is verified in the
# integration tests (date_filter_default_test, date_filter_test). Here we
# only assert the shape contract: which inputs leave the query untouched,
# and which add a filter expression. Inspecting Ash internals is brittle —
# we only check `query.filter` is or is not nil.
alias Mv.Membership.Member, as: MemberResource
# The production caller (`MvWeb.MemberLive.Index.load_members/1`) hands
# `apply_ash_filter/2` an already-built `%Ash.Query{}`, matching the
# convention of the sibling `apply_*_filters` helpers. The shape contract
# tests mirror that convention so they exercise the exact call shape used
# in production.
defp base_query, do: Ash.Query.new(MemberResource)
test ":all mode and empty join_date is a no-op" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :all, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
# No filter was applied — Ash leaves filter as nil when nothing is added.
assert is_nil(query.filter)
end
test "default (:active_only) adds an exit_date filter" do
query = DateFilter.apply_ash_filter(base_query(), DateFilter.default())
refute is_nil(query.filter)
end
test ":inactive_only adds an exit_date filter" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :inactive_only, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test ":custom mode with bounds adds an exit_date filter" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :custom, from: ~D[2024-01-01], to: ~D[2024-12-31]}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test ":custom mode with both bounds nil adds no filter" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :custom, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
assert is_nil(query.filter)
end
test "join_date from adds a filter" do
filters = %{
join_date: %{from: ~D[2024-01-01], to: nil},
exit_date: %{mode: :all, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test "join_date to adds a filter" do
filters = %{
join_date: %{from: nil, to: ~D[2024-12-31]},
exit_date: %{mode: :all, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test "raises FunctionClauseError when caller passes a bare resource module" do
# The function now requires `%Ash.Query{}` — the production convention
# used by every sibling `apply_*_filters` helper in
# `MvWeb.MemberLive.Index`. A bare resource module is no longer accepted.
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :all, from: nil, to: nil}
}
# Indirect through a variable so the compiler's static type analysis
# does not flag the deliberately invalid call shape we want to assert on.
bare_resource = Function.identity(MemberResource)
assert_raise FunctionClauseError, fn ->
DateFilter.apply_ash_filter(bare_resource, filters)
end
end
test "join_date map missing :to key still applies the from bound" do
# A caller-supplied map can omit one bound key entirely (not just set it
# to nil). The Ash filter must still be applied for the bound that is
# present.
filters = %{
join_date: %{from: ~D[2024-01-01]},
exit_date: %{mode: :all, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test "join_date map missing :from key still applies the to bound" do
filters = %{
join_date: %{to: ~D[2024-12-31]},
exit_date: %{mode: :all, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test ":custom exit_date with only :from key still applies the bound" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :custom, from: ~D[2024-01-01]}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test ":custom exit_date with only :to key still applies the bound" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :custom, to: ~D[2024-12-31]}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
end
describe "active_custom_field_ids/2" do
test "returns UUID keys with at least one bound set whose UUID matches a date field" do
id_a = "11111111-1111-1111-1111-111111111111"
id_b = "22222222-2222-2222-2222-222222222222"
id_unknown = "33333333-3333-3333-3333-333333333333"
filters =
DateFilter.default()
|> Map.put(id_a, %{from: ~D[2024-01-01], to: nil})
|> Map.put(id_b, %{from: nil, to: nil})
|> Map.put(id_unknown, %{from: ~D[2024-06-01], to: nil})
ids =
DateFilter.active_custom_field_ids(filters, [
date_custom_field(id_a),
date_custom_field(id_b)
])
assert ids == [id_a]
end
test "ignores non-binary keys (built-in atoms)" do
assert DateFilter.active_custom_field_ids(DateFilter.default(), []) == []
end
test "returns [] when no date custom fields are supplied" do
id_a = "11111111-1111-1111-1111-111111111111"
filters =
DateFilter.default()
|> Map.put(id_a, %{from: ~D[2024-01-01], to: nil})
assert DateFilter.active_custom_field_ids(filters, []) == []
end
end
describe "from_params/2 — custom date field entries" do
test "includes entry for known custom date field UUID" do
id = "11111111-2222-3333-4444-555555555555"
params = %{"cdf_#{id}_from" => "2024-06-01", "cdf_#{id}_to" => "2024-06-30"}
filters = DateFilter.from_params(params, [date_custom_field(id)])
assert filters[id] == %{from: ~D[2024-06-01], to: ~D[2024-06-30]}
end
test "ignores UUID not in date_custom_fields list" do
id = "11111111-2222-3333-4444-555555555555"
other = "99999999-8888-7777-6666-555555555555"
params = %{"cdf_#{other}_from" => "2024-06-01"}
filters = DateFilter.from_params(params, [date_custom_field(id)])
refute Map.has_key?(filters, other)
end
test "ignores malformed custom date field bound" do
id = "11111111-2222-3333-4444-555555555555"
params = %{"cdf_#{id}_from" => "notadate"}
filters = DateFilter.from_params(params, [date_custom_field(id)])
# Either no entry, or entry with nil bounds — both satisfy "silently ignored"
case Map.get(filters, id) do
nil -> :ok
%{from: nil, to: nil} -> :ok
other -> flunk("expected nil bound for malformed input, got #{inspect(other)}")
end
end
test "strips only the trailing _from / _to suffix, not internal substrings" do
# Construct an id that itself contains "_from" / "_to" — a quirky but
# legal MapSet key. Trailing-only suffix stripping must leave the
# internal substrings intact and recover the original id.
id_with_internal = "abc_from_xyz_to_def"
params = %{
"cdf_#{id_with_internal}_from" => "2024-06-01",
"cdf_#{id_with_internal}_to" => "2024-06-30"
}
filters =
DateFilter.from_params(params, [date_custom_field(id_with_internal)])
assert filters[id_with_internal] ==
%{from: ~D[2024-06-01], to: ~D[2024-06-30]}
end
test "drops cdf_-prefixed keys whose id exceeds the UUID length cap" do
# Matches the DoS-protection contract enforced by the sibling boolean,
# group, and fee_type filter parsers: an over-long id-portion (post
# prefix-strip, pre suffix-strip) is rejected without invoking the
# heavier String.replace_suffix / MapSet.member? path. The id we
# construct is well past `Mv.Constants.max_uuid_length()` (36).
known_id = "11111111-2222-3333-4444-555555555555"
over_long_id = String.duplicate("a", 200)
params = %{
"cdf_#{over_long_id}_from" => "2024-06-01",
"cdf_#{over_long_id}_to" => "2024-06-30"
}
filters = DateFilter.from_params(params, [date_custom_field(known_id)])
refute Map.has_key?(filters, over_long_id)
refute Map.has_key?(filters, "#{over_long_id}_from")
refute Map.has_key?(filters, "#{over_long_id}_to")
end
end
end
defmodule MvWeb.MemberLive.Index.DateFilterIntegrationTest do
@moduledoc """
Integration tests for the date filter URL query result-set pipeline.
Covers §1.7, §1.8, §1.9, §1.10, §1.11, §1.12, §1.15, §1.16, §1.17, §1.18,
§1.19. Custom date field filters are covered in the dedicated custom-field
integration test.
"""
# async: false: mutates the global member table.
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
today = Date.utc_today()
{:ok, alice} =
Mv.Membership.create_member(
%{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com",
join_date: ~D[2020-01-01]
},
actor: system_actor
)
{:ok, bob} =
Mv.Membership.create_member(
%{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com",
join_date: ~D[2022-06-15]
},
actor: system_actor
)
{:ok, carla} =
Mv.Membership.create_member(
%{
first_name: "Carla",
last_name: "Carter",
email: "carla@example.com",
join_date: ~D[2024-03-20]
},
actor: system_actor
)
{:ok, dan} =
Mv.Membership.create_member(
%{
first_name: "Dan",
last_name: "Dixon",
email: "dan@example.com"
# no join_date — should be excluded by any join_date range filter
},
actor: system_actor
)
{:ok, former} =
Mv.Membership.create_member(
%{
first_name: "Frida",
last_name: "Former",
email: "frida@example.com",
join_date: Date.add(today, -1000),
exit_date: Date.add(today, -10)
},
actor: system_actor
)
%{alice: alice, bob: bob, carla: carla, dan: dan, former: former, today: today}
end
describe "join_date filters" do
test "jd_from includes members with join_date >= bound; excludes nil (§1.7, §1.17)",
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?jd_from=2022-01-01")
refute html =~ alice.first_name
assert html =~ bob.first_name
assert html =~ carla.first_name
refute html =~ dan.first_name
end
test "jd_to excludes members with join_date > bound (§1.8)",
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?jd_to=2022-12-31")
assert html =~ alice.first_name
assert html =~ bob.first_name
refute html =~ carla.first_name
refute html =~ dan.first_name
end
test "jd_from and jd_to combine into an inclusive range (§1.9)",
%{conn: conn, alice: alice, bob: bob, carla: carla} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?jd_from=2022-01-01&jd_to=2023-12-31")
refute html =~ alice.first_name
assert html =~ bob.first_name
refute html =~ carla.first_name
end
test "no active filter imposes no constraint on the join_date field (§1.18)",
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
# Nil join_date members are still visible when no join_date filter is active.
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ alice.first_name
assert html =~ bob.first_name
assert html =~ carla.first_name
assert html =~ dan.first_name
end
end
describe "exit_date filters" do
test "ed_mode=inactive_only shows only former members (§1.10)",
%{conn: conn, alice: alice, former: former} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?ed_mode=inactive_only")
refute html =~ alice.first_name
assert html =~ former.first_name
end
test "ed_mode=all shows all members regardless of exit_date (§1.11)",
%{conn: conn, alice: alice, former: former} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?ed_mode=all")
assert html =~ alice.first_name
assert html =~ former.first_name
end
test "ed_mode=custom range hides members outside the range (§1.12)",
%{conn: conn, alice: alice, former: former, today: today} do
conn = conn_with_oidc_user(conn)
from = Date.add(today, -30) |> Date.to_iso8601()
to = Date.to_iso8601(today)
{:ok, _view, html} = live(conn, "/members?ed_mode=custom&ed_from=#{from}&ed_to=#{to}")
refute html =~ alice.first_name
assert html =~ former.first_name
end
end
describe "filter combination and URL persistence" do
test "join_date filter combined with the default (active-only) shows only active members in range (§1.15)",
%{conn: conn, alice: alice, bob: bob, former: former} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?jd_from=2020-01-01")
# Default active-only hides the former member even though they match join_date range.
refute html =~ former.first_name
assert html =~ alice.first_name
assert html =~ bob.first_name
end
test "date filter survives reload via URL params (§1.16)",
%{conn: conn, alice: alice, bob: bob, carla: carla} do
conn = conn_with_oidc_user(conn)
url = "/members?jd_from=2022-01-01&jd_to=2023-12-31"
{:ok, _view, html1} = live(conn, url)
{:ok, _view, html2} = live(conn, url)
# Same URL → same visible result set.
for member <- [alice, carla] do
refute html1 =~ member.first_name
refute html2 =~ member.first_name
end
assert html1 =~ bob.first_name
assert html2 =~ bob.first_name
end
test "malformed jd_from is silently ignored (§1.19)",
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?jd_from=notadate")
# The filter is dropped, so default behavior (no join_date filter) applies
# and every member shows up.
assert html =~ alice.first_name
assert html =~ bob.first_name
assert html =~ carla.first_name
assert html =~ dan.first_name
end
end
end

View file

@ -0,0 +1,89 @@
defmodule MvWeb.MemberLive.Index.CustomFieldValueLookupTest do
@moduledoc """
Unit tests for the shared custom-field-value lookup helper.
The lookup must handle both shapes a CFV entry can take on a loaded member:
* `%{custom_field_id: id, value: ...}` id present directly
* `%{custom_field: %{id: id, ...}, value: ...}` id nested under loaded relation
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
defp uuid, do: "11111111-2222-3333-4444-555555555555"
describe "find_by_id/2" do
test "matches when custom_field_id key is present" do
id = uuid()
cfv = %{custom_field_id: id, value: :anything}
member = %{custom_field_values: [cfv]}
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
end
test "matches when nested custom_field relation is loaded" do
id = uuid()
cfv = %{custom_field: %{id: id, value_type: :date}, value: :anything}
member = %{custom_field_values: [cfv]}
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
end
test "compares stringified ids — accepts atom or binary ids on the cfv side" do
id = uuid()
cfv = %{custom_field_id: id, value: :v}
member = %{custom_field_values: [cfv]}
# Same id, passed as binary
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
end
test "returns nil when no entry has a matching id" do
member = %{
custom_field_values: [
%{custom_field_id: "11111111-1111-1111-1111-111111111111", value: 1}
]
}
assert CustomFieldValueLookup.find_by_id(member, uuid()) == nil
end
test "returns nil when custom_field_values is nil" do
assert CustomFieldValueLookup.find_by_id(%{custom_field_values: nil}, uuid()) == nil
end
test "returns nil when custom_field_values is not loaded (Ash.NotLoaded)" do
member = %{custom_field_values: %Ash.NotLoaded{type: :relationship}}
assert CustomFieldValueLookup.find_by_id(member, uuid()) == nil
end
test "returns nil when custom_field_values is empty" do
assert CustomFieldValueLookup.find_by_id(%{custom_field_values: []}, uuid()) == nil
end
end
describe "find_by_field/2" do
test "matches a custom_field struct via its :id" do
id = uuid()
cfv = %{custom_field_id: id, value: :v}
member = %{custom_field_values: [cfv]}
custom_field = %{id: id, value_type: :boolean}
assert CustomFieldValueLookup.find_by_field(member, custom_field) == cfv
end
test "matches when only the nested custom_field is present" do
id = uuid()
cfv = %{custom_field: %{id: id}, value: :v}
member = %{custom_field_values: [cfv]}
assert CustomFieldValueLookup.find_by_field(member, %{id: id}) == cfv
end
test "returns nil when no entry matches" do
member = %{custom_field_values: [%{custom_field_id: "other", value: :v}]}
assert CustomFieldValueLookup.find_by_field(member, %{id: uuid()}) == nil
end
end
end

View file

@ -0,0 +1,85 @@
defmodule MvWeb.MemberLive.Index.FilterParamsTest do
@moduledoc """
Unit tests for the shared filter-param parsers.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.FilterParams
describe "parse_prefix_filters/3" do
test "extracts only entries whose key starts with the prefix" do
params = %{
"group_abc" => "in",
"group_def" => "not_in",
"fee_type_xyz" => "in",
"unrelated" => "in",
"query" => "alice"
}
result = FilterParams.parse_prefix_filters(params, "group_", & &1)
assert result == %{"abc" => "in", "def" => "not_in"}
end
test "strips exactly one occurrence of the prefix, even when the rest starts with the prefix again" do
# Quirky but legal: a key like "p_p_abc" with prefix "p_" must produce id "p_abc".
params = %{"p_p_abc" => "v"}
result = FilterParams.parse_prefix_filters(params, "p_", & &1)
assert result == %{"p_abc" => "v"}
end
test "applies parse_value_fn to every value" do
params = %{"x_one" => "in", "x_two" => "not_in", "x_three" => "garbage"}
result =
FilterParams.parse_prefix_filters(
params,
"x_",
&FilterParams.parse_in_not_in_value/1
)
assert result == %{"one" => :in, "two" => :not_in, "three" => nil}
end
test "returns empty map when no key matches the prefix" do
params = %{"a" => "1", "b" => "2"}
assert FilterParams.parse_prefix_filters(params, "z_", & &1) == %{}
end
test "ignores non-binary keys" do
params = %{"x_a" => "1", :atom_key => "2", 123 => "3"}
result = FilterParams.parse_prefix_filters(params, "x_", & &1)
assert result == %{"a" => "1"}
end
test "returns empty map for empty input" do
assert FilterParams.parse_prefix_filters(%{}, "x_", & &1) == %{}
end
end
describe "parse_in_not_in_value/1" do
test "maps 'in' to :in" do
assert FilterParams.parse_in_not_in_value("in") == :in
end
test "maps 'not_in' to :not_in" do
assert FilterParams.parse_in_not_in_value("not_in") == :not_in
end
test "trims whitespace around recognized values" do
assert FilterParams.parse_in_not_in_value(" in ") == :in
assert FilterParams.parse_in_not_in_value("\tnot_in\n") == :not_in
end
test "returns nil for unrecognized strings" do
assert FilterParams.parse_in_not_in_value("yes") == nil
assert FilterParams.parse_in_not_in_value("") == nil
end
test "returns nil for non-binary input" do
assert FilterParams.parse_in_not_in_value(nil) == nil
assert FilterParams.parse_in_not_in_value(:in) == nil
assert FilterParams.parse_in_not_in_value(123) == nil
end
end
end

View file

@ -0,0 +1,35 @@
defmodule MvWeb.LiveUserAuthTest do
@moduledoc """
Regression tests for the `MvWeb.LiveUserAuth` on_mount guards:
the unauthenticated `:live_user_required` redirect to the sign-in page and
the authenticated `:live_no_user` redirect away from the sign-in page.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
describe ":live_user_required" do
@tag role: :unauthenticated
test "unauthenticated request to a protected route is redirected to sign-in", %{conn: conn} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/members")
assert to == "/sign-in"
end
@tag role: :admin
test "authenticated user can mount a protected route", %{conn: conn} do
assert {:ok, _view, _html} = live(conn, "/members")
end
end
describe ":live_no_user" do
@tag role: :admin
test "authenticated user visiting the sign-in page is redirected to root", %{conn: conn} do
assert {:error, {:redirect, %{to: "/"}}} = live(conn, "/sign-in")
end
@tag role: :unauthenticated
test "unauthenticated user can reach the sign-in page", %{conn: conn} do
assert {:ok, _view, _html} = live(conn, "/sign-in")
end
end
end

View file

@ -268,6 +268,28 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
# Should not crash # Should not crash
assert html =~ member.first_name assert html =~ member.first_name
end end
test "create_cycle with an unparseable date shows an error instead of crashing", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
view
|> element("button[phx-click='open_create_cycle_modal']")
|> render_click()
html =
view
|> element("form[phx-submit='create_cycle']")
|> render_submit(%{"date" => "not-a-date", "amount" => "10"})
assert html =~ "Invalid date format"
end
end end
describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do