Compare commits
29 commits
24e1d101ce
...
8429fb2b9c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8429fb2b9c | ||
| d51dcb1ac3 | |||
| 1ef6ea502e | |||
| 9a14cedc14 | |||
| b5756d8e00 | |||
| a7ad608051 | |||
| 6a4a99f638 | |||
| ec6422d450 | |||
| 2db467d5d1 | |||
| c41d24113f | |||
| 05f66ccf74 | |||
| d9a5a081df | |||
| c0395f16e8 | |||
| 848f0cd013 | |||
| 04ab05f556 | |||
| 5352a635c6 | |||
| fd8e6ac178 | |||
| 263857ee26 | |||
| ce57d046b9 | |||
| 35b884e6e1 | |||
| a27425b5fb | |||
| ba66bc15db | |||
| 22955bdd9e | |||
| c6578662d8 | |||
| d36703450a | |||
| d6671daf1a | |||
| e3295ab4b5 | |||
| ddd4a9a878 | |||
| 143c0c5c24 |
86 changed files with 3962 additions and 765 deletions
9
.deps_audit_ignore
Normal file
9
.deps_audit_ignore
Normal 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
11
.dialyzer_ignore.exs
Normal 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
184
.drone.jsonnet
Normal 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',
|
||||
],
|
||||
}],
|
||||
},
|
||||
]
|
||||
298
.drone.yml
298
.drone.yml
|
|
@ -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
|
||||
|
|
@ -24,7 +24,7 @@ ASSOCIATION_NAME="Sportsclub XYZ"
|
|||
# OIDC_CLIENT_ID=mv
|
||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/oidc/callback
|
||||
# OIDC_CLIENT_SECRET=your-oidc-client-secret
|
||||
# OIDC_CLIENT_SECRET=mv-dev-shared-secret-not-for-production-do-not-use-anywhere-else
|
||||
|
||||
# Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope)
|
||||
# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in.
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -49,3 +49,7 @@ notes.md
|
|||
# Do NOT commit these — they are local to the dev machine
|
||||
.pipeline/
|
||||
.claude/
|
||||
|
||||
# Dialyzer PLT files — built locally and in CI cache, never tracked.
|
||||
/priv/plts/*.plt
|
||||
/priv/plts/*.plt.hash
|
||||
|
|
|
|||
43
Justfile
43
Justfile
|
|
@ -29,7 +29,27 @@ seed-database:
|
|||
start-database:
|
||||
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 5–15 min; subsequent runs are seconds. PLT files live in
|
||||
# priv/plts/ and are gitignored.
|
||||
plt: install-dependencies
|
||||
@mkdir -p priv/plts
|
||||
mix dialyzer --plt
|
||||
|
||||
# Typecheck via Dialyzer. Slow stage, NOT part of ci-dev.
|
||||
typecheck: plt
|
||||
mix dialyzer --format short
|
||||
|
||||
# Full CI: inner loop plus typecheck. Use locally before pushing; Drone CI
|
||||
# runs equivalent steps with PLT caching.
|
||||
ci: ci-dev typecheck
|
||||
|
||||
gettext:
|
||||
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'
|
||||
mix gettext.extract --check-up-to-date
|
||||
|
||||
audit:
|
||||
# Static security scan (Sobelow).
|
||||
sobelow:
|
||||
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
|
||||
|
||||
# Run all tests
|
||||
test *args: install-dependencies
|
||||
# Run all tests. No install-dependencies prerequisite so single-file runs stay
|
||||
# fast; run `just install-dependencies` once on a fresh checkout.
|
||||
test *args:
|
||||
mix test {{args}}
|
||||
|
||||
# Run only fast tests (excludes slow/performance and UI tests)
|
||||
test-fast *args: install-dependencies
|
||||
# Fast tests only (excludes slow/performance and UI tests).
|
||||
test-fast *args:
|
||||
mix test --exclude slow --exclude ui {{args}}
|
||||
|
||||
# Affected fast tests only (mix test --stale) with reduced property runs.
|
||||
test-stale *args:
|
||||
PROPERTY_RUNS=25 mix test --stale --exclude slow --exclude ui {{args}}
|
||||
|
||||
# Run only UI tests
|
||||
ui *args: install-dependencies
|
||||
mix test --only ui {{args}}
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -124,8 +124,8 @@ mix archive.install hex phx_new
|
|||
1. Copy env file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Set OIDC_CLIENT_SECRET inside .env
|
||||
```
|
||||
The dev `OIDC_CLIENT_SECRET` is already preset — no manual GUI step needed.
|
||||
|
||||
2. Start everything (database, Mailcrab, Rauthy, app):
|
||||
```bash
|
||||
|
|
@ -139,21 +139,9 @@ mix archive.install hex phx_new
|
|||
|
||||
## 🔐 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`
|
||||
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!
|
||||
Rauthy admin UI: <http://localhost:8080> — login `admin@localhost`, password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in `docker-compose.yml`.
|
||||
|
||||
### OIDC with other providers (Authentik, Keycloak, etc.)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected).
|
||||
config :ash, warn_on_transaction_hooks?: false
|
||||
|
||||
# StreamData property tests: generated cases per property, overridable via PROPERTY_RUNS
|
||||
# (the `just check` recipe sets it low for speed; default 100 otherwise).
|
||||
config :stream_data, max_runs: String.to_integer(System.get_env("PROPERTY_RUNS") || "100")
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ services:
|
|||
- BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345
|
||||
# Disable strict IP validation to allow access from multiple Docker networks
|
||||
- SESSION_VALIDATE_IP=false
|
||||
# Auto-seed the `mv` OIDC client (id + plain secret) on first DB init.
|
||||
# Re-runs after `docker compose down -v` because the DB is empty again.
|
||||
- BOOTSTRAP_DIR=/app/bootstrap
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
|
|
@ -46,6 +49,7 @@ services:
|
|||
- local
|
||||
volumes:
|
||||
- rauthy-data:/app/data
|
||||
- ./rauthy-bootstrap:/app/bootstrap:ro
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
|
|
|||
|
|
@ -17,16 +17,10 @@ defmodule Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist do
|
|||
form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{}
|
||||
|
||||
allowlist_ids =
|
||||
case Membership.get_join_form_allowlist() do
|
||||
list when is_list(list) ->
|
||||
list
|
||||
|> Enum.map(fn item -> item.id end)
|
||||
|> MapSet.new()
|
||||
|> MapSet.difference(MapSet.new(@typed_fields))
|
||||
|
||||
_ ->
|
||||
MapSet.new()
|
||||
end
|
||||
Membership.get_join_form_allowlist()
|
||||
|> Enum.map(fn item -> item.id end)
|
||||
|> MapSet.new()
|
||||
|> MapSet.difference(MapSet.new(@typed_fields))
|
||||
|
||||
filtered =
|
||||
form_data
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ defmodule Mv.Membership.Member do
|
|||
|
||||
require Logger
|
||||
|
||||
@typedoc "An `Mv.Membership.Member` resource record."
|
||||
@type t :: %__MODULE__{}
|
||||
|
||||
# Module constants
|
||||
@member_search_limit 10
|
||||
|
||||
|
|
@ -791,7 +794,7 @@ defmodule Mv.Membership.Member do
|
|||
# nil/[] when membership_fee_type is missing.
|
||||
|
||||
@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
|
||||
today = Date.utc_today()
|
||||
|
||||
|
|
@ -821,7 +824,7 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
|
||||
@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
|
||||
today = Date.utc_today()
|
||||
|
||||
|
|
@ -867,7 +870,7 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
|
||||
@doc false
|
||||
@spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()]
|
||||
@spec get_overdue_cycles(t()) :: [MembershipFeeCycle.t()]
|
||||
def get_overdue_cycles(member) do
|
||||
today = Date.utc_today()
|
||||
|
||||
|
|
@ -939,7 +942,7 @@ defmodule Mv.Membership.Member do
|
|||
# Already in transaction: use advisory lock directly
|
||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
||||
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
_ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
||||
end
|
||||
|
||||
|
|
@ -947,7 +950,7 @@ defmodule Mv.Membership.Member do
|
|||
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
||||
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
||||
Repo.transaction(fn ->
|
||||
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
_ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
|
||||
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
||||
{:ok, notifications} ->
|
||||
|
|
@ -1093,7 +1096,7 @@ defmodule Mv.Membership.Member do
|
|||
initiator: initiator
|
||||
) do
|
||||
{:ok, cycles, notifications} ->
|
||||
send_notifications_if_any(notifications)
|
||||
_ = send_notifications_if_any(notifications)
|
||||
|
||||
log_cycle_generation_success(member, cycles, notifications,
|
||||
sync: true,
|
||||
|
|
@ -1112,7 +1115,7 @@ defmodule Mv.Membership.Member do
|
|||
initiator: initiator
|
||||
) do
|
||||
{:ok, cycles, notifications} ->
|
||||
send_notifications_if_any(notifications)
|
||||
_ = send_notifications_if_any(notifications)
|
||||
|
||||
log_cycle_generation_success(member, cycles, notifications,
|
||||
sync: false,
|
||||
|
|
@ -1231,8 +1234,6 @@ defmodule Mv.Membership.Member do
|
|||
|> String.replace("_", "\\_")
|
||||
end
|
||||
|
||||
defp sanitize_search_query(_), do: ""
|
||||
|
||||
# ============================================================================
|
||||
# Search Filter Builders
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -37,9 +37,10 @@ defmodule Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil do
|
|||
{:ok, %{user: user}} when not is_nil(user) ->
|
||||
# User's :update action only accepts [:email]; use :update_user so
|
||||
# manage_relationship(:member, ..., on_missing: :unrelate) runs and clears member_id.
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|
||||
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
|
||||
_ =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|
||||
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
|
||||
|
||||
changeset
|
||||
|
||||
|
|
|
|||
|
|
@ -836,7 +836,10 @@ defmodule Mv.Membership do
|
|||
- `{:ok, rejected_request}` - Rejected JoinRequest
|
||||
- `{: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
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
|||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
|
||||
@impl true
|
||||
|
|
@ -83,11 +81,6 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
|||
field: :membership_fee_type_id,
|
||||
message: "not found"
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
# Log warning for other unexpected errors
|
||||
Logger.warning("Could not auto-set membership_fee_start_date: #{inspect(reason)}")
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
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 action :: :read | :create | :update | :destroy
|
||||
|
||||
|
|
@ -88,7 +89,7 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
iex> PermissionSets.all_permission_sets()
|
||||
[:own_data, :read_only, :normal_user, :admin]
|
||||
"""
|
||||
@spec all_permission_sets() :: [atom()]
|
||||
@spec all_permission_sets() :: [permission_set_name(), ...]
|
||||
def all_permission_sets do
|
||||
[:own_data, :read_only, :normal_user, :admin]
|
||||
end
|
||||
|
|
@ -107,7 +108,7 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
iex> PermissionSets.get_permissions(:invalid)
|
||||
** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin]
|
||||
"""
|
||||
@spec get_permissions(atom()) :: permission_set()
|
||||
@spec get_permissions(permission_set_name()) :: permission_set()
|
||||
|
||||
def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do
|
||||
raise ArgumentError,
|
||||
|
|
|
|||
|
|
@ -207,8 +207,6 @@ defmodule Mv.Config do
|
|||
end
|
||||
end
|
||||
|
||||
defp derive_app_url_from_api_url(_), do: nil
|
||||
|
||||
@doc """
|
||||
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
|
||||
nil -> false
|
||||
v when is_binary(v) -> String.trim(v) != ""
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -270,9 +267,6 @@ defmodule Mv.Config do
|
|||
value when is_binary(value) ->
|
||||
v = String.trim(value) |> String.downcase()
|
||||
v in ["true", "1", "yes"]
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -328,7 +322,6 @@ defmodule Mv.Config do
|
|||
|
||||
defp present?(nil), do: false
|
||||
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
||||
defp present?(_), do: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC authentication
|
||||
|
|
@ -409,7 +402,7 @@ defmodule Mv.Config do
|
|||
@doc """
|
||||
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
|
||||
case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do
|
||||
nil -> "groups"
|
||||
|
|
@ -492,7 +485,7 @@ defmodule Mv.Config do
|
|||
- ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_PORT`
|
||||
- Settings mode: read from Settings only
|
||||
"""
|
||||
@spec smtp_port() :: non_neg_integer() | nil
|
||||
@spec smtp_port() :: pos_integer() | nil
|
||||
def smtp_port do
|
||||
if smtp_env_mode?() do
|
||||
parse_smtp_port_env(System.get_env("SMTP_PORT"))
|
||||
|
|
@ -638,9 +631,15 @@ defmodule Mv.Config do
|
|||
"""
|
||||
@spec mail_from_name() :: String.t()
|
||||
def mail_from_name do
|
||||
case System.get_env("MAIL_FROM_NAME") do
|
||||
nil -> get_from_settings(:smtp_from_name) || "Mila"
|
||||
value -> trim_nil(value) || "Mila"
|
||||
name =
|
||||
case System.get_env("MAIL_FROM_NAME") do
|
||||
nil -> get_from_settings(:smtp_from_name)
|
||||
value -> trim_nil(value)
|
||||
end
|
||||
|
||||
case name do
|
||||
nil -> "Mila"
|
||||
name -> name
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,18 @@ defmodule Mv.Constants do
|
|||
|
||||
@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_uuid_length 36
|
||||
|
|
@ -84,6 +96,70 @@ defmodule Mv.Constants do
|
|||
"""
|
||||
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 """
|
||||
Returns the maximum number of boolean custom field filters allowed per request.
|
||||
|
||||
|
|
|
|||
|
|
@ -225,7 +225,10 @@ defmodule Mv.Helpers.SystemActor do
|
|||
# This allows configuration via SYSTEM_ACTOR_EMAIL env var
|
||||
@spec system_user_email_config() :: String.t()
|
||||
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
|
||||
|
||||
# Loads the system actor from the database
|
||||
|
|
@ -257,7 +260,7 @@ defmodule Mv.Helpers.SystemActor do
|
|||
end
|
||||
|
||||
# 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
|
||||
case load_admin_user_fallback() do
|
||||
{:ok, admin_user} ->
|
||||
|
|
@ -393,15 +396,18 @@ defmodule Mv.Helpers.SystemActor do
|
|||
# 1. Only creates system user with known email
|
||||
# 2. Only called during system actor initialization (bootstrap)
|
||||
# 3. Once created, all subsequent operations use proper authorization
|
||||
Accounts.create_user!(%{email: system_user_email_config()},
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email,
|
||||
authorize?: false
|
||||
)
|
||||
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||
user =
|
||||
Accounts.create_user!(%{email: system_user_email_config()},
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email,
|
||||
authorize?: false
|
||||
)
|
||||
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||
|
||||
%Accounts.User{} = user
|
||||
end
|
||||
|
||||
# Finds a user by email address
|
||||
|
|
|
|||
|
|
@ -190,6 +190,4 @@ defmodule Mv.Mailer do
|
|||
defp valid_email?(email) when is_binary(email) do
|
||||
Regex.match?(@email_regex, String.trim(email))
|
||||
end
|
||||
|
||||
defp valid_email?(_), do: false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -100,7 +100,8 @@ defmodule Mv.Membership.Import.CsvParser do
|
|||
|> String.replace("\r", "\n")
|
||||
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.CsvParserComma
|
||||
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: ","
|
||||
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
|
||||
case parse_single_record(parser, header_record, nil) do
|
||||
{:ok, fields} -> Enum.count(fields, &(String.trim(&1) != ""))
|
||||
|
|
|
|||
|
|
@ -26,14 +26,8 @@ defmodule Mv.Membership.Import.ImportRunner do
|
|||
{: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, Exception.message(reason)}
|
||||
{:error, to_string(:file.format_error(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -210,8 +210,6 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
MapSet.member?(HeaderMapper.known_member_fields(), normalized)
|
||||
end
|
||||
|
||||
defp member_field?(_), do: false
|
||||
|
||||
# Validates that row count doesn't exceed limit
|
||||
defp validate_row_count(rows, max_rows) do
|
||||
if length(rows) > max_rows do
|
||||
|
|
|
|||
|
|
@ -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
|
||||
defp resolve_actor(changeset, context) do
|
||||
ctx = changeset.context || %{}
|
||||
ctx = changeset.context
|
||||
|
||||
get_in(ctx, [:private, :actor]) ||
|
||||
Map.get(ctx, :actor) ||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,21 @@ defmodule Mv.Membership.MemberExport do
|
|||
alias MvWeb.MemberLive.Index
|
||||
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)) ++
|
||||
["membership_fee_type", "membership_fee_status", "groups"]
|
||||
@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,
|
||||
:show_current_cycle, :cycle_status_filter, :boolean_filters.
|
||||
"""
|
||||
@spec parse_params(map()) :: map()
|
||||
@spec parse_params(map()) :: parsed_params()
|
||||
def parse_params(params) do
|
||||
# DB fields come from "member_fields"
|
||||
raw_member_fields = extract_list(params, "member_fields")
|
||||
|
|
@ -458,9 +473,6 @@ defmodule Mv.Membership.MemberExport do
|
|||
computed_fields,
|
||||
member_fields
|
||||
) do
|
||||
computed_fields = computed_fields || []
|
||||
member_fields = member_fields || []
|
||||
|
||||
db_with_insert =
|
||||
Enum.flat_map(db_fields_ordered, fn f ->
|
||||
expand_field_with_computed(f, member_fields, computed_fields)
|
||||
|
|
@ -507,6 +519,4 @@ defmodule Mv.Membership.MemberExport do
|
|||
other -> other
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_computed_fields(_), do: []
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ defmodule Mv.Membership.MembersCSV do
|
|||
Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body.
|
||||
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
|
||||
header = build_header(columns)
|
||||
rows = Enum.map(members, fn member -> build_row(member, columns) end)
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ defmodule Mv.Membership.MembersPDF do
|
|||
|
||||
defp convert_to_template_format(export_data, locale, club_name) do
|
||||
# Set locale for translations
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
headers = Enum.map(export_data.columns, & &1.label)
|
||||
column_count = length(export_data.columns)
|
||||
|
|
@ -211,9 +211,6 @@ defmodule Mv.Membership.MembersPDF do
|
|||
{:ok, datetime, _offset} ->
|
||||
format_datetime(datetime, locale)
|
||||
|
||||
{:ok, datetime} ->
|
||||
format_datetime(datetime, locale)
|
||||
|
||||
{:error, _} ->
|
||||
# Try NaiveDateTime if DateTime parsing fails
|
||||
case NaiveDateTime.from_iso8601(iso8601_string) do
|
||||
|
|
@ -257,8 +254,6 @@ defmodule Mv.Membership.MembersPDF do
|
|||
end
|
||||
end
|
||||
|
||||
defp format_date(_, _), do: ""
|
||||
|
||||
defp format_dates_in_rows(rows, columns, locale) do
|
||||
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
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
|||
{: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
|
||||
Logger.info("Starting membership fee cycle generation job")
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
|
@ -98,7 +98,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
|||
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
|
||||
Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}")
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
|
@ -135,7 +135,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
|||
- `{: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
|
||||
today = Date.utc_today()
|
||||
|
||||
|
|
@ -166,7 +166,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
|||
- `{: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
|
||||
Logger.info("Generating cycles for member #{member_id}")
|
||||
CycleGenerator.generate_cycles_for_member(member_id)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
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 """
|
||||
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)
|
||||
|
||||
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
|
||||
{:ok, cycles, notifications} ->
|
||||
|
|
@ -159,7 +166,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
- `{: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
|
||||
today = Keyword.get(opts, :today, Date.utc_today())
|
||||
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
|
||||
case generate_cycles_for_member(member, today: today) do
|
||||
{:ok, _cycles, notifications} = ok ->
|
||||
send_notifications_for_batch_job(notifications)
|
||||
_ = send_notifications_for_batch_job(notifications)
|
||||
{member.id, ok}
|
||||
|
||||
{:error, _reason} = err ->
|
||||
|
|
|
|||
88
lib/mv/oidc/discovery.ex
Normal file
88
lib/mv/oidc/discovery.ex
Normal 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
|
||||
|
|
@ -87,8 +87,6 @@ defmodule Mv.OidcRoleSync do
|
|||
ArgumentError -> nil
|
||||
end
|
||||
|
||||
defp safe_get_atom(_map, _key), do: nil
|
||||
|
||||
defp peek_jwt_claims(token) do
|
||||
parts = String.split(token, ".")
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,6 @@ defmodule Mv.OidcRoleSyncConfig do
|
|||
|
||||
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
|
||||
def oidc_groups_claim do
|
||||
Mv.Config.oidc_groups_claim() || "groups"
|
||||
Mv.Config.oidc_groups_claim()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ defmodule Mv.Release do
|
|||
require Logger
|
||||
|
||||
def migrate do
|
||||
load_app()
|
||||
_ = load_app()
|
||||
|
||||
for repo <- repos() do
|
||||
{: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")
|
||||
|
||||
prev = Code.compiler_options()
|
||||
Code.compiler_options(ignore_module_conflict: true)
|
||||
_ = Code.compiler_options(ignore_module_conflict: true)
|
||||
|
||||
try do
|
||||
Code.eval_file(bootstrap_path)
|
||||
_ = Code.eval_file(bootstrap_path)
|
||||
IO.puts("✅ Bootstrap seeds completed.")
|
||||
|
||||
if System.get_env("RUN_DEV_SEEDS") == "true" do
|
||||
Code.eval_file(dev_path)
|
||||
_ = Code.eval_file(dev_path)
|
||||
IO.puts("✅ Dev seeds completed.")
|
||||
end
|
||||
after
|
||||
|
|
@ -92,7 +92,7 @@ defmodule Mv.Release do
|
|||
end
|
||||
|
||||
def rollback(repo, version) do
|
||||
load_app()
|
||||
_ = load_app()
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||
end
|
||||
|
||||
|
|
@ -139,10 +139,11 @@ defmodule Mv.Release do
|
|||
{:ok, %Role{} = admin_role} ->
|
||||
case get_user_by_email(email) do
|
||||
{:ok, %User{} = user} ->
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
_ =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|
||||
:ok
|
||||
|
||||
|
|
@ -189,15 +190,16 @@ defmodule Mv.Release do
|
|||
defp create_admin_user(email, password, admin_role) do
|
||||
case Accounts.create_user(%{email: email}, authorize?: false) do
|
||||
{:ok, user} ->
|
||||
user
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> then(fn u ->
|
||||
u
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
_ =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||
|> 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
|
||||
|
||||
|
|
@ -207,15 +209,16 @@ defmodule Mv.Release do
|
|||
end
|
||||
|
||||
defp update_admin_user(user, password, admin_role) do
|
||||
user
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> then(fn u ->
|
||||
u
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
_ =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||
|> 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
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,4 +19,12 @@ defmodule Mv.Repo do
|
|||
def min_pg_version do
|
||||
%Version{major: 17, minor: 2, patch: 0}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ defmodule Mv.Vereinfacht.Client do
|
|||
"""
|
||||
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"
|
||||
|
||||
@doc """
|
||||
|
|
@ -31,7 +37,7 @@ defmodule Mv.Vereinfacht.Client do
|
|||
{:error, :not_configured}
|
||||
"""
|
||||
@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
|
||||
if blank?(api_url) or blank?(api_key) or blank?(club_id) do
|
||||
{:error, :not_configured}
|
||||
|
|
@ -92,13 +98,12 @@ defmodule Mv.Vereinfacht.Client do
|
|||
|
||||
@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).
|
||||
# `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
|
||||
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
|
||||
|
||||
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.
|
||||
"""
|
||||
@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
|
||||
fetch_contact(contact_id, [])
|
||||
end
|
||||
|
|
|
|||
|
|
@ -37,9 +37,10 @@ defmodule Mv.Vereinfacht.SyncFlash do
|
|||
def create_table! do
|
||||
# :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.
|
||||
if :ets.whereis(@table) == :undefined do
|
||||
:ets.new(@table, [:set, :public, :named_table])
|
||||
end
|
||||
_ =
|
||||
if :ets.whereis(@table) == :undefined do
|
||||
:ets.new(@table, [:set, :public, :named_table])
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ defmodule Mv.Vereinfacht do
|
|||
- `{:error, {:http, status, message}}` – API returned an error (e.g. 401, 403)
|
||||
- `{: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
|
||||
Client.test_connection(
|
||||
Mv.Config.vereinfacht_api_url(),
|
||||
|
|
|
|||
|
|
@ -113,8 +113,7 @@ defmodule MvWeb.Authorization do
|
|||
iex> can_access_page?(mitglied, "/members")
|
||||
false
|
||||
"""
|
||||
@spec can_access_page?(map() | nil, String.t() | Phoenix.VerifiedRoutes.unverified_path()) ::
|
||||
boolean()
|
||||
@spec can_access_page?(map() | nil, String.t()) :: boolean()
|
||||
def can_access_page?(nil, _page_path), do: false
|
||||
|
||||
def can_access_page?(user, page_path) do
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ defmodule MvWeb.AuthController do
|
|||
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
alias Mv.Config
|
||||
alias Mv.Oidc.Discovery
|
||||
|
||||
def success(conn, {:password, :sign_in} = _activity, user, token) do
|
||||
if Config.oidc_only?() do
|
||||
|
|
@ -334,14 +335,29 @@ defmodule MvWeb.AuthController do
|
|||
end
|
||||
end
|
||||
|
||||
defp redact_url(_), do: "[redacted]"
|
||||
|
||||
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
|
||||
|> clear_session(:mv)
|
||||
|> put_flash(:success, gettext("You are now signed out"))
|
||||
|> redirect(to: return_to)
|
||||
case oidc_end_session_url() do
|
||||
{:ok, url} ->
|
||||
redirect(conn, external: url)
|
||||
|
||||
: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
|
||||
|
|
|
|||
|
|
@ -25,31 +25,33 @@ defmodule MvWeb.MemberExportController do
|
|||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
||||
def export(conn, params) do
|
||||
actor = current_actor(conn)
|
||||
if is_nil(actor), do: return_forbidden(conn)
|
||||
|
||||
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
|
||||
case current_actor(conn) do
|
||||
nil -> return_forbidden(conn)
|
||||
actor -> export_with_actor(conn, actor, params["payload"])
|
||||
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
|
||||
conn.assigns[:current_user]
|
||||
|> Actor.ensure_loaded()
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ defmodule MvWeb.SignInLive do
|
|||
# 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
|
||||
# language-selector `selected` attribute in Layouts.public_page).
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
Gettext.put_locale(locale)
|
||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
_ = Gettext.put_locale(locale)
|
||||
|
||||
# Prepend DE-specific overrides when locale is German so that components
|
||||
# without _gettext support (e.g. HorizontalRule) still render in German.
|
||||
|
|
|
|||
62
lib/mv_web/live/auth/sign_out_live.ex
Normal file
62
lib/mv_web/live/auth/sign_out_live.ex
Normal 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
|
||||
|
|
@ -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).
|
||||
- `:boolean_custom_fields` - List of boolean custom fields to display
|
||||
- `: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)
|
||||
- `: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 `{: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 `{: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
|
||||
|
||||
alias MvWeb.MemberLive.Index.DateFilter
|
||||
alias MvWeb.MemberLive.Index.FilterParams
|
||||
|
||||
@group_filter_prefix Mv.Constants.group_filter_prefix()
|
||||
@fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix()
|
||||
@custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix()
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
|
|
@ -50,19 +60,42 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|
||||
|> assign(:groups, assigns[:groups] || [])
|
||||
|> assign(:group_filters, assigns[:group_filters] || %{})
|
||||
|> assign(:group_filter_prefix, @group_filter_prefix)
|
||||
|> assign(:fee_types, assigns[:fee_types] || [])
|
||||
|> 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_group_assigns(assigns)
|
||||
|> assign_fee_type_assigns(assigns)
|
||||
|> assign_boolean_assigns(assigns)
|
||||
|> assign_date_assigns(assigns)
|
||||
|> assign(:member_count, assigns[:member_count] || 0)
|
||||
|
||||
{:ok, socket}
|
||||
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
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -81,7 +114,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
"gap-2",
|
||||
(@cycle_status_filter || map_size(@group_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"
|
||||
]}
|
||||
phx-click="toggle_dropdown"
|
||||
|
|
@ -99,7 +133,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
@fee_types,
|
||||
@fee_type_filters,
|
||||
@boolean_custom_fields,
|
||||
@boolean_filters
|
||||
@boolean_filters,
|
||||
@date_filters
|
||||
)}
|
||||
</span>
|
||||
<.badge
|
||||
|
|
@ -111,7 +146,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
</.badge>
|
||||
<.badge
|
||||
: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
|
||||
}
|
||||
variant="primary"
|
||||
|
|
@ -329,6 +366,163 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
</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 -->
|
||||
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
|
||||
<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)
|
||||
|
||||
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 =
|
||||
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)
|
||||
new_date_filters = DateFilter.from_params(params, socket.assigns.date_custom_fields)
|
||||
|
||||
dispatch_payment_filter_change(socket, payment_filter)
|
||||
dispatch_group_filter_changes(socket, group_filters_parsed)
|
||||
dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed)
|
||||
dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed)
|
||||
dispatch_date_filters_change(socket, new_date_filters)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
|
@ -486,17 +690,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
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
|
||||
params
|
||||
|> Map.get("custom_boolean", %{})
|
||||
|
|
@ -543,6 +736,12 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
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
|
||||
defp button_label(
|
||||
cycle_status_filter,
|
||||
|
|
@ -551,14 +750,16 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
fee_types,
|
||||
fee_type_filters,
|
||||
boolean_custom_fields,
|
||||
boolean_filters
|
||||
boolean_filters,
|
||||
date_filters
|
||||
) do
|
||||
active_count =
|
||||
count_active_filter_categories(
|
||||
cycle_status_filter,
|
||||
group_filters,
|
||||
fee_type_filters,
|
||||
boolean_filters
|
||||
boolean_filters,
|
||||
date_filters
|
||||
)
|
||||
|
||||
if active_count >= 2 do
|
||||
|
|
@ -579,6 +780,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
map_size(boolean_filters) > 0 ->
|
||||
boolean_filter_label(boolean_custom_fields, boolean_filters)
|
||||
|
||||
date_filters_active?(date_filters) ->
|
||||
gettext("Dates")
|
||||
|
||||
true ->
|
||||
gettext("Apply filters")
|
||||
end
|
||||
|
|
@ -589,17 +793,27 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
cycle_status_filter,
|
||||
group_filters,
|
||||
fee_type_filters,
|
||||
boolean_filters
|
||||
boolean_filters,
|
||||
date_filters
|
||||
) do
|
||||
[
|
||||
cycle_status_filter,
|
||||
map_size(group_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)
|
||||
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,
|
||||
do: gettext("All")
|
||||
|
||||
|
|
@ -721,7 +935,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
{nil, true} -> "#{base_classes} btn-active"
|
||||
{:in, true} -> "#{base_classes} btn-success btn-active"
|
||||
{:not_in, true} -> "#{base_classes} btn-error btn-active"
|
||||
_ -> "#{base_classes} btn-outline"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -768,4 +981,35 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
"#{base_classes} btn-outline"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
# 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")
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
custom_fields = load_custom_fields(actor)
|
||||
|
|
|
|||
|
|
@ -836,12 +836,6 @@ defmodule MvWeb.GroupLive.Show do
|
|||
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
|
||||
socket = reload_group(socket, group.slug, actor)
|
||||
|
||||
|
|
|
|||
|
|
@ -47,14 +47,11 @@ defmodule MvWeb.ImportLive do
|
|||
# after this limit is reached.
|
||||
@max_errors 50
|
||||
|
||||
# Maximum length for error messages before truncation
|
||||
@max_error_message_length 200
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
# Get locale from session for translations
|
||||
locale = session["locale"] || "de"
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
# Get club name from settings
|
||||
club_name =
|
||||
|
|
@ -193,16 +190,6 @@ defmodule MvWeb.ImportLive do
|
|||
:error,
|
||||
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
|
||||
|
||||
|
|
@ -223,64 +210,6 @@ defmodule MvWeb.ImportLive do
|
|||
{:noreply, socket}
|
||||
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
|
||||
def handle_info({:process_chunk, idx}, socket) do
|
||||
case socket.assigns do
|
||||
|
|
@ -337,32 +266,33 @@ defmodule MvWeb.ImportLive do
|
|||
actor: actor
|
||||
]
|
||||
|
||||
if Config.sql_sandbox?() do
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
else
|
||||
Task.Supervisor.start_child(
|
||||
Mv.TaskSupervisor,
|
||||
fn ->
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
end
|
||||
)
|
||||
end
|
||||
_ =
|
||||
if Config.sql_sandbox?() do
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
else
|
||||
Task.Supervisor.start_child(
|
||||
Mv.TaskSupervisor,
|
||||
fn ->
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
|
@ -378,7 +308,7 @@ defmodule MvWeb.ImportLive do
|
|||
live_view_pid,
|
||||
idx
|
||||
) 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)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -287,8 +287,6 @@ defmodule MvWeb.JoinLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp member_field_input_type(_), do: "text"
|
||||
|
||||
defp member_field_atom(field_id) when is_binary(field_id) do
|
||||
Mv.Constants.member_fields()
|
||||
|> Enum.find(&(Atom.to_string(&1) == field_id))
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
||||
alias MvWeb.MemberLive.Index.DateFilter
|
||||
alias MvWeb.MemberLive.Index.FieldSelection
|
||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||
alias MvWeb.MemberLive.Index.FilterParams
|
||||
|
|
@ -87,6 +89,13 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> Enum.filter(&(&1.value_type == :boolean))
|
||||
|> 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)
|
||||
groups =
|
||||
Mv.Membership.Group
|
||||
|
|
@ -143,6 +152,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||
|> assign(:all_custom_fields, all_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(:user_field_selection, initial_selection)
|
||||
|> assign(:fields_in_url?, false)
|
||||
|
|
@ -448,6 +459,25 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||
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
|
||||
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
|
||||
handle_info(
|
||||
|
|
@ -502,6 +532,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:group_filters, Map.get(opts, :group_filters, %{}))
|
||||
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|
||||
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|
||||
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|
||||
|> load_members()
|
||||
|> update_selection_assigns()
|
||||
|
||||
|
|
@ -632,6 +663,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> maybe_update_group_filters(params)
|
||||
|> maybe_update_fee_type_filters(params)
|
||||
|> maybe_update_boolean_filters(params)
|
||||
|> maybe_update_date_filters(params)
|
||||
|> maybe_update_show_current_cycle(params)
|
||||
|> assign(:fields_in_url?, fields_in_url?)
|
||||
|> assign(:query, params["query"])
|
||||
|
|
@ -683,7 +715,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters,
|
||||
socket.assigns.user_field_selection,
|
||||
socket.assigns[:visible_custom_field_ids] || []
|
||||
socket.assigns[:visible_custom_field_ids] || [],
|
||||
socket.assigns[:date_filters]
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -783,7 +816,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
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_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
|
||||
|
||||
defp opts_for_query_params(socket, overrides \\ %{}) do
|
||||
|
|
@ -795,7 +833,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
group_filters: socket.assigns[:group_filters] || %{},
|
||||
show_current_cycle: socket.assigns.show_current_cycle,
|
||||
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)
|
||||
end
|
||||
|
|
@ -941,26 +980,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> Ash.Query.new()
|
||||
|> Ash.Query.select(@overview_fields)
|
||||
|
||||
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)
|
||||
|
||||
ids_to_load =
|
||||
(visible_custom_field_ids ++ active_boolean_filter_ids)
|
||||
|> Enum.uniq()
|
||||
|
||||
query = load_custom_field_values(query, ids_to_load)
|
||||
query = load_custom_field_values(query, compute_ids_to_load(socket))
|
||||
|
||||
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
|
||||
|
||||
|
|
@ -984,6 +1004,13 @@ defmodule MvWeb.MemberLive.Index do
|
|||
query =
|
||||
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)
|
||||
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
|
||||
# No need for in-memory filtering anymore
|
||||
|
||||
# Apply cycle status filter if set
|
||||
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
|
||||
)
|
||||
members = apply_in_memory_filters(members, socket)
|
||||
|
||||
# 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
|
||||
|
|
@ -1037,6 +1050,55 @@ defmodule MvWeb.MemberLive.Index do
|
|||
assign(socket, :members, members)
|
||||
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, custom_field_ids) do
|
||||
|
|
@ -1156,8 +1218,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
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, status, show_current)
|
||||
|
|
@ -1235,8 +1295,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
defp valid_sort_field?(_), do: false
|
||||
|
||||
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
|
||||
non_sortable_fields = [:notes]
|
||||
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))
|
||||
end
|
||||
|
||||
defp maybe_update_group_filters(socket, _), do: socket
|
||||
|
||||
defp maybe_update_fee_type_filters(socket, params) when is_map(params) do
|
||||
prefix = @fee_type_filter_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))
|
||||
end
|
||||
|
||||
defp maybe_update_fee_type_filters(socket, _), do: socket
|
||||
|
||||
defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do
|
||||
key_str = to_string(key)
|
||||
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
|
||||
|
||||
# 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
|
||||
# -------------------------------------------------------------
|
||||
|
||||
def get_custom_field_value(member, custom_field) do
|
||||
case member.custom_field_values do
|
||||
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
|
||||
CustomFieldValueLookup.find_by_field(member, custom_field)
|
||||
end
|
||||
|
||||
def get_boolean_custom_field_value(member, custom_field) do
|
||||
|
|
@ -1725,29 +1775,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
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
|
||||
cfv -> extract_boolean_value(cfv.value) == filter_value
|
||||
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
|
||||
members
|
||||
|> Enum.filter(fn member ->
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@
|
|||
fee_type_filters={@fee_type_filters}
|
||||
boolean_custom_fields={@boolean_custom_fields}
|
||||
boolean_filters={@boolean_custom_field_filters}
|
||||
date_custom_fields={@date_custom_fields}
|
||||
date_filters={@date_filters}
|
||||
member_count={length(@members)}
|
||||
/>
|
||||
<.tooltip
|
||||
|
|
|
|||
|
|
@ -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
|
||||
454
lib/mv_web/live/member_live/index/date_filter.ex
Normal file
454
lib/mv_web/live/member_live/index/date_filter.ex
Normal 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
|
||||
|
|
@ -103,8 +103,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
|||
end)
|
||||
end
|
||||
|
||||
defp parse_cookie_header(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Saves field selection to cookie.
|
||||
|
||||
|
|
@ -218,8 +216,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
|||
end
|
||||
end
|
||||
|
||||
defp parse_json(_), do: %{}
|
||||
|
||||
# Parses a comma-separated string of field names
|
||||
defp parse_fields_string(fields_string) do
|
||||
fields_string
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
defmodule MvWeb.MemberLive.Index.FilterParams do
|
||||
@moduledoc """
|
||||
Shared parsing helpers for member list filter URL/params (in/not_in style).
|
||||
Used by MemberLive.Index and MemberFilterComponent to avoid duplication and recursion bugs.
|
||||
Shared parsing helpers for member list filter URL/params.
|
||||
|
||||
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 """
|
||||
Parses a value for group or fee-type filter params.
|
||||
Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion.
|
||||
|
|
@ -19,4 +23,29 @@ defmodule MvWeb.MemberLive.Index.FilterParams do
|
|||
end
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1027,7 +1027,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign(:create_cycle_error, format_error(error))}
|
||||
end
|
||||
else
|
||||
:error ->
|
||||
{:error, reason} when reason in [:invalid_format, :invalid_date, :incompatible_calendars] ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:create_cycle_error, gettext("Invalid date format"))}
|
||||
|
|
|
|||
|
|
@ -464,7 +464,6 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||
end
|
||||
|
||||
defp format_error(error) when is_binary(error), do: error
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
|
|
|
|||
|
|
@ -326,7 +326,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
|
||||
case submit_form(socket.assigns.form, params, actor) do
|
||||
{:ok, membership_fee_type} ->
|
||||
notify_parent({:saved, membership_fee_type})
|
||||
_ = notify_parent({:saved, membership_fee_type})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|
|
@ -341,7 +341,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
@spec notify_parent(any()) :: any()
|
||||
@spec notify_parent(any()) :: {module(), any()}
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||
|
|
|
|||
|
|
@ -214,7 +214,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||
end
|
||||
|
||||
defp format_error(error) when is_binary(error), do: error
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
|
||||
# Info card explaining the membership fee type concept
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ defmodule MvWeb.RoleLive.Form do
|
|||
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, role_params, actor) do
|
||||
{:ok, role} ->
|
||||
notify_parent({:saved, role})
|
||||
_ = notify_parent({:saved, role})
|
||||
|
||||
redirect_path =
|
||||
if socket.assigns.return_to == "show" do
|
||||
|
|
@ -186,7 +186,7 @@ defmodule MvWeb.RoleLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
@spec notify_parent(any()) :: any()
|
||||
@spec notify_parent(any()) :: {module(), any()}
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||
|
|
|
|||
|
|
@ -734,7 +734,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -775,7 +775,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
)}
|
||||
end
|
||||
|
||||
@spec notify_parent(any()) :: any()
|
||||
@spec notify_parent(any()) :: {module(), any()}
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
# 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)
|
||||
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
|
||||
case Authorization.list_roles(actor: actor) do
|
||||
{:ok, roles} -> roles
|
||||
|
|
@ -922,7 +922,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
|
||||
# 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
|
||||
# Take first error and extract message
|
||||
case List.first(errors) do
|
||||
|
|
@ -932,6 +932,5 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
defp extract_error_message(error) when is_binary(error), do: error
|
||||
defp extract_error_message(_), do: gettext("Unknown error")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ defmodule MvWeb.LiveHelpers do
|
|||
|
||||
def on_mount(:default, _params, session, socket) do
|
||||
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)
|
||||
connect_params = socket.private[:connect_params] || %{}
|
||||
|
|
@ -145,7 +145,10 @@ defmodule MvWeb.LiveHelpers do
|
|||
end
|
||||
"""
|
||||
@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
|
||||
AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor))
|
||||
end
|
||||
|
|
|
|||
|
|
@ -31,27 +31,24 @@ defmodule MvWeb.LiveUserAuth do
|
|||
end
|
||||
end
|
||||
|
||||
def on_mount(:live_user_required, _params, session, socket) do
|
||||
socket = LiveSession.assign_new_resources(socket, session)
|
||||
|
||||
def on_mount(:live_user_required, _params, _session, socket) do
|
||||
case socket.assigns do
|
||||
%{current_user: %{} = user} ->
|
||||
{:cont, assign(socket, :current_user, user)}
|
||||
|
||||
_ ->
|
||||
socket = LiveView.redirect(socket, to: ~p"/sign-in")
|
||||
{:halt, socket}
|
||||
{:halt, LiveView.redirect(socket, to: ~p"/sign-in")}
|
||||
end
|
||||
end
|
||||
|
||||
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).
|
||||
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
{:cont, assign(socket, :locale, locale)}
|
||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
socket = assign(socket, :locale, locale)
|
||||
|
||||
if socket.assigns[:current_user] do
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
|
||||
{:halt, LiveView.redirect(socket, to: ~p"/")}
|
||||
else
|
||||
{:cont, assign(socket, :current_user, nil)}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ defmodule MvWeb.Router do
|
|||
|
||||
# ASHAUTHENTICATION GENERATED AUTH ROUTES
|
||||
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
|
||||
sign_in_route register_path: "/register",
|
||||
|
|
@ -188,7 +188,7 @@ defmodule MvWeb.Router do
|
|||
get_locale_from_cookie(conn) ||
|
||||
extract_locale_from_headers(conn.req_headers)
|
||||
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
conn
|
||||
|> put_session(:locale, locale)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ defmodule MvWeb.Translations.FieldTypes do
|
|||
"""
|
||||
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(:integer), do: gettext("Number")
|
||||
def label(:boolean), do: gettext("Yes/No-Selection")
|
||||
|
|
|
|||
23
mix.exs
23
mix.exs
|
|
@ -12,6 +12,7 @@ defmodule Mv.MixProject do
|
|||
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
||||
aliases: aliases(),
|
||||
deps: deps(),
|
||||
dialyzer: dialyzer(),
|
||||
listeners: [Phoenix.CodeReloader],
|
||||
gettext: [write_reference_line_numbers: false]
|
||||
]
|
||||
|
|
@ -38,8 +39,8 @@ defmodule Mv.MixProject do
|
|||
[
|
||||
{:tidewave, "~> 0.5", only: [:dev]},
|
||||
{:sourceror, "~> 1.8", only: [:dev, :test]},
|
||||
{:live_debugger, "~> 0.8", only: [:dev]},
|
||||
{:ash_admin, "~> 0.14"},
|
||||
{:live_debugger, "~> 1.0", only: [:dev]},
|
||||
{:ash_admin, "~> 1.0"},
|
||||
{:ash_postgres, "~> 2.0"},
|
||||
{:ash_phoenix, "~> 2.0"},
|
||||
{:ash, "~> 3.0"},
|
||||
|
|
@ -80,6 +81,7 @@ defmodule Mv.MixProject do
|
|||
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
|
||||
{:bypass, "~> 2.1", only: [:dev, :test]},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
|
||||
{:picosat_elixir, "~> 0.1"},
|
||||
{:ecto_commons, "~> 0.3"},
|
||||
{:slugify, "~> 1.3"},
|
||||
|
|
@ -112,4 +114,21 @@ defmodule Mv.MixProject do
|
|||
"phx.routes": ["phx.routes", "ash_authentication.phoenix.routes"]
|
||||
]
|
||||
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
|
||||
|
|
|
|||
44
mix.lock
44
mix.lock
|
|
@ -1,39 +1,42 @@
|
|||
%{
|
||||
"ash": {:hex, :ash, "3.24.7", "6e2f32011e7c8f0809dae36712ccfb2efaf3c669cbda7443685436e80acdebf7", [: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", "c9fb4d21c3c8bb85636338d448afdc283dd98a433d869e4b2210ac57ade00624"},
|
||||
"ash_admin": {:hex, :ash_admin, "0.14.0", "1a8f61f6cef7af757852e94a916a152bd3f3c3620b094de84a008120675adccd", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 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", "d3bc34c266491ae3177f2a76ad97bbe916c4d3a41d56196db9d95e76413b3455"},
|
||||
"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_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_phoenix": {:hex, :ash_phoenix, "2.3.22", "f59a347ee09e1fa9973fe1b2faf7f8f793acc14dc09341062783b8eb1a9f5c99", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "4b34ea84e122c238ad1843888b8fd4d21aec27605b9b1e6e27e1b70329560fbb"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "2.9.1", "bf4229d65706f794650edb47c9f30138a6e2d5af6efe002ca38e619306cca9f6", [:mix], [{:ash, "~> 3.24", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, "~> 0.6", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "72c0366649985a858d4ef8f906968cee339dfd7519bb0beaa2b4d87f3d5b0bb9"},
|
||||
"ash_sql": {:hex, :ash_sql, "0.6.3", "a708b34ba71b40141dab9e75dc44a095885ae4635b25135d3fd4c3620b299b97", [:mix], [{:ash, ">= 3.24.5 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "3ee461380d96dca32766a210ea60c64783f690ad5565f0434a00cd475e71e8b9"},
|
||||
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
|
||||
"bandit": {:hex, :bandit, "1.11.0", "dbdd9c9963f146ee9da9860d1ee5b0ffd65cea51fe2aab3f3273df84329d133a", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c949d93a325a28da2333dde5a9ab61986ad2c2b7226347db6a28303b9139865e"},
|
||||
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
|
||||
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
|
||||
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [: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]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
|
||||
"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"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [: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", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
|
||||
"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"},
|
||||
"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"},
|
||||
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [: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", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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_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"},
|
||||
"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"},
|
||||
"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_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"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [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", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||
"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"},
|
||||
"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"},
|
||||
|
|
@ -42,7 +45,7 @@
|
|||
"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"},
|
||||
"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.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{: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", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"},
|
||||
"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"},
|
||||
"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"},
|
||||
|
|
@ -50,41 +53,42 @@
|
|||
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
|
||||
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
|
||||
"live_debugger": {:hex, :live_debugger, "0.8.0", "02545b5accdf42f48aa9bddabdd7574ddf532d5aa0cb0270d7e35031bc184286", [: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, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "4ecdec3c4267e665afd2266e3cb86239dd457f8c8fc4e63de6e150a2a7665920"},
|
||||
"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"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [: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", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
"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"},
|
||||
"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_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.6", "7106a0da114619c4b12b056bbaef39fdbc75d3d0cf9cf24af683364064c12dc3", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, 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.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d0d0b7931916c8196b6903a1efa118b5da28487e7a75ad32a54dfd77de59d421"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, 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.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"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_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_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_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
||||
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
|
||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||
"plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
|
||||
"plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"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"},
|
||||
"reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [: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]}, {:libgraph, "~> 0.16", [hex: :libgraph, 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", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"},
|
||||
"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"},
|
||||
"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.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"},
|
||||
"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"},
|
||||
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
||||
"sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
|
||||
"spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"},
|
||||
"spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"},
|
||||
"spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"},
|
||||
"splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"},
|
||||
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
|
||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||
|
|
@ -94,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_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"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,3 +152,13 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Register"
|
||||
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 ""
|
||||
|
|
|
|||
|
|
@ -148,3 +148,13 @@ msgstr "Sprache auswählen"
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Register"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -2208,11 +2208,6 @@ msgstr "Keine Mitglieder in dieser Gruppe"
|
|||
msgid "No members selected"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
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
|
||||
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."
|
||||
|
||||
#: 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."
|
||||
|
|
|
|||
|
|
@ -2209,11 +2209,6 @@ msgstr ""
|
|||
msgid "No members selected"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
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
|
||||
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
||||
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 ""
|
||||
|
|
|
|||
|
|
@ -145,3 +145,13 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Register"
|
||||
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 ""
|
||||
|
|
|
|||
|
|
@ -2209,11 +2209,6 @@ msgstr ""
|
|||
msgid "No members selected"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
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
|
||||
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."
|
||||
|
||||
#: 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 ""
|
||||
|
|
|
|||
19
rauthy-bootstrap/clients.json
Normal file
19
rauthy-bootstrap/clients.json
Normal 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
|
||||
}
|
||||
]
|
||||
33
test/mv/constants_test.exs
Normal file
33
test/mv/constants_test.exs
Normal 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
|
||||
33
test/mv/membership/import/import_runner_test.exs
Normal file
33
test/mv/membership/import/import_runner_test.exs
Normal 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
|
||||
|
|
@ -101,6 +101,29 @@ defmodule Mv.Membership.MembersPDFTest do
|
|||
assert byte_size(pdf_binary) > 1000
|
||||
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
|
||||
export_data = %{
|
||||
columns: [
|
||||
|
|
|
|||
|
|
@ -209,6 +209,57 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
|||
# Button should still contain some text (truncated version or indicator)
|
||||
assert String.length(button_html) > 0
|
||||
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
|
||||
|
||||
describe "badge" do
|
||||
|
|
@ -268,6 +319,293 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
|||
refute dropdown_html =~ "String Field"
|
||||
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
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,87 @@ defmodule MvWeb.AuthControllerTest do
|
|||
assert redirected_to(conn) == ~p"/"
|
||||
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
|
||||
case Regex.run(~r/name="_csrf_token"[^>]*value="([^"]+)"/, html) do
|
||||
[_, token] ->
|
||||
|
|
|
|||
319
test/mv_web/live/member_live/date_filter_custom_field_test.exs
Normal file
319
test/mv_web/live/member_live/date_filter_custom_field_test.exs
Normal 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
|
||||
144
test/mv_web/live/member_live/date_filter_default_test.exs
Normal file
144
test/mv_web/live/member_live/date_filter_default_test.exs
Normal 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
|
||||
137
test/mv_web/live/member_live/date_filter_property_test.exs
Normal file
137
test/mv_web/live/member_live/date_filter_property_test.exs
Normal 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
|
||||
628
test/mv_web/live/member_live/date_filter_test.exs
Normal file
628
test/mv_web/live/member_live/date_filter_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
85
test/mv_web/live/member_live/index/filter_params_test.exs
Normal file
85
test/mv_web/live/member_live/index/filter_params_test.exs
Normal 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
|
||||
35
test/mv_web/live_user_auth_test.exs
Normal file
35
test/mv_web/live_user_auth_test.exs
Normal 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
|
||||
|
|
@ -268,6 +268,28 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
# Should not crash
|
||||
assert html =~ member.first_name
|
||||
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
|
||||
|
||||
describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue