diff --git a/.deps_audit_ignore b/.deps_audit_ignore new file mode 100644 index 0000000..27c623d --- /dev/null +++ b/.deps_audit_ignore @@ -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 diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs new file mode 100644 index 0000000..c89978c --- /dev/null +++ b/.dialyzer_ignore.exs @@ -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 +[] diff --git a/.drone.jsonnet b/.drone.jsonnet new file mode 100644 index 0000000..388e8f4 --- /dev/null +++ b/.drone.jsonnet @@ -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', + ], + }], + }, +] diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index b0fb160..0000000 --- a/.drone.yml +++ /dev/null @@ -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 diff --git a/.env.example b/.env.example index d63e019..bc0ef7a 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/.gitignore b/.gitignore index b9096bd..14620df 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Justfile b/Justfile index cae8cfb..d08cef8 100644 --- a/Justfile +++ b/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}} diff --git a/README.md b/README.md index 9fc2f83..8b26327 100644 --- a/README.md +++ b/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: — login `admin@localhost`, password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in `docker-compose.yml`. ### OIDC with other providers (Authentik, Keycloak, etc.) diff --git a/config/test.exs b/config/test.exs index ef54982..7343a6a 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml index 512626b..01a0bd2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex b/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex index 5de15c8..8dae2d1 100644 --- a/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex +++ b/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex @@ -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 diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 85f5562..cddc23f 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -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 # ============================================================================ diff --git a/lib/membership/member/changes/unrelate_user_when_argument_nil.ex b/lib/membership/member/changes/unrelate_user_when_argument_nil.ex index dc4d097..da8a291 100644 --- a/lib/membership/member/changes/unrelate_user_when_argument_nil.ex +++ b/lib/membership/member/changes/unrelate_user_when_argument_nil.ex @@ -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 diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 7fa35dc..72be69b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -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) diff --git a/lib/membership_fees/changes/set_membership_fee_start_date.ex b/lib/membership_fees/changes/set_membership_fee_start_date.ex index 0e9cf00..8f5aa56 100644 --- a/lib/membership_fees/changes/set_membership_fee_start_date.ex +++ b/lib/membership_fees/changes/set_membership_fee_start_date.ex @@ -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 diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index 3ffae93..ae84cdb 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -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, diff --git a/lib/mv/config.ex b/lib/mv/config.ex index 870d1d3..750a7db 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -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 diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 517ad2f..4d09c89 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -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__from / cdf__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. diff --git a/lib/mv/helpers/system_actor.ex b/lib/mv/helpers/system_actor.ex index 8cd93d2..7b86a3c 100644 --- a/lib/mv/helpers/system_actor.ex +++ b/lib/mv/helpers/system_actor.ex @@ -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 diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex index ec8f357..1e55b6e 100644 --- a/lib/mv/mailer.ex +++ b/lib/mv/mailer.ex @@ -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 diff --git a/lib/mv/membership/import/csv_parser.ex b/lib/mv/membership/import/csv_parser.ex index 2de75ee..142450f 100644 --- a/lib/mv/membership/import/csv_parser.ex +++ b/lib/mv/membership/import/csv_parser.ex @@ -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) != "")) diff --git a/lib/mv/membership/import/import_runner.ex b/lib/mv/membership/import/import_runner.ex index eccd75f..5f953d4 100644 --- a/lib/mv/membership/import/import_runner.ex +++ b/lib/mv/membership/import/import_runner.ex @@ -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 diff --git a/lib/mv/membership/import/member_csv.ex b/lib/mv/membership/import/member_csv.ex index 23e0d93..dda1d04 100644 --- a/lib/mv/membership/import/member_csv.ex +++ b/lib/mv/membership/import/member_csv.ex @@ -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 diff --git a/lib/mv/membership/member/validations/email_change_permission.ex b/lib/mv/membership/member/validations/email_change_permission.ex index 2b1c041..073da07 100644 --- a/lib/mv/membership/member/validations/email_change_permission.ex +++ b/lib/mv/membership/member/validations/email_change_permission.ex @@ -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) || diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index 16341c4..a98b125 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -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 diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex index 6331893..0a19810 100644 --- a/lib/mv/membership/members_csv.ex +++ b/lib/mv/membership/members_csv.ex @@ -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) diff --git a/lib/mv/membership/members_pdf.ex b/lib/mv/membership/members_pdf.ex index b2989ca..a1c8418 100644 --- a/lib/mv/membership/members_pdf.ex +++ b/lib/mv/membership/members_pdf.ex @@ -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 diff --git a/lib/mv/membership_fees/cycle_generation_job.ex b/lib/mv/membership_fees/cycle_generation_job.ex index 71a3158..b38886c 100644 --- a/lib/mv/membership_fees/cycle_generation_job.ex +++ b/lib/mv/membership_fees/cycle_generation_job.ex @@ -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) diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index 8f1bc7c..189f40a 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -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 -> diff --git a/lib/mv/oidc/discovery.ex b/lib/mv/oidc/discovery.ex new file mode 100644 index 0000000..a3a373a --- /dev/null +++ b/lib/mv/oidc/discovery.ex @@ -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 diff --git a/lib/mv/oidc_role_sync.ex b/lib/mv/oidc_role_sync.ex index a13748a..0f6467c 100644 --- a/lib/mv/oidc_role_sync.ex +++ b/lib/mv/oidc_role_sync.ex @@ -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, ".") diff --git a/lib/mv/oidc_role_sync_config.ex b/lib/mv/oidc_role_sync_config.ex index 2a8574c..bbb5770 100644 --- a/lib/mv/oidc_role_sync_config.ex +++ b/lib/mv/oidc_role_sync_config.ex @@ -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 diff --git a/lib/mv/release.ex b/lib/mv/release.ex index 116b276..5db4751 100644 --- a/lib/mv/release.ex +++ b/lib/mv/release.ex @@ -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 diff --git a/lib/mv/repo.ex b/lib/mv/repo.ex index 0a4a04d..183c54f 100644 --- a/lib/mv/repo.ex +++ b/lib/mv/repo.ex @@ -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 diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 3cbba71..999bd44 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -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 diff --git a/lib/mv/vereinfacht/sync_flash.ex b/lib/mv/vereinfacht/sync_flash.ex index 874a717..5c643b6 100644 --- a/lib/mv/vereinfacht/sync_flash.ex +++ b/lib/mv/vereinfacht/sync_flash.ex @@ -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 diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex index 83492b7..4d58f8d 100644 --- a/lib/mv/vereinfacht/vereinfacht.ex +++ b/lib/mv/vereinfacht/vereinfacht.ex @@ -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(), diff --git a/lib/mv_web/authorization.ex b/lib/mv_web/authorization.ex index d821416..de009b6 100644 --- a/lib/mv_web/authorization.ex +++ b/lib/mv_web/authorization.ex @@ -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 diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index adde4e8..42bdcfa 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -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 diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 9b08f5d..e9c4a2a 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -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() diff --git a/lib/mv_web/live/auth/sign_in_live.ex b/lib/mv_web/live/auth/sign_in_live.ex index fb41f1b..c519914 100644 --- a/lib/mv_web/live/auth/sign_in_live.ex +++ b/lib/mv_web/live/auth/sign_in_live.ex @@ -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. diff --git a/lib/mv_web/live/auth/sign_out_live.ex b/lib/mv_web/live/auth/sign_out_live.ex new file mode 100644 index 0000000..569337a --- /dev/null +++ b/lib/mv_web/live/auth/sign_out_live.ex @@ -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""" + +
+
+
+

