diff --git a/.deps_audit_ignore b/.deps_audit_ignore deleted file mode 100644 index 27c623d..0000000 --- a/.deps_audit_ignore +++ /dev/null @@ -1,9 +0,0 @@ -# Temporarily ignored security advisories -# -# Format: one GHSA ID per line. -# Remove an entry once a patched version is available and the dependency is updated. - -# cowlib >= 2.9.0 <= 2.16.1 — Cookie Request Header Injection via cow_cookie:cookie/1 -# Severity: low. No patched version available as of 2026-05-20. -# Tracked upstream: https://github.com/advisories/GHSA-g2wm-735q-3f56 -GHSA-g2wm-735q-3f56 diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs deleted file mode 100644 index c89978c..0000000 --- a/.dialyzer_ignore.exs +++ /dev/null @@ -1,11 +0,0 @@ -# Dialyzer ignore list. -# -# This file is for PROVEN false positives only. Each entry must carry a -# `# why:` comment explaining why Dialyzer is wrong about the call site. -# Real findings get fixed by adjusting @spec, return types, or pattern -# matches — never silenced here. -# -# Format: each entry is either a path string, a {path, warning} tuple, -# or a {path, warning, line} tuple. See: -# https://hexdocs.pm/dialyxir/readme.html#elixir-format -[] diff --git a/.drone.jsonnet b/.drone.jsonnet deleted file mode 100644 index 388e8f4..0000000 --- a/.drone.jsonnet +++ /dev/null @@ -1,184 +0,0 @@ -local elixir = 'docker.io/library/elixir:1.18.3-otp-27'; -local postgres_image = 'docker.io/library/postgres:18.3'; - -local pg_service = { - name: 'postgres', - image: postgres_image, - environment: { - POSTGRES_USER: 'postgres', - POSTGRES_PASSWORD: 'postgres', - }, -}; - -local cache_volume = { name: 'cache', host: { path: '/tmp/drone_cache' } }; -local cache_mount = [{ name: 'cache', path: '/cache' }]; - -local step_compute_cache = { - name: 'compute cache key', - image: elixir, - commands: [ - "mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)", - 'echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key', - // Print cache key for debugging - 'cat .cache_key', - ], -}; - -local step_restore_cache = { - name: 'restore-cache', - image: 'drillster/drone-volume-cache', - settings: { restore: true, mount: ['./deps', './_build', './priv/plts'], ttl: 30 }, - volumes: cache_mount, -}; - -local step_lint = { - name: 'lint', - image: elixir, - commands: [ - 'mix local.hex --force', // Install hex package manager - 'mix deps.get', // Fetch dependencies - 'mix compile --warnings-as-errors', // Check for compilation errors & warnings - 'mix format --check-formatted', // Check formatting - 'mix sobelow --config', // Security checks - 'mix deps.audit --ignore-file .deps_audit_ignore', // Known vulnerabilities - 'mix hex.audit', // Unmaintained dependencies - 'mix credo --strict', // Code quality hints - 'mix gettext.extract --check-up-to-date', // Translations up to date - ], -}; - -local step_typecheck = { - name: 'typecheck', - image: elixir, - commands: [ - 'mix local.hex --force', - 'mix deps.get', - 'mkdir -p priv/plts', - // Build/refresh PLT — no-op on cache hit, full build (5-15 min) on cache miss. - 'mix dialyzer --plt', - // Actual typecheck. --format short keeps log noise down on red builds. - 'mix dialyzer --format short', - ], -}; - -local step_wait_postgres = { - name: 'wait_for_postgres', - image: postgres_image, - commands: [ - ||| - for i in {1..20}; do - if pg_isready -h postgres -U postgres; then - exit 0 - else - true - fi - sleep 2 - done - echo "Postgres did not become available, aborting." - exit 1 - |||, - ], -}; - -local step_rebuild_cache = { - name: 'rebuild-cache', - image: 'drillster/drone-volume-cache', - settings: { rebuild: true, mount: ['./deps', './_build', './priv/plts'] }, - volumes: cache_mount, -}; - -// test_cmd is the only thing that differs between the fast and full suites. -local test_step(name, test_cmd) = { - name: name, - image: elixir, - environment: { - MIX_ENV: 'test', - TEST_POSTGRES_HOST: 'postgres', - TEST_POSTGRES_PORT: '5432', - }, - commands: ['mix local.hex --force', 'mix deps.get', test_cmd], -}; - -local test_fast = test_step('test-fast', 'mix test --exclude slow --exclude ui --max-cases 2'); -local test_all = test_step('test-all', 'mix test'); - -// A full check pipeline: identical steps, only name + trigger + test step vary. -local check_pipeline(name, trigger, test) = { - kind: 'pipeline', - type: 'docker', - name: name, - services: [pg_service], - trigger: trigger, - steps: [ - step_compute_cache, - step_restore_cache, - step_lint, - ] + (if test.name == 'test-all' then [step_typecheck] else []) + [ - step_wait_postgres, - test, - step_rebuild_cache, - ], - volumes: [cache_volume], -}; - -local docker_publish(name, extra_settings, trigger_event, deps) = { - kind: 'pipeline', - type: 'docker', - name: name, - trigger: trigger_event, - steps: [{ - name: 'build-and-publish-container' + (if name == 'build-and-publish' then '-branch' else ''), - image: 'plugins/docker', - settings: { - registry: 'git.local-it.org', - repo: 'git.local-it.org/local-it/mitgliederverwaltung', - username: { from_secret: 'DRONE_REGISTRY_USERNAME' }, - password: { from_secret: 'DRONE_REGISTRY_TOKEN' }, - } + extra_settings, - when: trigger_event, - }], - depends_on: deps, -}; - -[ - check_pipeline('check-fast', { branch: { exclude: ['main'] }, event: ['push'] }, test_fast), - check_pipeline('check-full', { branch: ['main'], event: ['push'] }, test_all), - check_pipeline('check-full-promote', { event: ['promote'], target: ['production'] }, test_all), - check_pipeline('check-full-tag', { event: ['tag'] }, test_all), - - docker_publish( - 'build-and-publish', - { tags: ['latest', '${DRONE_COMMIT_SHA:0:8}'] }, - { branch: ['main'], event: ['push'] }, - ['check-full'], - ), - docker_publish( - 'build-and-release', - { auto_tag: true }, - { event: ['tag'] }, - ['check-full-tag'], - ), - - { - kind: 'pipeline', - type: 'docker', - name: 'renovate', - trigger: { event: ['cron', 'custom'], branch: ['main'] }, - environment: { LOG_LEVEL: 'debug' }, - steps: [{ - name: 'renovate', - image: 'renovate/renovate:43.165', - environment: { - RENOVATE_CONFIG_FILE: 'renovate_backend_config.js', - RENOVATE_TOKEN: { from_secret: 'RENOVATE_TOKEN' }, - GITHUB_COM_TOKEN: { from_secret: 'GITHUB_COM_TOKEN' }, - }, - commands: [ - // https://github.com/renovatebot/renovate/discussions/15049 - 'unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL', - 'renovate-config-validator', - 'renovate', - ], - }], - }, -] diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..b0fb160 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,298 @@ +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 bc0ef7a..d63e019 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=mv-dev-shared-secret-not-for-production-do-not-use-anywhere-else +# OIDC_CLIENT_SECRET=your-oidc-client-secret # 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 14620df..b9096bd 100644 --- a/.gitignore +++ b/.gitignore @@ -49,7 +49,3 @@ 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 d08cef8..cae8cfb 100644 --- a/Justfile +++ b/Justfile @@ -29,27 +29,7 @@ seed-database: start-database: docker compose up -d -# Full check suite: lint + audit + the fast tests (slow/ui excluded). No Dialyzer. -ci-dev: install-dependencies lint audit test-fast - -# Fast pre-commit check: lint + sobelow + only the affected tests (mix test --stale) -# with reduced property runs. Run the full `ci-dev` before pushing. -check: install-dependencies lint sobelow test-stale - -# Build the Dialyzer PLT. Idempotent — no-op once the PLT is up to date. -# First build takes 5–15 min; subsequent runs are seconds. PLT files live in -# priv/plts/ and are gitignored. -plt: install-dependencies - @mkdir -p priv/plts - mix dialyzer --plt - -# Typecheck via Dialyzer. Slow stage, NOT part of ci-dev. -typecheck: plt - mix dialyzer --format short - -# Full CI: inner loop plus typecheck. Use locally before pushing; Drone CI -# runs equivalent steps with PLT caching. -ci: ci-dev typecheck +ci-dev: lint audit test-fast gettext: mix gettext.extract @@ -63,28 +43,19 @@ 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 -# Static security scan (Sobelow). -sobelow: +audit: mix sobelow --config - -# Full security audit: Sobelow + dependency advisory scans. -audit: sobelow - mix deps.audit --ignore-file .deps_audit_ignore + mix deps.audit mix hex.audit -# Run all tests. No install-dependencies prerequisite so single-file runs stay -# fast; run `just install-dependencies` once on a fresh checkout. -test *args: +# Run all tests +test *args: install-dependencies mix test {{args}} -# Fast tests only (excludes slow/performance and UI tests). -test-fast *args: +# Run only fast tests (excludes slow/performance and UI tests) +test-fast *args: install-dependencies 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 8b26327..9fc2f83 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,9 +139,21 @@ mix archive.install hex phx_new ## 🔐 Testing SSO locally -A local **Rauthy** instance is provided in dev. The `mv` client is auto-seeded from `rauthy-bootstrap/clients.json` on first start (and after `docker compose down -v`), so the secret in `.env.example` always matches. +Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance is provided. -Rauthy admin UI: — login `admin@localhost`, password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in `docker-compose.yml`. +1. `just run` +2. go to [localhost:8080](http://localhost:8080), go to the Admin area +3. Login with "admin@localhost" and password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in docker-compose.yml +4. add client from the admin panel + - Client ID: mv + - redirect uris: http://localhost:4000/auth/user/oidc/callback + - Authorization Flows: authorization_code + - allowed origins: http://localhost:4000 + - access/id token algortihm: RS256 (EDDSA did not work for me, found just few infos in the ashauthentication docs) +5. copy client secret to `.env` file +6. abort and run `just run` again + +Now you can log in to Mila via OIDC! ### OIDC with other providers (Authentik, Keycloak, etc.) diff --git a/config/test.exs b/config/test.exs index 7343a6a..ef54982 100644 --- a/config/test.exs +++ b/config/test.exs @@ -62,7 +62,3 @@ 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 01a0bd2..512626b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,9 +36,6 @@ 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: @@ -49,7 +46,6 @@ 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 8dae2d1..5de15c8 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,10 +17,16 @@ defmodule Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist do form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{} allowlist_ids = - Membership.get_join_form_allowlist() - |> Enum.map(fn item -> item.id end) - |> MapSet.new() - |> MapSet.difference(MapSet.new(@typed_fields)) + 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 filtered = form_data diff --git a/lib/membership/member.ex b/lib/membership/member.ex index cddc23f..85f5562 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -51,9 +51,6 @@ defmodule Mv.Membership.Member do require Logger - @typedoc "An `Mv.Membership.Member` resource record." - @type t :: %__MODULE__{} - # Module constants @member_search_limit 10 @@ -794,7 +791,7 @@ defmodule Mv.Membership.Member do # nil/[] when membership_fee_type is missing. @doc false - @spec get_current_cycle(t()) :: MembershipFeeCycle.t() | nil + @spec get_current_cycle(Member.t()) :: MembershipFeeCycle.t() | nil def get_current_cycle(member) do today = Date.utc_today() @@ -824,7 +821,7 @@ defmodule Mv.Membership.Member do end @doc false - @spec get_last_completed_cycle(t()) :: MembershipFeeCycle.t() | nil + @spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil def get_last_completed_cycle(member) do today = Date.utc_today() @@ -870,7 +867,7 @@ defmodule Mv.Membership.Member do end @doc false - @spec get_overdue_cycles(t()) :: [MembershipFeeCycle.t()] + @spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()] def get_overdue_cycles(member) do today = Date.utc_today() @@ -942,7 +939,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 @@ -950,7 +947,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} -> @@ -1096,7 +1093,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, @@ -1115,7 +1112,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, @@ -1234,6 +1231,8 @@ 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 da8a291..dc4d097 100644 --- a/lib/membership/member/changes/unrelate_user_when_argument_nil.ex +++ b/lib/membership/member/changes/unrelate_user_when_argument_nil.ex @@ -37,10 +37,9 @@ 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 72be69b..7fa35dc 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -836,10 +836,7 @@ 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()} - | {:ok, JoinRequest.t(), [Ash.Notifier.Notification.t()]} - | {:error, term()} + @spec reject_join_request(String.t(), keyword()) :: {:ok, JoinRequest.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 8f5aa56..0e9cf00 100644 --- a/lib/membership_fees/changes/set_membership_fee_start_date.ex +++ b/lib/membership_fees/changes/set_membership_fee_start_date.ex @@ -26,6 +26,8 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do """ use Ash.Resource.Change + require Logger + alias Mv.MembershipFees.CalendarCycles @impl true @@ -81,6 +83,11 @@ 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 ae84cdb..3ffae93 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -43,7 +43,6 @@ 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 @@ -89,7 +88,7 @@ defmodule Mv.Authorization.PermissionSets do iex> PermissionSets.all_permission_sets() [:own_data, :read_only, :normal_user, :admin] """ - @spec all_permission_sets() :: [permission_set_name(), ...] + @spec all_permission_sets() :: [atom()] def all_permission_sets do [:own_data, :read_only, :normal_user, :admin] end @@ -108,7 +107,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(permission_set_name()) :: permission_set() + @spec get_permissions(atom()) :: 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 750a7db..870d1d3 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -207,6 +207,8 @@ 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). """ @@ -249,6 +251,7 @@ defmodule Mv.Config do case System.get_env(key) do nil -> false v when is_binary(v) -> String.trim(v) != "" + _ -> false end end @@ -267,6 +270,9 @@ defmodule Mv.Config do value when is_binary(value) -> v = String.trim(value) |> String.downcase() v in ["true", "1", "yes"] + + _ -> + false end end @@ -322,6 +328,7 @@ 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 @@ -402,7 +409,7 @@ defmodule Mv.Config do @doc """ Returns the OIDC groups claim name (default "groups"). ENV first, then Settings. """ - @spec oidc_groups_claim() :: String.t() + @spec oidc_groups_claim() :: String.t() | nil def oidc_groups_claim do case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do nil -> "groups" @@ -485,7 +492,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() :: pos_integer() | nil + @spec smtp_port() :: non_neg_integer() | nil def smtp_port do if smtp_env_mode?() do parse_smtp_port_env(System.get_env("SMTP_PORT")) @@ -631,15 +638,9 @@ defmodule Mv.Config do """ @spec mail_from_name() :: String.t() def mail_from_name do - 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 + case System.get_env("MAIL_FROM_NAME") do + nil -> get_from_settings(:smtp_from_name) || "Mila" + value -> trim_nil(value) || "Mila" end end diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 4d09c89..517ad2f 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -26,18 +26,6 @@ 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 @@ -96,70 +84,6 @@ 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 7b86a3c..8cd93d2 100644 --- a/lib/mv/helpers/system_actor.ex +++ b/lib/mv/helpers/system_actor.ex @@ -225,10 +225,7 @@ 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 - case System.get_env("SYSTEM_ACTOR_EMAIL") do - nil -> "system@mila.local" - email -> email - end + System.get_env("SYSTEM_ACTOR_EMAIL") || "system@mila.local" end # Loads the system actor from the database @@ -260,7 +257,7 @@ defmodule Mv.Helpers.SystemActor do end # Handles database error when loading system user - @spec handle_system_user_error({:error, Ash.Error.t()}) :: Mv.Accounts.User.t() | no_return() + @spec handle_system_user_error(term()) :: Mv.Accounts.User.t() | no_return() defp handle_system_user_error(error) do case load_admin_user_fallback() do {:ok, admin_user} -> @@ -396,18 +393,15 @@ 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 - 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 + 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) end # Finds a user by email address diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex index 1e55b6e..ec8f357 100644 --- a/lib/mv/mailer.ex +++ b/lib/mv/mailer.ex @@ -190,4 +190,6 @@ 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 142450f..2de75ee 100644 --- a/lib/mv/membership/import/csv_parser.ex +++ b/lib/mv/membership/import/csv_parser.ex @@ -100,8 +100,7 @@ defmodule Mv.Membership.Import.CsvParser do |> String.replace("\r", "\n") end - @spec get_parser(String.t()) :: - Mv.Membership.Import.CsvParserSemicolon | Mv.Membership.Import.CsvParserComma + @spec get_parser(String.t()) :: module() defp get_parser(";"), do: Mv.Membership.Import.CsvParserSemicolon defp get_parser(","), do: Mv.Membership.Import.CsvParserComma defp get_parser(_), do: Mv.Membership.Import.CsvParserSemicolon @@ -117,10 +116,7 @@ defmodule Mv.Membership.Import.CsvParser do if semicolon_score >= comma_score, do: ";", else: "," end - @spec header_field_count( - Mv.Membership.Import.CsvParserSemicolon | Mv.Membership.Import.CsvParserComma, - binary() - ) :: non_neg_integer() + @spec header_field_count(module(), binary()) :: non_neg_integer() defp header_field_count(parser, header_record) do case parse_single_record(parser, header_record, nil) do {:ok, fields} -> Enum.count(fields, &(String.trim(&1) != "")) diff --git a/lib/mv/membership/import/import_runner.ex b/lib/mv/membership/import/import_runner.ex index 5f953d4..eccd75f 100644 --- a/lib/mv/membership/import/import_runner.ex +++ b/lib/mv/membership/import/import_runner.ex @@ -26,8 +26,14 @@ 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, to_string(:file.format_error(reason))} + {:error, Exception.message(reason)} end end diff --git a/lib/mv/membership/import/member_csv.ex b/lib/mv/membership/import/member_csv.ex index dda1d04..23e0d93 100644 --- a/lib/mv/membership/import/member_csv.ex +++ b/lib/mv/membership/import/member_csv.ex @@ -210,6 +210,8 @@ 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 073da07..2b1c041 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 a98b125..16341c4 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -16,21 +16,6 @@ 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"] @@ -320,7 +305,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()) :: parsed_params() + @spec parse_params(map()) :: map() def parse_params(params) do # DB fields come from "member_fields" raw_member_fields = extract_list(params, "member_fields") @@ -473,6 +458,9 @@ 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) @@ -519,4 +507,6 @@ 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 0a19810..6331893 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()] | Enumerable.t() + @spec export([struct() | map()], [map()]) :: iodata() 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 a1c8418..b2989ca 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,6 +211,9 @@ 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 @@ -254,6 +257,8 @@ 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) @@ -316,7 +321,7 @@ defmodule Mv.Membership.MembersPDF do defp format_cell_date_datetime(cell_value, locale) do case DateTime.from_iso8601(cell_value) do - {:ok, datetime, _offset} -> format_datetime(datetime, locale) + {:ok, datetime} -> 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 b38886c..71a3158 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, CycleGenerator.results_summary()} | {:error, Ash.Error.t()} + @spec run() :: {:ok, map()} | {:error, term()} 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, CycleGenerator.results_summary()} | {:error, Ash.Error.t()} + @spec run(keyword()) :: {:ok, map()} | {:error, term()} def run(opts) when is_list(opts) do Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}") start_time = System.monotonic_time(:millisecond) @@ -135,7 +135,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do - `{:error, reason}` - Error with reason """ - @spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, Ash.Error.t()} + @spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, term()} 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()) :: CycleGenerator.generate_result() + @spec run_for_member(String.t()) :: {:ok, [map()]} | {:error, term()} def run_for_member(member_id) when is_binary(member_id) do Logger.info("Generating cycles for member #{member_id}") CycleGenerator.generate_cycles_for_member(member_id) diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index 189f40a..8f1bc7c 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -1,11 +1,4 @@ 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. @@ -122,7 +115,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} -> @@ -166,8 +159,7 @@ defmodule Mv.MembershipFees.CycleGenerator do - `{:error, reason}` - Error with reason """ - @spec generate_cycles_for_all_members(keyword()) :: - {:ok, results_summary()} | {:error, Ash.Error.t()} + @spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()} def generate_cycles_for_all_members(opts \\ []) do today = Keyword.get(opts, :today, Date.utc_today()) batch_size = Keyword.get(opts, :batch_size, 10) @@ -220,7 +212,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 deleted file mode 100644 index a3a373a..0000000 --- a/lib/mv/oidc/discovery.ex +++ /dev/null @@ -1,88 +0,0 @@ -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 0f6467c..a13748a 100644 --- a/lib/mv/oidc_role_sync.ex +++ b/lib/mv/oidc_role_sync.ex @@ -87,6 +87,8 @@ 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 bbb5770..2a8574c 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() + Mv.Config.oidc_groups_claim() || "groups" end end diff --git a/lib/mv/release.ex b/lib/mv/release.ex index 5db4751..116b276 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,11 +139,10 @@ 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 @@ -190,16 +189,15 @@ 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}) + 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) |> Ash.update!(authorize?: false) - |> 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) + end) :ok @@ -209,16 +207,15 @@ defmodule Mv.Release do end defp update_admin_user(user, password, admin_role) do - _ = - user - |> Ash.Changeset.for_update(:admin_set_password, %{password: password}) + 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) |> Ash.update!(authorize?: false) - |> 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) + end) :ok end diff --git a/lib/mv/repo.ex b/lib/mv/repo.ex index 183c54f..0a4a04d 100644 --- a/lib/mv/repo.ex +++ b/lib/mv/repo.ex @@ -19,12 +19,4 @@ 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 999bd44..3cbba71 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -8,12 +8,6 @@ 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 """ @@ -37,7 +31,7 @@ defmodule Mv.Vereinfacht.Client do {:error, :not_configured} """ @spec test_connection(String.t() | nil, String.t() | nil, String.t() | nil) :: - {:ok, :connected} | {:error, error_reason()} + {:ok, :connected} | {:error, term()} 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} @@ -98,12 +92,13 @@ 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 Mv.Config.sql_sandbox?(), do: [retry: false] ++ opts, else: opts + if @env == :test, do: [retry: false] ++ opts, else: opts end defp post_and_parse_contact(url, body, api_key) do @@ -235,7 +230,7 @@ defmodule Mv.Vereinfacht.Client do Returns the full response body (decoded JSON) for debugging/display. """ - @spec get_contact(String.t()) :: {:ok, map()} | {:error, error_reason()} + @spec get_contact(String.t()) :: {:ok, map()} | {:error, term()} 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 5c643b6..874a717 100644 --- a/lib/mv/vereinfacht/sync_flash.ex +++ b/lib/mv/vereinfacht/sync_flash.ex @@ -37,10 +37,9 @@ 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 4d58f8d..83492b7 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, Mv.Vereinfacht.Client.error_reason()} + @spec test_connection() :: {:ok, :connected} | {:error, term()} 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 de009b6..d821416 100644 --- a/lib/mv_web/authorization.ex +++ b/lib/mv_web/authorization.ex @@ -113,7 +113,8 @@ defmodule MvWeb.Authorization do iex> can_access_page?(mitglied, "/members") false """ - @spec can_access_page?(map() | nil, String.t()) :: boolean() + @spec can_access_page?(map() | nil, String.t() | Phoenix.VerifiedRoutes.unverified_path()) :: + 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 42bdcfa..adde4e8 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -16,7 +16,6 @@ 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 @@ -335,29 +334,14 @@ defmodule MvWeb.AuthController do end end + defp redact_url(_), do: "[redacted]" + def sign_out(conn, _params) do - conn = clear_session(conn, :mv) |> put_flash(:success, gettext("You are now signed out")) + return_to = get_session(conn, :return_to) || ~p"/" - 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 + conn + |> clear_session(:mv) + |> put_flash(:success, gettext("You are now signed out")) + |> redirect(to: return_to) end end diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index e9c4a2a..9b08f5d 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -25,33 +25,31 @@ defmodule MvWeb.MemberExportController do @custom_field_prefix Mv.Constants.custom_field_prefix() def export(conn, params) do - case current_actor(conn) do - nil -> return_forbidden(conn) - actor -> export_with_actor(conn, actor, params["payload"]) + 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 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 c519914..fb41f1b 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 deleted file mode 100644 index 569337a..0000000 --- a/lib/mv_web/live/auth/sign_out_live.ex +++ /dev/null @@ -1,62 +0,0 @@ -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 b66d259..ddd3538 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -23,11 +23,6 @@ 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) @@ -36,18 +31,13 @@ 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 @@ -60,42 +50,19 @@ defmodule MvWeb.Components.MemberFilterComponent do socket |> assign(:id, assigns.id) |> assign(:cycle_status_filter, assigns[:cycle_status_filter]) - |> assign_group_assigns(assigns) - |> assign_fee_type_assigns(assigns) - |> assign_boolean_assigns(assigns) - |> assign_date_assigns(assigns) + |> 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(: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""" @@ -114,8 +81,7 @@ 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 || - date_filters_active?(@date_filters)) && + active_boolean_filters_count(@boolean_filters) > 0) && "btn-active" ]} phx-click="toggle_dropdown" @@ -133,8 +99,7 @@ defmodule MvWeb.Components.MemberFilterComponent do @fee_types, @fee_type_filters, @boolean_custom_fields, - @boolean_filters, - @date_filters + @boolean_filters )} <.badge @@ -146,9 +111,7 @@ defmodule MvWeb.Components.MemberFilterComponent do <.badge :if={ - (@cycle_status_filter || map_size(@group_filters) > 0 || - map_size(@fee_type_filters) > 0 || - date_filters_active?(@date_filters)) && + (@cycle_status_filter || map_size(@group_filters) > 0 || map_size(@fee_type_filters) > 0) && active_boolean_filters_count(@boolean_filters) == 0 } variant="primary" @@ -366,163 +329,6 @@ 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">
@@ -632,27 +438,17 @@ defmodule MvWeb.Components.MemberFilterComponent do payment_filter = parse_payment_filter(params) group_filters_parsed = - FilterParams.parse_prefix_filters( - params, - @group_filter_prefix, - &FilterParams.parse_in_not_in_value/1 - ) + parse_prefix_filters(params, @group_filter_prefix, &FilterParams.parse_in_not_in_value/1) fee_type_filters_parsed = - FilterParams.parse_prefix_filters( - params, - @fee_type_filter_prefix, - &FilterParams.parse_in_not_in_value/1 - ) + 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 @@ -690,6 +486,17 @@ 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", %{}) @@ -736,12 +543,6 @@ 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, @@ -750,16 +551,14 @@ defmodule MvWeb.Components.MemberFilterComponent do fee_types, fee_type_filters, boolean_custom_fields, - boolean_filters, - date_filters + boolean_filters ) do active_count = count_active_filter_categories( cycle_status_filter, group_filters, fee_type_filters, - boolean_filters, - date_filters + boolean_filters ) if active_count >= 2 do @@ -780,9 +579,6 @@ 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 @@ -793,27 +589,17 @@ defmodule MvWeb.Components.MemberFilterComponent do cycle_status_filter, group_filters, fee_type_filters, - boolean_filters, - date_filters + boolean_filters ) do [ cycle_status_filter, map_size(group_filters) > 0, map_size(fee_type_filters) > 0, - map_size(boolean_filters) > 0, - date_filters_active?(date_filters) + map_size(boolean_filters) > 0 ] |> 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") @@ -935,6 +721,7 @@ 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 @@ -981,35 +768,4 @@ 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 6a456fe..6a1c926 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 7cd4378..b9f22c8 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -836,6 +836,12 @@ 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 a8c5a95..f3d4941 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -47,11 +47,14 @@ 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 = @@ -190,6 +193,16 @@ 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 @@ -210,6 +223,64 @@ 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 @@ -266,33 +337,32 @@ 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 @@ -308,7 +378,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 b679127..ba0e476 100644 --- a/lib/mv_web/live/join_live.ex +++ b/lib/mv_web/live/join_live.ex @@ -287,6 +287,8 @@ 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 6196fc4..c258d5f 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -36,8 +36,6 @@ 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 @@ -89,13 +87,6 @@ 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 @@ -152,8 +143,6 @@ 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) @@ -459,25 +448,6 @@ 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( @@ -532,7 +502,6 @@ 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() @@ -663,7 +632,6 @@ 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"]) @@ -715,8 +683,7 @@ 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[:date_filters] + socket.assigns[:visible_custom_field_ids] || [] } end @@ -816,12 +783,7 @@ 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) - 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)) + add_boolean_filters(base_params, opts.boolean_filters || %{}) end defp opts_for_query_params(socket, overrides \\ %{}) do @@ -833,8 +795,7 @@ 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] || %{}, - date_filters: socket.assigns.date_filters + fee_type_filters: socket.assigns[:fee_type_filters] || %{} } |> Map.merge(overrides) end @@ -980,7 +941,26 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.new() |> Ash.Query.select(@overview_fields) - query = load_custom_field_values(query, compute_ids_to_load(socket)) + 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 = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) @@ -1004,13 +984,6 @@ 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 @@ -1030,7 +1003,21 @@ 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 - members = apply_in_memory_filters(members, socket) + # 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 + ) # 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 @@ -1050,55 +1037,6 @@ 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 @@ -1218,6 +1156,8 @@ 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) @@ -1295,6 +1235,8 @@ 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 @@ -1554,6 +1496,8 @@ 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) @@ -1580,6 +1524,8 @@ 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) @@ -1703,20 +1649,24 @@ 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 - CustomFieldValueLookup.find_by_field(member, custom_field) + 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 end def get_boolean_custom_field_value(member, custom_field) do @@ -1775,12 +1725,29 @@ defmodule MvWeb.MemberLive.Index do end defp matches_filter?(member, custom_field_id_str, filter_value) do - case CustomFieldValueLookup.find_by_id(member, custom_field_id_str) do + case find_custom_field_value_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 13dc89e..efc1eb7 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -54,8 +54,6 @@ 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 deleted file mode 100644 index 6d2298c..0000000 --- a/lib/mv_web/live/member_live/index/custom_field_value_lookup.ex +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 162524a..0000000 --- a/lib/mv_web/live/member_live/index/date_filter.ex +++ /dev/null @@ -1,454 +0,0 @@ -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 846cf1d..1e77b09 100644 --- a/lib/mv_web/live/member_live/index/field_selection.ex +++ b/lib/mv_web/live/member_live/index/field_selection.ex @@ -103,6 +103,8 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do end) end + defp parse_cookie_header(_), do: %{} + @doc """ Saves field selection to cookie. @@ -216,6 +218,8 @@ 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 52ebe86..df20d25 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() :: [:membership_fee_status | :membership_fee_type | :groups, ...] + @spec computed_member_fields() :: [atom()] 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 790b31f..9b5e800 100644 --- a/lib/mv_web/live/member_live/index/filter_params.ex +++ b/lib/mv_web/live/member_live/index/filter_params.ex @@ -1,12 +1,8 @@ defmodule MvWeb.MemberLive.Index.FilterParams do @moduledoc """ - 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. + 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. """ - @doc """ Parses a value for group or fee-type filter params. Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion. @@ -23,29 +19,4 @@ 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 0cba316..e8ddff4 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, reason} when reason in [:invalid_format, :invalid_date, :incompatible_calendars] -> + :error -> {: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 15030c1..e153d18 100644 --- a/lib/mv_web/live/membership_fee_settings_live.ex +++ b/lib/mv_web/live/membership_fee_settings_live.ex @@ -464,6 +464,7 @@ 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 a4d8506..bfdfa2d 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()) :: {module(), any()} + @spec notify_parent(any()) :: 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 65f840d..a34480b 100644 --- a/lib/mv_web/live/membership_fee_type_live/index.ex +++ b/lib/mv_web/live/membership_fee_type_live/index.ex @@ -214,6 +214,7 @@ 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 2e315b9..51f5cac 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()) :: {module(), any()} + @spec notify_parent(any()) :: 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 60763ab..4a26078 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()) :: {module(), any()} + @spec notify_parent(any()) :: 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()] | Ash.Page.page() + @spec load_roles(any()) :: [Mv.Authorization.Role.t()] 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(Ash.Error.t()) :: String.t() + @spec extract_error_message(any()) :: 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,5 +932,6 @@ 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 003c36c..4206aa6 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,10 +145,7 @@ defmodule MvWeb.LiveHelpers do end """ @spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) :: - {:ok, Ash.Resource.record() | nil | [Ash.Notifier.Notification.t()]} - | {:ok, Ash.Resource.record(), [Ash.Notifier.Notification.t()]} - | :ok - | {:error, AshPhoenix.Form.t()} + {:ok, Ash.Resource.t()} | {: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 617b079..f3f3fc9 100644 --- a/lib/mv_web/live_user_auth.ex +++ b/lib/mv_web/live_user_auth.ex @@ -31,24 +31,27 @@ defmodule MvWeb.LiveUserAuth do end end - def on_mount(:live_user_required, _params, _session, socket) do + def on_mount(:live_user_required, _params, session, socket) do + socket = LiveSession.assign_new_resources(socket, session) + case socket.assigns do %{current_user: %{} = user} -> {:cont, assign(socket, :current_user, user)} _ -> - {:halt, LiveView.redirect(socket, to: ~p"/sign-in")} + socket = LiveView.redirect(socket, to: ~p"/sign-in") + {:halt, socket} 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) - socket = assign(socket, :locale, locale) + Gettext.put_locale(MvWeb.Gettext, locale) + {:cont, assign(socket, :locale, locale)} if socket.assigns[:current_user] do - {:halt, LiveView.redirect(socket, to: ~p"/")} + {:halt, Phoenix.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 64036c9..591dead 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", live_view: MvWeb.SignOutLive + sign_out_route AuthController # 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 1580b99..969f20b 100644 --- a/lib/mv_web/translations/field_types.ex +++ b/lib/mv_web/translations/field_types.ex @@ -12,9 +12,7 @@ defmodule MvWeb.Translations.FieldTypes do """ use Gettext, backend: MvWeb.Gettext - @type field_type :: :string | :integer | :boolean | :date | :email - - @spec label(field_type()) :: String.t() + @spec label(atom()) :: 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 fa31c04..8a455e7 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,6 @@ 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] ] @@ -81,7 +80,6 @@ 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"}, @@ -114,21 +112,4 @@ 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 e39ebbc..4eb37fa 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,5 @@ %{ - "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": {:hex, :ash, "3.25.2", "d23c52a9f823e98895d0cf1dc8bbf5d22943ffa45ba087e583d94bb05d205b2e", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c4e3fb9252719dd3fec84610a5a19e309f298265076da23c0bef21de237e98bb"}, "ash_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"}, @@ -16,27 +16,25 @@ "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.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": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"}, "cowboy_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.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.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"}, + "decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"}, "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.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"}, + "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, "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"}, @@ -45,7 +43,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.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"}, + "igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"}, "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"}, @@ -56,7 +54,7 @@ "live_debugger": {:hex, :live_debugger, "1.0.0", "0bbcbdcb3b40b6862b6dfbb76579e7832e2787fee643031e5958f597def6fb32", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 1.1.7 and < 2.0.0-0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b80f5d9db874d3270eb534738a10982b2b4ce55d58f94544e43b4a36111585fc"}, "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "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"}, + "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, "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"}, @@ -69,7 +67,7 @@ "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_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.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_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_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"}, @@ -81,7 +79,7 @@ "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"}, "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, "reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"}, - "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"}, + "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"}, "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"}, @@ -98,13 +96,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.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "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.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"}, "ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"}, } diff --git a/priv/gettext/auth.pot b/priv/gettext/auth.pot index 4fbbeda..cd46c56 100644 --- a/priv/gettext/auth.pot +++ b/priv/gettext/auth.pot @@ -152,13 +152,3 @@ 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 602a106..07583be 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -148,13 +148,3 @@ 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 81d91f7..a85b4cf 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2208,6 +2208,11 @@ 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." @@ -3892,83 +3897,3 @@ 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 5e9abca..b995b1a 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2209,6 +2209,11 @@ 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." @@ -3892,78 +3897,3 @@ 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 8f78349..564e640 100644 --- a/priv/gettext/en/LC_MESSAGES/auth.po +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -145,13 +145,3 @@ 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 1ae6a49..f4526d1 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2209,6 +2209,11 @@ 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." @@ -3892,83 +3897,3 @@ 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 deleted file mode 100644 index 6869410..0000000 --- a/rauthy-bootstrap/clients.json +++ /dev/null @@ -1,19 +0,0 @@ -[ - { - "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 deleted file mode 100644 index 4ae689f..0000000 --- a/test/mv/constants_test.exs +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 88d189e..0000000 --- a/test/mv/membership/import/import_runner_test.exs +++ /dev/null @@ -1,33 +0,0 @@ -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 2b83e3b..78a8ca6 100644 --- a/test/mv/membership/members_pdf_test.exs +++ b/test/mv/membership/members_pdf_test.exs @@ -101,29 +101,6 @@ 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 bb55fa5..d32993c 100644 --- a/test/mv_web/components/member_filter_component_test.exs +++ b/test/mv_web/components/member_filter_component_test.exs @@ -209,57 +209,6 @@ 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 @@ -319,293 +268,6 @@ 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 - # `
` `