+ {dgettext("auth", "Sign out")} +

+

+ {dgettext("auth", "Are you sure you want to sign out?")} +

+ <.form for={%{}} action={@sign_out_path} method="delete"> + + +
+
+
+
+ """ + end +end diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index ddd3538..b66d259 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -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 )} <.badge @@ -111,7 +146,9 @@ defmodule MvWeb.Components.MemberFilterComponent do <.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 + +
+
+ {gettext("Dates")} +
+
+ + {gettext("Exit date")} + +
+ + + + +
+
+ <.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)} + /> +
+
+
+ + {gettext("Join date")} + +
+ <.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)} + /> +
+
+
+ + +
0} class="mb-4"> +
+ {gettext("Custom date fields")} +
+
5, do: "max-h-60 overflow-y-auto pr-2", else: "" + }> +
+ + {field.name} + +
+ <.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)} + /> +
+
+
+
+
0} class="mb-2">
@@ -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 diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 6a1c926..6a456fe 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -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) diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index b9f22c8..7cd4378 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -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) diff --git a/lib/mv_web/live/import_live.ex b/lib/mv_web/live/import_live.ex index f3d4941..a8c5a95 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -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 diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex index ba0e476..b679127 100644 --- a/lib/mv_web/live/join_live.ex +++ b/lib/mv_web/live/join_live.ex @@ -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)) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index c258d5f..6196fc4 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -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 -> diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index efc1eb7..13dc89e 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -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 diff --git a/lib/mv_web/live/member_live/index/custom_field_value_lookup.ex b/lib/mv_web/live/member_live/index/custom_field_value_lookup.ex new file mode 100644 index 0000000..6d2298c --- /dev/null +++ b/lib/mv_web/live/member_live/index/custom_field_value_lookup.ex @@ -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 diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex new file mode 100644 index 0000000..162524a --- /dev/null +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -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): + "" => %{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__from"` / `"cdf__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__from"` / + `"cdf__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 diff --git a/lib/mv_web/live/member_live/index/field_selection.ex b/lib/mv_web/live/member_live/index/field_selection.ex index 1e77b09..846cf1d 100644 --- a/lib/mv_web/live/member_live/index/field_selection.ex +++ b/lib/mv_web/live/member_live/index/field_selection.ex @@ -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 diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex index df20d25..52ebe86 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -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 """ diff --git a/lib/mv_web/live/member_live/index/filter_params.ex b/lib/mv_web/live/member_live/index/filter_params.ex index 9b5e800..790b31f 100644 --- a/lib/mv_web/live/member_live/index/filter_params.ex +++ b/lib/mv_web/live/member_live/index/filter_params.ex @@ -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 diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index e8ddff4..0cba316 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -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"))} diff --git a/lib/mv_web/live/membership_fee_settings_live.ex b/lib/mv_web/live/membership_fee_settings_live.ex index e153d18..15030c1 100644 --- a/lib/mv_web/live/membership_fee_settings_live.ex +++ b/lib/mv_web/live/membership_fee_settings_live.ex @@ -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 diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex index bfdfa2d..a4d8506 100644 --- a/lib/mv_web/live/membership_fee_type_live/form.ex +++ b/lib/mv_web/live/membership_fee_type_live/form.ex @@ -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() diff --git a/lib/mv_web/live/membership_fee_type_live/index.ex b/lib/mv_web/live/membership_fee_type_live/index.ex index a34480b..65f840d 100644 --- a/lib/mv_web/live/membership_fee_type_live/index.ex +++ b/lib/mv_web/live/membership_fee_type_live/index.ex @@ -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 diff --git a/lib/mv_web/live/role_live/form.ex b/lib/mv_web/live/role_live/form.ex index 51f5cac..2e315b9 100644 --- a/lib/mv_web/live/role_live/form.ex +++ b/lib/mv_web/live/role_live/form.ex @@ -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() diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 4a26078..60763ab 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -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 diff --git a/lib/mv_web/live_helpers.ex b/lib/mv_web/live_helpers.ex index 4206aa6..003c36c 100644 --- a/lib/mv_web/live_helpers.ex +++ b/lib/mv_web/live_helpers.ex @@ -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 diff --git a/lib/mv_web/live_user_auth.ex b/lib/mv_web/live_user_auth.ex index f3f3fc9..617b079 100644 --- a/lib/mv_web/live_user_auth.ex +++ b/lib/mv_web/live_user_auth.ex @@ -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 diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 591dead..64036c9 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -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) diff --git a/lib/mv_web/translations/field_types.ex b/lib/mv_web/translations/field_types.ex index 969f20b..1580b99 100644 --- a/lib/mv_web/translations/field_types.ex +++ b/lib/mv_web/translations/field_types.ex @@ -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") diff --git a/mix.exs b/mix.exs index c037e46..fa31c04 100644 --- a/mix.exs +++ b/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 diff --git a/mix.lock b/mix.lock index 12acd0a..e39ebbc 100644 --- a/mix.lock +++ b/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"}, } diff --git a/priv/gettext/auth.pot b/priv/gettext/auth.pot index cd46c56..4fbbeda 100644 --- a/priv/gettext/auth.pot +++ b/priv/gettext/auth.pot @@ -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 "" diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index 07583be..602a106 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -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" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index a85b4cf..81d91f7 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -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." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index b995b1a..5e9abca 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -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 "" diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po index 564e640..8f78349 100644 --- a/priv/gettext/en/LC_MESSAGES/auth.po +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index f4526d1..1ae6a49 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -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 "" diff --git a/rauthy-bootstrap/clients.json b/rauthy-bootstrap/clients.json new file mode 100644 index 0000000..6869410 --- /dev/null +++ b/rauthy-bootstrap/clients.json @@ -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 + } +] diff --git a/test/mv/constants_test.exs b/test/mv/constants_test.exs new file mode 100644 index 0000000..4ae689f --- /dev/null +++ b/test/mv/constants_test.exs @@ -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 diff --git a/test/mv/membership/import/import_runner_test.exs b/test/mv/membership/import/import_runner_test.exs new file mode 100644 index 0000000..88d189e --- /dev/null +++ b/test/mv/membership/import/import_runner_test.exs @@ -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 diff --git a/test/mv/membership/members_pdf_test.exs b/test/mv/membership/members_pdf_test.exs index 78a8ca6..2b83e3b 100644 --- a/test/mv/membership/members_pdf_test.exs +++ b/test/mv/membership/members_pdf_test.exs @@ -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: [ diff --git a/test/mv_web/components/member_filter_component_test.exs b/test/mv_web/components/member_filter_component_test.exs index d32993c..bb55fa5 100644 --- a/test/mv_web/components/member_filter_component_test.exs +++ b/test/mv_web/components/member_filter_component_test.exs @@ -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 `` 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 + # `
` `